내부 private repo 작업을 바탕으로 썼다. 외부 접근이 불가한 저장소라 링크 없이, 익명화 중심으로 정리한다.

TL;DR

  • 쿠키 삭제 범위를 고치는 리팩터링을 하면서, 릴리스 검증 문서에 “이렇게 하면 화면이 이렇게 바뀐다”를 적었다. 화면은 한 번도 보지 않았다.
  • 검증 담당자는 그 문장을 판정 기준으로 삼았고, 정상 동작을 결함으로 등록했다.
  • 오탐을 확인하러 들어갔다가 같은 화면에 살아 있던 진짜 버그를 찾았다. 증상은 실측으로 확정했고, 원인은 placeholderData + useEffect 후처리 조합이라는 가설까지 세웠다.
  • 코드는 아직 안 고쳤다. 먼저 고친 건 검증 항목을 쓰는 절차다. 그리고 그 절차를 만든 날 오후에 또 어겼다.

검증 문서는 문서가 아니라 스펙이다

릴리스마다 검증 문서를 쓴다. 무엇이 바뀌었고 무엇을 봐야 하는지 적는 표다. 검증 담당자는 코드를 보지 않는다. 브라우저 화면만 본다. 그러니 내가 쓴 문장이 곧 그 사람의 판정 기준이 된다.

이번에 이렇게 적었다.

할인 항목을 전부 해제하고 구매하면, 결제 화면에 할인이 적용되지 않아야 합니다. 해제했는데 적용돼 있으면 기존 버그가 남은 것입니다.

논리는 이랬다. 값을 지우는 코드에 삭제 범위가 빠져 있어 실제로는 아무것도 안 지워지고 있었다. 범위를 채워 넣었으니 이제 진짜로 지워진다. 지워지면 화면에 반영될 것이다.

앞의 두 문장은 맞았다. 마지막 문장이 틀렸다.

두 겹으로 틀렸다

검증 담당자는 시킨 대로 했고, 할인이 그대로 적용되는 걸 보고 결함으로 등록했다. 확인해 보니 내 문장이 두 군데에서 어긋나 있었다.

하나. 시킬 조작이 화면에 없었다. 상세 화면에서 항목을 해제하는 UI는 오래전에 사라져 있었다. “자동으로 최적 할인을 적용한다”는 정책으로 바뀌면서, 선택 화면을 여는 핸들러가 상위 컴포넌트에서 prop으로 받고 그대로 버려지고 있었다. 컴포넌트 파일은 그대로 남아 있으니 grep으로는 살아 보인다. 실행 경로만 끊겨 있었다.

둘. 기대한 값을 내가 정하지 않는다. 결제 화면의 할인 적용 여부는 응답의 isSelected가 정한다. 클라이언트는 선택을 전달할 뿐이고, 전달할 게 없으면 서버가 기본값으로 최적 할인을 적용한다. 즉 “안 보냄”은 “없음”이 아니라 “선호 없음”이었다.

그래서 “삭제가 동작한다”“화면에 적용되지 않는다”는 서로 다른 명제였는데, 나는 등호로 묶었다.

확인은 어렵지 않았다. 항목을 아직 받지도 않은 계정으로 들어가 봤더니, 해제라는 행위를 하지 않아도 할인이 적용돼 있었다. 여기서 이미 내 문장은 무너진다. 화면을 한 번만 열어봤으면 5분 만에 알 수 있었다.

오탐을 파다가 나온 진짜 버그

그 확인 과정에서 검증 담당자가 다른 걸 발견했다. 결제 화면에서 할인 항목을 직접 해제해도 반영되지 않는다는 것이었다. 이번엔 오탐이 아니었다. 운영 환경에서도 그대로 재현됐다.

네트워크를 보니 요청이 연달아 두 번 나가고 있었다.

요청 응답의 선택 상태
?auto=false (수동 선택) 없음 — 해제 성공
직후 ?auto=true 직전 항목 재선택

서버는 사용자의 해제를 정확히 반영했다. 클라이언트가 곧바로 되돌리고 있었다.

되돌리는 코드는 이랬다. 의도는 “사용자가 고른 결과가 자동 최적화 결과와 같으면, 자동 모드 배지를 다시 보여준다”였다.

