junyeokk
Blog
Socket·2025. 12. 11

Opcode 기반 메시지 프로토콜

WebSocket으로 실시간 통신을 구현하면 클라이언트와 서버가 자유롭게 메시지를 주고받을 수 있다. 그런데 문제가 하나 있다. 메시지가 그냥 문자열이나 바이너리 덩어리로 도착하기 때문에, 이게 인증 요청인지, 데이터 업데이트인지, 에러 알림인지 구분할 방법이 없다.

가장 단순한 해결책은 메시지에 type 같은 문자열 필드를 넣는 것이다.

json
{ "type": "auth", "data": { "token": "abc123" } }
{ "type": "project_update", "data": { "id": "p1" } }
{ "type": "notification", "data": { "message": "새 댓글" } }

이 방식도 동작은 한다. 하지만 문자열 비교는 오타에 취약하고, 메시지 타입이 늘어날수록 if-else 체인이 길어지며, 서버와 클라이언트 간에 타입 문자열을 정확히 맞춰야 하는 부담이 생긴다.

Opcode(Operation Code) 기반 프로토콜은 메시지 타입을 숫자로 정의해서 이 문제를 해결한다. Discord, Slack, 게임 서버 등 대부분의 실시간 시스템이 이 방식을 사용한다. 숫자는 비교가 빠르고, 대역폭이 적으며, 범위별로 의미를 부여할 수 있다.


기본 구조

Opcode 프로토콜의 메시지는 보통 세 가지 필드로 구성된다.

typescript
interface SocketMessage {
  op: number;     // 어떤 종류의 메시지인지
  evt?: string;   // 세부 이벤트 타입 (선택)
  d?: unknown;    // 실제 데이터 페이로드 (선택)
}

op는 메시지의 대분류다. 모든 메시지에 반드시 포함되며, 수신 측에서 가장 먼저 확인하는 필드다. evt는 같은 opcode 안에서 세부 동작을 구분할 때 사용한다. d는 실제 전달할 데이터다.

이 구조의 핵심은 계층적 분류다. op로 큰 범주를 나누고, 필요한 경우 evt로 세부 동작을 구분한다. 모든 메시지 타입을 flat하게 나열하는 것보다 훨씬 체계적이다.


Opcode 설계 전략

숫자 범위로 도메인 분리

Opcode를 설계할 때 가장 중요한 결정은 숫자 범위를 어떻게 나눌 것인가다. 일반적으로 10 단위나 100 단위로 도메인을 분리한다.

typescript
// Client → Server
const Opcode = {
  AUTH: 10,
  WORKSPACE: 20,
  PROJECT: 30,
} as const;

// Server → Client
const ServerOpcode = {
  ERROR: -1,
  CLOSED: 0,
  SUCCESS_AUTH: 11,
  SET_WORKSPACE: 21,
  SET_PROJECT: 31,
  NOTIFICATION: 41,
  UPDATE: 42,
} as const;

이 설계에서 10번대는 인증, 20번대는 워크스페이스, 30번대는 프로젝트, 40번대는 서버 푸시 이벤트다. 새로운 도메인이 추가되면 50번대를 할당하면 된다. 범위가 겹치지 않기 때문에 opcode만 보고도 어떤 도메인의 메시지인지 즉시 파악할 수 있다.

요청-응답 매핑

클라이언트가 보내는 opcode와 서버가 응답하는 opcode를 쌍으로 설계하면 흐름을 추적하기 쉽다.

Client → ServerServer → Client설명
AUTH (10)SUCCESS_AUTH (11)인증 요청 → 인증 성공
WORKSPACE (20)SET_WORKSPACE (21)워크스페이스 구독 → 구독 확인
PROJECT (30)SET_PROJECT (31)프로젝트 구독 → 구독 확인
NOTIFICATION (41)서버 → 클라이언트 알림
UPDATE (42)서버 → 클라이언트 데이터 변경

요청 opcode에 1을 더한 값이 응답 opcode가 되는 패턴이다. 단방향 푸시(41, 42)는 대응하는 클라이언트 opcode가 없다.

에러와 종료는 특수 값

에러(-1)와 연결 종료(0)는 도메인에 속하지 않는 메타 메시지이므로 일반 범위 바깥의 값을 사용한다. 음수나 0을 사용하면 일반 opcode와 자연스럽게 구분된다.

typescript
const ServerOpcode = {
  ERROR: -1,   // 에러는 음수
  CLOSED: 0,   // 연결 종료는 0
  // 나머지는 양수...
} as const;

타입 안전한 메시지 정의

