장애 대응 중에 사용자 화면 캡처를 받았다. 다이얼로그에 이런 문자열이 떠 있었다.
GET /api/v4/orders/{id} [500] (sentry-allow: true)
사용자에게 보일 문구가 아니다. 이건 우리가 로그 쪽에서 쓰려고 만든 것이다. 어느 요청이 어떤 상태로 실패했는지를 한 줄로 만들어 에러 그룹핑 키로 쓰고, 뒤에 붙은 플래그로는 이 에러를 로깅 대상에 포함할지를 표시한다. 화면에 나올 이유가 없는데 나왔다.
message 하나가 두 소비자를 겸하고 있었다
원인을 찾는 데는 오래 걸리지 않았다. 통신 계층에서 에러를 만들 때 이렇게 하고 있었다.
const fingerprint = `${method.toUpperCase()} ${requestUrl} [${status}]`;
throw new APIError({
status,
message: `${fingerprint} (sentry-allow: ${sentryAllow})`,
data: error.response.data,
});
message에 진단 문자열을 넣었다. 그리고 표시 지점에서는 이렇게 쓰고 있었다.
dialog.show({ content: error.message });
두 코드는 각각 보면 이상하지 않다. 앞은 로그를 잘 남기려고 한 것이고, 뒤는 서버가 준 메시지를 사용자에게 보여주려고 한 것이다. 문제는 둘이 같은 필드를 서로 다른 용도로 읽고 있었다는 데 있다.
Error.message는 이런 일이 생기기 쉬운 자리다. 언어가 기본으로 주는 문자열 필드라서
“에러에 대한 설명”이면 무엇이든 여기에 들어간다. 처음에는 소비자가 하나뿐이라 문제가
없다. 로깅을 붙이면서 진단 정보를 넣는 순간 소비자가 둘이 되는데, 그때 표시 지점은
바뀌지 않는다. 아무도 코드를 잘못 쓰지 않았는데 화면이 깨진다.
고치는 방향은 금방 나왔다
원칙은 한 줄로 정리됐다.
로그에는 원문이 있어야 하고, 화면에는 없어야 한다.
그래서 표시 전용 파서를 하나 만들고, 그것만 화면 문구를 만들도록 했다. 판단 기준은 상태 코드다.
- 5xx — 서버 내부 사정이다. 본문 문구를 믿을 수 없으니 기본 안내 문구로 덮는다.
- 4xx — “재고가 없습니다”, “이미 사용한 쿠폰입니다” 같은 사용자가 알아야 할 사실이 들어 있다. 이건 살린다.
- 상태 코드를 못 읽는 경우 — 우리가 만든 진단 문자열일 가능성이 있으니 기본 문구로 덮는다.
호출부는 이렇게 바뀐다.
- dialog.show({ content: error.message });
+ dialog.show({ content: parseErrorMessage(error, '주문에 실패했습니다.') });
로깅 쪽은 건드리지 않았다. 원문은 그대로 로그에 남아야 하기 때문이다.
어려운 건 파서가 아니라 전수 적용이었다
여기까지는 반나절이면 된다. 진짜 문제는 표시 지점이 몇 개인지 모른다는 것이었다.
모노레포에 앱이 열 개 넘게 있고, 각 앱이 자기 에러 유틸을 따로 갖고 있었다. 같은 일을 하는 래퍼가 앱마다 다른 이름으로 존재했고, 어떤 것은 배럴 파일을 거쳐 재수출되고 있어서 호출부만 봐서는 원본이 어디인지 보이지 않았다. 재수출 뒤에 숨은 소비처를 리뷰 범위에 어떻게 넣게 됐는지는 앞서 적었다.
이런 작업은 “다 고쳤다”고 말하기가 어렵다. 고친 곳은 셀 수 있는데 안 고친 곳이 몇 개인지는 셀 수 없기 때문이다. 리뷰어도 같은 이유로 확인해줄 수가 없다. 백 개가 넘는 파일 목록을 받아서 “빠진 게 없는지”를 사람이 판정하는 건 불가능하다.
그래서 찾는 grep과 닫는 grep을 나눴다
작업 중간에 방향을 바꿨다. 고친 목록을 관리하는 대신, 남아 있으면 안 되는 모양을 정의했다.
처음에는 후보 범위를 넓혀, 네 필드에서 에러의 message를 직접 전달하는 코드를 모았다.
# 전수 조사용 — 후보 필드에서 표시·로그 직접 전달을 넓게 찾는다
git grep -nE '(content|description|message|title)\s*:\s*\(?\s*(e|err|error)\b[^,]*\.message'
여기서 후보를 하나씩 판정해 고쳤다. 그리고 완료 조건은 따로 좁게 잡았다. 사용자에게 보이는 필드만 남긴 것이다.
# 완료 조건 — 이게 0이어야 끝난다
git grep -nE '(content|description|title)\s*:\s*\(?\s*(e|err|error)\b[^,]*\.message'
차이는 message가 빠졌다는 것 하나다. 다이얼로그·스낵바에 들어가는 필드에 에러의
message를 그대로 넣는 코드가 0건이면 끝이다.
이 한 줄이 작업의 성격을 바꿨다.
| 이전 | 이후 |
|---|---|
| 고친 파일 목록을 관리 | 남으면 안 되는 패턴이 0인지 확인 |
| 빠진 곳을 셀 수 없음 | 빠진 곳이 곧 검색 결과 |
| 리뷰어가 전수를 검증 못 함 | 리뷰어가 명령 한 줄을 다시 돌리면 됨 |
| 나중에 다시 생기면 모름 | 같은 명령으로 언제든 재확인 |
완료 조건이 실행 가능한 형태가 되면 그때부터는 기계가 센다. 내가 목록을 잘 관리했는지는 더 이상 중요하지 않다.
덧붙여 이 방식은 작업이 끝난 뒤에도 쓸모가 남는다. 나중에 누군가 같은 패턴을 새로 넣으면 같은 명령에서 잡힌다. 고친 파일 목록은 머지되는 순간 쓸모가 없어지는데 이건 아니다.
남아 있어야 정상인 것도 있었다
완료 조건 쪽 grep은 0건이 됐다. 그런데 넓은 쪽을 다시 돌려보니 두 건이 남아 있었다. 둘 다 이런 모양이었다.
logger.error('요청 실패', {
statusCode: isFetcherError(error) ? error.statusCode : null,
message: error instanceof Error ? error.message : String(error), // ← 넓은 grep에 걸린 줄
});
snackBar.show({
description: parseErrorMessage(error, '처리 중 오류가 발생했습니다'),
});
로그 페이로드다. 여기엔 원문이 있어야 맞다. 바로 아래 사용자 표시는 이미 파서를 타고 있었다.
두 패턴을 갈라놓은 이유가 여기 있다. 넓은 쪽을 완료 조건으로 삼았으면 이 두 건 때문에 영원히 0이 되지 않는다. 그렇다고 넓은 쪽을 안 돌리면 후보를 다 못 찾는다. 찾는 그물과 닫는 조건은 같을 수 없다.
패턴 검색은 이런 걸 구분하지 못한다. 그래서 넓은 검색 결과로 남은 건에 대해서는 왜 제외해도 되는지를 한 줄씩 적었다. 결과가 남았다는 사실보다 남은 이유를 설명할 수 있느냐가 중요했다.
이 구분을 코드에도 남겨뒀다.
// 위 logger.error 가 원문을 이미 남기고 있다.
description: parseErrorMessage(error, '리뷰 처리 중 오류가 발생했습니다'),
배포하고 나서 화면으로 확인했다
이런 변경은 정상 경로에서는 아무것도 바뀌지 않는다. 에러가 날 때만 문구가 달라진다. 그래서 배포 후 확인이 애매해진다. 페이지를 열어봐야 “안 바뀐 것”만 보인다.
에러를 실제로 일으켜봐야 하는데, 운영에서 5xx를 만들 수는 없다. 대신 사용자가 정상적으로 만들 수 있는 실패를 찾았다. 세 갈래가 나왔다.
| 만든 방법 | 화면에 뜬 것 |
|---|---|
| 잘못된 쿠폰 코드 등록 | “쿠폰등록에 실패했습니다. / 부적절한 형태의 쿠폰 키워드입니다.” |
| 존재하지 않는 계정으로 로그인 | “5회 로그인 실패 시, 로그인이 10분 동안 제한됩니다. (1/5)” |
| 판매 종료된 상품 상세 진입 | “판매중인 상품이 아닙니다.” |
셋 다 서버가 본문에 담아 보낸 문구가 그대로 나왔다. 기본 문구로 덮이지도 않았고 진단 문자열이 새지도 않았다. 파서가 양쪽을 다 하고 있다는 뜻이다 — 막아야 할 것은 막고, 살려야 할 것은 살린다. 한쪽만 확인하면 반만 검증한 것이다.
이 셋을 찾는 게 이번 검증에서 제일 품이 들었다. 그리고 제일 값어치가 있었다. 유닛 테스트는 파서의 반환값을 확인하고, 이건 그 반환값이 실제로 화면까지 도달하는지를 확인한다.
바뀐 파일이 아니라 도달 가능한 지면으로 세워야 했다
검증 계획을 처음에는 “이번에 바뀐 파일”로 세웠다. 앱별로 파일을 세어 목록을 만들었는데, 막상 돌려보니 절반이 확인 불가였다.
- 어떤 화면은 앱 웹뷰 전용이었다. 웹에서 열면 앱 설치 유도만 뜬다.
- 어떤 화면은 계정 종류에 따라 갈렸다. 통합 계정으로 로그인한 상태에서는 그 컴포넌트가 아예 렌더되지 않고 외부 도메인으로 넘어간다.
- 어떤 화면은 특정 ID가 있어야 열린다. 유효한 값을 구하는 것부터가 일이었다.
파일이 바뀌었다는 사실과 그 코드에 도달할 수 있다는 사실은 다르다. 나는 이걸 실행하면서 알았는데, 코드를 먼저 읽었으면 브라우저를 열기 전에 알 수 있었다. 컴포넌트가 어느 분기에서만 렌더되는지는 파일 안에 적혀 있었다.
다음부터는 순서를 바꾸려 한다. 바뀐 파일 목록에서 시작하되, 각각에 대해 “이 코드에 도달하려면 무엇이 필요한가”를 먼저 적는다. 로그인 상태, 계정 종류, 필요한 데이터, 플랫폼. 그러고 나서 도달 가능한 것만 검증 목록에 넣고, 나머지는 왜 못 보는지를 적는다.
목록이 짧아지는 게 아니라 정직해진다. 확인 못 한 것을 확인한 척하지 않는 게 더 중요하다.
남는 것
작업이 끝나고 보니 오래 남을 것은 파서가 아니었다.
하나는 필드에 소비자가 몇인지 세어보는 습관이다. message 같은 범용 필드에 무언가를
넣을 때, 이미 그걸 읽고 있는 곳이 있는지 본다. 소비자가 둘이 되는 순간 그 필드는 더 이상
한 가지 뜻이 아니다. 이번 건은 로깅을 붙이면서 소비자가 둘이 됐고, 그때 아무도 표시 지점을
다시 보지 않았다.
다른 하나는 완료 조건을 실행 가능한 형태로 적는 것이다. “다 고쳤다”는 검증할 수 없지만 “이 검색 결과가 0이다”는 누구든 다시 돌려볼 수 있다. 백 곳이 넘는 변경에서 이건 리뷰어에 대한 배려이기도 하고, 몇 달 뒤의 나에 대한 배려이기도 하다.
고친 목록은 머지되면 사라진다. 다시 돌릴 수 있는 명령은 남는다.