onSuccess: (next) => {
  if (isEqual(autoResult, next) && !params.auto) {
    setAuto(true);   // 자동 모드로 되돌림
  }
}

여기서부터는 가설이다. 아래 코드를 읽고 세운 설명이고, 아직 실행으로 확인하지 못했다.

의심하는 곳은 이 onSuccess가 호출되는 방식이다.

const { data } = useQuery({
  queryKey: ['form', params],
  queryFn: () => fetchForm(params),
  placeholderData: keepPreviousData,   // 전환 중 깜빡임 방지
});

useEffect(() => {
  if (data !== undefined) onSuccess?.(data);
}, [onSuccess, data]);

keepPreviousData는 params가 바뀌어도 새 응답이 올 때까지 이전 데이터를 그대로 들고 있는다. 화면 깜빡임을 줄이려고 넣은 옵션이다. 그런데 그 사이에 useEffect가 돌면서 onSuccess이전 데이터로 발화한다.

이전 데이터는 자동 최적화 결과다. 그렇다면 isEqual(autoResult, next)자동 결과를 자기 자신과 비교하게 되고, 항상 참이 된다. 사용자가 무엇을 고르든 자동 모드로 되돌아간다 — 관측한 증상과 맞아떨어진다.

onSuccess가 매 렌더마다 새로 만들어지는 인라인 함수라 의존성 배열이 계속 바뀌는 것도 발화 기회를 늘렸을 것이다.

여기까지가 추론이다. 확정된 것은 요청 두 번과 그 응답, 그리고 자동 모드를 끄면 정상 동작한다는 사실뿐이다. 되돌림이 걸리는지는 실행으로 짚어봐야 한다. 티켓에도 “원인(후보)”으로 적었고, 착수할 때 이 가설부터 다시 확인할 생각이다.

가설이 맞다면 고치는 건 작다. placeholder 구간을 걸러내면 된다.

const { data, isPlaceholderData } = useQuery({ ... });

useEffect(() => {
  if (data !== undefined && !isPlaceholderData) {
    onSuccess?.(data);
  }
}, [onSuccess, data, isPlaceholderData]);

여담이지만, useQueryonSuccessv5에서 제거된 API다. 제거된 이유 중 하나가 정확히 이거다 — 콜백이 언제 몇 번 불릴지가 캐시 상태에 좌우돼서, “응답이 왔을 때”라고 읽히지만 실제로는 아니다. 그걸 useEffect로 되살리면 같은 함정이 그대로 돌아온다. placeholderData는 이전 데이터를 유지하는 옵션이지 “새 데이터가 왔다”는 신호가 아니다.

고친 것은 코드가 아니라 절차다

버그는 별도 건으로 넘겼다. 아직 안 고쳤다. 먼저 고친 건 검증 항목을 쓰는 절차다.

원래도 검증 항목에 관문이 있었다. 못 할 항목을 시키지 않으려고 만든 것이다.

  1. 검증 담당자가 그 상태에 스스로 도달할 수 있나
  2. 판정이 이 변경에만 좌우되나
  3. 실패하면 이 변경 탓이라 말할 수 있나

이번 항목은 셋 다 통과했다. 화면은 열리고(①), 흔들 변수도 없고(②), 동작이 바뀐다고 믿었다(③).

빠진 질문은 두 개였다.

  • 가 보니 시킬 게 거기 있나 — ①은 “그 화면까지 갈 수 있나”만 묻는다. 화면은 열리는데 조작 대상이 없는 경우를 못 거른다.
  • 그 값을 누가 정하나 — 어느 관문도 기대 결과의 결정 주체를 묻지 않았다.

그래서 관문을 하나 더 뒀다. “시킬 조작과 기대가 실물 화면에 있나.” 그리고 규칙 세 줄을 붙였다.

  • 화면 변화를 주장하는 항목은 직접 보고 쓴다. 못 봤으면 “화면 변화 없음 + 회귀만”으로 내린다.
  • 기대할 값이 응답 필드에서 오면 클라이언트 변경만으로 단정하지 않는다. 값이 없을 때 서버가 무엇을 하는지까지 확인해야 기대를 쓸 수 있다.
  • 죽은 코드 검사에 UI 진입점을 넣는다. 컴포넌트가 있다는 것과 화면에 뜬다는 것은 다르다.

