서버는 에러 문장을 만들지 않습니다 — 에러 코드와 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. 에러 코드와 문장은 설명을 위해 만든 예시입니다.

API 설계에러 처리RFC 9457Problem Detailsi18n다국어