일시적으로 데이터를 담아 둘 때, Map을 활용합니다. 데이터베이스에서 아이디를 통해 사용자 데이터를 가져오는 로직이 있다고 가정해봅시다.

class UserFinder {

  findById(id: string) {
    return await this.db.findUserById(id)
  }
}

매번 데이터베이스에서 사용자 데이터를 가져오게 되면 서버에 많은 부하가 걸리게 됩니다. 이를 완화하기 위해 한번 가져온 사용자는 캐시에 담아두기로 하였습니다. 간단히 구현해보면 다음과 같은 방식이 됩니다.

class UserFinder {
  #cachedUsers = new Map()
  
  findById(id: string) {
    if (!this.#cachedUsers.has(id)) {
      const user = await this.db.findUserById(id)
      this.#cachedUsers.set(id, user)
    }
    return this.#cachedUsers.get(id)
  }
}

물론.. 위 로직으로 서비스를 운영하다보면 다음과 같은 에러 메시지를 만날 수 있습니다. 😅

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - process out of memory

사용자가 많으면 많을수록 Map(#cachedUsers)에도 많은 객체가 쌓이게 됩니다. 더이상 사용하지 않는 객체는 적절히 삭제되어야 합니다. 자바스크립트에서는 사용하지 않는 객체는 가비지 콜렉터(Garbage Collector)에 의해 삭제됩니다. 하지만 Map에 담겨있는 객체는 참조를 잃지 않기 때문에 가비지 콜렉션(Garbage Collection)의 대상이 되지 않습니다.

가비지 콜렉션 하면 가장 먼저 WeakMap이 생각납니다. WeakMap은 키를 객체로 갖고 해당 키가 가비지 콜렉터의 대상이 될 때 키에 해당하는 값이 삭제되는 방식입니다.

const user = { id: 1 }

const map = new WeakMap()

map.set(user, { username: 'wan2land' })

map.get(user) // { username: 'wan2land' }
map.get({ id: 1 }) // undefined

WeakMap은 값({ id: 1 })이 같아도 객체가 다르면 원하는 결과를 가져올 수 없습니다. 그래서 처음에 작성한 UserFinder예제에서 사용하기 적절하지 않습니다. 더 자세한 WeakMap의 내용은 다음 링크를 참고해주세요.

The Modern JavaScript Tutorial - WeakMap을 사용한 캐싱의 예시

WeakMap은 키를 객체로 가집니다. 우리는 스칼라값(String, Number, Boolean …)을 키로 갖는 WeakMap과 유사한 가비지 콜렉터의 대상이 되는 어메이징한(?) 무언가(?!)를 원합니다. 이때 ES2021에 추가된 WeakRef를 이용해봅시다. :-)

WeakRef는 참조된 객체가 가비지 콜렉터의 대상이 될 때, 그 내부의 객체가 사라집니다. WeakMapderef() 메서드를 호출하게 되면, 가비지 콜렉션 전에는 포함하고 있는 내부의 값이 정상적으로 불러옵니다. 가비지 콜렉션 뒤에는 내부의 값이 사라져서 undefined 값을 반환합니다. 이를 코드로 표현하면 다음과 같습니다.

let user = { id: 1 }
const ref = new WeakRef(user)

ref.deref() // { id: 1 }

user = null // 가비지 콜렉션 발생! (..실제로는 환경에 따라 일어나지 않을 수도 있습니다..)

ref.deref() // undefined

이제 WeakRef를 활용해서 UserFinder를 개선해봅시다.

class UserFinder {
  #cachedUsers = new Map()
  
  findById(id: string) {
    if (!this.#cachedUsers.has(id)) {
      const user = await this.db.findUserById(id)
      this.#cachedUsers.set(id, new WeakRef(user)) // WeakRef!
    }
    return this.#cachedUsers.get(id).deref()
  }
}

모든 문제가 해결된 것 같지만, WeakRef 자체를 가지고 있는 Map은 여전히 쓸모없는 WeakRef객체로 가득합니다. WeakRef 내부의 객체가 사라질 때, Map에서 WeakRef 객체도 삭제해야 합니다. 이 때 FinalizationRegistry를 사용하면 됩니다. FinalizationRegistry는 등록된 객체가 가비지 컬레션의 대상이 될 때 함께 등록된 값을 이벤트로 불러줍니다.


const registry = new FinalizationRegistry((heldValue) => {
  console.log(heldValue)
})

let user = { id: 2 }

registry.register(user, 'id is 2')

user = null // 가비지 콜렉션 발생! (..실제로는 환경에 따라 일어나지 않을 수도 있습니다..)
// 여기서 위 핸들러의 console.log("id is 2") 호출!

이제 FinalizationRegistry를 이용해서 코드를 보완하면 다음과 같이 됩니다.

class UserFinder {
  #cachedUsers = new Map()
  #registry
  
  constructor() {
    this.#registry = new FinalizationRegistry((id) => {
      this.#cachedUsers.delete(id)
    })
  }
  
  findById(id: string) {
    if (!this.#cachedUsers.has(id)) {
      const user = await this.db.findUserById(id)
      this.#cachedUsers.set(id, new WeakRef(user))
      this.#registry.register(user, id)
    }
    return this.#cachedUsers.get(id).deref()
  }
}

기획이 추가되어 UserFinder 말고 ArticleFinder에서도 구현해야합니다. 그 다음에는 CommentFinder에도 적용해야 한다고 합니다…. 매번 위와 같은 코드를 반복해서 사용하기에는 좀 귀찮습니다. 😅

다음과 같이 InvertedWeakMap 클래스를 만들고 사용하면 됩니다. (이게 젤 중요!!)

class InvertedWeakMap<K extends string | symbol, V extends object> {
  _map = new Map<K, WeakRef<V>>()
  _registry: FinalizationRegistry<K>

  constructor() {
    this._registry = new FinalizationRegistry<K>((key) => {
      this._map.delete(key)
    })
  }

  set(key: K, value: V) {
    this._map.set(key, new WeakRef(value))
    this._registry.register(value, key)
  }

  get(key: K): V | undefined {
    const ref = this._map.get(key)
    if (ref) {
      return ref.deref()
    }
  }

  has(key: K): boolean {
    return this._map.has(key) && this.get(key) !== undefined
  }
}

이제 UserFinder를 개선해봅시다.

class UserFinder {
  #cachedUsers = new InvertedWeakMap()
  
  findById(id: string) {
    if (!this.#cachedUsers.has(id)) {
      const user = await this.db.findUserById(id)
      this.#cachedUsers.set(id, user)
    }
    return this.#cachedUsers.get(id)
  }
}

실제 서비스에서 캐싱은 레디스(Redis)와 같은 Key-Value Storage를 이용합니다. 위 예시는 어디까지나 WeakRefFinalizationRegistry의 이해를 위해 작성되었습니다. 가비지 콜렉션의 타이밍이 예측불가능하기 때문에 확실한 이해 없이 사용하게 되면 위험할 수 있습니다. 음.. 언젠가 ORM같은 라이브러리에서 트랜잭션 캐시 목적으로 사용되지 않을까 조심스럽게 예측해봅니다. 🙂

참고