옵저버 패턴을 활용하여 인스타그램 피드 렌더링 해보기

2020. 9. 9

옵저버 패턴

옵저버 패턴은 디자인 패턴중에 하나로서 스타크래프트의 옵저버 처럼 관찰을 하는 역할을 수행한다. 그 관찰한 결과를 본진(넥서스)에서 관리하여 다음 행동을 결정하고 수행한다.

프로그래밍에서도 동일하다.

옵저버를 가지고 있는(상속받는) 객체는 항상 관찰을 당하고 있으며, lifecycle을 감지하여 해당 정보(데이터, 행동)를 본체에게 넘겨 관리를 시키게 된다.

만약 이런 디자인패턴을 사용하지 않고 일반적으로 구현을 하게 된다면 각각의 객체별로 변수와 메소드를 관리하게 될 것이고, 이는 가독성이 떨어지는 코드와 재사용성이 없는 객체들이 계속 생겨날 것이다.

그래서 옵저버 패턴을 활용하여 인스타그램 피드 부분을 만들어보며 학습해보려고 한다.

Subject (전역, 본체)

class Subject {
  observerCollection = []

  constructor() {
    this.app = document.getElementById('app')
  }

  render() {
    this.app.innerHTML += this.observerCollection.map((component) => component.render()).join('')
  }

  registration(observer) {
    this.observerCollection.push(observer)
  }
}

const subject = new Subject()

module.exports = { Subject, subject }

Subject는 전역 객체로써 트리구조의 가장 최상단을 관리한다.

그리고 등록된 최상위 컴포넌트(여기선 피드 객체 하나)를 반복하며 render를 해주는 메소드를 만들었다.

최초 1회만 객체를 생성하여야 하기 때문에 생성한 객체를 내보내준다.

이 객체에서는 말 그대로 전역을 관리하는 넥서스라고 보면 된다. 넥서스에서 옵저버의 정보를 받아와 다음 행동을 판단하고 수행하듯이 여기서 관찰한 객체를 판단해서 등록하고, 최종적으론 렌더해주는 역할을 수행한다.

Observable (관찰, 옵저버)

const { subject } = require('./Subject')

class Observable {
  registration(target) {
    if (target) {
      subject.registration(target)
    }
  }
}

module.exports = Observable

이 객체의 뜻은 관찰 가능한 것을 말한다. 즉, 이 객체를 가지고 있게 되면 관찰되고 있는 대상이 되는 것이다.

그러면 그 대상은 항상 전역에서 관리가 되어야 하니 이전에 생성한 subject 객체를 가지고 있어야 할 것이고, 만약 최상단의 컴포넌트가 있다면 전역에서 관리할 컴포넌트 대상이기 때문에 추가를 해주어야 한다.

Component (컴포넌트, 대상)

const Observable = require('./Observable')

class Component extends Observable {
  components = []

  constructor(parent) {
    super()
    super.registration(!parent ? this : null)
    if (parent) {
      parent.addChild(this)
    }
  }

  addChild(child) {
    this.components.push(child)
  }
}

module.exports = Component

컴포넌트에서는 항상 관찰의 대상이 되고 행동을 감시당할 수 있도록 하기 위해 Observable을 상속받는다.

여기서는 해당 컴포넌트가 트리의 최상위인지 아닌지를 판별하여 만약 최상위라면 해당 컴포넌트를 전역에 등록하고, 아니면 부모의 자식 컴포넌트로 넣어주는 역할을 담당한다.

이제 위 세가지를 활용한 실제 렌더되는 컴포넌트를 만들 차례다. 이 컴포넌트들은 항상 관찰되어야 하니 Component를 상속받아 부모객체의 여부를 선언해주어 차후 렌더링에 사용된다.

Feed

const Component = require('./lib/Component')

class Feed extends Component {
  constructor() {
    super()
  }

  render() {
    return `<article class="feed">${this.components
      .map((component) => component.render())
      .join('')}</article>`
  }
}

module.exports = Feed

Feed 컴포넌트는 최상위 컴포넌트(부모)이므로 따로 parent를 넘기지 않았다. 그렇다면 subject에서 관리가 되는 대상이고 컴포넌트 객체에 자식들이 등록이 될 것이기 때문에 this로 감시하는 컴포넌트에 접근하여 자식들을 렌더링해준다.

Header

const Component = require('./lib/Component')

class Header extends Component {
  constructor(parent, thumbnail, nickname) {
    super(parent)
    this.thumbnail = thumbnail
    this.nickname = nickname
  }

  render() {
    return `
    <header class="feed-header">
      <div class="user-profile">
        <a class="thumbnail" href="">
          <img src="${this.thumbnail}" alt="userthumbnail" />
        </a>
        <a class="nickname" href="">
          <span>${this.nickname}</span>
        </a>
      </div>
      <div class="user-tab">
        <button type="button">
          <img src="../src/images/more.png" alt="" />
        </button>
      </div>
    </header>
    `
  }
}

