당신이 클로저를 알아야하는 이유

이호연·2025년 1월 25일
5
post-thumbnail

클로저...너란 녀석

아하
이제는 너무 유명해서 옆집 꼬마 김민재(9)까지도 안다는 클로저... 프론트엔드 개발을 하시는 분들께는 너무도 중요한 개념으로 자리 잡고 있어서 잘 아실 것 같아요.

클로저가 뭐냐면...

클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합입니다. 즉, 클로저는 내부 함수에서 외부 함수의 범위에 대한 접근을 제공합니다. - MDN

?? 뭐라는거여

처음 클로저를 공부할 때는 이런 정의가 이해가 잘 안 가더라고요. 그래서 실제 사용 예시를 찾아보다가 리액트 훅이 대표적인 활용 예시라는 걸 알게 되었죠. 구글링해도 대부분의 예시가 훅으로 나오니까요.

하지만 저는 훅만으로는 클로저의 원리를 완전히 이해하지 못했어요. 클로저의 원리를 알아야만 훅을 사용할 수 있는 것은 아니었으니까요. 그래서 오늘은 실무에서 클로저를 어떻게 활용할 수 있는지에 대해 이야기해 보려고 합니다.

실무에서의 클로저

로깅 시스템 구축

도식1
로깅 시스템을 구축하다 보면 여러 서비스로 로깅을 해야 할 때가 있습니다. 예를 들어 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>

마무리

오늘은 제가 로깅 시스템을 구축한 경험을 기반으로 실무에서 클로저를 어떻게 활용하는지에 대해서 알아봤어요. 클로저를 통해 초기화 상태를 관리하고, 로그 큐를 구현함으로써 동기 및 비동기 초기화 문제를 해결할 수 있었습니다.

클로저는 단순한 개념이지만, 실무에서 이렇게 유용하게 활용할 수 있다는 점에서 그 중요성이 부각된다고 생각해요. 여러분의 프로젝트에서도 클로저를 적극적으로 활용해보는건 어떨까요?

1개의 댓글

comment-user-thumbnail
2025년 2월 4일

잘 봤습니다!!

답글 달기

관련 채용 정보