WebSocket 에러 설계, type으로 분리해야 하는 이유

dobby·2026년 4월 26일

Let's git it BE

목록 보기
7/20

들어가며

프로젝트에서 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"
}

여기서 errorCodeOptional로 명시되어 있었다.
즉, 성공이면 없고, 실패면 있는 구조다.


왜 문제인가

1. 성공 응답에 에러 필드가 섞인다

만약 성공 응답에도 errorCode 필드를 포함시키면?

{
  "type": "CONTRIBUTION_INPUT_RESULT",
  "playerId": "...",
  "isCorrect": true,
  "errorCode": null
}

성공인데 에러 관련 필드가 존재한다. 개념적으로 이미 충돌이다.

2. 클라이언트 코드가 지저분해진다

function handleInputResult({ isCorrect, errorCode }) {
  if (isCorrect && !errorCode) {
    // 성공
  } else if (!isCorrect && errorCode === 'INVALID_BRANCH') {
    // 브랜치 에러
  } else if (!isCorrect && !errorCode) {
    // 일반 오타
  }
}

isCorrecterrorCode조합해서 상태를 판단해야 한다.
조건이 늘어날수록 분기가 복잡해진다.

3. null 체크가 항상 따라다닌다

errorCode가 Optional이기 때문에, 받는 쪽에서 항상 null 여부를 확인해야 한다. 빠뜨리면 런타임 에러로 이어진다.


해결 — type으로 분리하기

성공과 실패를 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로 세분화한다.

{ "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_MESSAGEUSER_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든, 결국 같은 원칙이다.

profile
느리게 한걸음

0개의 댓글