본문으로 건너뛰기

변경 경계와 Strangler Fig - 디자인 패턴, 시스템 수준 편 ep.12

공개한 것은 약속이 된다. 약속은 지우기 어렵고, 지우려면 절차가 필요하다.


이 경계는 무엇을 청구하는가

앞 편에서 배포 단위를 나눴습니다. 나누는 순간 계약이 생겼습니다. 이번 편은 그 계약을 바꾸는 이야기입니다.

변경 경계는 약속과 구현을 가르는 선입니다. 선 안쪽은 언제든 바꿔도 됩니다. 선 바깥은 바꾸려면 절차가 필요합니다.

문제는 이 선이 명시적으로 그어지지 않는다는 점입니다. 대개는 누군가 쓰기 시작한 순간 약속이 됩니다.

  • 내부용으로 만든 API에 다른 팀이 붙었습니다. 이제 약속입니다.
  • 실험용으로 노출한 컴포넌트 prop을 세 프로덕트가 씁니다. 이제 약속입니다.
  • 응답에 실수로 포함된 필드를 클라이언트가 파싱합니다. 이제 약속입니다.

마지막 사례가 하이럼의 법칙(Hyrum’s Law) 입니다. 사용자가 충분히 많으면, 문서에 없는 동작까지 누군가는 의존합니다.

이 경계가 청구하는 것은 하위 호환 비용입니다. 옛 방식과 새 방식을 동시에 유지해야 하고, 옛 것을 언제까지 유지하고 지울 건지 협상해야 합니다.

변경 경계가 답하는 질문은 하나입니다. 어느 부분이 약속이고 어느 부분이 구현인가.


이 경계에 붙어 있는 이름들

공개 표면을 좁게 유지하기

가장 싼 방법은 애초에 약속을 적게 하는 것입니다.

ep.01 모듈 경계의 index.ts 이야기가 여기서 다시 나옵니다. 모듈이 내보내는 것이 곧 약속입니다. 내부 함수를 실수로 export 하면, 그것도 약속이 됩니다.

// features/checkout/index.ts - 여기 있는 것만 약속입니다
export { CheckoutForm } from './components/checkout-form'
export type { CheckoutState } from './types'

// calculateTax는 내보내지 않습니다 - 언제든 바꿀 수 있습니다

API도 같습니다. 응답에 필드를 넣기 전에 물어봅니다. 이 필드를 영원히 유지할 수 있는가. 답이 아니라면 넣지 않거나, 명시적으로 실험 표시를 답니다.

// 실험적 필드임을 이름으로 표시합니다
type ProductResponse = {
  id: string
  name: string
  price: number
  experimental_aiSummary?: string   // 예고 없이 사라질 수 있습니다
}

Expand-Contract

계약을 바꾸는 표준 절차입니다. 확장(expand) → 이행(migrate) → 축소(contract) 세 단계로 나눕니다.

어느 순간에도 옛 코드와 새 코드가 동시에 동작할 수 있어야 합니다. ep.11 배포 경계에서 본 버전 스큐 때문입니다. 배포 중에는 두 버전이 함께 돌기 때문에, 한 번에 바꾸면 그 순간 깨집니다.

데이터베이스 컬럼 이름을 name에서 full_name으로 바꾸는 예입니다.

-- ① Expand: 새 컬럼을 추가합니다. 옛 컬럼은 그대로 둡니다.
ALTER TABLE users ADD COLUMN full_name TEXT;
// ② 이중 쓰기: 양쪽 모두에 씁니다
await db.user.update({
  where: { id },
  data: { name: value, full_name: value },
})
-- ③ 백필: 기존 행을 채웁니다
UPDATE users SET full_name = name WHERE full_name IS NULL;
// ④ 읽기 전환: 새 컬럼을 읽습니다
const displayName = user.full_name ?? user.name   // 폴백을 잠시 유지합니다
-- ⑤ Contract: 아무도 옛 컬럼을 쓰지 않는 것을 확인한 뒤 지웁니다
ALTER TABLE users DROP COLUMN name;

⑤가 가장 어렵습니다. “아무도 쓰지 않는다”는 관측으로만 확인할 수 있습니다. 옛 경로에 로그를 심고, 일정 기간 호출이 0인 것을 확인한 뒤 지웁니다. 여기가 ep.13 관측 경계와 이어지는 지점입니다.

// 옛 경로에 흔적을 남깁니다
function getLegacyName(user: User) {
  logger.warn('legacy_field_access', { field: 'name', userId: user.id })
  return user.name
}

