공개한 것은 약속이 된다. 약속은 지우기 어렵고, 지우려면 절차가 필요하다.
이 경계는 무엇을 청구하는가
앞 편에서 배포 단위를 나눴습니다. 나누는 순간 계약이 생겼습니다. 이번 편은 그 계약을 바꾸는 이야기입니다.
변경 경계는 약속과 구현을 가르는 선입니다. 선 안쪽은 언제든 바꿔도 됩니다. 선 바깥은 바꾸려면 절차가 필요합니다.
문제는 이 선이 명시적으로 그어지지 않는다는 점입니다. 대개는 누군가 쓰기 시작한 순간 약속이 됩니다.
- 내부용으로 만든 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
}
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년이 되는 일이 흔합니다. 완료 기한을 정하고 그것을 로드맵에 넣는 것이 필요합니다.
데이터가 문제입니다. 라우팅은 쉽지만, 두 시스템이 같은 데이터를 봐야 하는 경우가 대부분입니다. 데이터 계층을 먼저 공유하고 애플리케이션 계층부터 옮기는 순서가 대체로 안전합니다.
API 버저닝
계약을 바꿔야 하는데 하위 호환이 불가능할 때, 버전을 나눕니다. 방식은 몇 가지입니다.
| 방식 | 예 | 장점 | 단점 |
|---|---|---|---|
| URL 경로 | /v2/orders | 명확하고 캐시하기 쉬움 | 경로가 늘어남 |
| 헤더 | Accept: application/vnd.api+json;version=2 | URL이 깨끗함 | 브라우저에서 테스트 어려움 |
| 쿼리 파라미터 | /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”라고만 쓰고 기한이 없으면, 쓰는 쪽은 미룹니다. 기한을 적는 순간 그것이 일정이 되고, 일정이 있어야 마이그레이션이 시작됩니다.
이 세 항목은 ep.03 계약 경계에서 다룬 계약이 시간축으로 확장된 형태입니다. 계약은 형태만이 아니라 언제까지 유효한가까지 포함합니다.
참고 자료
- Fowler, M. (2004). StranglerFigApplication. https://martinfowler.com/bliki/StranglerFigApplication.html
- Sato, D. (2014). ParallelChange (Expand-Contract). https://martinfowler.com/bliki/ParallelChange.html
- Wright, H. Hyrum’s Law. https://www.hyrumslaw.com/
- Semantic Versioning 2.0.0. https://semver.org/
- jscodeshift. https://github.com/facebook/jscodeshift
다음 편 예고
본편의 마지막입니다. 앞의 열두 편이 그은 모든 선은 관측의 사각지대를 하나씩 만들었습니다. 경계 너머에서 무슨 일이 있었는지 어떻게 알 수 있는가.