커머스 프론트엔드에서 목록 항목에 붙는 조건부 표시(배지)의 데이터 소스를 바꾸는, 작아 보이는 작업이었다. 그런데 그 과정에서 피처 플래그를 어떻게 설계해야 나중에 아프지 않은지, 그리고 레거시 구조가 어떻게 작은 변경을 긴 작업으로 만드는지를 다시 배웠다. 기능 자체는 익명화하고 구조와 판단만 남긴다.

TL;DR

  • 피처 플래그에는 두 종류가 있다. 영구적인 on/off용다음 상태로 넘어가기 위한 전환용이다. 후자는 나중에 지우기 쉽게 만드는 것이 설계 목표여야 한다.
  • 그래서 두 정책을 불리언 하나로 묶지 않고, legacy 함수와 신규 함수로 분리하고 분기는 호출부 한 곳에만 뒀다. 삭제할 코드와 살아남을 코드를 섞지 않는 방식이다.
  • 정책 판단이 비 React 계층인 클래스 기반 데이터 서비스 안에 있으면 플래그를 훅으로 읽을 수 없어 긴 매개변수 전달이 생긴다. 이것이 조건 판정 시점을 데이터 조회에서 렌더링으로 옮긴 이유다.
  • 같은 데이터를 두 군데서 재파생하던 숨은 지뢰를 만났다. 특정 실행 경로에서만 나타나 대표 경로 테스트로는 발견하기 어려운 문제였다.

문제 설정 (익명화)

원래 서버는 “이미 계산된” 목록을 내려줬다.

type LegacyItem = { value: number | null; active: boolean }; // active = 조건 충족 여부(서버가 판정)

프론트엔드는 이 값을 그대로 옵션 목록으로 그렸다. 전환 후에는 서버가 원천 값 목록판정 규칙을 따로 내려주고, 프론트엔드가 판정한다.

type Source = {
  values: number[];                                    // 선택 가능한 원천 값들
  rules: { values: number[]; threshold: number }[];    // "이 값들은 어떤 수치 >= threshold이면 active"
  legacy: LegacyItem[];                                 // deprecated: 롤아웃 후 서버가 제거 예정
};

레거시 legacyvalues + rules로 갈아끼우는 전환이고, legacy는 결국 사라진다.

1. on/off가 아니라 “다음”으로 가는 플래그

여기서 중요한 인식은 이 플래그가 기능을 켤지 말지를 영구적으로 선택하기 위한 것이 아니라는 점이다. OFF는 임시로 남겨둔 기존 경로일 뿐, 롤아웃이 끝나면 삭제된다. 좋은 전환 플래그의 조건은 켜고 끄기 쉬운가가 아니라 전환이 끝난 뒤 지우기 쉬운가이다.

이 관점 하나가 이후 모든 설계 결정을 바꿨다.

2. 레거시와 신규를 섞지 않았다

흔한 모양은 함수 하나에 플래그로 분기하는 것이다.

// 한 함수에 두 정책을 불리언으로 묶으면 삭제할 코드가 살아남을 코드에 얽힌다.
function buildOptions(item, useNewSource) {
  if (useNewSource) return /* 신규 정책 */;
  return /* legacy */;
}

이러면 나중에 legacy를 걷어낼 때 함수 내부를 파헤쳐야 한다. 대신 수명이 다른 두 함수로 쪼갰다.

// [legacy: 플래그 제거 시 삭제] 서버가 이미 판정한 값을 그대로 사용한다.
export const buildOptionsFromLegacy = (legacy: LegacyItem[] | null | undefined): Option[] =>
  (legacy ?? []).map((x) => {
    const { value } = x;
    const hasValue = value != null;

    return {
      label: hasValue ? formatLabel(value, x.active) : BASE_LABEL,
      value: hasValue ? String(value) : '',
      active: x.active,
    };
  });

// [신규] values로 정적 골격만 만든다. active는 현재 화면 값에 의존하므로 렌더링 시점에 채운다.
export const buildOptionsFromValues = (values: number[] | null | undefined): Option[] =>
  !values?.length
    ? [BASE_OPTION]
    : [BASE_OPTION, ...values.map((v) => ({ label: `${v}`, value: String(v), active: false }))];

그리고 플래그 분기는 호출부 딱 한 곳에 격리했다.

options:
  useNewSource
    ? buildOptionsFromValues(item.values)
    : buildOptionsFromLegacy(item.legacy),

롤아웃이 끝나면 buildOptionsFromLegacy를 지우고, 위 삼항 연산자를 buildOptionsFromValues(item.values) 한 줄로 바꾸면 끝이다. 전환 코드가 레거시 함수와 분기 한 곳에만 모여 있어 제거 범위가 명확하다.