Expand-Contract의 다섯 단계 다이어그램. 위에서 아래로 다섯 단계가 순서대로 배치됩니다. 첫 단계 확장에서는 기존 name 컬럼 옆에 full_name 컬럼이 추가되고 둘 다 존재하는 상태가 표시됩니다. 두 번째 이중 쓰기 단계에서는 애플리케이션이 두 컬럼 모두에 값을 쓰는 화살표가 그려집니다. 세 번째 백필 단계에서는 기존 행들의 full_name이 채워지는 모습이 표시됩니다. 네 번째 읽기 전환 단계에서는 읽기 경로가 full_name으로 옮겨가고 폴백이 잠시 유지되는 상태가 표시됩니다. 다섯 번째 축소 단계에서는 name 컬럼이 제거되고, 이 단계 앞에 관측 확인이 필요하다는 점이 강조됩니다. 각 단계 우측에는 그 시점에 옛 코드와 새 코드가 모두 동작하는지가 체크로 표시되어 있어, 어느 단계에서도 배포 중 버전 스큐로 깨지지 않는다는 점이 드러납니다.

Strangler Fig

큰 시스템을 통째로 다시 만드는 것은 대개 실패합니다. 새 시스템이 완성될 때까지 옛 시스템도 계속 바뀌기 때문입니다. 목표가 도망갑니다.

Strangler Fig(교살 무화과) 는 옛 시스템을 감싸고, 기능을 하나씩 새 시스템으로 옮기고, 마지막에 옛 것을 걷어내는 방식입니다.

이름은 열대 우림의 무화과나무에서 왔습니다. 새가 떨어뜨린 씨앗이 다른 나무의 가지 위에서 싹을 틔우고, 뿌리를 아래로 내려 숙주의 줄기를 감쌉니다. 뿌리는 해가 갈수록 굵어져 서로 붙고, 숙주는 빛과 양분을 빼앗겨 말라 죽습니다. 마지막에 남는 것은 숙주의 모양대로 속이 빈 무화과나무입니다. 마틴 파울러가 호주에서 이 나무를 보고 시스템 교체 방식에 붙인 이름입니다.

패턴이 식물과 닮은 점은 세 가지입니다.

  • 새 시스템은 옛 시스템 위에서 시작합니다. 씨앗이 땅이 아니라 가지에서 싹트듯, 파사드가 옛 시스템 앞에 먼저 놓입니다.
  • 옛 시스템은 그동안 계속 동작합니다. 숙주가 감싸이는 동안에도 살아 있듯, 옮기는 중에도 서비스는 멈추지 않습니다.
  • 끝나면 옛 것을 걷어냅니다. 속이 빈 나무가 숙주의 모양을 간직하듯, 새 시스템의 경계도 옛 시스템이 하던 약속을 그대로 따릅니다.
// 파사드 - 요청을 어느 쪽으로 보낼지 결정합니다
export async function handleRequest(req: Request) {
  const path = new URL(req.url).pathname

  // 이미 옮긴 경로는 새 시스템으로
  if (MIGRATED_ROUTES.some((r) => path.startsWith(r))) {
    return newSystem.handle(req)
  }

  // 나머지는 아직 옛 시스템으로
  return legacySystem.handle(req)
}

const MIGRATED_ROUTES = ['/api/products', '/api/search']

이 방식의 장점은 언제든 멈출 수 있다는 것입니다. 절반쯤 옮긴 상태에서도 시스템은 동작합니다. 예산이 끊기거나 우선순위가 바뀌어도, 지금까지의 작업이 낭비되지 않습니다.

주의할 점도 있습니다.

중간 상태가 오래갑니다. 두 시스템을 동시에 유지하는 기간이 예상보다 길어집니다. “일단 반만 옮기고 나중에”가 3년이 되는 일이 흔합니다. 완료 기한을 정하고 그것을 로드맵에 넣는 것이 필요합니다.

데이터가 문제입니다. 라우팅은 쉽지만, 두 시스템이 같은 데이터를 봐야 하는 경우가 대부분입니다. 데이터 계층을 먼저 공유하고 애플리케이션 계층부터 옮기는 순서가 대체로 안전합니다.

