Node.js + TypeScript 기반의 게임 서버를 기준으로, 3-Layer Architecture부터 Model, Repository, MySQL, Redis 캐시, 도메인 모델, 인증, 트랜잭션, 메시지 큐까지 각 구성 요소의 역할과 의존 관계를 하나의 체계로 정리한다.
핵심은 각각의 기술을 따로 공부하는 것이 아니라, 클라이언트 요청이 들어왔을 때 어떤 계층을 거쳐 데이터를 조회·수정하고, 어디에 저장하며, 캐시와 데이터 정합성을 어떻게 유지하는지 이해하는 것이다.
전체 아키텍처 한눈에 보기
Client / Game Client
Unity · Web · Mobile · API Request
Presentation Layer
HTTP Controller · Router · Middleware · DTO · Validation · Response
Application / Business Flow
Application Service · Use Case · Transaction · Authorization
요청 흐름을 제어하고 도메인 로직 실행 및 저장소 호출을 조율한다.
Domain Layer
Entity · Value Object · Aggregate · Domain Service · Domain Event
게임 규칙, 상태 변경 조건, 불변식 등 핵심 비즈니스 규칙을 관리한다.
Infrastructure / Persistence
MySQL
영구 데이터
Redis
캐시 · 세션 · TTL
Message Queue
비동기 작업
Logs / Metrics
관측 · 분석
이 그림은 역할을 설명하기 위한 논리적 구조다. 실제 3-Layer Architecture에서는 Domain Layer나 Infrastructure Layer를 별도 계층으로 분리하지 않을 수도 있다. 3-Layer, DDD, Clean Architecture는 서로 다른 설계 관점이며 필요에 따라 함께 사용할 수 있다.
3-Layer Architecture의 구성 요소
3-Layer Architecture는 시스템을 크게 Presentation, Business, Data Access 계층으로 분리한다.
Presentation Layer — 표현 계층
클라이언트 요청을 받고 응답을 반환한다.
Router · Controller · Middleware · DTO · Validator · Response Serializer
Business Layer — 비즈니스 계층
요청의 실행 순서를 제어하고 비즈니스 규칙을 적용한다.
Application Service · Use Case · Domain Model · Domain Service · Unit of Work
Data Access Layer — 데이터 접근 계층
영구 데이터 및 캐시의 조회와 저장을 담당한다.
Repository · ORM · MySQL Driver · Redis Client · Cache Adapter
실제 프로젝트에서는 이 세 계층만으로 충분하지 않을 수 있다. 인증, 외부 API, 메시지 큐, 로깅, 도메인 모델 등의 책임을 별도 모듈로 분리할 수 있다.
각 구성 요소의 역할 전체 목록
표현 계층
| 구성 요소 | 역할 |
|---|---|
| Router | URL과 HTTP 메서드를 처리 함수에 연결 |
| Controller | 요청을 해석하고 Application Service를 호출 |
| Middleware | 인증, 로깅, CORS, 요청 제한 등 공통 처리 |
| DTO | 요청·응답 데이터의 구조 정의 |
| Validator | 입력값의 형식과 허용 범위 검증 |
| Serializer | 내부 데이터를 외부 응답 형식으로 변환 |
| Exception Handler | 오류를 HTTP 응답으로 변환 |
| API Versioning | API 버전별 호환성 관리 |
| OpenAPI / Swagger | API 명세 작성 및 문서화 |
비즈니스 계층
| 구성 요소 | 역할 |
|---|---|
| Application Service | 유스케이스의 실행 흐름 조율 |
| Use Case | 특정 기능의 실행 단위 |
| Entity | 식별성과 생명주기를 갖는 도메인 객체 |
| Value Object | 값 자체로 의미를 표현하는 불변 객체 |
| Aggregate | 도메인 객체의 일관성을 보장하는 경계 |
| Aggregate Root | Aggregate의 외부 접근 진입점 |
| Domain Service | 특정 Entity 하나에 속하지 않는 도메인 규칙 |
| Domain Event | 도메인에서 발생한 중요한 사건 표현 |
| Policy | 조건에 따른 비즈니스 규칙 및 의사결정 |
| Specification | 복합적인 도메인 조건을 캡슐화 |
| Unit of Work | 변경 사항의 저장 및 트랜잭션 단위 관리 |
데이터 접근 계층
| 구성 요소 | 역할 |
|---|---|
| Repository | 도메인 객체의 조회와 저장을 추상화 |
| Repository Implementation | 실제 DB 접근 구현 |
| ORM | 객체와 관계형 테이블 사이의 매핑 |
| Query Builder | SQL을 프로그래밍 방식으로 구성 |
| Data Mapper | DB 데이터와 도메인 객체를 변환 |
| DAO | 데이터 접근 연산을 캡슐화 |
| Redis Cache | 반복 조회 결과를 메모리에 캐싱 |
| Cache Manager | 캐시 조회, 생성, 삭제, 만료 정책 관리 |
| Connection Pool | DB 연결을 재사용 |
| Transaction Manager | 트랜잭션의 시작, 커밋, 롤백 관리 |
| Migration | DB 스키마 변경 이력 관리 |
| Read/Write Router | 읽기와 쓰기 DB를 분리해 라우팅 |
Repository와 DAO는 유사하지만 동일한 개념은 아니다. Repository는 일반적으로 도메인 객체의 컬렉션처럼 동작하는 추상화이며, DAO는 데이터 접근 작업 자체를 중심으로 설계한다.
Model, Entity, DTO, ORM의 차이
Model은 문맥에 따라 의미가 달라진다. 모든 Model이 같은 역할을 하는 것은 아니다.
| 개념 | 의미 | 예시 |
|---|---|---|
| Domain Model | 비즈니스 규칙과 상태를 표현 | 플레이어 레벨업, 인벤토리 |
| Entity | 고유 식별자를 갖는 객체 | Player, Item |
| Value Object | 값으로 동일성을 판단하는 객체 | Position, Money |
| Persistence Model | DB 테이블과 대응하는 데이터 구조 | PlayerRow |
| DTO | 계층 또는 시스템 사이에서 전달하는 데이터 | PlayerResponseDto |
| ORM Model | ORM 라이브러리에서 관리하는 데이터 객체 | Sequelize Model, Prisma 반환 타입 |
| View Model | 화면 또는 API 응답에 맞춘 데이터 구조 | 프로필 화면 데이터 |
이 구분은 특히 중요하다.
예를 들어 플레이어 레벨업이라는 규칙을 단순한 DB 객체에 넣을 수도 있지만, 도메인 모델로 분리하면 규칙을 독립적으로 테스트하고 재사용하기 쉬워진다.
// Domain Model
class Player {
constructor(
public readonly id: string,
private level: number,
private exp: number,
) {}
gainExp(amount: number): void {
if (amount <= 0) {
throw new Error("경험치는 양수여야 합니다.");
}
this.exp += amount;
while (this.exp >= this.requiredExp()) {
this.exp -= this.requiredExp();
this.level++;
}
}
private requiredExp(): number {
return this.level * 100;
}
getLevel(): number {
return this.level;
}
getExp(): number {
return this.exp;
}
}
이 객체는 경험치 증가와 레벨업 규칙을 담당한다. MySQL이나 Redis에 직접 접근하지 않는다.
반면 ORM Model은 DB의 데이터를 읽고 쓰기 위한 라이브러리의 객체일 수 있다. ORM을 사용하는 모든 프로젝트가 별도의 Domain Model을 필요로 하는 것은 아니지만, 비즈니스 규칙이 복잡해질수록 역할을 분리할 가치가 커진다.
Repository와 MySQL의 관계
Repository는 DB 그 자체가 아니다. DB 접근 방법을 상위 계층으로부터 숨기는 추상화다.
Application Service
플레이어 조회 유스케이스
PlayerRepository Interface
findById · save · delete
MySQL PlayerRepository Implementation
SQL · ORM · 데이터 변환
MySQL
영구 데이터 저장소
예를 들어 Application Service는 SELECT 문이나 특정 ORM 라이브러리의 사용법을 알 필요가 없다. PlayerRepository에 플레이어를 조회하라고 요청하면 된다.
interface PlayerRepository {
findById(id: string): Promise<Player | null>;
save(player: Player): Promise<void>;
}
실제 구현은 MySQL을 사용할 수도 있고, 테스트에서는 메모리 저장소를 사용할 수도 있다.
class GetPlayerService {
constructor(
private readonly players: PlayerRepository,
) {}
async execute(id: string): Promise<Player | null> {
return this.players.findById(id);
}
}
이 설계에서는 DB 구현이 바뀌어도 Application Service의 코드는 유지할 수 있다.
Redis 캐시는 어디에 들어가는가?
Redis 캐시는 데이터 접근 계층의 일부로 두는 경우가 많지만, 캐시를 관리하는 위치는 설계에 따라 달라진다.
일반적인 Cache-Aside 구조는 다음과 같다.
Application Service
Player Repository / Cached Repository
Redis
캐시 조회
MySQL
원본 데이터
캐시 미스 시 MySQL에서 조회하고 결과를 Redis에 저장한다.
캐시를 Repository 내부에서 처리할 수도 있고, Repository를 감싸는 별도의 캐시 어댑터로 분리할 수도 있다.
예를 들어 다음과 같이 구성할 수 있다.
PlayerRepository: 플레이어 데이터 조회 및 저장 계약MySqlPlayerRepository: MySQL을 이용한 영구 저장RedisPlayerCache: 캐시 조회·저장·삭제CachedPlayerRepository: Redis와 MySQL을 조합한 조회 정책
핵심은 Redis가 MySQL의 대체 원본이 아니라 조회 성능을 개선하는 보조 저장소인지, 아니면 세션·실시간 상태처럼 별도의 책임을 갖는 저장소인지 명확히 구분하는 것이다.
캐시 패턴
| 패턴 | 데이터 흐름 | 특징 |
|---|---|---|
| Cache-Aside | 캐시 조회 → 미스 시 DB 조회 → 캐시 저장 | 범용적이며 애플리케이션이 캐시를 관리 |
| Read-Through | 캐시 계층에 조회 요청 → 미스 시 로더가 DB 조회 | 캐시 계층이 로딩 책임을 가짐 |
| Write-Through | 쓰기 시 캐시 계층을 통해 원본 DB도 갱신 | 쓰기 일관성을 관리하기 편하지만 쓰기 지연이 증가할 수 있음 |
| Write-Behind / Write-Back | 캐시에 먼저 기록하고 DB 저장을 나중에 수행 | 성능에 유리할 수 있지만 장애 시 데이터 유실 위험 |
| Write-Around | DB에 직접 쓰고 캐시는 읽을 때 생성 | 쓰기 직후 캐시가 비어 있을 수 있음 |
일반적인 게임 백엔드의 플레이어 프로필 조회에는 Cache-Aside가 실용적인 출발점이다. 반면 재화 차감이나 결제처럼 정합성이 중요한 데이터는 캐시를 권위 있는 원본으로 취급하지 않는 편이 안전하다.
데이터 변경과 캐시 무효화
캐시를 사용하는 시스템에서 가장 중요한 문제는 DB와 캐시 사이의 정합성이다.
일반적인 Cache-Aside 쓰기 흐름은 다음과 같다.
- Application Service가 비즈니스 규칙을 검증한다.
- MySQL 트랜잭션에서 데이터를 수정하고 커밋한다.
- 커밋 성공 후 관련 Redis 캐시를 삭제한다.
- 다음 조회에서 캐시 미스가 발생하면 MySQL 데이터를 다시 읽어 캐시를 생성한다.
DB를 먼저 수정하고 캐시를 삭제하는 방식은 DB 저장에 실패했는데 캐시만 변경되는 문제를 피하기 쉽다. 다만 DB 커밋 후 Redis 삭제에 실패하면 오래된 캐시가 남을 수 있으므로 TTL, 재시도, 캐시 버전, Outbox 패턴 등의 보완책이 필요할 수 있다.
캐시와 DB를 동시에 수정하는 것만으로 원자성이 보장되지는 않는다. MySQL 트랜잭션은 Redis의 변경까지 자동으로 롤백하지 않는다.
트랜잭션과 Unit of Work
트랜잭션은 여러 DB 작업을 하나의 원자적 단위로 묶는다.
예를 들어 게임에서 아이템을 구매한다면 다음 작업이 함께 처리되어야 한다.
- 플레이어의 재화 차감
- 아이템 지급
- 구매 기록 저장
BEGIN
플레이어 재화 확인 및 차감
아이템 인벤토리 반영
구매 기록 저장
COMMIT
중간 작업이 실패하면 ROLLBACK을 수행한다.
Unit of Work는 작업 중 발생한 변경을 모아 저장하는 설계 패턴이다. 실제 DB 트랜잭션과 결합할 수 있지만 두 개념은 동일하지 않다.
또한 동시 구매 요청이 들어오면 단순한 조회 후 차감만으로는 재화가 음수가 되는 경쟁 조건이 발생할 수 있다. 조건부 UPDATE, 적절한 잠금, 격리 수준, 버전 기반 낙관적 잠금 등을 함께 고려해야 한다.
DDD와 3-Layer Architecture의 관계
DDD(Domain-Driven Design)는 도메인 모델과 비즈니스 규칙을 중심으로 소프트웨어를 설계하는 접근법이다. 3-Layer Architecture는 책임에 따라 코드를 분리하는 구조다.
둘은 경쟁 관계가 아니다.
| 개념 | 해결하려는 문제 |
|---|---|
| 3-Layer Architecture | 표현·비즈니스·데이터 접근 책임 분리 |
| DDD | 복잡한 도메인 규칙과 모델의 표현 |
| Clean Architecture | 의존성이 핵심 비즈니스 로직을 향하도록 설계 |
| Hexagonal Architecture | 도메인과 외부 시스템을 포트·어댑터로 분리 |
| CQRS | 명령과 조회 모델 및 처리 경로 분리 |
| Event-Driven Architecture | 이벤트를 통해 컴포넌트 간 비동기 연결 |
모든 시스템에 DDD, CQRS, 이벤트 소싱을 도입할 필요는 없다. 단순한 CRUD 서비스는 3-Layer만으로 충분할 수 있다. 게임 규칙과 상태 전이가 복잡해질수록 도메인 모델을 분리하는 효과가 커진다.
권장 프로젝트 디렉터리 구조
Node.js + TypeScript + Express + MySQL + Redis 기반 게임 서버를 예시로 들면 다음과 같다.
src/
├── presentation/
│ ├── routes/
│ ├── controllers/
│ ├── middlewares/
│ ├── validators/
│ └── dtos/
│
├── application/
│ ├── players/
│ │ ├── GetPlayerService.ts
│ │ ├── UpdatePlayerService.ts
│ │ └── PurchaseItemService.ts
│ ├── ports/
│ └── transactions/
│
├── domain/
│ ├── player/
│ │ ├── Player.ts
│ │ ├── PlayerRepository.ts
│ │ └── PlayerDomainService.ts
│ ├── inventory/
│ ├── economy/
│ └── events/
│
├── infrastructure/
│ ├── database/
│ │ ├── mysql/
│ │ ├── migrations/
│ │ └── repositories/
│ ├── cache/
│ │ ├── RedisClient.ts
│ │ └── RedisPlayerCache.ts
│ ├── messaging/
│ ├── logging/
│ └── external/
│
├── shared/
│ ├── errors/
│ ├── config/
│ └── utils/
│
├── app.ts
└── server.ts
tests/
├── unit/
├── integration/
└── e2e/
이 구조는 필수 표준이 아니라 책임 분리를 위한 예시다. 규모가 작은 프로젝트에서는 디렉터리를 합칠 수 있고, DDD를 적용하지 않는다면 domain/을 별도로 두지 않아도 된다.
게임 서버에 필요한 추가 아키텍처
| 영역 | 주요 구성 요소 | 목적 |
|---|---|---|
| 인증·보안 | Password Hash, Salt, Session, JWT, Refresh Token, 2FA | 사용자 인증 및 권한 관리 |
| 네트워크 | HTTP, WebSocket, WebRTC, RPC | 클라이언트 통신 |
| 실시간 게임 | Game Room, Matchmaking, Authoritative Server | 게임 상태와 매치 관리 |
| 데이터베이스 | MySQL, 인덱스, 트랜잭션, Read Replica | 영구 데이터와 조회 성능 관리 |
| 캐시 | Redis, TTL, Cache-Aside, 분산 락 | 반복 조회와 공유 상태 처리 |
| 메시징 | RabbitMQ, Kafka, Redis Streams | 비동기 작업 및 이벤트 전달 |
| 확장성 | Load Balancer, Horizontal Scaling, Kubernetes | 트래픽 증가와 장애 대응 |
| 관측성 | Pino, OpenTelemetry, Prometheus, Loki, Grafana | 로그·메트릭·트레이스 관측 |
| API 문서화 | OpenAPI, Swagger | 인터페이스 명세와 테스트 |
| 품질 관리 | Unit Test, Integration Test, E2E Test | 기능 검증 및 회귀 방지 |
이 요소들은 모두 하나의 계층에 속하는 것이 아니다. 인증 미들웨어는 표현 계층에 위치할 수 있고, 인증 정책은 Application 또는 Domain 계층에 있을 수 있다. 메시징과 로깅의 실제 구현은 대개 Infrastructure 계층에 위치한다.
반드시 이해해야 하는 의존성 규칙
- Controller는 Application Service를 호출한다.
- Application Service는 도메인 객체를 사용하고 Repository 등의 추상화에 의존할 수 있다.
- Domain Model은 일반적으로 HTTP, Redis, MySQL 구현을 직접 알지 않는다.
- Repository 구현은 DB와 ORM을 사용한다.
- Redis 캐시 정책은 캐시 어댑터 또는 별도의 캐시 서비스에서 관리할 수 있다.
- 외부 API, 메시지 큐, 파일 시스템 등은 Infrastructure 계층에서 연결한다.
- 트랜잭션 경계는 여러 저장 작업을 조율하는 Application Service 또는 Unit of Work에서 관리할 수 있다.
최종적으로 기억할 구조는 다음과 같다.
Client
→ Router / Middleware
→ Controller / DTO
→ Application Service
→ Domain Model
→ Repository Interface
→ Repository Implementation
→ Redis Cache / MySQL
단, 모든 요청이 반드시 모든 단계를 거치는 것은 아니다. 단순 조회, 실시간 WebSocket 처리, 비동기 이벤트 처리 등은 서로 다른 실행 경로를 가질 수 있다. 중요한 것은 계층의 개수가 아니라 각 구성 요소의 책임과 의존 방향을 명확히 유지하는 것이다.