TypeScript 기반 멀티프로세스 · Worker Thread · Redis · MySQL · Socket.IO를 활용한 턴제 PvP 게임 서버 개발
이번 프로젝트는 TypeScript로 개발한 모바일 턴제 PvP 게임 서버다.
Unity 클라이언트와 REST API, Socket.IO를 사용했고 Redis와 MySQL을 함께 사용했다. 서버는 하나의 프로세스에서 모든 기능을 처리하지 않고 Main-App, Mono-App, PvP-App, System-App으로 역할을 나눴다.
구조를 잡을 때 가장 많이 고민했던 부분은 서버를 몇 개로 나눌 것인가가 아니었다.
각 상태를 어디에서 관리할 것인가였다.
로그인이나 상점처럼 요청 하나로 끝나는 기능이 있는 반면, 매칭은 사용자가 대기하는 동안 상태가 계속 유지되어야 하고 PvP는 게임이 끝날 때까지 Room 상태를 가지고 있어야 한다.
그래서 기능의 처리 방식과 상태의 수명을 기준으로 서버를 분리했다.
Unity Client -> Main-App(REST) / Mono-App(Matching) / PvP-App(Battle) -> Redis / MySQL
Main-App은 일반적인 게임 API와 세션을 담당하고, Mono-App은 로비와 매칭을 처리한다. 실제 PvP는 PvP-App의 Worker Thread에서 Room 단위로 처리했다.
System-App은 Cron 작업이나 캐시 초기화, 데이터 반영과 같은 별도의 작업을 담당하도록 구성했다.
서버 구조를 분리한 이유
처음부터 마이크로서비스 구조를 만들려고 한 것은 아니다.
REST 요청과 실시간 게임을 같은 프로세스에서 처리하는 것보다, 서로 다른 생명주기를 가진 상태를 분리하는 편이 관리하기 좋다고 판단했다.
REST -> Main-App
Matching -> Mono-App
PvP Room -> PvP Worker
Batch / Cache -> System-App
Main-App은 Node.js Cluster를 사용해 CPU 코어 수만큼 프로세스를 생성했다.
HTTP 요청은 서로 독립적인 경우가 많기 때문에 여러 Worker Process가 나눠 처리할 수 있었다. 한 프로세스에 문제가 생겨도 Cluster에서 다시 프로세스를 만들 수 있다는 점도 이 구조를 선택한 이유였다.
PvP는 조금 달랐다.
게임 하나가 Room이라는 상태를 계속 유지해야 했기 때문에 worker_threads를 사용했다.
Worker마다 별도의 V8 실행 환경과 Event Loop를 가지고 있고, Room 객체를 해당 Worker의 메모리에 유지했다.
PvP-App Master -> Worker 1 -> Room A / Room B
-> Worker 2 -> Room C / Room D
-> Worker 3 -> Room E
Room 하나가 특정 Worker에 들어가면 그 Room에서 발생하는 이벤트도 같은 Worker에서 처리했다.
덕분에 전투 상태를 매번 외부 저장소에서 읽어오지 않아도 됐고, Socket 이벤트도 Room 내부에서 바로 처리할 수 있었다.
다만 이 선택은 나중에 다른 문제를 만들었다.
Room의 생명주기가 Worker의 생명주기에 묶였다.
Worker 메모리에 Room을 둔 구조
전투방은 Redis에 모든 상태를 넣지 않고 Worker 메모리에 유지했다.
실시간 전투에서 매 패킷마다 Redis를 조회할 필요는 없다고 봤다. 턴 정보나 플레이어 상태처럼 계속 접근하는 데이터까지 외부 저장소에 넣으면 네트워크 왕복이 계속 발생한다.
그래서 Worker 내부에서 Room을 관리했다.
Client
-> Socket.IO
-> PvP Worker
-> PVPRoom
-> Turn / Player State
게임 Room은 FSM 형태로 관리했다.
InitializeRoom -> ReadyUser -> JoinUser -> Turn Loop -> EndGame / BreakGame
Ready 단계에서는 플레이어의 연결을 기다리고, Join 단계에서는 게임 진입을 확인한다.
그 이후에는 턴을 반복하면서 게임을 진행하고, 게임이 끝나면 결과를 처리하고 Room을 정리한다.
구현 당시에는 이 방식이 꽤 깔끔했다.
문제는 Worker가 죽었을 때였다.
Worker의 Heap에 Room이 있기 때문에 Worker가 종료되면 Room도 같이 사라진다. 진행 중인 게임을 다시 구성할 방법도 없었다.
개발 환경에서는 Worker가 계속 살아 있기 때문에 크게 신경 쓰지 않았지만, 서버 장애까지 생각해보니 명확한 한계였다.
그래서 이후 구조를 다시 검토하면서 Worker를 실행을 담당하는 영역으로 보고, 복구에 필요한 상태까지 Worker 메모리에만 의존하지 않도록 분리하는 방향을 잡았다.
다시 만든다면 중요한 Room 상태는 Redis나 별도의 상태 저장소에 외부화하고 Worker는 실제 전투 처리를 담당하게 만들 것이다.
Worker Thread를 사용한 것 자체보다 이 경험이 더 기억에 남았다.
프로세스를 나누는 순간부터 프로세스가 가지고 있는 상태가 언제 사라지는지까지 같이 생각해야 했다.
매칭 시스템을 Redis로 구성했다
매칭은 Redis 자료구조를 이용했다.
티어별 ZSET에 사용자의 UUID를 저장하고 트로피를 score로 사용했다. Socket ID와 대기 횟수는 별도의 HASH에서 관리했다.
ZSET -> userUUID / trophy
HASH -> userUUID / socketID
HASH -> userUUID / waitingCount
매칭 요청이 들어오면 ZSET과 HASH에 사용자를 등록하고 일정 주기로 대기열을 검사했다.
대기 중인 사용자를 가져와 두 명씩 연결하고 PvP 서버로 넘기는 구조였다.
처음에는 Redis를 사용했으니 충분히 빠르게 처리할 수 있다고 생각했다.
그런데 코드를 다시 따라가면서 성능보다 먼저 데이터 정합성 문제가 보였다.
ZSET과 HASH를 별도의 명령으로 수정하고 있었다.
ZSET 등록은 성공했는데 HASH 등록이 실패하면 Redis에는 대기 중인 사용자가 존재하지만 Socket 정보는 없는 상태가 될 수 있다.
반대로 Socket 정보만 남는 경우도 가능했다.
또한 대기 시간이 길어진 사용자를 다른 매칭 범위에 넣는 과정에서도 기존 큐의 데이터를 바로 제거하지 않는 부분이 있었다.
같은 사용자가 여러 큐에 존재할 수 있는 구조였다.
그래서 매칭 등록과 상태 변경을 하나의 원자적인 작업으로 처리하도록 수정했다. Redis Lua Script를 이용해 여러 Redis 명령 사이에 다른 요청이 끼어드는 상황을 줄였고, 한 사용자가 동시에 여러 매칭 상태를 가지지 않도록 조건을 정리했다.
그리고 기존 매칭 알고리즘도 다시 확인했다.
ZSET을 사용한다고 해서 MMR 매칭이 되는 것은 아니었다.
실제 구현은 트로피 기준으로 정렬한 뒤 앞에서부터 두 명씩 묶는 방식이었다.
ZSET 정렬 -> 대기 유저 탐색 -> 앞의 두 명 Pairing
결과적으로 MMR 차이를 계산하는 방식보다는 정렬된 큐의 Greedy Pairing에 가까웠다.
이 부분을 확인하면서 Redis 자료구조를 어떤 걸 사용했느냐보다 그 자료구조가 실제 게임에서 어떤 의미를 가지는지를 먼저 봐야 한다는 걸 알게 됐다.
매칭 성능도 다시 확인했다
매칭 큐를 조회하는 과정에서는 전체 ZSET을 가져온 다음 각 사용자에 대해 Socket 정보를 HASH에서 확인하는 방식이 사용되고 있었다.
전체 데이터를 가져오는 ZRANGE 0 -1은 대기 인원이 많아질수록 처리해야 할 데이터가 증가한다.
여기에 각 사용자마다 Redis 조회가 추가되면 네트워크 왕복도 같이 늘어난다.
ZRANGE
-> User 1 HEXISTS
-> User 2 HEXISTS
-> User 3 HEXISTS
-> ...
대기열이 커졌을 때 굳이 모든 사용자를 가져올 이유가 없었다.
그래서 매칭 대상 범위를 제한하고 필요한 데이터만 조회하는 방향으로 수정했다.
매칭은 1초 간격으로 실행되기 때문에 한 번의 처리 시간이 길어지면 다음 매칭 주기와 겹칠 가능성도 있었다.
기존에는 endChecked 같은 실행 중 플래그로 일부 중복 실행을 막고 있었는데, 다른 매칭 모드에서는 이 보호가 빠져 있는 부분도 있었다.
이런 코드를 보면서 단순히 “매칭이 된다”만 확인하는 것과 매칭 루프가 계속 돌아가는 상황을 보는 것은 차이가 컸다.
턴 시스템은 서버가 권한을 가지도록 했다
턴제 게임에서 가장 고민했던 부분은 서버와 클라이언트의 역할을 어디까지 나눌 것인가였다.
클라이언트에서 물리 기반 전투가 이루어지고 있었기 때문에 서버에서 모든 물리 계산을 다시 수행하는 방식은 비용이 컸다.
그래서 물리 시뮬레이션은 클라이언트가 담당하고 서버는 게임 진행에 필요한 권한을 가지고 있도록 했다.
Client -> Physics Simulation
Server -> Turn / Permission / Relay / Timeout / Settlement
서버에서는 다음과 같은 항목을 검증했다.
Loadout
-> Current Turn
-> Player Permission
-> Data Relay
-> Timeout
-> Connection State
-> Game Result
-> Reward
현재 턴을 가지고 있는 플레이어인지 서버에서 확인하고, 해당 플레이어가 아닌 경우에는 행동을 처리하지 않았다.
게임 중 Socket 연결이 끊기거나 플레이어가 나가면 남은 플레이어가 승리하도록 처리했다.
턴 Timeout도 서버에서 관리했다.
여기서 실제 버그 하나를 발견했다.
현재 턴을 가진 플레이어만 Timeout 대상이어야 하는데 두 플레이어 모두에게 Timer가 생성되고 있었다.
Player 1 -> Timeout Timer
Player 2 -> Timeout Timer
턴은 하나인데 Timer가 두 개였다.
정상적인 플레이에서는 문제가 바로 드러나지 않았지만 특정 타이밍에는 두 플레이어의 Timeout이 서로 영향을 줄 수 있었다.
현재 턴의 Player만 Timer를 가지도록 수정했다.
Current Player -> Action / Timeout
Other Player -> Wait
이후에는 턴 상태와 Timer의 관계를 명확하게 분리했다.
턴 시스템을 구현하면서 서버가 모든 게임 계산을 해야 하는 것은 아니지만, 게임 진행 권한까지 클라이언트에게 넘기면 안 된다는 기준이 생겼다.
클라이언트 결과를 어디까지 믿을 것인가
물리 계산은 클라이언트에서 수행하기 때문에 최종 결과 DTO도 클라이언트에서 서버로 전달됐다.
이 구조에서는 서버가 모든 물리 결과를 다시 계산할 수 없기 때문에 클라이언트 결과를 어느 정도 받아들일 수밖에 없다.
대신 서버가 결정할 수 있는 값은 서버가 가지고 있어야 했다.
현재 턴인지, 해당 Room의 플레이어인지, 허용된 데이터인지, 게임이 끝날 수 있는 상태인지 같은 부분이다.
결과적으로 서버가 모든 게임 결과를 직접 시뮬레이션하는 구조는 아니었지만, 최소한 게임을 진행할 수 있는 권한과 최종 정산 권한은 서버가 갖는 구조로 정리했다.
이 부분은 패킷 암호화와는 다른 문제였다.
패킷을 암호화해도 클라이언트가 자기에게 유리한 데이터를 만들어 보내는 것까지 막을 수 있는 것은 아니다.
결국 서버에서 신뢰할 데이터를 명확하게 정해야 했다.
PvP 입장은 JWT로 연결했다
매칭이 완료되면 Mono-App에서 PvP JWT를 발급했다.
초기에는 토큰에 Room UUID뿐만 아니라 Room 데이터와 PvP 서버 IP까지 넣었다.
Mono-App
-> Match Complete
-> JWT 발급
-> Client
-> PvP Worker
-> JWT Verify
-> Room Initialize
PvP Worker가 다시 Mono-App에 요청하지 않아도 JWT를 통해 Room을 구성할 수 있다는 장점이 있었다.
매칭 서버와 전투 서버 사이의 의존성도 줄어들었다.
다만 토큰에 Room 데이터를 넣으면서 문제가 생겼다.
토큰은 발급된 순간의 데이터를 가지고 있기 때문에 이후 실제 서버 상태가 변경되어도 JWT 안의 정보는 그대로다.
그래서 JWT 자체를 Room의 최종 상태로 사용하지 않고 서버 상태와 비교하도록 수정했다.
PvP Worker에서는 JWT의 Room UUID를 검증한 뒤 Redis의 사용자 상태에 저장된 roomUUID와 비교했다.
JWT Verify
-> Redis User State Verify
-> Room Match
-> PvP Join
다시 설계한다면 JWT에는 Room UUID, Worker ID, 만료 시간 정도만 넣고 실제 Room 데이터는 외부 상태 저장소에서 가져오는 구조로 만들 것이다.
Worker 부하 계산에서 단순한 버그를 찾았다
PvP Worker를 여러 개 실행하기 때문에 새로운 Room을 어느 Worker에 넣을지 결정해야 했다.
Redis에 Worker별 Session Count를 기록하고 가장 적은 Worker를 선택하는 방식이었다.
그런데 입장과 퇴장 코드를 확인하다가 이상한 부분이 있었다.
Enter -> +1
Exit -> +1
퇴장에서도 Counter를 증가시키고 있었다.
원래는 다음이어야 했다.
Enter -> +1
Exit -> -1
이 상태로 서버를 오래 실행하면 Redis에 기록된 숫자와 실제 Room 수가 계속 달라진다.
처음에는 단순한 구현 오류처럼 보였다.
그런데 이 값이 Worker 선택 기준으로 사용되고 있다는 것을 다시 확인하니 단순한 숫자 문제가 아니었다. 잘못된 Counter 때문에 실제로는 한가한 Worker가 선택되지 않을 수 있다.
그래서 Counter 수정과 함께 Worker 종료나 Room 정리 시에도 값이 정확하게 반영되는지 다시 확인했다.
이후부터는 서버에서 사용하는 Counter를 볼 때 현재 값 하나만 맞는지 확인하지 않고 증가와 감소가 항상 대칭을 이루는지 같이 확인했다.
Redis와 MySQL의 역할을 다시 구분했다
유저 데이터는 Redis와 MySQL을 함께 사용했다.
로그인할 때 MySQL 데이터를 Redis로 가져오고 게임 중에는 Redis를 수정했다. 이후 로그아웃이나 특정 저장 시점에 MySQL에 반영하는 Write-Back 형태였다.
Login
-> MySQL Load
-> Redis
Game
-> Redis Update
Logout
-> MySQL Persistence
게임 중 발생하는 데이터를 매번 MySQL에 저장하지 않아도 된다는 점에서는 편했다.
하지만 Redis에 있는 모든 데이터가 같은 성격은 아니었다.
세션처럼 다시 만들 수 있는 데이터도 있고, 게임 결과처럼 사라지면 안 되는 데이터도 있었다.
그런데 System-App에 Redis를 초기화하기 위해 FLUSHDB를 호출하는 코드가 있었다.
캐시를 비우기 위한 코드였지만 같은 DB에 아직 MySQL에 저장되지 않은 데이터가 존재한다면 그것까지 삭제된다.
그래서 Redis 전체를 비우는 방식은 제거하고 필요한 데이터만 다시 구성하도록 변경했다.
Redis 논리 DB도 용도에 따라 구분했다.
DB0 -> Global / Game Data
DB1 -> User Data
DB2 -> Matchmaking
다만 논리 DB가 다르다고 해서 물리적으로 완전히 분리된 것은 아니다.
하나의 Redis 인스턴스에서 관리하기 때문에 관리 명령 하나가 다른 데이터에도 영향을 줄 수 있다.
이 부분을 확인하면서 Redis를 단순히 “빠른 DB”로 생각하면 안 된다는 걸 느꼈다.
데이터가 없어졌을 때 다시 만들 수 있는가부터 봐야 했다.
보상 정산 순서도 수정했다
게임 결과를 처리하는 코드에서는 Redis를 먼저 업데이트한 뒤 클라이언트에 성공을 반환하고 MySQL을 업데이트하는 흐름이 있었다.
Redis Update
-> Client Response
-> MySQL Update
이 구조에서 MySQL 업데이트 직전에 서버가 종료되면 문제가 생긴다.
클라이언트는 성공 응답을 받았지만 영구 데이터에는 보상이 반영되지 않을 수 있다.
그래서 성공 응답의 기준을 다시 잡았다.
Redis Update
-> MySQL Persistence
-> Client Response
보상과 같이 유저에게 실제로 귀속되는 데이터는 응답을 보내기 전에 영속 저장이 완료됐는지를 기준으로 처리하도록 수정했다.
캐시와 DB를 같이 사용하는 구조에서는 어느 데이터가 최신인지보다 어느 시점을 성공으로 볼 것인지가 중요하다는 걸 확인한 부분이다.
MySQL Connection 누수도 수정했다
DB 코드를 다시 따라가면서 Connection Pool을 반환하지 않는 예외 경로도 발견했다.
정상적인 경우에는 Connection을 가져오고 Query를 실행한 다음 release()를 호출했다.
하지만 Query에서 Exception이 발생하면 바로 throw하는 코드가 있었고, 그 뒤에는 release()가 실행되지 않았다.
getConnection
-> query
-> error
-> throw
-> release() 실행 안 됨
이 문제가 반복되면 Pool이 점점 고갈될 수 있다.
그래서 Connection 반환을 finally에서 보장하도록 수정했다.
getConnection -> query -> finally -> release
이런 문제는 기능 테스트에서는 잘 나타나지 않는다.
정상적인 요청이 계속 성공하면 Connection Pool도 정상적으로 보인다.
실패가 반복됐을 때도 리소스가 정상적으로 돌아오는지 봐야 했다.
인증 코드도 다시 확인했다
세션 인증에는 암호화된 Token과 JWT를 사용했다.
세션 Token에는 사용자 UUID와 시간을 넣어 암호화했다.
그런데 AES-256-CBC의 IV 생성 과정에서 randomBytes(8)을 사용하고 있어 충분한 길이의 IV가 아니었다.
16바이트 IV를 사용하도록 수정했고, 암호화 키도 단순 MD5 변환에 의존하지 않도록 정리했다.
TLS 설정도 확인했다.
NODE_TLS_REJECT_UNAUTHORIZED=0이 설정되어 있어 Node.js에서 TLS 인증서 검증을 전역적으로 끄고 있었다.
개발 중에는 연결 문제를 피하기 편하지만 실제 서버 환경에서 그대로 사용할 설정은 아니기 때문에 제거했다.
JWT 처리에서는 jwt.verify()와 단순 decode가 같이 사용되는 부분도 확인했다.
JWT 내용을 읽는 것과 서명을 검증하는 것은 다른 작업이다.
인증에 사용하는 값이라면 단순 decode 결과를 신뢰하지 않고 서명 검증을 거치도록 수정했다.
이런 부분은 보안 코드라서 기능이 정상적으로 동작하는 것만으로는 충분하지 않았다.
토큰이 있다는 사실과 그 토큰의 내용을 서버가 믿어도 된다는 것은 별개의 문제였다.
자잘하지만 운영에서 문제가 될 수 있는 코드들
전체 코드를 다시 확인하면서 큰 구조 외에도 몇 가지 문제가 더 나왔다.
대표적인 것이 조건문이었다.
if (turnCount >= 4)
else if (turnCount >= 10)
첫 번째 조건에서 이미 10 이상인 경우가 포함되기 때문에 두 번째 조건은 실행될 수 없다.
또한 PvP 서버 주소를 전달하기 위해 외부 IP를 조회하는 코드에서는 외부 조회에 실패했을 때 사설 IP가 사용될 가능성도 있었다.
서버가 자기 자신에게 접속할 때는 문제가 없을 수 있지만 클라이언트에게 그 주소를 전달하면 외부에서는 접근할 수 없다.
이런 문제는 각각의 함수만 보면 별것 아닌 코드다.
하지만 전체 흐름에 연결해보면 실제 접속 실패나 특정 분기 미실행으로 이어질 수 있다.
그래서 마지막에는 함수 단위보다 요청 하나가 처음부터 끝까지 어떤 상태를 거치는지를 따라가면서 코드를 확인했다.
다시 설계한다면
현재 구조를 완전히 버리기보다는 지금까지 확인한 문제를 기준으로 몇 가지를 바꿀 것이다.
먼저 상태의 수명을 명확하게 나눈다.
Request State
-> Session State
-> Matching State
-> Room State
-> Persistent State
각 상태마다 저장 위치와 장애 시 복구 방법을 정한다.
매칭은 Redis Lua Script를 사용해 enqueue와 dequeue를 원자적으로 처리하고, 단순히 전체 ZSET을 읽는 방식보다는 매칭 범위를 제한해서 필요한 데이터만 조회하도록 구성한다.
PvP Room은 Worker 메모리를 실시간 처리에 사용하되, Worker가 죽어도 복구할 수 있는 최소한의 상태는 외부 저장소에 둔다.
Worker 선택도 단순 Session Count가 아니라 원자적인 capacity 관리 구조로 바꾼다.
보상 정산은 전투 Worker가 직접 처리하기보다 이벤트를 발행하고 별도의 정산 로직에서 DB 트랜잭션을 처리하는 방식도 고려할 수 있다.
Redis 역시 세션과 캐시, 매칭 데이터를 같은 인스턴스와 논리 DB에 계속 의존하기보다는 장애 범위까지 생각해서 저장소 자체를 분리하는 편이 낫다.
프로젝트를 다시 보면서 달라진 점
개발할 때는 정상적인 게임 흐름을 먼저 만들었다.
Login -> Match -> Join -> Battle -> Reward
기능이 모두 연결되고 게임이 돌아가면 일단 하나의 목표는 달성한 셈이다.
그런데 마지막에 코드를 다시 볼 때는 질문을 바꿨다.
같은 요청이 두 번 들어오면?
-> Redis 데이터 하나만 저장되면?
-> Worker가 게임 중에 죽으면?
-> DB 저장 전에 서버가 종료되면?
-> Redis가 초기화되면?
-> 사용자가 갑자기 연결을 끊으면?
-> 서버를 오래 실행하면 Counter가 계속 맞는가?
이렇게 하나씩 조건을 바꿔서 따라가니 처음 만들 때 보이지 않던 문제가 나왔다.
매칭은 Redis에 데이터를 넣는 것에서 끝나는 게 아니었다.
ZSET과 HASH가 같은 상태를 가리켜야 했고, 중복 매칭도 막아야 했다.
PvP는 Worker에서 Room을 관리하는 것에서 끝나는 게 아니었다.
Worker가 죽었을 때 Room을 어떻게 처리할지도 생각해야 했다.
Redis도 마찬가지였다.
빠르게 데이터를 읽고 쓰는 것보다 그 데이터가 없어졌을 때 어떻게 되는지를 먼저 봐야 했다.
결국 이번 프로젝트에서 가장 많이 확인한 건 특정 기술의 사용법이 아니었다.
상태가 어디에 있고, 누가 변경하며, 언제 없어지고, 다른 시스템은 그 상태를 언제 믿을 수 있는가.
처음에는 서버 구조를 Main-App, Mono-App, PvP-App처럼 기능 단위로 봤다.
프로젝트를 다시 수정하고 나서는 Request, Session, Matching, Room, Persistent Data처럼 상태의 생명주기를 먼저 보게 됐다.
이 차이가 생각보다 컸다.
마무리
이번 프로젝트는 처음부터 완벽하게 설계된 서버는 아니었다.
오히려 기능을 먼저 만들고 나중에 전체 코드를 다시 확인하면서 구조적인 문제를 많이 발견했다.
그 과정에서 Worker Thread, Redis, Socket.IO, MySQL 같은 기술 자체보다 더 오래 고민했던 것은 상태와 권한이었다.
누가 Room을 가지고 있는지.
현재 턴을 누가 결정하는지.
매칭 상태를 어디에서 믿을지.
Redis와 MySQL 중 어느 시점의 데이터를 기준으로 볼지.
서버가 죽었을 때 어떤 데이터를 복구해야 하는지.
이런 것들이 정리되지 않은 상태에서 서버를 여러 프로세스로 나누는 것만으로는 큰 의미가 없었다.
특히 턴제 PvP에서는 실시간 통신을 구현하는 것보다 게임 상태를 어떻게 유지하고 확정할 것인지가 더 중요했다.
이번 프로젝트를 하면서 서버를 바라보는 기준도 조금 달라졌다.
기능이 정상적으로 동작하는지에서 끝나는 게 아니라, 그 기능이 실패했을 때 상태가 어디에 남는지까지 확인하게 됐다.
그래서 다음에 비슷한 서버를 다시 만든다면 기능부터 구현하기보다는 상태의 생명주기와 권한부터 정리할 것이다.
상태 정의
-> 상태 소유자 결정
-> 상태 전이 정의
-> 원자성 확보
-> 장애 상황 정의
-> 복구 방법 결정
-> 기능 구현
이번 프로젝트에서 가장 크게 얻은 건 결국 이 순서였다.