서버는 에러 문장을 만들지 않습니다 — 에러 코드와 Problem Details
Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 아홉 번째 글입니다. 이번 요구는 같은 서버 응답을 한국어 화면과 다른 언어 화면이 함께 써야 한다는 것입니다.
화면이 한 언어뿐일 때는 서버가 에러 문장을 만들어 보내도 별문제가 없어 보입니다. 두 번째 언어가 생기는 순간 사정이 달라집니다. 서버가 보낸 한국어 문장을 다른 언어 화면에서 어떻게 보여 줄지부터 막히기 때문입니다.
서버가 문장을 보내면, 그 문장이 곧 약속이 됩니다
서버가 "수량은 100개까지 입력할 수 있습니다." 같은 문장을 보낸다고 해 보겠습니다. 화면은 이 문장을 그대로 보여 주면 되니 편합니다. 그런데 시간이 지나면 이 문장에 기대는 코드가 늘어납니다. 화면은 문장에 "100개"가 들어 있는지 보고 입력 칸을 빨갛게 칠하고, 테스트는 문장이 정확히 같은지 비교합니다.
그 상태에서 누군가 문구를 다듬으면 무슨 일이 생기는지 재현해 봤습니다. 동작은 하나도 바뀌지 않았는데 테스트가 깨집니다.
문구를 다듬기 전: 문장 비교 테스트 = true
문구를 다듬은 뒤: 문장 비교 테스트 = false ← 동작은 그대로인데 테스트가 깨진다
코드 비교 테스트 = true ← 문구와 무관
문장은 사람을 위한 것인데, 프로그램이 그 문장을 약속처럼 읽기 시작하면 문구 하나 고치는 일이 기능 변경이 됩니다. 두 번째 언어는 말할 것도 없습니다.
서버는 "무슨 일이 일어났는지"만 코드로 보냅니다
그래서 서버는 문장 대신 두 가지만 보내도록 바꿨습니다. 에러 코드는 사람이 읽는 문장 대신 프로그램이 읽는 짧은 이름표입니다. 파라미터는 문장을 만드는 데 필요한 재료, 예를 들어 최대 수량과 실제로 입력한 수량입니다.
| 서버가 보내는 것 | 화면이 만드는 것 | |
|---|---|---|
| 예전 | "수량은 100개까지 입력할 수 있습니다." | 그대로 표시 |
| 지금 | QUANTITY_TOO_LARGE + { max: 100, actual: 150 } | 언어별 문장 |
화면은 코드마다 언어별 문장을 갖고, 서버가 보낸 재료로 문장을 완성합니다.
const catalog = {
ko: {
QUANTITY_TOO_LARGE: ({ max, actual }) => `수량은 ${max}개까지 입력할 수 있습니다. (입력: ${actual}개)`,
},
en: {
QUANTITY_TOO_LARGE: ({ max, actual }) => `Quantity must be ${max} or less (got ${actual}).`,
},
};
[ko] 수량은 100개까지 입력할 수 있습니다. (입력: 150개)
[en] Quantity must be 100 or less (got 150).
에러 응답에는 표준 모양이 있습니다
에러 응답의 모양을 처음부터 새로 정할 필요는 없었습니다. HTTP API 의 에러를 담는 표준 형식으로 RFC 9457, Problem Details 가 있습니다. 2023년에 예전 판인 RFC 7807 을 대체했습니다. 기본 항목은 에러의 종류를 가리키는 type, 짧은 요약 title, 상태 코드 status, 자세한 설명 detail, 이번 사건을 가리키는 instance 입니다. 그리고 에러 종류마다 필요한 항목을 덧붙여도 되고, 모르는 항목은 받는 쪽이 무시하라고 정해 두었습니다.
{
"type": "https://example.com/problems/validation",
"title": "Validation failed",
"status": 422,
"errors": [
{ "code": "QUANTITY_TOO_LARGE", "params": { "max": 100, "actual": 150 } },
{ "code": "FIELD_REQUIRED", "params": { "field": "dueDate" } }
]
}
errors 는 표준이 허용하는 덧붙인 항목입니다. title 은 개발자가 기록을 읽을 때 쓰는 요약으로 두고, 사용자에게 보여 주는 문장은 코드와 파라미터로 화면이 만들게 했습니다.
언어마다 문법이 달라서, 문장은 화면이 만들어야 합니다
문장을 화면이 만들어야 하는 이유가 하나 더 있었습니다. 한국어는 조사가 앞 단어의 받침에 따라 바뀝니다. "납기일을 입력해 주세요"와 "메모를 입력해 주세요"는 조사가 다릅니다. 서버가 "{항목}을(를) 입력해 주세요" 같은 틀을 보내면 이 차이를 처리할 곳이 없습니다.
// 마지막 글자에 받침이 있으면 '을', 없으면 '를'
const hasFinalConsonant = (word: string) => {
const code = word.charCodeAt(word.length - 1);
return code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0;
};
const eulReul = (word: string) => word + (hasFinalConsonant(word) ? '을' : '를');
[ko] 납기일을 입력해 주세요. / 메모를 입력해 주세요.
[en] Please enter the due date. / Please enter a memo.
영어는 관사를, 다른 언어는 또 다른 규칙을 챙겨야 합니다. 그 규칙은 그 언어의 화면이 가장 잘 압니다. 서버가 모든 언어의 문법을 알 수는 없다는 생각이 들었습니다.
서버가 보내는 글자가 모두 번역 대상은 아닙니다
바꾸는 과정에서 헷갈렸던 것은, 서버 응답에 들어 있는 글자가 모두 같은 종류가 아니라는 점이었습니다. 세 부류로 나눠 처방을 달리했습니다.
| 부류 | 예 | 처방 |
|---|---|---|
| 서버가 만든 안내 문장 | "이미 확정돼 수정할 수 없습니다" | 코드와 파라미터로 바꾼다 |
| 우연히 글자인 데이터 | 사용자가 적은 메모 | 그대로 둔다(번역하지 않는다) |
| 데이터로 온 화면 문구 | 합계 행의 이름 "전체" | 응답에서 빼고 화면이 붙인다 |
가르는 질문은 하나였습니다. 그 글자가 "무엇이 일어났는가"를 말하면 코드로 바꾸고, "이 값이 무엇인가"를 말하면 데이터로 둡니다. 사용자의 메모를 코드로 바꾸는 것은 말이 안 되고, 합계 행의 이름은 애초에 서버가 정할 일이 아니었습니다.
코드 목록도 약속이라서 테스트로 지킵니다
문장이 약속이던 자리를 이제 코드 목록이 대신합니다. 그래서 서버가 보낼 수 있는 코드마다 화면 쪽 문장이 있는지를 테스트로 검사했습니다. 서버에 새 코드가 생겼는데 문장이 없으면 테스트가 실패합니다. 그래도 빠진 코드가 실제로 도착하면, 화면은 멈추지 않고 "알 수 없는 오류(코드)"를 보여 주며 코드 이름을 남깁니다.
정리하며
- 서버는 코드와 재료를, 화면은 문장을 만듭니다. 문구를 다듬는 일이 기능 변경이 되지 않게 합니다.
- 에러 응답은 표준 모양을 따릅니다. RFC 9457 의 기본 항목에, 필요한 항목을 덧붙입니다.
- 글자의 종류를 먼저 가립니다. 일어난 일은 코드로, 데이터는 그대로, 화면 문구는 화면으로 보냅니다.
처음에는 번역 때문에 시작한 일이었는데, 끝나고 보니 테스트가 문구에 흔들리지 않게 된 것이 더 큰 수확이었습니다. 사람에게 보여 줄 말과 프로그램이 믿을 약속을 나누는 일이었다는 생각이 들었습니다.
실험 환경: Node.js v26.5.0 · 2026-09-29. 에러 코드와 문장은 설명을 위해 만든 예시입니다.
관련 글
삭제한 기능이 남긴 참조가 새 빌드를 죽였습니다
서버를 옮기며 오래된 코드를 새로 빌드하자, 잘 돌던 화면이 첫 렌더부터 하얗게 죽었습니다. 5개월 동안 배포하지 않은 main에 숨어 있던, 삭제된 기능이 남긴 dangling 참조를 추적한 기록입니다.
같은 요청이 두 번 와도 한 번만 — 멱등 키(Idempotency-Key) 구현하기
응답이 사라지면 재시도는 피할 수 없습니다. 그래서 같은 요청이 두 번 와도 한 번만 처리하고, 두 번째에도 처음과 같은 응답을 돌려주는 멱등 키를 MySQL 로 직접 구현했습니다. 다섯 요청이 동시에 와도 확정은 한 번이었던 실험을 함께 정리했습니다.
재시도는 모두가 동시에 하면 안 됩니다 — 지수 백오프와 지터
같은 순간에 실패한 요청들이 똑같이 기다리면 다시 한꺼번에 몰려옵니다. 고정 간격, 지수 백오프, 풀 지터를 시뮬레이션으로 비교했더니 무작위 지터 하나가 회복 시간을 10초에서 2초로 줄였습니다. Retry-After 해석 방법까지 함께 정리했습니다.