세 번째가 특히 아팠다. 원래 검사는 상수·enum의 참조 수만 셌다. 이번처럼 핸들러가 prop으로 안 넘어가 도달 불가능해진 경우는 grep에 잡히지 않는다. 파일은 멀쩡히 있으니까.

그리고 같은 날 오후, 또 틀렸다

여기서 끝났으면 깔끔한 회고가 됐을 것이다. 그런데 관문을 추가한 그날 오후에 같은 실수를 다시 했다.

오탐을 정정하면서 검증 담당자에게 이렇게 안내했다.

해제는 결제 화면의 변경 → 선택 안 함에서 하시면 됩니다.

선택지가 화면에 있는 것은 확인했다. 그걸 눌렀을 때 실제로 해제되는지는 확인하지 않았다. 몇 시간 전에 “조작이 있는지, 기대한 결과가 나오는지 둘 다 보라”고 규칙을 적어놓고, 앞의 절반만 보고 문장을 보냈다.

이 글에 나오는 진짜 버그는 그 안내를 따라간 담당자가 찾은 것이다. 내가 안 본 그 절반에 버그가 있었다.

규칙을 쓰는 것과 그 규칙 아래서 일하는 것은 다른 일이다. 적어놨다고 몸에 붙지 않는다. 이 절을 글에 남겨두는 이유이기도 하다.

배운 것

하나. 검증 문서에 쓴 문장은 문서가 아니라 스펙이다. 다른 사람이 그걸 기준으로 통과·실패를 적는다. 틀린 문장은 잘못된 정보가 아니라 잘못된 작업 지시가 된다. 검증 담당자는 정확히 시킨 대로 했다. 시킨 쪽이 틀렸다.

둘. 관문을 통과했다는 게 안전하다는 뜻은 아니다. 세 개를 다 지났는데도 뚫렸다. 관문이 자기 신념을 검증하지 못하면 몇 개를 쌓아도 같은 구멍이 난다. ③번은 “동작이 바뀐다고 믿었다”로 통과했다. 믿음은 관문이 아니다.

그리고 관문은 만든 사람도 막지 못한다. 네 번째를 추가한 당일 오후에 내가 그걸 어겼다. 규칙은 쓰는 순간이 아니라 어긴 기록이 쌓일 때 힘이 생긴다.

셋. diff는 “무엇이 바뀌었나”까지만 답한다. “화면이 어떻게 보이나”는 다른 질문이다. 코드를 읽고 추론한 것과 화면에서 관측한 것 사이에는, 죽은 코드 경로와 서버 기본값 같은 것들이 조용히 끼어 있다. 착수 전에 코드를 읽는 것이 필요한 만큼, 결과를 주장하기 전에 화면을 여는 것도 필요하다.

넷. 빠른 산출물일수록 검증 대상이다. 잘못 이해한 요구도 빠르게 구현된다는 걸 이미 한 번 겪었는데, 이번엔 구현이 아니라 검증 항목에서 같은 일이 났다. 잘 쓰인 문장은 근거가 있는 것처럼 읽힌다. 다섯 줄짜리 검증 항목은 그럴듯해서 아무도 의심하지 않았다.

지금 하는 것

검증 항목에 화면 변화를 적을 때는, 그 화면을 열어 조작이 실제로 있는지·기대한 결과가 실제로 나오는지 먼저 본다. 못 보면 적지 않는다. 항목이 줄어드는 건 손해가 아니다. 확인 못 할 항목 여섯 개보다 확인되는 항목 다섯 개가 낫다.

관문 이야기는 스킬을 만들어 놓고 안 쓰던 기록과도 이어진다. 규칙은 만드는 것보다 어디서 뚫렸는지 기록해 두는 쪽이 오래 간다. 이번 건도 “3중 관문을 전부 통과했다”는 사실을 같이 적어 뒀다. 다음에 관문을 믿고 싶어질 때 읽으려고.

읽을 거리