여기에 한 발 더 나아가 코드에 제거 지점을 주석으로 남기고, 문서에는 파일별 제거 체크리스트를 작성했다. 나중에 이 플래그를 제거할 사람이 grep부터 시작하지 않아도 되도록 했다.

교훈: 전환 플래그는 쓰기 쉽게 만드는 것보다 지우기 쉽게 만드는 것이 중요하다. 삭제될 코드와 남을 코드를 한 함수에 섞지 말고, 분기는 한 곳에 격리하며, 제거 방법을 코드와 문서에 남긴다.

3. 훅을 쓸 수 없는 계층에 플래그 넣기

가장 어려웠던 부분은 옵션을 만드는 로직이 클래스 기반 데이터 서비스 안에 있었다는 점이다.

useSomethingQuery(React)   <- 여기서 useFeatureFlag로 플래그를 읽을 수 있음
  -> fetchData(params)     <- 여기부터는 클래스. 훅 사용 불가.
    -> format
      -> formatSection
        -> buildOptions    <- 플래그가 필요한 실제 지점

React 훅은 컴포넌트나 다른 훅 안에서만 쓸 수 있으므로 클래스 메서드는 useFeatureFlag를 호출할 수 없다. 결국 플래그를 React 쿼리 훅에서 읽어 매개변수로 전달할 수밖에 없었다. 작은 불리언 하나가 여러 단계를 관통하는 긴 전달 경로로 늘어났다.

이 구조는 더 근본적인 문제를 드러냈다. 현재 화면 값에 의존하는 판단이 데이터 조회 계층에 들어가 있었다. 서버 응답을 가져오고 정규화하는 시점에 사용자 조작으로 계속 변하는 값까지 확정하면, 조회 이후의 변화를 반영할 수 없다.

그래서 이번에는 조건 판정의 실행 시점을 데이터 조회(fetch)가 아니라 렌더링(render) 시점으로 옮겼다. 이유는 두 가지다.

  1. 아키텍처: 화면 상태에 의존하는 값의 조합은 React 계층에서 수행하고, 판정 규칙 자체는 순수 함수로 분리할 수 있다.
  2. 정확성: activerules[].threshold현재 화면 값을 비교해 정해진다. 이 값은 사용자 조작으로 조회 이후에도 계속 바뀌므로 조회 시점에 고정하면 잘못된 결과가 남는다.
// 렌더링 시점에 현재 화면 값을 기준으로 active를 다시 판정한다.
const options = useMemo(
  () => resolveOptions(item, useNewSource, reactiveAmount),
  [item, useNewSource, reactiveAmount],
);

// 규칙 중 "이 값 포함 && 수치 >= threshold"가 하나라도 있으면 active
const isActive = (value: number, rules: Rule[] | null | undefined, amount: number): boolean =>
  rules?.some((r) => r.values.includes(value) && amount >= r.threshold) ?? false;

조회 계층의 buildOptionsFromValues는 어떤 값이 있는지에 관한 정적 골격만 만들고, 렌더링 계층의 resolveOptions가 현재 값에서 활성화되는지에 관한 동적 판단을 얹는다. 이때 실제 규칙은 isActive 같은 순수 함수에 남겨 컴포넌트에 결합되지 않도록 했다.

교훈: 화면 상태에 의존하는 최종 판단을 데이터 조회 시점에 고정하면 긴 플래그 전달과 오래된 상태가 함께 생긴다. 정적 형태는 조회 계층에서 만들고, 동적 조합은 렌더링 시점에 수행하며, 판정 규칙은 순수 함수로 분리한다.

4. 숨은 지뢰: 같은 데이터를 두 군데서 재파생

여기서 회귀가 발생할 뻔했다. 이 옵션을 사용하는 또 다른 지점의 코드는 다음과 같았다.

// 이미 options를 만들었지만 여기서는 legacy에서 다시 값을 추출한다.
const derived = item.legacy.flatMap(({ value }) => value ?? []);

옵션 목록을 이미 만들어 뒀지만 이 사용 지점은 그 결과를 무시하고 legacy에서 값을 다시 추출했다. 같은 데이터를 두 곳에서 서로 다른 방식으로 파생하고 있었다.

문제는 플래그가 켜지고 서버가 legacy를 제거하면 이 값이 비게 된다는 점이다. 그러면 해당 기능은 오류를 내지 않은 채 동작을 멈춘다.

더 위험한 점은 이 문제가 대표 경로에서는 드러나지 않는다는 것이었다. 일부 실행 경로는 해당 사용 지점을 거치지 않기 때문에 그 경로만 테스트하면 정상으로 보인다. 문제는 특정 조건과 라우팅에서만 발생한다.