module.exports = Header

Header는 각각의 피드 안에 최상단에 위치하여 썸네일과 닉네임을 표시해준다.

그래서 parent를 받고 Feed의 자식으로 등록이 된다.

Picture

const Component = require('./lib/Component')

class Picture extends Component {
  constructor(parent, pictures) {
    super(parent)
    this.pictures = pictures
  }

  render() {
    return `
      <div class="main-pictures">
        <ul class="pictures">
            ${this.pictures.map(
              (picture) =>
                `
              <li class="picture">
                <img src="${picture.picture}" alt="" />
              </li>
              `,
            )}
        </ul>
      </div>
      `
  }
}

module.exports = Picture

Picture는 메인 사진이 올라오는 부분으로 마찬가지로 Feed를 부모로 가지고 있기 때문에 parent를 가지고 있다.

Detail

const Component = require('./lib/Component')

class Detail extends Component {
  constructor(parent, nickname, likes, content, comments, date) {
    super(parent)
    this.nickname = nickname
    this.likes = likes
    this.content = content
    this.comments = comments
    this.date = date
  }

  render() {
    return `
      <div class="feed-detail">
      <div class="feed-info">
        <div class="tabs">
          <div class="left-tab">
            <span class="like">
              <img src="../src/images/heart.png" alt="" />
            </span>
            <span class="comment">
              <img src="../src/images/comment.png" alt="" />
            </span>
            <span class="share">
              <img src="../src/images/share.png" alt="" />
            </span>
          </div>
          <div class="right-tab">
            <span class="pictures-length"></span>
            <span class="save">
              <img src="../src/images/save.png" alt="" />
            </span>
          </div>
        </div>
        <div class="likes">
          <button type="button">
            좋아요
            <span class="likes-length">${this.likes.length}</span>
          </button>
        </div>
        <div class="contents">
          <span class="nickname">${this.nickname}</span>
          <span class="content"
            >${this.content}</span
          >
          <span class="more">
            <button type="button">더보기</button>
          </span>
        </div>
        <div class="comments">
          <a class="comments-length" href="">
            <span>댓글 ${this.comments.length}개 모두 보기</span>
          </a>
          <ul class="comment-lists">
          ${this.comments
            .map(
              (comment) =>
                `
              <li class="list">
                <div class="comment-info">
                  <span class="nickname">
                    <a href="">${comment.nickname}</a>
                  </span>
                  <span class="list">${comment.comment}</span>
                </div>
              </li>
              `,
            )
            .join('')}
          </ul>
          <div class="since">
            <a href="">
              <span>${this.date}</span>
            </a>
          </div>
        </div>
      </div>
    </div>
    <div class="comment-area">
      <form action="POST">
        <textarea class="text-comment" placeholder="댓글 달기..."></textarea>
        <button type="button">게시</button>
      </form>
    </div>
      `
  }
}

module.exports = Detail

Detail은 글의 내용부터 댓글, 댓글 등록 등 게시글에 대한 세부 정보들이 다 들어있다. 마찬가지로 Feed에 포함되는 내용이기 때문에 parent를 가지고 있다.

자 이제 컴포넌트를 실제로 인스턴스화 하여 사용해보자

Index

const datas = require('./data.json')
const { subject } = require('./src/lib/Subject')

const Feed = require('./src/Feed')
const Header = require('./src/Header')
const Picture = require('./src/Picture')
const Detail = require('./src/Detail')

datas.forEach((data) => {
  const feed = new Feed()
  new Header(feed, data.thumbnail, data.nickname)
  new Picture(feed, data.pictures)
  new Detail(feed, data.nickname, data.likes, data.content, data.comments, data.date)
})

subject.render()

특정 데이터들을 받아와 반복하면서 가장 최상위 객체인 Feed를 생성한다.

그리고 나머지 컴포넌트들은 Feed의 자식이니 인스턴스화를 하면서 부모인 feed를 가져와 함께 생성해준다.

마지막으로 전역에 등록된 Feed를 렌더링한다.

결과

observer response

전역에서는 총 2개의 Feed가 등록되었으며, 자식 컴포넌트는 의도된바와 같이 세 컴포넌트가 정상적으로 등록된 것을 콘솔로 확인할 수 있다.

observer result

렌더링 또한 아주 잘 되었다! 👍

이렇게 각각의 컴포넌트들을 항상 관찰하며 컴포넌트를 활용할 수 있다. 옵저버 패턴의 단점인 컴포넌트가 너무 많아 복잡해지는 것을 트리구조로 극복하여 보다 나은 재사용성과 가독성을 가져올 수 있었다.