Tech

사내 npm 개발기

배포 없이 기능을 켜고 끄기 위해 사내 피처 플래그를 npm 패키지로 만든 기록

배포없이 기능 적용하는 것이 필요하다!

QA까지 문제없다가 PR에서 터져버린 서비스

QA까지 문제가 없던 기능이 배포 뒤에 터졌다. 얽혀 있는 마이그레이션이 많아 롤백도 쉽지 않은 상황이었다.

배포 후 장애가 발생했지만 롤백이 어려웠던 상황 정리

피처 플래그, 해답이 될 수 있을까?

이 일을 겪고 나니 배포 없이도 기능을 껐다 켤 수 있는 환경이 필요하다는 이야기가 나왔다. 안전한 배포뿐 아니라 A/B 테스트에도 쓸 수 있는 장치였다.

이미 나와 있는 서비스를 쓰지 않고 직접 만들기로 한 데에는 세 가지 이유가 있었다.

  • 비용 — 기업이 쓰려면 유료 플랜을 제시하는 경우가 있다
  • 확장 — 조금만 더 발전시키면 A/B 테스트나 로깅 같은 부가 이벤트를 붙여 사내 마케팅 도구로 쓸 수 있다
  • 팀의 목표와 부합 — 제품들의 마케팅 기술 기반을 마련하는 일이었다

초기 기획과 고객 가치

초기 기획

피처 플래그 초기 기획 - 전체 구성도

피처 플래그를 단계적으로 확장해 가는 도입 방안

고객 가치 - 이 작업의 고객은 사용자가 아닌 개발자

고객을 먼저 정한 이유는, 앞으로 이어질 수많은 의사결정에서 기준을 확보하기 위해서였다. 이 작업의 고객은 서비스 사용자가 아니라 사내 개발자였고, 사내 개발자들이 많이 사용하기를 바라는 것이 이 작업의 고객 가치였다.

설계 결정

s3 vs api : 빠른 반응성 vs 확장 가능성

  • s3를 이용할 경우
    • 빠른 응답으로 즉각적으로 사용자에게 제공 - 최소한의 지연시간
    • 데이터만 가지고 있어서 연산은 프론트에서 처리해야함 - 확률 같은
  • api를 사용할 경우
    • 유저정보를 활용할 수 있어 추후 다양한 부가기능 추가에 유리
    • 서버 통신시간동안 지연이 발생

결국 마케팅 도구로의 성장을 바라봤고, 매번 호출하기보다 캐싱해서 지연 시간을 없애는 것이 보편적인 방법이라 s3의 장점이 약해졌다. 그래서 api로 만들기로 했다.

더 편한 사용을 위해 npm을 고민

  • api로 만들었지만 데이터요청, 캐싱, 보일러플레이트를 줄일 npm 개발
  • 복잡한 설정이나 이해 과정 없이, 직관적이고 단순하고 익숙한 인터페이스 목표
  • 이미 제공하고 있는 피처플래그 서비스들과 sdk 제공방식을 참고