Strangler Fig 마이그레이션의 타임라인 다이어그램. 가로축은 시간이고, 세로로 쌓인 막대가 시스템의 구성을 나타냅니다. 초기 시점에는 전체가 레거시 시스템 하나입니다. 파사드가 앞에 놓인 뒤부터 시간이 지날수록 새 시스템의 비중이 점점 늘어나고 레거시 비중이 줄어듭니다. 중간 지점들에는 어떤 기능이 옮겨졌는지가 라벨로 표시됩니다. 상품 조회, 검색, 주문 순입니다. 마지막에는 레거시가 완전히 사라지고 파사드도 제거된 상태가 표시됩니다. 타임라인 아래에는 이 방식의 특징이 표시됩니다. 어느 시점에서 멈춰도 시스템이 동작한다는 점과, 중간 상태가 예상보다 오래간다는 경고입니다. 우측에는 데이터 계층을 먼저 공유하고 애플리케이션 계층부터 옮기는 순서가 별도로 표시되어 있습니다.

API 버저닝

계약을 바꿔야 하는데 하위 호환이 불가능할 때, 버전을 나눕니다. 방식은 몇 가지입니다.

방식예장점단점
URL 경로/v2/orders명확하고 캐시하기 쉬움경로가 늘어남
헤더Accept: application/vnd.api+json;version=2URL이 깨끗함브라우저에서 테스트 어려움
쿼리 파라미터/orders?version=2간단함캐시 키가 복잡해짐
날짜 기반Api-Version: 2026-01-01점진 변경에 유리관리할 버전이 많아짐

버저닝은 비용입니다. v2를 만들면 v1도 계속 돌려야 합니다. 두 벌의 코드, 두 벌의 테스트, 두 벌의 문서가 필요합니다.

그래서 버전을 나누기 전에, 하위 호환으로 해결할 수 있는지 먼저 확인합니다.

  • 필드 추가 → 하위 호환입니다.
  • 필드 이름 변경 → 둘 다 내려주면 하위 호환입니다.
  • 필드 제거 → 하위 호환이 아닙니다. deprecation 기간이 필요합니다.
  • 의미 변경 → 가장 위험합니다. 같은 필드가 다른 뜻이 되면 아무도 눈치채지 못합니다.

마지막이 특히 무섭습니다. status: "pending"이 “결제 대기”였다가 “배송 대기”가 되면, 타입은 그대로이고 화면만 조용히 어긋납니다. 의미를 바꿔야 한다면 이름도 바꿉니다.

안티패턴

예고 없는 파괴적 변경: 릴리스 노트에 적지 않고 응답 형태를 바꾸면, 클라이언트는 프로덕션이 깨진 뒤에야 변경을 알게 됩니다.

끝나지 않는 deprecation: “v1은 곧 지원 종료”라고 3년째 쓰여 있으면, 아무도 믿지 않습니다. 기한이 없는 deprecation은 deprecation이 아닙니다.

한 번에 다 바꾸는 마이그레이션: Big Bang 방식은 롤백이 전부 아니면 전무입니다.

버전을 올리고 옛 것을 방치: v2를 만들고 v1은 아무도 손대지 않으면, 보안 패치가 v1에는 적용되지 않습니다.


이 경계가 화면에 드러나는 자리

디자인 시스템의 메이저 버전 업그레이드가 이 경계의 가장 큰 사례입니다.

v1에서 v2로

버튼의 variant 값 하나를 바꾸는 일이, 서른 개 프로덕트에서 이천 곳을 고치는 일이 됩니다. 그리고 그 프로덕트들은 각자의 일정이 있습니다.

앞서 본 Expand-Contract를 적용하면 됩니다.

// ① Expand - 새 값을 추가하되 옛 값도 받습니다
type ButtonVariant =
  | 'primary' | 'secondary'          // 새 이름
  | 'cta' | 'default'                // 옛 이름 (deprecated)

const VARIANT_ALIAS = {
  cta: 'primary',
  default: 'secondary',
} as const

function Button({ variant, ...props }: ButtonProps) {
  const resolved = VARIANT_ALIAS[variant] ?? variant

  if (import.meta.env.DEV && variant in VARIANT_ALIAS) {
    console.warn(
      `[DS] variant="${variant}"는 곧 제거됩니다. "${resolved}"를 사용하세요.`,
    )
  }

  return <button data-variant={resolved} {...props} />
}

개발 환경에서만 경고를 띄우는 것이 중요합니다. 프로덕션 콘솔을 어지럽히지 않으면서, 작업하는 사람에게는 보입니다.

코드모드

이천 곳을 손으로 고칠 수는 없습니다. 코드모드(codemod) 는 코드를 자동으로 변환하는 스크립트입니다.

// jscodeshift 예 - variant 값을 일괄 치환합니다
export default function transformer(file, api) {
  const j = api.jscodeshift
  const root = j(file.source)

  root
    .find(j.JSXAttribute, { name: { name: 'variant' } })
    .forEach((path) => {
      const value = path.node.value?.value
      if (value === 'cta') path.node.value = j.literal('primary')
      if (value === 'default') path.node.value = j.literal('secondary')
    })

  return root.toSource()
}