수정은 단순했다. 이미 계산한 options를 재사용했다.

// 같은 소스인 options에서 값만 추출하고 빈 항목(value '')은 제외한다.
const derived = options.map(({ value }) => Number(value)).filter((v) => v > 0);

기존 구조에 있던 이중 파생도 함께 사라졌다.

교훈: “이 경로에서는 동작한다”는 사실만으로 검증이 끝나지 않는다. 데이터가 실행 경로별로 다르게 흐르는 곳에서는 경로별 매트릭스로 확인해야 한다.

5. OFF는 정말 기존 동작과 같은가: 증명하기

전환 플래그의 생명은 OFF면 기존과 동일하게 동작한다는 신뢰다. 그래서 마지막에 변경 파일을 하나씩 놓고 OFF 경로의 출력이 기존과 같은지 대조했다.

  • 대부분은 “OFF 경로 = 기존 로직 그대로”였다. 예를 들어 buildOptionsFromLegacy는 기존 map을 그대로 옮긴 코드였다.
  • 이 과정에서 내가 만든 회귀 2건을 잡았다.
    • 응답 데이터는 플래그와 무관하고 호출 여부만 달랐는데도 캐시 키에 플래그를 넣었다가, 기존 코드가 이전 키로 예열해 둔 캐시와 어긋나 같은 요청이 중복 호출됐다. 플래그를 키에서 빼고 enabled 조건으로만 처리했다.
    • 앞에서 설명한 중복 파생 경로의 누락을 발견했다.

“OFF면 같겠지”라고 넘어갔으면 둘 다 놓쳤을 것이다. 특히 첫 번째는 순전히 내가 넣은 것이라, 자기 변경을 자기가 의심하는 검증이 아니면 안 잡혔다.

교훈: OFF면 당연히 같을 것이라고 가정하지 말고 파일별로 증명한다. 그 과정에서 내가 방금 만든 회귀가 가장 잘 잡힌다.

6. 마주친 구조적 제약과 건드리지 않은 이유

작업하며 눈에 밟힌 것들이 있었다.

  • 서버 상태 캐시가 있는데 클라이언트 스토어가 그 파생 데이터를 통째로 다시 보관하고 있었다. 사용자의 선택은 클라이언트 상태가 맞지만, 선택된 대상의 전체 객체인 서버 데이터를 복제해 보관하면 이중 진실 공급원(double source of truth)이 된다.
  • 훅이 훅을 호출하는 얇은 래퍼가 이어져 있었다. A -> B -> C를 모두 열어보면 결국 매개변수만 바꿔 넘기는 몇 줄짜리 코드였다.

답답했지만, 이번 변경에서는 건드리지 않았다. 이유는 명확하다.

  1. 스토어에서 파생 데이터를 걷어내려면 여러 사용 지점을 수정해야 한다. 전환 플래그를 넣는 작업에 섞으면 회귀 위험과 리뷰 범위가 크게 늘어난다.
  2. 이런 변경은 지금 함께 처리할 일이 아니라 별도 작업으로 범위와 테스트를 제대로 잡아야 한다.

대신 후속 작업 개요로 기록했다. 측정 결과 얇은 래퍼 자체가 성능 병목이라는 근거는 없었고, 동일한 쿼리 키의 요청은 캐시 계층에서 중복 제거됐다. 따라서 긴급한 성능 문제가 아닌 구조 개선 항목으로 백로그에 남겼다.

교훈: 눈에 밟히는 구조적 부채를 한꺼번에 고치려는 충동은 민감한 도메인에서 특히 위험하다. 변경은 작게 유지하고, 부채는 측정하고 기록해서 사라지지 않게 한다. 즉석 리팩터링 대신 근거가 담긴 백로그로 남긴다.


마치며

작은 표시 변경 하나였지만, 남은 것은 코드 몇 줄보다 여러 판단의 기준이었다.

  • 전환 플래그는 지우기 쉽게 설계한다. 레거시와 신규를 분리하고, 분기를 격리하며, 제거 체크리스트를 남긴다.
  • 정적 형태는 조회 계층에서 만들고, 화면 상태에 의존하는 동적 조합은 렌더링 시점에 수행한다.
  • 한 경로에서 동작한다는 사실만 믿지 말고 경로별로 검증한다.
  • OFF가 기존과 같다고 가정하지 말고 증명한다.
  • 구조 부채는 최소 변경 + 측정 + 기록으로 다룬다.

레거시는 미워할 대상이 아니라, 왜 이렇게 됐는지 이해하고 안전하게 다음으로 옮기는 대상이다. 그 “다음”을 지우기 쉽게 심어두는 것까지가 이번 작업이었다.