// 라이브러리 초기화 단계
const flagInstance = createInstance({
  apiHost: "https://cdn.our-company.com/flags",
  clientKey: "production-key",
  
  // 핵심: 플래그가 평가될 때마다 실행되는 콜백 함수
  onTracking: (flagKey, variationValue) => {
    // 자체 DB로 보내는 대신, 기존의 GTM dataLayer나 Amplitude로 이벤트를 쏴줍니다.
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'feature_flag_exposure',
      flag_key: flagKey,             // 예: 'new-checkout-button'
      variation: variationValue,     // 예: 'true', 'red', 1 등
      user_id: currentUser.id
    });
  }
});
// 라이브러리 초기화 단계
const flagInstance = createInstance({
  apiHost: "https://cdn.our-company.com/flags",
  clientKey: "production-key",
  
  // 핵심: 플래그가 평가될 때마다 실행되는 콜백 함수
  onTracking: (flagKey, variationValue) => {
    // 자체 DB로 보내는 대신, 기존의 GTM dataLayer나 Amplitude로 이벤트를 쏴줍니다.
    window.dataLayer = window.dataLayer || [];
    window.dataLayer.push({
      event: 'feature_flag_exposure',
      flag_key: flagKey,             // 예: 'new-checkout-button'
      variation: variationValue,     // 예: 'true', 'red', 1 등
      user_id: currentUser.id
    });
  }
});
  • 초기버전 사용예시 - 실제 사용할때 어떤 문제가 있을까?

    import { FeatureFlag } from "feature-flag";
    
    const ff = new FeatureFlag({ endpoint: "https://example.com/api/v1" });
    
    // 앱 진입 시 최초 1회 호출
    await ff.init();
    
    // 특정 플래그 값 조회
    const isEnabled = ff.get("ENABLE_NEW_HERO", false);
    const welcomeMsg = ff.get("WELCOME_MESSAGE", "");
    const price = ff.get("ITEM_PRICE", 0);
    
    // 전체 플래그 조회
    const allFlags = ff.getAll();
    // { ENABLE_NEW_HERO: true, WELCOME_MESSAGE: "안녕하세요!", ITEM_PRICE: 15 }
    
    // 플래그 갱신
    await ff.refresh();
    import { FeatureFlag } from "feature-flag";
    
    const ff = new FeatureFlag({ endpoint: "https://example.com/api/v1" });
    
    // 앱 진입 시 최초 1회 호출
    await ff.init();
    
    // 특정 플래그 값 조회
    const isEnabled = ff.get("ENABLE_NEW_HERO", false);
    const welcomeMsg = ff.get("WELCOME_MESSAGE", "");
    const price = ff.get("ITEM_PRICE", 0);
    
    // 전체 플래그 조회
    const allFlags = ff.getAll();
    // { ENABLE_NEW_HERO: true, WELCOME_MESSAGE: "안녕하세요!", ITEM_PRICE: 15 }
    
    // 플래그 갱신
    await ff.refresh();

    https://ko.react.dev/reference/react/useSyncExternalStore#usage

  • 초기버전 소스코드

    import { createLoader, fetchFlags } from "./core/fetcher.js";
    import { getAllValues, getValue } from "./core/reader.js";
    
    class FeatureFlag {
      #endpoint;
      #cache = null;
      #load;
    
      #isReady() {
        if (!this.#cache) {
          console.error("FeatureFlag: call init() first.");
          return false;
        }
        return true;
      }
    
      constructor({ endpoint }) {
        this.#endpoint = endpoint;
        this.#load = createLoader(async () => {
          const flags = await fetchFlags(this.#endpoint);
          if (flags) this.#cache = Object.freeze(flags);
          return flags !== null;
        });
      }
    
      async init() {
        this.#cache = null;
        return this.#load();
      }
    
      async refresh() {
        return this.#load();
      }
    
      getAll() {
        if (!this.#isReady()) return {};
        return getAllValues(this.#cache);
      }
    
      get(key, defaultValue) {
        if (!this.#isReady()) return defaultValue;
        return getValue(this.#cache, key, defaultValue);
      }
    }
    
    export { FeatureFlag };
    import { createLoader, fetchFlags } from "./core/fetcher.js";
    import { getAllValues, getValue } from "./core/reader.js";
    
    class FeatureFlag {
      #endpoint;
      #cache = null;
      #load;
    
      #isReady() {
        if (!this.#cache) {
          console.error("FeatureFlag: call init() first.");
          return false;
        }
        return true;
      }
    
      constructor({ endpoint }) {
        this.#endpoint = endpoint;
        this.#load = createLoader(async () => {
          const flags = await fetchFlags(this.#endpoint);
          if (flags) this.#cache = Object.freeze(flags);
          return flags !== null;
        });
      }
    
      async init() {
        this.#cache = null;
        return this.#load();
      }
    
      async refresh() {
        return this.#load();
      }
    
      getAll() {
        if (!this.#isReady()) return {};
        return getAllValues(this.#cache);
      }
    
      get(key, defaultValue) {
        if (!this.#isReady()) return defaultValue;
        return getValue(this.#cache, key, defaultValue);
      }
    }
    
    export { FeatureFlag };

완성

관리자 화면에서 플래그를 만들고 값을 켜고 끄면, 각 제품이 npm 패키지로 그 값을 읽어 가는 형태로 완성했다.

완성된 피처 플래그 목록 화면

완성된 피처 플래그 관리자 화면

서비스가 터지면 안 되니까

휴먼 에러 방어하기

const isEnabled = ff.get("ENABLE_NEW_HERO", false);
const isEnabled = ff.get("ENABLE_NEW_HERO", false);
  • 개발자의 실수 방어하기
    • 플래그 키를 잘못 설정
    • 값을 잘못 변경하거나 플래그를 실수로 삭제할 경우
  • 피처 플래그 때문에 멀쩡한 프로덕트 화면이 터지는 대참사를 막아야 함

그래서 값을 읽는 순서를 db 데이터 → 사용자가 넘긴 기본값 → npm의 기본값 → 콘솔 에러로 두어, 어느 단계가 비어도 화면은 살아 있도록 했다.

Code gen을 통한 IDE 타입지원이 정말 편할까?

FLAG.SHOW_MAIN_BANNER
FLAG.SHOW_MAIN_BANNER
  • 강력한 타입 추론을 제공하기 위해 Code Gen(코드 자동 생성) 도입 시도
  • 하지만 세팅을 위해 동료들이 복잡한 경로와 엔드포인트를 직접 설정해야 하는 허들 발생
  • 들인 수고에 비해 얻는 가치가 미미하다고 판단, 과감하게 제거

대안으로 키 복사버튼 제공

피처 플래그 관리 목록. Key 열마다 복사 아이콘이 붙어 있다

피처 플래그 수정 화면. Key 입력칸 옆에 복사 버튼이 있다

베타버전 완성후 사용자 플로우 테스트

베타버전을 놓고 실제 사용 흐름을 따라가 보니, 너무 기본적인 기능만 제공하다 보니 없느니만 못한 물건이 되어 있었다. 어디서도 쓸 수 있지만 어디서도 쓸 이유가 없는 상태였다.

첫 버전에서 npm을 포기하다

정말 개발자들이 쓰기 편한가를 다시 물었고, 첫 버전에서는 npm 배포를 접기로 했다.

  • 사내 백엔드 SDK 환경(api-gen 등)이 이미 잘 구축되어 있었다
  • 패키지 자체의 로직이 아직 단순했고, 무엇보다 빠른 사내 도입, 즉 출시 시점이 중요했다
  • npm이 정말 개발자들을 위한 것인가를 따져 보니, 아직은 내 기술 역량 강화의 목적이 더 컸다

그럼에도 불구하고 : 우리팀의 종속성 만들기

그렇다고 npm을 영영 접을 생각은 아니었다. 지금 쓰이지 않는 이유가 패키지가 주는 가치가 얇기 때문이라면, 가치를 두껍게 만들면 될 일이었다.

어떻게 npm 패키지의 가치를 높일 것인가?

  • 더 쉬운 사용

    • npm으로 컴포넌트 제공
    // src/app/layout.tsx (Server Component)
    import { FeatureFlagProvider } from 'feature-flag/react-server';
    
    export default async function RootLayout({ children }) {
      return (
        <FeatureFlagProvider>
          <App />
        </FeatureFlagProvider>
      );
    }
    // src/app/layout.tsx (Server Component)
    import { FeatureFlagProvider } from 'feature-flag/react-server';
    
    export default async function RootLayout({ children }) {
      return (
        <FeatureFlagProvider>
          <App />
        </FeatureFlagProvider>
      );
    }
    import { Feature } from 'feature-flag/react-client';
    
    export function Dashboard() {
      return (
        <main>
          <h1>대시보드</h1>
          
          <Feature 
            flag="NEW_DASHBOARD_UI" 
            loadingFallback={<SkeletonDashboard />} 
            fallback={<OldDashboard />}             
          >
            <NewDashboard />                        
          </Feature>
        </main>
      );
    }
    import { Feature } from 'feature-flag/react-client';
    
    export function Dashboard() {
      return (
        <main>
          <h1>대시보드</h1>
          
          <Feature 
            flag="NEW_DASHBOARD_UI" 
            loadingFallback={<SkeletonDashboard />} 
            fallback={<OldDashboard />}             
          >
            <NewDashboard />                        
          </Feature>
        </main>
      );
    }
  • 더 편리한 환경

로컬에서 플래그 상태를 조작할 수 있는 devtools 화면

마무리

이번 작업에서 가장 크게 남은 건, 내가 얼마나 만들었는지가 판단 기준이 아니라는 점이다.

npm 패키지는 시간을 들여 직접 설계하고 구현한 것이었다. 그래서 막상 걷어낼 때는 아까웠다. 하지만 기준을 내 노력에 두지 않고 실제로 쓸 개발자에게 두자 답은 분명했다. 이 패키지가 그들에게 가치를 주는가, 이걸 쓰는 게 정말 편한가. 이미 사내 SDK 환경이 잘 갖춰져 있고 패키지가 주는 것이 얇은 상황에서, 답은 아니었다.

그래서 첫 버전에서 내 코드를 걷어냈고, 지금도 잘한 선택이라고 생각한다. 만들었다는 이유로 남겨 두었다면 동료들에게는 쓸 이유가 없는 선택지가 하나 더 늘었을 뿐이다.

이 글이 도움이 되었나요?좋아요는 다음 글을 쓰는 힘이 됩니다

다른 글