TypeScript를 사용하면 각 opcode에 대응하는 메시지 타입을 정의해서 컴파일 타임에 잘못된 메시지 구조를 잡아낼 수 있다.

typescript
// 각 opcode별 메시지 타입을 개별 정의
interface AuthMessage {
  op: typeof Opcode.AUTH;
  d: { token: string | null };
}

interface WorkspaceMessage {
  op: typeof Opcode.WORKSPACE;
  d: { workspaceId: string };
}

interface ProjectMessage {
  op: typeof Opcode.PROJECT;
  d: { projectId: string; accessKey?: string };
}

// Union 타입으로 묶기
type ClientMessage = AuthMessage | WorkspaceMessage | ProjectMessage;

typeof Opcode.AUTHnumber가 아니라 리터럴 타입 10이다. as const로 선언했기 때문이다. 이 덕분에 op 필드로 discriminated union이 동작한다.

typescript
function handleMessage(msg: ClientMessage) {
  switch (msg.op) {
    case Opcode.AUTH:
      // msg.d는 자동으로 { token: string | null }
      authenticate(msg.d.token);
      break;
    case Opcode.WORKSPACE:
      // msg.d는 자동으로 { workspaceId: string }
      joinWorkspace(msg.d.workspaceId);
      break;
    case Opcode.PROJECT:
      // msg.d는 자동으로 { projectId: string; accessKey?: string }
      joinProject(msg.d.projectId, msg.d.accessKey);
      break;
  }
}

switch 문에서 op 값에 따라 d의 타입이 자동으로 좁혀진다. 이게 문자열 기반 타입 구분과의 가장 큰 차이다. 문자열은 TypeScript가 자동으로 좁히기 어렵지만, as const로 선언된 숫자 리터럴은 discriminant로 완벽하게 동작한다.

서버 메시지도 같은 패턴으로 정의한다.

typescript
interface ErrorMessage {
  op: typeof ServerOpcode.ERROR;
  d: { code: string; message: string };
}

interface UpdateMessage {
  op: typeof ServerOpcode.UPDATE;
  evt: string;
  d: UpdateMessageData;
}

interface NotificationMessage {
  op: typeof ServerOpcode.NOTIFICATION;
  d: NotificationMessageData;
}

type ServerMessage = ErrorMessage | UpdateMessage | NotificationMessage;

evt 필드: 같은 opcode 안에서 세부 분류

하나의 opcode가 여러 종류의 이벤트를 커버해야 할 때 evt 필드를 사용한다. 예를 들어 UPDATE(42) opcode는 프로젝트 수정, 버전 추가, 댓글 작성 등 다양한 변경 이벤트를 전달해야 한다. 이것들을 각각 별도 opcode로 만들면 opcode가 폭발적으로 늘어난다.

typescript
const UpdateEvent = {
  EDIT_PROJECT: 'EDIT_PROJECT',
  REMOVE_PROJECT: 'REMOVE_PROJECT',
  ADD_VERSION: 'ADD_VERSION',
  EDIT_VERSION: 'EDIT_VERSION',
  REMOVE_VERSION: 'REMOVE_VERSION',
  ADD_COMMENT: 'ADD_COMMENT',
  EDIT_COMMENT: 'EDIT_COMMENT',
  REMOVE_COMMENT: 'REMOVE_COMMENT',
  ADD_COMMENT_REACTION: 'ADD_COMMENT_REACTION',
  REMOVE_COMMENT_REACTION: 'REMOVE_COMMENT_REACTION',
} as const;

evt는 문자열이지만 as const로 리터럴 타입으로 제한했기 때문에 오타 방지가 된다. 이 패턴은 opcode가 "카테고리" 역할을, evt가 "구체적 동작" 역할을 하는 2단계 분류 체계다.

수신 측에서는 opcode로 먼저 분기하고, 그 안에서 evt로 다시 분기한다.

typescript
const handleMessage = (data: SocketMessage) => {
  if (data.op === ServerOpcode.UPDATE && data.evt && data.d) {
    handleUpdateEvent(data.evt, data.d);
  } else if (data.op === ServerOpcode.NOTIFICATION) {
    handleNotificationEvent(data.d);
  }
};

const handleUpdateEvent = (evt: string, d: UpdateMessageData) => {
  switch (evt) {
    case UpdateEvent.EDIT_PROJECT:
      // 프로젝트 수정 처리
      break;
    case UpdateEvent.ADD_COMMENT:
      // 댓글 추가 처리
      break;
    // ...
  }
};

실전 활용: React Query 캐시 무효화 연동

Opcode 프로토콜이 빛을 발하는 대표적인 사례가 서버 푸시 이벤트를 받아서 클라이언트 캐시를 갱신하는 패턴이다.

