본문 바로가기
C.W.K.
Stream
Lesson 02 of 05 · published

그걸 증명한 버그

~13 min · cross-app-bug, vocabulary, integration, measurement

Level 0흩어진 부품
0 XP0/48 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

이름 하나, 철자 둘

형제 앱들이 다 말을 거는 그 조수는 여러 모델 두뇌 위에서 돌 수 있어. 시스템의 두 부분이 그 두뇌들 이름을 대야 했고, 서로 다른 시점에 서로 다른 관심사가 썼어.

설정 쪽은 사용자가 고르는 목록을 내놨고, 그중 하나가 벤더의 소비자용 브랜드 이름이었어. 채팅 라우팅 쪽은 엔드포인트를 쥐고 있었고, 같은 두뇌를 그 커맨드라인 도구 이름으로 알고 있었지. 두 철자 다 자기 쪽 안에서는 맞아. 어느 쪽도 틀리지 않았어. 그냥 두 쪽이 만나서 합의해야 하는 자리가 아예 없었을 뿐이야.

그래서 형제 앱 전부가 같은 결함을 갖고 있었어. 설정에서 그 두뇌를 고르면 조수 패널이 죽어. 앱 하나가 아니라 전부, 동시에, 같은 이유로, 각자 따로 발견됐지. 그 모양이 바로 결함이 어느 앱에도 없다는 신호야.

왜 있던 테스트는 하나도 안 깨졌나

여기선 잠깐 멈춰서 곱씹어 볼 만해. 저장소 각각은 자기 안에서 앞뒤가 맞았어. 설정 코드는 설정 엔드포인트가 내놓는 걸 저장했고, 라우팅 코드는 라우팅 표가 선언한 걸 라우팅했어. 어느 쪽 단위 테스트든 통과해. 앱 하나 안의 통합 테스트도 통과해. 테스트에 쓴 두뇌가 두 철자가 우연히 같은 두뇌이기만 하면 되는데, 대부분이 그랬어. 깨지는 테스트는 따로 있어. 고르는 화면이 낼 수 있는 값을 하나씩 라우팅 표에 넣어보는 테스트. 이건 오늘 앱 하나 안에서도 쓸 수 있고, 아래 예제 맨 끝에 있는 게 그거야. 아무도 안 쓴 이유는 그게 어느 쪽 절반도 시험하지 않아서야. 그 테스트가 보는 건 건너는 자리고, 그 자리엔 주인이 없었어.

결함은 있던 테스트 스위트 어느 것도 가로지르지 않은 두 부품 사이 공간에 살았어. 중복이 이름 분열을 만든 게 아냐. 그 분열은 앱 하나 안에서도 가능했어. 중복이 한 일은 피해 반경을 곱하고 소유권을 나눠버린 것이야. 앱 셋이 그 버그를 가졌고, 그게 살던 경계를 책임진 앱은 0개였어.

고침은 집이 셋이고, 어긋남은 집이 없어. 그 비대칭이 논증 전부야. 중복 코드가 성가신 건 바꿀 때 N 번 적용해야 해서고, 그건 다들 이미 알아. 더 심각한 쪽은 그 반대야. 복사본 둘이 어긋나면 그 어긋남을 소유한 저장소가 없어서, 어느 저장소도 그걸 찾는 테스트를 안 썼고, 어느 저장소의 관리자도 잘못이 아니게 돼. 추출은 타이핑을 덜 하는 얘기가 아냐. 틈에 집을 마련해주는 얘기야.

처방이 규칙을 말해줘

수리는 어느 철자를 바꾸는 게 아니었어. 두 이름 공간 다 정당해. 고르는 화면은 설정 주인이 내놓는 걸 보여주고, 라우트는 라우트 쪽이 선언한 걸 받아. 처방은 그 경계에 주인을 붙이는 거였어. 공용 층에 있는 번역 함수 하나, 설정 값이 채팅 라우트로 넘어가는 바로 그 순간에만 적용하고 절대 거꾸로는 안 하는 것.

"어휘를 통일한다"보다 훨씬 작은 변경이고, 더 정직해. 진짜 이유가 있어서 존재하는 이름 공간 둘은 존재하게 둬야 해. 두면 안 되는 건 그 사이를 건너는 일이 암묵적이고, 안 적혀 있고, 호출자마다 다시 구현되는 거야. 트랙 6 에서 여기로 돌아와. 이 시스템은 나중에 두 번째 정규화를 반대 방향으로 기르거든. 둘 다 있어야 하는 이유가 이 퀘스트에서 공용 코드가 진짜 뭘 위한 건지 보여주는 제일 날카로운 예야.

Code

분열의 모양, 그리고 그걸 닫는 한 줄짜리 경계·python
# --- What each half legitimately believed -------------------------

# The settings owner publishes the PICKER vocabulary. Users see brand
# names; this list is also what gets persisted in device settings.
PICKER_BRAINS = ("claude", "chatgpt", "gemini", "grok", "ollama")

# The chat routes own the ENDPOINT vocabulary. This half knows the
# same brain by the name of the tool it actually invokes.
CHAT_ROUTES = {
    "claude": "/api/chat",
    "codex":  "/api/codex/chat",     # <- the same brain as "chatgpt"
    "gemini": "/api/gemini/chat",
    "grok":   "/api/grok/chat",
    "ollama": "/api/ollama/chat",
}


def ask_broken(brain: str, prompt: str):
    """Every app shipped this. Correct-looking, and dead for one value."""
    return post(CHAT_ROUTES[brain], prompt)   # KeyError: 'chatgpt'


# --- The boundary, owned in one place -----------------------------

BRAIN_ALIASES = {"chatgpt": "codex", "gpt": "codex"}


def canonical_brain(brain: str) -> str:
    """Settings vocabulary -> routing vocabulary. Applied at the
    crossing, never stored back: the picker namespace belongs to the
    settings owner, and writing the translated value into settings
    would corrupt the very list the user is choosing from."""
    key = (brain or "").strip().lower()
    return BRAIN_ALIASES.get(key, key)


def ask(brain: str, prompt: str):
    return post(CHAT_ROUTES[canonical_brain(brain)], prompt)


# The test that would have caught it is not a unit test of either
# half. It is a test ACROSS the boundary, and it belongs wherever
# the boundary is now owned:
def test_every_picker_value_reaches_a_route():
    for value in PICKER_BRAINS:
        assert canonical_brain(value) in CHAT_ROUTES, value

External links

Exercise

네 시스템에서 부품 둘이 같은 개념을 다르게 부르는 경계를 하나 찾아. 상태 문자열, 역할 이름, 통화 코드, 이벤트 종류, 아무거나. 건너기 단언을 써. A 쪽이 낼 수 있는 모든 값이 B 쪽에서 유효한 뭔가로 풀린다. 돌려봐. 통과하면 남겨두고, 깨지면 방금 기존 테스트가 아무도 안 찾던 결함을 하나 찾은 거야.
Hint
제일 좋은 후보는 한쪽이 사용자용 어휘고 다른 쪽이 구현 어휘인 경계야. 그 둘은 *달라야* 정상이거든. 위험한 건 다르다는 게 아니라, 그 사이 번역을 호출자마다 기억에 의존해서 하고 있다는 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.