코드모드를 함께 제공하는 것이 디자인 시스템 팀의 책임입니다. “v2로 올리세요”라고만 하면 각 팀이 각자 고생하고, 결국 아무도 올리지 않습니다. 마이그레이션 도구가 함께 나오면 업그레이드율이 달라집니다.

코드모드가 모든 것을 잡지는 못합니다. 동적으로 계산된 값, 변수에 담긴 값은 놓칩니다. 자동 변환 후 남은 것을 목록으로 뽑아 주는 것까지가 도구의 몫입니다.

Deprecation 정책

폐기 절차를 문서로 정해 두면 매번 협상하지 않아도 됩니다.

단계기간하는 일
예고릴리스 시점릴리스 노트에 명시, 대체 방법 안내
경고3개월개발 환경 콘솔 경고, 코드모드 제공
최종 경고1개월사용 중인 팀에 직접 연락
제거메이저 버전삭제, 마이그레이션 가이드 유지

필요한 기간은 조직마다 다릅니다. 중요한 것은 기한이 정해져 있다는 사실입니다. 정해져 있지 않으면, 지울 때마다 “너무 갑작스럽다”는 반발이 나옵니다.

릴리스 노트가 계약서입니다

디자인 시스템의 릴리스 노트는 마케팅 문서가 아니라 계약 변경 통지입니다.

## v2.0.0

### 파괴적 변경
- `Button`의 `variant="cta"` 제거 → `variant="primary"` 사용
  - 코드모드: `npx @ds/codemod button-variant-v2`
- `Modal`의 `onClose` 필수화
  - 이전에는 선택이었으나 접근성 요구사항으로 필수가 되었습니다

### 추가
- `Button`에 `loading` prop 추가

### Deprecated (v3에서 제거 예정 - 2026-12)
- `Card`의 `shadow` prop → `elevation` 사용

무엇이 깨지는가, 무엇으로 바꾸는가, 언제까지 유예되는가, 이 세 가지가 있어야 합니다.

세 번째가 자주 빠집니다. “deprecated”라고만 쓰고 기한이 없으면, 쓰는 쪽은 미룹니다. 기한을 적는 순간 그것이 일정이 되고, 일정이 있어야 마이그레이션이 시작됩니다.

deprecation 정책과 릴리스 노트의 구조 다이어그램. 상단에는 폐기 절차 네 단계가 좌에서 우로 배치되어 있습니다. 첫 단계 예고는 릴리스 시점에 릴리스 노트에 명시하고 대체 방법을 안내하는 단계입니다. 두 번째 경고는 3개월간 개발 환경 콘솔 경고를 띄우고 코드모드를 제공하는 단계입니다. 세 번째 최종 경고는 1개월간 사용 중인 팀에 직접 연락하는 단계입니다. 마지막 제거 단계는 메이저 버전에서 삭제하되 마이그레이션 가이드는 유지하는 단계로, 다른 색으로 강조되어 있습니다. 좌측 하단에는 릴리스 노트 예시가 문서 형태로 배치되어 있습니다. 파괴적 변경 항목에 Button의 variant cta 제거와 대체 방법, 코드모드 명령이 적혀 있고, 추가 항목과 deprecated 항목이 이어지며 deprecated 항목에는 제거 예정 시점이 명시되어 있습니다. 우측 하단에는 릴리스 노트에 반드시 있어야 할 세 가지가 번호와 함께 정리되어 있습니다. 무엇이 깨지는가, 무엇으로 바꾸는가, 언제까지 유예되는가입니다. 세 번째 항목은 가장 자주 빠지는 항목으로 강조되며, 기한이 없으면 쓰는 쪽은 미룬다는 설명이 붙습니다. 최하단에는 코드모드를 함께 제공하는 것이 디자인 시스템 팀의 책임이라는 경고 영역이 배치되어 있습니다.

이 세 항목은 ep.03 계약 경계에서 다룬 계약이 시간축으로 확장된 형태입니다. 계약은 형태만이 아니라 언제까지 유효한가까지 포함합니다.


참고 자료


다음 편 예고

ep.13 - 관측 경계와 분산 추적

본편의 마지막입니다. 앞의 열두 편이 그은 모든 선은 관측의 사각지대를 하나씩 만들었습니다. 경계 너머에서 무슨 일이 있었는지 어떻게 알 수 있는가.