다른 사용자가 프로젝트를 수정하면, 서버가 UPDATE(42) opcode에 EDIT_PROJECT evt를 담아서 해당 프로젝트를 구독 중인 모든 클라이언트에게 보낸다. 클라이언트는 이 메시지를 받으면 해당 프로젝트의 React Query 캐시를 무효화해서 최신 데이터를 다시 가져온다.

typescript
function useSocketEventHandler() {
  const socket = useSocket();
  const queryClient = useQueryClient();

  useEffect(() => {
    if (!socket) return;

    const handleMessage = (data: SocketMessage) => {
      if (data.op === ServerOpcode.UPDATE) {
        const d = data.d as UpdateMessageData;

        switch (data.evt) {
          case UpdateEvent.EDIT_PROJECT:
            if (d.project?.id) {
              queryClient.invalidateQueries({
                queryKey: projectKeys.detail(d.project.id),
              });
            }
            break;

          case UpdateEvent.ADD_COMMENT:
            if (d.version?.id) {
              queryClient.invalidateQueries({
                queryKey: commentKeys.versionComments(d.version.id),
              });
            }
            // 대댓글이면 부모 댓글의 replies도 무효화
            if (d.comment?.parentId) {
              queryClient.invalidateQueries({
                queryKey: commentKeys.replies(d.comment.parentId),
              });
            }
            break;

          case UpdateEvent.ADD_COMMENT_REACTION:
          case UpdateEvent.REMOVE_COMMENT_REACTION:
            if (d.comment?.id) {
              queryClient.invalidateQueries({
                queryKey: commentKeys.reactions(d.comment.id),
              });
            }
            break;
        }
      } else if (data.op === ServerOpcode.NOTIFICATION) {
        queryClient.invalidateQueries({
          queryKey: notificationKeys.all,
        });
      }
    };

    socket.on('message', handleMessage);
    return () => socket.off('message', handleMessage);
  }, [socket, queryClient]);
}

이 패턴의 핵심은 opcode 기반 라우팅이다. 메시지가 도착하면 op 값으로 어떤 핸들러에게 전달할지 결정하고, evt로 구체적인 캐시 무효화 범위를 결정한다. 구조가 명확하기 때문에 새로운 이벤트 타입이 추가되어도 switch 문에 case를 하나 추가하면 된다.


문자열 타입 vs Opcode 비교

기준문자열 타입Opcode
가독성"auth_request" 직관적10 자체로는 의미 불명
성능문자열 비교 (느림)숫자 비교 (빠름)
대역폭"project_update" = 14bytes42 = 2bytes
도메인 분류네이밍 컨벤션에 의존숫자 범위로 강제
타입 안전성문자열 리터럴 유니온 가능as const + discriminated union
확장성이름 충돌 위험범위만 겹치지 않으면 OK

가독성은 문자열이 낫다. 코드를 읽을 때 "auth_request"는 즉시 이해되지만 10은 상수 정의를 봐야 한다. 하지만 상수를 Opcode.AUTH로 참조하면 이 단점은 거의 사라진다. 나머지 모든 측면에서 opcode가 우위다.

실무에서는 순수한 양자택일이 아니라 혼합하는 경우가 많다. 대분류는 숫자 opcode로, 세부 이벤트 타입은 문자열 evt로 표현하는 식이다. 이렇게 하면 라우팅 성능과 가독성을 모두 확보할 수 있다.


설계 시 고려사항

opcode 간격을 넉넉하게

opcode를 1, 2, 3... 순서로 할당하면 나중에 중간에 새로운 도메인을 끼워넣기 어렵다. 10 단위나 100 단위로 간격을 두면 확장이 편하다.

방향별 분리

클라이언트→서버 opcode와 서버→클라이언트 opcode를 별도 상수 객체로 분리하면 역할이 명확해진다. 같은 숫자를 양방향에서 다른 의미로 사용하는 실수를 방지할 수 있다.

페이로드 타입 강제

각 opcode에 대응하는 d 필드의 타입을 명시적으로 정의해야 한다. d: any로 두면 opcode를 나눈 의미가 없다. TypeScript의 discriminated union을 활용하면 opcode에 따라 페이로드 타입이 자동으로 결정되므로 런타임 에러를 크게 줄일 수 있다.

버전 관리

프로토콜이 변경될 때를 대비해서 연결 시 프로토콜 버전을 교환하는 것이 좋다. 클라이언트와 서버가 다른 버전의 opcode 체계를 사용하면 메시지 파싱이 깨질 수 있기 때문이다.


관련 문서