분산 기능이란?
단일 서버에서는 메모리와 함수 호출만으로 처리할 수 있는 작업도 서버가 여러 대로 늘어나면 복잡해진다.
예를 들어 실시간 멀티플레이 게임 서버가 여러 대 운영되고 있다고 가정하자. 한 서버에서 플레이어가 게임을 종료했을 때, 다른 서버의 친구 목록 서비스와 로비 서비스도 해당 플레이어의 상태 변경을 알아야 할 수 있다.
각 서버는 서로 다른 프로세스에서 실행되므로 한 서버의 메모리를 다른 서버가 직접 참조할 수 없다. 이 문제를 해결하려면 서버 간 메시지 전달, 이벤트 저장 및 재처리, 동시 작업 제어를 위한 분산 기능이 필요하다.
대표적인 기능은 다음과 같다.
- Pub/Sub: 메시지를 구독자에게 실시간으로 전달한다.
- Redis Streams: 메시지를 저장하고 소비자가 읽거나 재처리할 수 있도록 한다.
- 분산 락: 여러 서버가 공유 자원에 동시에 접근하는 상황을 제어한다.
- 서버 간 이벤트 전달: 서버 간 결합도를 낮추고 상태 변경을 전파한다.
- 중복 처리와 장애 대응: 메시지 재전달, 서버 장애, 네트워크 단절에 대비한다.
이 기능들은 서로 대체 관계가 아니다. 각각 해결하려는 문제가 다르며, 실제 시스템에서는 여러 기능을 조합해서 사용한다.
Pub/Sub
Pub/Sub의 개념
Pub/Sub(Publish/Subscribe)는 메시지를 발행하는 주체와 메시지를 수신하는 주체를 분리하는 메시징 패턴이다.
발행자는 메시지를 직접 수신자에게 전달하지 않고 특정 채널에 발행한다. 해당 채널을 구독 중인 소비자는 메시지를 전달받는다.
[게임 서버 A]
|
| Publish
v
[Redis Channel]
|
+-----------> [친구 서비스]
|
+-----------> [로비 서버]
|
+-----------> [알림 서비스]
발행자는 구독자가 몇 개 존재하는지, 각 구독자가 어떤 서버에서 실행되는지 알 필요가 없다.
이 구조는 서버 간 의존성을 줄이고 새로운 소비자를 추가하기 쉽게 만든다.
Redis Pub/Sub 구현
Redis는 PUBLISH, SUBSCRIBE 명령으로 Pub/Sub 기능을 제공한다.
Node.js에서는 ioredis를 사용해 다음과 같이 구현할 수 있다.
발행자
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function publishPlayerOffline(playerId) {
await redis.publish(
"player:offline",
JSON.stringify({
playerId,
timestamp: Date.now(),
})
);
}
await publishPlayerOffline("player-123");
구독자
import Redis from "ioredis";
const subscriber = new Redis(process.env.REDIS_URL);
await subscriber.subscribe("player:offline");
subscriber.on("message", async (channel, message) => {
if (channel !== "player:offline") {
return;
}
const event = JSON.parse(message);
console.log("플레이어 오프라인:", event.playerId);
// 친구 목록 및 로비 상태 갱신
});
실제 운영 환경에서는 JSON 파싱 오류, 이벤트 스키마 검증, 비동기 핸들러의 예외 처리도 추가해야 한다.
Redis Pub/Sub의 특징
Redis Pub/Sub은 구독자가 연결되어 있을 때 메시지를 실시간으로 전달하는 데 적합하다.
그러나 메시지 전달을 보장하는 영속적인 큐는 아니다.
구독자가 연결이 끊긴 동안 발행된 메시지는 나중에 재접속해도 받을 수 없다. 메시지를 처리하지 못했더라도 Redis가 해당 메시지를 보관해 주지 않는다.
따라서 다음과 같은 기능에 적합하다.
- 실시간 채팅 알림
- 서버 간 상태 변경 통지
- 로비 목록 갱신 알림
- 실시간 대시보드 업데이트
- 캐시 무효화 통지
반대로 결제, 아이템 지급, 게임 보상 정산처럼 메시지 유실이 허용되지 않는 작업에는 Pub/Sub만으로 충분하지 않다.
Redis Streams
Redis Streams의 개념
Redis Streams는 메시지를 스트림에 기록하고, 소비자가 해당 메시지를 읽어 처리할 수 있도록 하는 자료구조다.
Pub/Sub과 달리 메시지가 Redis에 저장되므로 소비자가 일시적으로 연결되지 않았더라도 이후에 메시지를 읽을 수 있다. 단, 메시지가 삭제되거나 보존 정책에 따라 제거되지 않았다는 전제가 필요하다.
[게임 서버]
|
| XADD
v
[Redis Stream]
|
+----> [Consumer A]
|
+----> [Consumer B]
|
+----> [Consumer Group]
|
+----> [Worker A]
|
+----> [Worker B]
Redis Streams는 메시지 ID를 기준으로 기록을 관리한다. 소비자는 특정 ID 이후의 메시지를 읽을 수 있으며, Consumer Group을 사용하면 여러 워커가 작업을 나누어 처리할 수 있다.
메시지 발행
XADD 명령으로 스트림에 메시지를 추가한다.
XADD game:events * \
type player_offline \
playerId player-123
*는 Redis가 고유한 스트림 ID를 생성하도록 지정한다.
Node.js에서는 다음과 같이 사용할 수 있다.
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function publishGameEvent(event) {
return redis.xadd(
"game:events",
"*",
"type", event.type,
"playerId", event.playerId,
"timestamp", String(Date.now())
);
}
await publishGameEvent({
type: "player_offline",
playerId: "player-123",
});
Consumer Group
Consumer Group은 여러 소비자가 하나의 스트림을 분담해 처리하도록 구성하는 기능이다.
예를 들어 보상 지급 작업을 여러 워커에 분산할 수 있다.
[game:events]
|
v
[Consumer Group: reward-workers]
|
+----> [Worker A]
|
+----> [Worker B]
|
+----> [Worker C]
동일한 Consumer Group 안에서는 일반적으로 하나의 새 메시지를 한 소비자가 할당받아 처리한다. 서로 다른 Consumer Group은 동일한 스트림의 메시지를 각자 독립적으로 소비할 수 있다.
예를 들어 reward-workers 그룹은 보상을 지급하고, analytics-workers 그룹은 통계를 수집하도록 구성할 수 있다.
메시지 읽기와 ACK
Consumer Group에서는 XREADGROUP으로 메시지를 읽고, 처리가 완료되면 XACK으로 완료 사실을 기록한다.
XREADGROUP GROUP reward-workers worker-a
COUNT 10
BLOCK 5000
STREAMS game:events >
위 명령은 그룹에서 아직 전달되지 않은 새 메시지를 최대 열 개까지 읽고, 새 메시지가 없으면 최대 몇 초간 대기한다.
처리가 끝난 메시지는 다음과 같이 확인한다.
XACK game:events reward-workers 1729780000000-0
실제 메시지 ID는 메시지를 읽을 때 반환된 값을 사용해야 한다.
ACK를 보내기 전에 소비자가 종료되면 메시지는 미확인 상태인 Pending Entries List(PEL)에 남을 수 있다. 다른 소비자가 일정 시간이 지난 메시지를 XAUTOCLAIM 등의 명령으로 회수해 재처리할 수 있다.
이 구조는 장애 복구에 유리하지만, 메시지가 한 번만 처리된다는 뜻은 아니다. 작업 수행 후 ACK 전송 전에 프로세스가 종료되면 동일한 작업이 다시 수행될 수 있다.
따라서 소비자는 중복 실행을 견딜 수 있도록 설계해야 한다.
Redis Pub/Sub과 Streams 비교
| 항목 | Redis Pub/Sub | Redis Streams |
|---|---|---|
| 메시지 저장 | 저장하지 않음 | 스트림에 저장 |
| 오프라인 소비자 | 메시지 유실 가능 | 보존된 메시지 재조회 가능 |
| 소비 완료 확인 | 기본 제공하지 않음 | Consumer Group ACK 지원 |
| 미처리 메시지 추적 | 기본 제공하지 않음 | PEL 지원 |
| 장애 후 재처리 | 별도 구현 필요 | 재조회 및 메시지 회수 가능 |
| 주요 용도 | 실시간 통지 | 비동기 작업과 이벤트 처리 |
Streams도 무제한 보존되는 것은 아니다. 스트림 길이 제한, 명시적 삭제, 메모리 부족에 따른 운영 정책 등으로 메시지가 제거될 수 있다. 중요한 이벤트라면 보존 기간과 영속성 설정까지 설계해야 한다.
분산 락과 한계
분산 락이 필요한 이유
일반적인 프로세스 내부에서는 Mutex나 Lock으로 공유 자원에 대한 동시 접근을 제어할 수 있다.
하지만 서버가 여러 대라면 각 서버의 메모리에 존재하는 락은 서로 독립적이다.
[서버 A] [서버 B]
Mutex A Mutex B
| |
+------ 공유 자원 --------+
서버 A가 락을 획득해도 서버 B는 자신의 Mutex를 획득할 수 있다. 두 서버가 동시에 같은 작업을 수행할 수 있다는 의미다.
분산 락은 여러 프로세스나 서버가 공유하는 조정 수단을 통해 특정 작업을 한 번에 한 주체만 수행하도록 유도한다.
예를 들어 다음 작업에 사용할 수 있다.
- 특정 계정의 정산 작업
- 중복된 스케줄 작업 방지
- 동일한 데이터의 동시 갱신 제어
- 여러 서버에서 실행되는 배치 작업의 조정
단, 잔액 변경이나 아이템 구매처럼 정확성이 중요한 작업은 분산 락만으로 보호하기보다 데이터베이스 트랜잭션과 제약 조건을 함께 사용해야 한다.
Redis를 이용한 락 획득
Redis에서는 SET 명령의 NX, PX 옵션을 이용해 락을 획득할 수 있다.
SET lock:account:123 unique-token NX PX 10000
NX: 키가 존재하지 않을 때만 설정한다.PX: 락의 만료 시간을 밀리초 단위로 설정한다.unique-token: 락을 획득한 주체를 식별하는 고유한 값이다.
명령이 성공하면 락을 획득한 것이고, 키가 이미 존재하면 획득에 실패한 것이다.
만료 시간은 락 소유자가 비정상 종료됐을 때 락이 영구적으로 남는 상황을 방지한다.
락 해제 시 주의점
다음과 같은 방식은 안전하지 않다.
GET lock:account:123
DEL lock:account:123
락을 확인한 시점과 삭제하는 시점 사이에 다른 프로세스가 락을 다시 획득할 수 있기 때문이다.
예를 들어 A의 락이 만료된 뒤 B가 새 락을 획득했는데, A가 뒤늦게 DEL을 실행하면 B의 락까지 삭제할 수 있다.
따라서 락을 해제할 때는 현재 저장된 토큰과 자신의 토큰이 일치하는 경우에만 삭제해야 한다. Redis에서는 Lua 스크립트로 비교와 삭제를 원자적으로 수행할 수 있다.
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
이 스크립트는 락 소유권을 확인하고 해당 락을 삭제하는 작업을 하나의 원자적 연산으로 처리한다.
분산 락의 한계
분산 락을 사용하더라도 동시성 문제가 완전히 사라지는 것은 아니다.
락 만료 이후에도 기존 작업이 실행될 수 있다.
A가 락을 획득하고 긴 작업을 수행하는 도중 락이 만료됐다고 가정하자. B가 새 락을 획득해 같은 작업을 시작할 수 있다. 이때 A가 계속 실행된다면 두 작업이 동시에 수행된다.
네트워크 단절로 소유권 판단이 어려울 수 있다.
서버 A가 Redis와 연결되지 않았더라도 이미 시작한 작업을 계속 실행할 수 있다. 락 서버가 락을 만료시켰다면 A의 작업과 새로운 소유자의 작업이 겹칠 수 있다.
락은 비즈니스 데이터의 정합성을 직접 보장하지 않는다.
락이 올바르게 작동하더라도 데이터베이스 트랜잭션이 실패하거나 외부 API 요청이 중복 실행될 수 있다.
따라서 강한 정합성이 필요하다면 다음 기법을 함께 고려해야 한다.
- 데이터베이스 트랜잭션
- 유니크 제약 조건
- 낙관적 동시성 제어와 버전 번호
- 소유권 세대 번호를 사용하는 Fencing Token
- 멱등성 키를 통한 중복 요청 방지
분산 락은 동시 실행을 줄이는 조정 도구이지, 모든 장애 상황에서 작업의 단일 실행을 보장하는 만능 장치가 아니다.
서버 간 이벤트 전달
이벤트 기반 구조
서버 간 이벤트 전달은 특정 상태 변화가 발생했을 때 그 사실을 이벤트로 발행하고, 관심 있는 서비스가 이벤트를 구독해 후속 작업을 수행하는 구조다.
예를 들어 플레이어가 게임에서 퇴장했다면 다음과 같은 이벤트를 발행할 수 있다.
{
"eventId": "evt-10001",
"type": "PlayerDisconnected",
"playerId": "player-123",
"sessionId": "session-456",
"occurredAt": "2026-10-24T09:00:00.000Z"
}
이벤트를 받은 각 서비스는 자신에게 필요한 작업을 수행한다.
[게임 서버]
|
| PlayerDisconnected
v
[이벤트 전달 계층]
|
+----> [친구 서비스: 온라인 상태 갱신]
|
+----> [로비 서비스: 참가자 목록 갱신]
|
+----> [통계 서비스: 접속 기록 저장]
|
+----> [알림 서비스: 필요 시 알림 전송]
게임 서버는 각 서비스의 구현 세부 사항을 알 필요가 없다. 새로운 기능이 추가되더라도 기존 게임 서버를 직접 수정하지 않고 새로운 소비자를 연결할 수 있다.
이벤트와 명령의 차이
이벤트는 이미 발생한 사실을 나타내고, 명령은 특정 작업을 수행하라는 요청을 나타낸다.
| 구분 | 이벤트 | 명령 |
|---|---|---|
| 의미 | 어떤 일이 발생했다 | 어떤 일을 수행하라 |
| 예시 | PlayerDisconnected | RemovePlayerFromRoom |
| 관점 | 과거의 사실 | 수행해야 할 작업 |
| 처리 주체 | 관심 있는 소비자들 | 지정된 처리 주체 |
이벤트 이름은 일반적으로 PlayerDisconnected, RewardGranted처럼 이미 일어난 일을 표현한다.
이벤트를 설계할 때는 이벤트 ID, 이벤트 종류, 발생 시각, 대상 식별자, 스키마 버전을 포함하는 것이 좋다. 다만 인증 토큰이나 비밀번호 같은 민감한 정보를 이벤트에 포함해서는 안 된다.
이벤트 전달 방식 선택
모든 이벤트에 같은 전달 방식을 사용할 필요는 없다.
- Redis Pub/Sub: 유실돼도 괜찮은 실시간 알림
- Redis Streams: 저장과 재처리가 필요한 비동기 이벤트
- 메시지 브로커: 대규모 이벤트 라우팅과 소비자 확장이 필요한 환경
- 데이터베이스 트랜잭션 및 Outbox 패턴: DB 변경과 이벤트 발행 사이의 유실을 방지해야 하는 환경
예를 들어 게임 로비의 일시적인 상태 갱신은 Pub/Sub으로 처리할 수 있다. 반면 게임 결과에 따른 보상 지급은 Streams나 별도 메시지 브로커, DB 기반 작업 큐 등을 활용해 복구 가능한 흐름으로 설계하는 것이 적절하다.
중복 처리와 장애 대응
메시지 중복은 정상적인 상황이다
분산 시스템에서는 메시지가 중복 전달될 수 있다.
예를 들어 소비자가 보상 지급 작업을 완료했지만 ACK를 보내기 전에 프로세스가 종료됐다고 가정하자. 다른 소비자가 미확인 메시지를 회수해 같은 작업을 다시 수행할 수 있다.
이 상황에서 메시지를 반드시 한 번만 전달하도록 만드는 데 의존하기보다, 같은 메시지가 여러 번 처리돼도 결과가 한 번만 반영되도록 설계하는 것이 중요하다.
이를 멱등성(Idempotency) 이라고 한다.
이벤트 ID를 이용한 중복 처리 방지
각 이벤트에 고유한 eventId를 부여하고, 처리한 이벤트 ID를 데이터베이스에 기록할 수 있다.
CREATE TABLE processed_events (
event_id VARCHAR(100) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);
이 테이블의 기본키는 동일한 이벤트 ID가 여러 번 등록되는 것을 방지한다.
처리 이력 기록과 실제 비즈니스 변경은 가능한 한 같은 데이터베이스 트랜잭션으로 묶어야 한다.
BEGIN TRANSACTION
- eventId 처리 이력 등록
- 이미 등록된 ID라면 중복으로 판단
- 비즈니스 데이터 변경
- 트랜잭션 커밋
COMMIT
예를 들어 보상 지급이라면 이벤트 처리 이력과 보상 지급 기록을 함께 트랜잭션으로 관리할 수 있다.
단순히 이벤트를 먼저 처리한 뒤 별도 트랜잭션으로 처리 이력을 기록하면, 두 작업 사이의 장애로 보상이 중복 지급될 수 있다.
외부 결제 API처럼 동일한 데이터베이스 트랜잭션에 포함할 수 없는 작업은 외부 시스템의 멱등성 키, 재시도 정책, 결과 조회 및 보상 절차를 함께 설계해야 한다.
재시도와 실패 처리
일시적인 네트워크 오류나 서비스 과부하가 발생했을 때는 재시도가 필요하다. 그러나 무조건 즉시 재시도하면 장애가 발생한 서비스에 요청이 집중되어 상황이 악화될 수 있다.
일반적인 대응 전략은 다음과 같다.
- Exponential Backoff: 재시도 간격을 점진적으로 늘린다.
- Jitter: 재시도 시점에 무작위 편차를 추가해 요청 집중을 완화한다.
- 최대 재시도 횟수: 무한 재시도를 방지한다.
- Dead Letter Queue 또는 별도 실패 스트림: 반복 실패 메시지를 격리한다.
- 모니터링과 알림: Pending 메시지, 처리 지연, 실패 횟수를 관찰한다.
Redis Streams에서는 Pending Entries List를 모니터링하고, 일정 시간 이상 처리되지 않은 메시지를 회수할 수 있다. 반복 실패한 메시지는 별도 스트림으로 옮기거나 실패 상태를 기록해 운영자가 원인을 확인할 수 있도록 해야 한다.
단, 실패한 메시지를 무조건 ACK 처리하면 복구되지 않은 작업이 유실될 수 있다. 실패 유형에 따라 재시도, 격리, 보상 처리 중 적절한 정책을 선택해야 한다.
Outbox 패턴
데이터베이스 변경과 메시지 발행 사이에는 중요한 장애 지점이 존재한다.
예를 들어 게임 결과를 DB에 저장한 후 이벤트를 발행한다고 가정하자.
- 게임 결과 DB 저장 성공
- 서버 프로세스 종료
- 이벤트 발행 실패
이 경우 DB에는 게임 결과가 있지만 다른 서비스는 결과를 알지 못한다.
Outbox 패턴은 비즈니스 데이터 변경과 이벤트 발행 대상을 같은 데이터베이스 트랜잭션으로 저장해 이 문제를 해결한다.
BEGIN TRANSACTION
- 게임 결과 저장
- Outbox 테이블에 이벤트 저장
COMMIT
별도의 발행 워커가 Outbox 테이블에서 미발행 이벤트를 읽어 Redis Streams나 메시지 브로커로 발행한다.
[게임 서버]
|
| 단일 DB 트랜잭션
v
[게임 결과 테이블] + [Outbox 테이블]
|
v
[발행 워커]
|
v
[Redis Streams]
|
v
[소비자]
발행 워커가 메시지를 발행한 직후 완료 상태를 기록하기 전에 종료되면 같은 이벤트가 다시 발행될 수 있다. 따라서 Outbox 패턴도 중복 전달을 완전히 제거하지는 않는다.
이벤트 ID와 소비자의 멱등성 처리를 결합하면 DB 변경과 이벤트 전달 사이의 유실 위험을 줄이면서 안정적인 복구 구조를 만들 수 있다.
실시간 게임 서버에 적용하기
실시간 게임에서는 이벤트의 중요도와 허용 가능한 지연에 따라 서로 다른 전달 방식을 사용해야 한다.
| 기능 | 권장 접근 방식 | 핵심 고려 사항 |
|---|---|---|
| 온라인 상태 변경 통지 | Pub/Sub 또는 상태 저장소 기반 조회 | 재접속과 상태 만료 |
| 로비 참가자 목록 갱신 | Pub/Sub 또는 Streams | 최신 상태와 이벤트 순서 |
| 게임 결과 저장 | DB 트랜잭션 + Outbox | 결과와 이벤트 발행의 정합성 |
| 보상 지급 | Streams 또는 내구성 있는 메시지 큐 | 멱등성, 재시도, 지급 기록 |
| 배치 작업 중복 실행 방지 | 분산 락 + DB 제약 조건 | 락 만료와 동시 실행 |
| 서버 간 캐시 무효화 | Pub/Sub | 메시지 유실 시 재동기화 |
| 분석 및 통계 이벤트 | Streams 또는 메시지 브로커 | 처리량과 보존 정책 |
온라인 상태나 로비 목록은 일정 수준의 지연이나 재동기화를 허용할 수 있다. 반면 보상 지급과 경제 데이터 변경은 중복 실행이나 유실이 직접적인 데이터 오류로 이어지므로 더 강한 보호 장치가 필요하다.
실시간 게임 서버에서는 모든 상태를 이벤트로만 관리할 필요도 없다. 현재 상태를 저장소에서 조회하고, 이벤트는 상태 변경 사실을 다른 서비스에 알리는 용도로 사용하는 구조가 더 단순하고 안정적일 수 있다.
설계 시 핵심 원칙
분산 기능을 설계할 때는 기술 선택보다 먼저 요구사항을 정의해야 한다.
- 메시지 유실이 허용되는가? 허용된다면 Pub/Sub이 적합할 수 있고, 허용되지 않는다면 영속적인 메시지 저장과 복구 경로가 필요하다.
- 소비자가 여러 번 처리해도 되는가? 재시도가 가능한 구조라면 멱등성 키와 중복 처리 방지 로직을 설계한다.
- 작업 완료를 어떻게 판단하는가? 메시지 수신과 실제 비즈니스 작업 완료를 구분하고 ACK 시점을 명확히 한다.
- 장애 발생 후 어떻게 복구하는가? Pending 메시지, 실패 스트림, 재시도 정책, 운영자 개입 절차를 마련한다.
- 동시 실행을 어떻게 제어하는가? 분산 락뿐 아니라 DB 트랜잭션, 유니크 제약 조건, 버전 검증을 함께 검토한다.
- 이벤트 발행과 DB 변경이 원자적인가? 두 작업 사이의 유실이 허용되지 않는다면 Outbox 패턴을 고려한다.