WeakRef와 FinalizationRegistry 이해하기
Sep 16, 2022일시적으로 데이터를 담아 둘 때, 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
는 참조된 객체가 가비지 콜렉터의 대상이 될 때, 그 내부의 객체가 사라집니다. WeakMap
의 deref()
메서드를 호출하게 되면, 가비지 콜렉션 전에는 포함하고 있는 내부의 값이 정상적으로 불러옵니다. 가비지 콜렉션 뒤에는 내부의 값이 사라져서 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를 이용합니다. 위 예시는 어디까지나 WeakRef
와 FinalizationRegistry
의 이해를 위해 작성되었습니다. 가비지 콜렉션의 타이밍이 예측불가능하기 때문에 확실한 이해 없이 사용하게 되면 위험할 수 있습니다. 음.. 언젠가 ORM같은 라이브러리에서 트랜잭션 캐시 목적으로 사용되지 않을까 조심스럽게 예측해봅니다. 🙂