프로젝트에서 WebSocket 에러 처리 구조를 설계하다가 고민이 생겼다.
성공 response에
errorCode필드를 추가할까, 아니면type자체를 분리할까?
결론부터 말하면 type 분리가 맞다. 그 이유를 정리해본다.
프로젝트에서 사용 중인 WebSocket 메시지 스펙은 다음과 같았다.
성공 응답
{
"type": "CONTRIBUTION_INPUT_RESULT",
"playerId": "550e8400-e29b-41d4-a716-446655440000",
"isCorrect": true
}
실패 응답
{
"type": "CONTRIBUTION_INPUT_RESULT",
"playerId": "550e8400-e29b-41d4-a716-446655440000",
"isCorrect": false,
"errorCode": "INVALID_BRANCH"
}
여기서 errorCode가 Optional로 명시되어 있었다.
즉, 성공이면 없고, 실패면 있는 구조다.
만약 성공 응답에도 errorCode 필드를 포함시키면?
{
"type": "CONTRIBUTION_INPUT_RESULT",
"playerId": "...",
"isCorrect": true,
"errorCode": null
}
성공인데 에러 관련 필드가 존재한다. 개념적으로 이미 충돌이다.
function handleInputResult({ isCorrect, errorCode }) {
if (isCorrect && !errorCode) {
// 성공
} else if (!isCorrect && errorCode === 'INVALID_BRANCH') {
// 브랜치 에러
} else if (!isCorrect && !errorCode) {
// 일반 오타
}
}
isCorrect와 errorCode를 조합해서 상태를 판단해야 한다.
조건이 늘어날수록 분기가 복잡해진다.
errorCode가 Optional이기 때문에, 받는 쪽에서 항상 null 여부를 확인해야 한다. 빠뜨리면 런타임 에러로 이어진다.
성공과 실패를 type 자체로 구분한다.
성공
{
"type": "CONTRIBUTION_INPUT_RESULT",
"playerId": "550e8400-e29b-41d4-a716-446655440000",
"isCorrect": true
}
실패
{
"type": "CONTRIBUTION_INPUT_ERROR",
"playerId": "550e8400-e29b-41d4-a716-446655440000",
"errorCode": "INVALID_BRANCH"
}
const messageHandlers = {
CONTRIBUTION_INPUT_RESULT: handleSuccess,
CONTRIBUTION_INPUT_ERROR: handleError,
};
function handlePrivateMessage(message) {
const handler = messageHandlers[message.type];
if (!handler) return;
handler(message);
}
function handleSuccess({ playerId }) {
// 성공 처리만
}
function handleError({ playerId, errorCode }) {
// 에러 처리만
if (errorCode === 'INVALID_BRANCH') {
showToast('존재하지 않는 브랜치입니다.');
}
}
각 핸들러가 자신의 역할만 담당한다.
성공 핸들러에 에러 관련 코드가 단 한 줄도 없다.
위에서 성공과 에러를 type으로 분리했다.
근데 에러 종류가 늘어나면 어떻게 될까?
{ "type": "AUTH_ERROR" }
{ "type": "RATE_LIMIT_ERROR" }
{ "type": "VALIDATION_ERROR" }
{ "type": "CONTRIBUTION_INPUT_ERROR" }
클라이언트 핸들러 맵이 이렇게 된다.
const messageHandlers = {
CONTRIBUTION_INPUT_RESULT: handleSuccess,
AUTH_ERROR: handleAuthError,
RATE_LIMIT_ERROR: handleRateLimit,
VALIDATION_ERROR: handleValidation,
CONTRIBUTION_INPUT_ERROR: handleInputError,
};
에러가 추가될 때마다 핸들러 맵을 수정해야 한다.
그리고 에러 공통 처리(로깅, 토스트, 재연결 트리거)가 필요할 때, 각 핸들러에 중복 코드가 생기거나 유틸 함수를 따로 만드는 우회책이 필요해진다.
에러는 type: "ERROR" 하나로 묶고, code로 세분화한다.
{ "type": "ERROR", "code": "AUTH_ERROR", "message": "인증이 필요합니다." }
{ "type": "ERROR", "code": "RATE_LIMIT_ERROR", "message": "요청이 너무 많습니다." }
{ "type": "ERROR", "code": "CONTRIBUTION_INPUT_ERROR", "message": "존재하지 않는 브랜치입니다." }
핸들러 맵은 ERROR 하나만 가진다.
const messageHandlers = {
CONTRIBUTION_INPUT_RESULT: handleSuccess,
ERROR: handleError, // 에러는 여기서 끝
};
function handleError({ code, message }) {
// 공통 처리
logger.error(code, message);
// 에러별 분기
switch (code) {
case 'AUTH_ERROR': return redirectToLogin();
case 'RATE_LIMIT_ERROR': return showRetryToast();
case 'CONTRIBUTION_INPUT_ERROR': return showInputError(message);
default: return showToast(message, 'error');
}
}
에러가 추가돼도 핸들러 맵은 건드리지 않는다.
handleError 내부 switch에 case 하나만 추가하면 끝이다.
성공 응답에 type: "SUCCESS", code: "CHAT_MESSAGE" 구조를 쓰면 어떻게 될까?
const messageHandlers = {
SUCCESS: handleSuccess, // 여기서 또 code로 분기
ERROR: handleError,
};
function handleSuccess({ code, data }) {
switch (code) { // 라우팅을 두 번 타게 됨
case 'CHAT_MESSAGE': ...
case 'USER_JOIN': ...
case 'GAME_START': ...
}
}
라우팅을 두 번 타는 구조가 돼버린다.
에러를 묶는 이유는 공통 처리할 것들이 있어서다. 로깅, 토스트, 재연결 트리거 등.
성공 응답은 CHAT_MESSAGE와 USER_JOIN이 완전히 다른 UI 로직을 타기 때문에 공통으로 묶을 게 없다.
"같은 처리를 공유하는 것들"만 묶는 게 기준이다.
HTTP REST API를 생각해보면 이미 우리는 이 방식에 익숙하다.
| HTTP Status | 의미 |
|---|---|
200 OK | 성공 body 반환 |
4xx / 5xx | 에러 body + errorCode 반환 |
HTTP도 status code(=type)로 성공과 실패를 분리하고,
에러는 status code 범주(4xx, 5xx)로 묶은 뒤 body의 errorCode로 세분화한다.
WebSocket도 동일한 철학을 적용하면 된다.
| 설계 원칙 | 방식 | 이유 |
|---|---|---|
| 성공 ↔ 에러 분리 | type으로 분리 | 개념 충돌 방지, 단일 책임 |
| 에러끼리 묶기 | type: ERROR + code | 공통 처리 일원화, 핸들러 맵 안정성 |
| 성공끼리 묶지 않기 | type을 메시지 종류 그대로 | 공통 처리할 게 없음, 라우팅 이중화 방지 |
같은 처리를 공유하는 것들만 묶어라. 성공 응답에 에러 관련 필드가 존재하는 것 자체가 설계 냄새(Code Smell) 다.
메시지 하나가 하나의 의미만 가지도록 설계하는 것.
WebSocket이든 REST든, 결국 같은 원칙이다.