이제는 너무 유명해서 옆집 꼬마 김민재(9)까지도 안다는 클로저... 프론트엔드 개발을 하시는 분들께는 너무도 중요한 개념으로 자리 잡고 있어서 잘 아실 것 같아요.
클로저가 뭐냐면...
클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합입니다. 즉, 클로저는 내부 함수에서 외부 함수의 범위에 대한 접근을 제공합니다. - MDN
?? 뭐라는거여
처음 클로저를 공부할 때는 이런 정의가 이해가 잘 안 가더라고요. 그래서 실제 사용 예시를 찾아보다가 리액트 훅이 대표적인 활용 예시라는 걸 알게 되었죠. 구글링해도 대부분의 예시가 훅으로 나오니까요.
하지만 저는 훅만으로는 클로저의 원리를 완전히 이해하지 못했어요. 클로저의 원리를 알아야만 훅을 사용할 수 있는 것은 아니었으니까요. 그래서 오늘은 실무에서 클로저를 어떻게 활용할 수 있는지에 대해 이야기해 보려고 합니다.
로깅 시스템을 구축하다 보면 여러 서비스로 로깅을 해야 할 때가 있습니다. 예를 들어 Google Analytics, Amplitude, Sentry, DataDog 등이죠. 각각의 서비스는 서로 다른 형태의 데이터를 요구해요.
serviceA.init(apiKey, { userSetting: ... });
serviceA.track("clicked", { serviceName: "myApp" });
await serviceB.init({ api_key: apiKey, user_setting: ... });
serviceB.log('myApp_clicked');
이 문제를 해결하기 위해 저는 공통된 인터페이스를 만들었어요.
type LoggingService<T> = (config: T) => {
init: () => void;
log: (eventType: EventType, payload: Payload) => void;
};
그러면 각 서비스별로 아래와 같이 만들 수 있었죠.
const loggerA: LoggingService<ConfigA> = (config) => ({
init: () => {
serviceA.init(config.apiKey, { userSetting: config.userSetting });
},
log: (eventType, payload) => {
serviceA.track(eventType, payload);
},
});
const loggerB: LoggingService<ConfigB> = (config) => ({
init: async () => {
await serviceB.init({ api_key: config.apiKey, user_setting: config.userSetting });
},
log: (eventType, payload) => {
serviceB.log(`${payload.serviceName}_${eventType}`);
},
});
이렇게 하면 서로 다른 로깅 클라이언트를 동일한 방식으로 처리할 수 있게 되었어요. 이제 플러그인처럼 꽂아주기만 하면 되겠죠.
<LogginServiceProvider
services={[
loggerA({ apiKey: "abc", userSetting: ... }),
loggerB({ apiKey: "abc", userSetting: ... })
]}
>
{/* 한번의 함수 호출로 A서비스와 B서비스로 동시에 요청을 보낼 수 있게 되었어요. */}
<Button onClick={() => { service.log("click", { ... }) }} />
</LogginServiceProvider>
여기서 문제가 생겼어요. 각 로깅 서비스가 초기화(init)되어 있는지, 초기화 중인지, 초기화가 완료되었는지 알 수가 없었어요. 그래서 init이 여러 번 되거나, 초기화 중에 init을 시도하는 문제가 발생했죠. 각 로깅 서비스마다 초기화 상태를 저장할 필요가 있었어요.
여기서 클로저가 뭔지만 알고 있다면, 클로저를 활용할 수 있어요. 클로저는 내부 함수에서 외부 함수의 범위에 대한 접근을 제공할 수 있게 해주니까요.
const loggerA = () => {
let initStatus: InitStatus = "uninitialized";
return {
init: () => {
if (initStatus === "uninitialized") {
initStatus = 'initializing';
serviceA.init();
initStatus = 'initialized';
}
},
log: () => {
if (initStatus === "initialized") {
serviceA.track();
}
},
};
};
이렇게 init 함수 내에서 상위에 있는 isInitialized에 접근하면, loggerA가 이미 실행되고 난 뒤라도, init함수는 isInitialized 상태에 접근할 수 있는 상태가 돼요.
하지만 어떤 서비스는 동기적으로, 어떤 서비스는 비동기적으로 초기화를 해야 하는 경우가 있었어요.
serviceA.init(apiKey, { userSetting: ... });
await serviceB.init({ api_key: apiKey, user_setting: ... });
이로 인해 초기화가 끝나기 전에 로그가 찍혀 누락되는 경우가 발생했죠. 비동기 처리가 필요한 상황이었어요. init
함수가 반환한 Promise
도 상태에 저장할 수 있을 것 같았어요.
const loggerA = () => {
let initStatus: InitStatus = "uninitialized";
let initPromise: Promise<void> | null = null;
return {
init: async () => {
if (initStatus === "uninitialized") {
initStatus = 'initializing';
initPromise = Promise.resolve(serviceB.init());
await initPromise; // resolve되면 다음 라인으로 이동
initStatus = 'initialized';
}
},
log: () => {
if (initStatus === "initialized") {
serviceB.track();
}
},
};
};
이렇게 하면 비동기적으로 초기화를 하는 서비스도 함께 관리할 수 있습니다.
사실, initPromise
가 초기화 상태를 함께 관리하면 저장해야 하는 상태를 하나 줄일 수 있어요. initPromise
를 다음과 같은 타입으로 변경해봤습니다.
let initPromise: null | (Promise<void> & { isInitialized?: boolean }) = null;
이렇게 하면 하나의 변수로 세 가지 상태를 표현할 수 있어요.
initPromise === null
: 초기화가 시작되지 않음initPromise가 pending 상태인 Promise
: 초기화 중initPromise가 fulfilled된 Promise
: 초기화 완료init: () => {
if (!initPromise) {
initPromise = Promise.resolve(serviceB.init()).then(() => {
if (initPromise === null) return;
initPromise.isInitialized = true;
});
}
}
조금 더 디테일을 추가해볼게요. 로깅 서비스가 초기화되는 도중에 로그를 찍으면 그 로그가 누락될 수 있어요. 실무에서는 이런 로그 누락이 큰 영향을 미칠 수 있죠.
const loggerA = (config) => {
const queue: Log[] = [];
let initPromise: null | (Promise<void> & { isInitialized?: boolean }) = null;
저는 이걸 큐를 이용해서 해결했어요. 이번엔 log 함수쪽을 먼저 볼게요.
log: (type, payload) => {
if (!initPromise?.isInitialized) {
queue.push({ type, payload });
return;
}
serviceB.log(type, payload);
}
initPromise의 isInitialized 상태를 보고, 아직 초기화되지 않았다면 큐에 이벤트를 저장해두어요.
init: () => {
if (!initPromise) {
initPromise = Promise.resolve(serviceB.init()).then(() => {
if (initPromise === null) return;
initPromise.isInitialized = true;
queue.forEach(({ type, payload }) => {
serviceB.log(type, payload);
});
queue.length = 0;
});
}
}
이렇게 하면 초기화 중인 동안의 로그는 큐에 저장해 두었다가 초기화가 완료되면 함께 처리할 수 있어요.
이 모든 로직을 매 로거마다 복붙하는 건 비효율적이겠죠. 그래서 팩토리 함수를 만들었어요. 이 함수는 로깅 서비스의 초기화 상태(initPromise
)와 로그 큐(queue
)를 클로저로 캡처하여 관리하도록 했어요.
사용자는 아래처럼 간단히 사용할 수 있어요.
const loggingServiceA = createLoggingService<ConfigA>((config) => ({
init: () => {
serviceA.init(config.apiKey, { userSetting: config.userSetting });
},
log: (eventType, payload) => {
serviceA.track(eventType, payload);
},
}));
const loggingServiceB = createLoggingService<ConfigB>((config) => ({
init: async () => {
await serviceB.init({ api_key: config.apiKey, user_setting: config.userSetting });
},
log: (eventType, payload) => {
serviceB.log(`${payload.serviceName}_${eventType}`);
},
}));
이렇게 정의한 서비스를 플러그인처럼 꽂아 쓰기만하면 초기화와 로깅은 알아서 되는 셈이죠.
<LogginServiceProvider
services={[
loggerA({ apiKey: "abc", userSetting: ... }),
loggerB({ apiKey: "abc", userSetting: ... })
]}
>
// ...
</LogginServiceProvider>
오늘은 제가 로깅 시스템을 구축한 경험을 기반으로 실무에서 클로저를 어떻게 활용하는지에 대해서 알아봤어요. 클로저를 통해 초기화 상태를 관리하고, 로그 큐를 구현함으로써 동기 및 비동기 초기화 문제를 해결할 수 있었습니다.
클로저는 단순한 개념이지만, 실무에서 이렇게 유용하게 활용할 수 있다는 점에서 그 중요성이 부각된다고 생각해요. 여러분의 프로젝트에서도 클로저를 적극적으로 활용해보는건 어떨까요?
잘 봤습니다!!