
Go + WebSocket로 실시간 대전 게임을 만들며, '작동한다'는 것은 끝이 아니라 시작임을 깨달은 이야기
요약
Go와 WebSocket을 활용하여 실시간 대전 게임을 개발하며 겪은 기술적 도전과 설계의 중요성을 다룹니다. 단순한 연결을 넘어 상태 관리, 예외 처리, 그리고 AI 코딩 시대에 필요한 아키텍처 설계의 필요성을 강조합니다.
핵심 포인트
- WebSocket 기반 실시간 통신 시 연결 유지 및 예외 상황 처리가 핵심임
- 상태 관리와 책임 분리가 모호하면 AI 코딩 시 병목과 오류가 발생함
- AI 시대일수록 레이어드 아키텍처 등 견고한 설계가 더욱 중요함
- 실시간 게임 개발 시 플레이어 이탈 및 재접속 등 다양한 시나리오 고려 필요
안녕하세요! 와카토(@0st_ts)입니다.
이번에는 Go + WebSocket으로 실시간 대전 게임을 만들었을 때 배운 점들을 적어보려 합니다.
만든 것은 이미지 인증인 reCAPTCHA를 모티브로 한 실시간 대전 게임입니다.
제시되는 문제에 맞는 이미지를 빠르게 선택하여, 상대보다 먼저 목표 점수에 도달하는 것을 목표로 합니다.
혼자서 즐길 수 있는 CPU전과 더불어, 1대1 온라인 대전, 최대 4인 배틀로얄 모드도 구현했습니다.
처음에는 WebSocket으로 브라우저끼리 연결되어 상대방의 상태가 실시간으로 보이는 순간,
'좋아, 작동한다! 완성이다!'
라고 생각했습니다.
하지만 실제로는 그때부터가 본게임이었습니다.
실시간 통신은 '연결되면 끝'이 아닙니다.
오히려,
'연결된 후에 어떻게 망가지지 않게 할 것인가'
가 가장 어려웠습니다.
게다가 이번 개발에서는 AI 코딩도 상당히 많이 사용했습니다.
그 과정에서 WebSocket의 어려움뿐만 아니라, AI 코딩에서 병목 현상(Bottleneck)이 발생하기 쉬운 코드의 특징도 조금 알게 되었습니다.
특히 책임(Responsibility)이 섞인 코드, 암묵적인 상태가 많은 코드, 어디서 무엇이 업데이트되는지 추적하기 어려운 코드는 AI에게도 인간에게도 위험합니다.
AI는 구현 속도를 높여줍니다.
하지만 상태 관리(State Management)나 책임 분리(Separation of Concerns)가 모호한 상태로 두면, 수정할 때마다 다른 곳이 망가집니다.
그렇기에 AI 시대일수록 설계(Design)는 오히려 더 중요하다고 느꼈습니다.
이 기사에서는 Go + WebSocket으로 실시간 대전 게임을 만드는 과정에서 빠졌던 함정과, 레이어드 아키텍처(Layered Architecture)에 가깝게 만들어가며 배운 점들을 정리합니다.
만든 게임은 reCAPTCHA 스타일의 이미지 선택 게임입니다.
예를 들어,
'다음 이미지를 모두 선택하세요: 자동차'
와 같은 문제가 표시되며, 9개의 이미지 패널 중에서 올바른 이미지를 선택합니다.
정답을 맞히면 점수가 가산되며, 상대보다 먼저 목표 점수에 도달하면 승리합니다.
주요 기능은 다음과 같습니다.
- CPU전
- 1대1 온라인 대전
- 최대 4인 배틀로얄
- 상대의 선택 상황을 실시간으로 표시하는 RIVAL VIEW
- 콤보를 통한 방해 효과
- 화면 흔들림, 회전, 블러(Blur), 색상 반전 등의 연출
- Go + WebSocket을 이용한 실시간 통신
프론트엔드는 React / TypeScript / Vite, 상태 관리는 Zustand, 통신에는 WebSocket을 사용하고 있습니다.
백엔드는 Go로 WebSocket 서버를 구현했습니다.
처음 WebSocket을 구현했을 때는 상당히 단순하게 생각했습니다.
클라이언트가 서버에 접속한다.
메시지를 보낸다.
서버가 상대에게 전달한다.
상대의 화면이 업데이트된다.
이것이 가능해진 순간,
'벌써 실시간 대전이 가능하잖아!'
라고 생각했습니다.
하지만 실제로 게임으로서 플레이할 수 있는 상태로 만들려고 하니 곧바로 문제들이 나타났습니다.
- 한쪽 플레이어가 새로고침을 하면 어떻게 할 것인가
- 탭을 닫으면 어떻게 할 것인가
- 통신이 잠시 끊기면 패배 처리할 것인가
- 동일한 플레이어가 여러 탭을 열면 어떻게 되는가
- 게임 종료 후 룸(Room) 정보는 사라지는가
- 상대가 나갔을 때 남은 플레이어를 어떻게 처리할 것인가
- 4인 대전에서는 누구에게 어떤 상태를 보내야 하는가
여기서 깨달았습니다.
WebSocket은 HTTP API와는 완전히 다릅니다.
HTTP API라면 기본적으로 요청(Request)이 오고 응답(Response)을 보내면 일단 끝납니다.
하지만 WebSocket은 연결이 계속 유지됩니다.
즉, 한 번 연결된 후에도,
- 언제 끊길지 모른다
- 메시지가 몇 번 올지 모른다
- 어떤 순서로 올지 모른다
- 상대가 아직 있는지 모른다
- 서버 측에 상태가 계속 남아있다
라는 문제와 마주해야 합니다.
'통신할 수 있다'와 '게임으로서 망가지지 않는다'는 전혀 다른 문제였습니다.
WebSocket 자체는 접속해서 메시지를 보내는 것뿐이라면 그렇게 어렵지 않습니다.
어려웠던 것은 그 이후였습니다.
실시간 대전 게임에서는 서버 측이 상태를 가집니다.
예를 들어,
- 어떤 플레이어가 어느 룸에 있는가
- 현재 문제는 무엇인가
- 각 플레이어의 점수는 얼마인가
- 누가 어떤 이미지를 선택하고 있는가
- 콤보는 몇 번 이어지고 있는가
- 방해 효과는 누구에게 날아가고 있는가
- 승패가 결정되었는가
- 접속을 끊은 플레이어는 돌아오는가
등입니다.
이 상태가 여러 클라이언트로부터 동시에 업데이트됩니다.
따라서 단순히 통신이 가능한 것만으로는 부족합니다.
어떤 상태를, 누가, 언제, 어떤 순서로 업데이트할 것인가
를 설계하지 않으면 금방 망가집니다.
이번 개발에서 가장 크게 느낀 점은,
WebSocket의 어려움은 통신 처리 그 자체보다, 접속 후의 상태 관리(State Management)에 있다
는 것이었습니다.
처음에는 어쨌든 동작하게 만드는 것을 우선시했습니다.
WebSocket의 핸들러(handler)에서 메시지를 받고, 그대로 게임 상태를 업데이트하고, 그대로 상대방에게 보낸다.
초기의 작은 구현이라면 그렇게 해도 동작합니다.
하지만 기능이 늘어남에 따라 상황은 순식간에 힘들어졌습니다.
JOIN_ROOM 처리를 바꿨더니 재접속 처리가 망가짐 - 스코어(Score) 업데이트를 바꿨더니 승패 판정이 망가짐
퇴장 처리를 바꿨더니 룸(Room) 삭제가 망가짐
- 4인 대전을 추가했더니 1대1 대전 표시가 망가짐
- 방해 효과를 추가했더니 상대방 업데이트 페이로드(payload)가 망가짐
그야말로,
하나가 버그 나면 전부 버그 나는
상태였습니다.
원인은 처리의 책임(Responsibility)이 너무 많이 섞여 있었기 때문입니다.
WebSocket의 핸들러(handler)가,
- 통신의 입구
- JSON 파싱 (parse)
- 룸 참여
- 플레이어 관리
- 게임 시작
- 정답 판정
- 스코어 업데이트
- 승패 판정
- 상대방에게 알림
- 접속 끊김 처리
- 클린업 (cleanup)
까지 전부 알고 있는 상태였습니다.
이렇게 되면 어디를 고쳐도 다른 곳에 영향을 줍니다.
즉, 버그의 영향 범위가 너무 넓었습니다.
그래서 레이어드 아키텍처 (Layered Architecture)에 가깝게 책임을 나누기로 했습니다.
이번 백엔드(Backend)에서는 대략 다음과 같은 계층으로 나누었습니다.
handler
WebSocket의 입출력을 다룸
usecase
...
이를 통해 핸들러(handler)에 전부 작성하는 것이 아니라, 각각의 책임을 나눌 수 있도록 했습니다.
예를 들어, WebSocket의 핸들러(handler)는,
- 메시지를 받는다
- 페이로드(payload)를 읽는다
- 필요한 유스케이스(usecase)를 호출한다
- 결과를 클라이언트에 반환한다
라는 역할에 집중합니다.
게임 규칙 그 자체는 도메인(domain)에 둡니다.
룸에 참여한다, 정답을 검증한다, 게임을 시작한다, 퇴장한다와 같은 애플리케이션 상의 흐름은 유스케이스(usecase)에 둡니다.
저장·조회나 ID 생성 같은 기술적인 상세 구현은 인프라스트럭처(infrastructure)에 둡니다.
이렇게 하면 적어도,
WebSocket 통신의 문제인지
게임 규칙의 문제인지
유스케이스(usecase) 흐름의 문제인지
저장·조회의 문제인지
를 나누어 생각할 수 있게 됩니다.
이 점이 상당히 컸습니다.
레이어드 아키텍처 (Layered Architecture)로 만든 가장 큰 이유는 깔끔한 설계를 하고 싶어서가 아니었습니다.
더 실용적으로,
하나가 버그 나면 하나만 버그 나는 상태로 만들고 싶었기
때문입니다.
물론 영향을 완전히 제로로 만들 수는 없습니다.
하지만 책임을 나누어 두면 버그가 발생했을 때 원인을 추적하기 쉬워집니다.
예를 들어 정답 판정이 이상하다면, 먼저 봐야 할 것은 VerifyAnswerUseCase나 도메인(domain) 측의 게임 상태입니다.
WebSocket 접속이 끊긴다면 핸들러(handler)나 연결(connection) 관리를 봐야 합니다.
룸에 들어갈 수 없다면 JoinRoomUseCase나 RoomRepository를 봐야 합니다.
승패 판정이 이상하다면 스코어 업데이트나 게임 종료 판정의 책임을 가진 곳을 보면 됩니다.
이 모든 것이 핸들러(handler)에 섞여 있으면,
어디가 잘못되었는지 알 수 없는
상태가 됩니다.
실제로 실시간 게임은 버그의 종류가 많습니다.
- 통신 버그
- 상태 관리 버그
- 병행 처리 (Concurrency) 버그
- UI 동기화 버그
- 재접속 버그
- 종료 처리 버그
이것들이 전부 섞이면 수정이 상당히 어려워집니다.
그렇기에,
버그를 없애는 것
보다 먼저,
버그가 났을 때 망가지는 범위를 좁히는 것
이 중요하다는 것을 느꼈습니다.
처음에 어려웠던 것은 접속이 끊긴 플레이어를 다루는 일이었습니다.
WebSocket에서는 사용자가 탭을 닫거나, 새로고침을 하거나, 네트워크가 불안정해지면 접속이 끊깁니다.
하지만 접속이 끊겼다고 해서 바로 플레이어 정보를 삭제해도 된다는 법은 없습니다.
예를 들어 사용자가 단순히 새로고침을 한 것이라면 금방 돌아올 가능성이 있습니다.
이때 즉시 퇴장 처리로 간주하면,
- 룸에서 사라짐
- 상대방에게 접속 끊김 알림이 전송됨
- 게임이 종료됨
- 새로고침한 본인은 복귀할 수 없음
이라는 문제가 발생합니다.
반대로, 연결이 끊겨도 아무것도 삭제하지 않으면,
- 존재하지 않는 플레이어가 남음
- 룸이 가득 찬 상태로 유지됨
- 메모리 상에 상태가 계속 남음
- 상대방의 화면이 부자연스러운 상태가 됨
이라는 문제가 발생합니다.
즉, 단순히,
// 연결이 끊기면 즉시 퇴장
만으로도,
// 연결이 끊겨도 아무것도 하지 않음
만으로도 안 되었습니다.
그래서 일정 시간 동안 유예를 두도록 했습니다.
"연결이 끊기면 즉시 퇴장"하는 것이 아니라,
잠시 기다렸다가, 돌아오지 않으면 퇴장 처리한다
라는 방식입니다.
이를 통해 새로고침과 같은 일시적인 단절과, 정말로 이탈한 케이스를 어느 정도 구분할 수 있게 되었습니다.
다음에 어려웠던 점은 리로드(Reload) 시의 플레이어 식별입니다.
처음에는 WebSocket 연결마다 clientID를 발행하고 있었습니다.
연결마다 ID를 갖는 것은 자연스러워 보입니다.
하지만 브라우저를 리로드하면 WebSocket 연결은 다시 만들어집니다.
즉, 리로드 전과 리로드 후의 clientID가 달라집니다.
이것만으로는 서버 측에서 보기에,
같은 사람이 돌아온 것인지,
새로운 사람이 들어온 것인지
알 수 없습니다.
그 결과, 리로드만 해도 별개의 플레이어로 취급되거나, 같은 룸에 같은 사람이 늘어나거나, 상대방의 표시가 깨지는 현상이 발생했습니다.
여기서 필요해진 것이,
- WebSocket 연결로서의 ID
- 게임 상의 플레이어 ID
- 브라우저 세션으로서의 ID
- 소속되어 있는 룸 ID
를 나누어 생각하는 것이었습니다.
처음에는 전부 묶어서 "사용자" 정도의 느낌으로 다루고 있었습니다.
하지만 실제로는 각각의 역할이 다릅니다.
| 종류 | 역할 |
|---|---|
| clientID | WebSocket 연결 단위의 ID |
이 구분을 하지 않으면 실시간 대전에서는 금방 상태가 망가집니다.
"누가 접속해 있는가"와 "누가 게임에 참여하고 있는가"는 별개의 문제였습니다.
1대1 대전일 때는 비교적 심플했습니다.
내가 정답을 맞히면, 상대방에게 나의 상태를 보낸다.
상대방이 선택하면, 나에게 상대방의 선택 상태를 보낸다.
이것만으로 어느 정도 작동합니다.
하지만 최대 4인 배틀로얄(Battle Royale)을 넣는 순간, 단번에 어려워졌습니다.
4인 대전에서는 A님이 보는 "상대"는 B / C / D님입니다.
B님이 보는 "상대"는 A / C / D님입니다.
즉, 같은 룸 내의 상태라도 각 플레이어마다 보여야 할 정보가 다릅니다.
처음에는,
룸 전체의 상태를 모두에게 브로드캐스트(Broadcast)하면 된다
라고 생각했습니다.
하지만 그렇게 하면 수신자마다의 관점이 섞이게 됩니다.
실시간 대전에서는 단순히 모두에게 같은 데이터를 보내면 되는 것이 아니었습니다.
필요했던 것은,
수신자 입장에서 본 상대방 목록을 만드는
처리였습니다.
예를 들어, A님에게 보낼 때는 B / C / D님의 상태를 정리한다.
B님에게 보낼 때는 A / C / D님의 상태를 정리한다.
이와 같이 송신 대상마다 페이로드(Payload)를 구성할 필요가 있었습니다.
여기서 처음으로,
실시간 통신은 보내는 것보다 "누구에게 무엇을 보낼 것인가"가 더 어렵다
는 것을 느꼈습니다.
WebSocket에서는 여러 플레이어로부터 동시에 메시지가 날아옵니다.
게임 중에는,
- 이미지를 선택한다
- 답변을 보낸다
- 콤보가 늘어난다
- 방해 효과가 발생한다
- 스코어가 갱신된다
- 승패 판정이 실행된다
와 같은 처리들이 동시에 일어납니다.
처음에는 상태 업데이트를 상당히 정직하게 작성했습니다.
하지만 여러 명이 거의 동시에 답변하면, 상태 업데이트 순서에 따라 결과가 달라질 가능성이 있습니다.
예를 들어,
- A님이 정답을 맞힌다
- B님도 거의 동시에 정답을 맞힌다
- 양쪽 모두 승리 조건에 도달한다
- 어느 쪽을 승자로 할 것인가
와 같은 케이스입니다.
또한, 방의 상태를 업데이트하는 도중에 다른 메시지가 들어오면, 오래된 상태를 바탕으로 처리해 버릴 가능성도 있습니다.
여기서 필요해진 것이 룸 단위로 처리를 보호하는 사고방식입니다.
Go에서는 고루틴(goroutine)을 사용함으로써 병행 처리를 쓰기 쉽지만, 공유 상태를 함부로 다루면 그대로 망가집니다.
"Go는 병행 처리에 능숙하다"라고 말하지만, 그것이 "아무 생각 없이 써도 안전하다"는 의미는 아닙니다.
오히려 상태를 가진 게임 서버에서는,
어떤 단위로 배타 제어(Mutual Exclusion)를 할 것인가
를 고민해야 했습니다.
실시간 통신에서는 서버에서 클라이언트로 빈번하게 메시지를 보냅니다.
하지만 클라이언트 측의 부하가 높거나 통신이 불안정하면, 송신 처리(Transmission processing)가 정체될 가능성이 있습니다.
처음에는 송신하고 싶은 타이밍에 그대로 WriteJSON을 하면 된다고 생각했습니다.
하지만 게임 중에는 여러 명의 사용자에게 반복해서 메시지를 보냅니다.
송신 처리가 정체되면 다른 처리에도 악영향을 미칩니다.
그래서 연결(Connection)마다 송신용 큐(Queue)를 갖도록 했습니다.
이미지는 다음과 같습니다.
type clientConnection struct {
conn *websocket.Conn
send chan Message
...
송신하고 싶은 쪽은 WebSocket에 직접 쓰는 것이 아니라, 먼저 send 채널(Channel)에 메시지를 쌓습니다.
그리고 별도의 고루틴(goroutine)이 그 큐를 읽어서 WebSocket에 씁니다.
이를 통해,
- 메시지를 보내는 쪽
- 실제로 WebSocket에 쓰는 쪽
을 분리할 수 있습니다.
단, 큐에도 상한(Limit)이 필요합니다.
무한히 쌓을 수 있게 만들면, 통신이 되지 않는 클라이언트에게 메시지가 계속 쌓이게 됩니다.
따라서 큐가 가득 찼을 경우에는 해당 연결을 끊을지 판단하는 것도 필요합니다.
실시간 통신에서는,
지연되는 클라이언트를 어디까지 기다려 줄 것인가
도 설계해야 했습니다.
WebSocket은 상대방이 사라졌다는 것을 항상 즉각적으로 감지할 수 있는 것은 아닙니다.
탭을 닫았거나, 네트워크가 끊겼거나, 스마트폰이 슬립(Sleep) 모드로 들어갔거나, Wi-Fi가 불안정해지는 경우.
이런 케이스에서는 서버 측이 즉시 "연결이 끊어졌다"는 것을 알지 못할 수 있습니다.
그래서 필요한 것이 하트비트(heartbeat)입니다.
하트비트란 WebSocket 연결이 아직 살아있는지 확인하기 위한 메커니즘입니다.
일정한 간격으로 서버에서 PING을 보내고, 클라이언트로부터 PONG이 돌아오는지 확인합니다.
server -> client: PING
client -> server: PONG
일정 시간 동안 PONG이 돌아오지 않으면,
이 연결은 이미 죽었다
라고 판단하여 연결을 닫거나 플레이어를 퇴장 처리합니다.
여기서 배운 것은,
연결되어 있는 것처럼 보이는 것과 실제로 살아있는 것은 다르다
는 점입니다.
실시간 대전에서는 상대방이 아직 있는지 여부가 게임 경험에 직결됩니다.
따라서 WebSocket에서는 "연결되었는가"뿐만 아니라, "그 연결이 지금도 살아있는가"를 확인하는 하트비트(heartbeat)가 중요해집니다.
게임이 끝났다고 해서 그것으로 끝이 아닙니다.
서버 측에는 룸(Room) 정보, 플레이어 정보, 연결 정보, 세션(Session) 정보 등이 남아 있습니다.
이를 삭제하지 않으면 다음 게임에 영향을 주거나, 메모리상에 불필요한 상태가 계속 남게 됩니다.
특히 실시간 게임에서는,
- 승패가 결정됨
- 한 명이 퇴장함
- 마지막 한 명이 남음
- 전원이 연결 끊김
등 종료 패턴이 여러 가지가 있습니다.
처음에는 "승패가 결정되면 끝"이라고 생각했습니다.
하지만 실제로는,
종료된 후 무엇을 어떤 순서로 삭제할 것인가
까지 포함하여 구현해야 했습니다.
게임 종료 후에는,
- 룸에서 플레이어를 제외함
- WebSocket 연관 관계를 해제함
- 세션 정보를 삭제함
- 타이머를 정지함
- 룸을 삭제함
과 같은 처리가 필요합니다.
이 청소를 소홀히 하면 다음 대전에서 알 수 없는 버그가 발생합니다.
"이전 게임의 상태가 남아 있다" 계열의 버그는 원인을 추적하기가 상당히 어렵습니다.
이번 개발에서는 AI 코딩도 상당히 많이 사용했습니다.
AI는 구현 속도를 크게 높여줍니다.
하지만 어떤 코드든 똑같이 잘 다룰 수 있는 것은 아닙니다.
이번 개발을 통해 AI 코딩에서 병목(Bottleneck)이 되기 쉬운 코드의 특징이 조금 보이기 시작했습니다.
하나의 함수가,
- WebSocket 메시지를 받음
- JSON을 파싱(parse)함
- 룸 상태를 업데이트함
- 점수를 계산함
- 승패를 판정함
- 상대에게 통지함
- 클린업(cleanup)함
까지 전부 수행하고 있으면 AI도 사람도 추적하기 어려워집니다.
AI에게 "이 부분을 고쳐줘"라고 부탁해도, 관계없는 부분까지 다시 쓰거나 다른 기능을 망가뜨리기 쉽습니다.
예를 들어,
- 이 변수가 접속 ID(Connection ID)인지, 플레이어 ID(Player ID)인지 알 수 없다
- 이 map에는 무엇이 들어있는지 이름만 봐서는 알 수 없다
- 이 함수를 호출하기 전에 다른 함수를 먼저 호출해 두어야 한다
- 이 상태가 게임 중에만 존재하는 것인지, 재접속 후에도 남는 것인지 알 수 없다
와 같은 코드입니다.
인간이 분위기로 기억하고 있다는 전제의 코드는 AI에게도 위험합니다.
AI는 코드상에 나타나지 않은 전제를 읽을 수 없습니다.
따라서 암묵적인 상태가 많으면, 수정 시에 전제를 깨뜨리기 쉽습니다.
실시간 게임에서는 상태의 업데이트가 많습니다.
점수(Score), 콤보(Combo), 선택 중인 이미지, 현재 문제, 상대방의 상태, 방해 효과(Effect) 등 다양한 상태가 동시에 움직입니다.
이때,
어디서 무엇이 업데이트되고 있는지
를 추적할 수 없으면 버그의 원인을 알 수 없게 됩니다.
AI에게도 "이 상태는 어디서 변하는가"를 알기 어려운 코드는 수정하기 어렵습니다.
에러가 발생했을 때, 단순히 return만 하는 코드도 위험합니다.
실패했다는 사실 자체를 볼 수 없기 때문에,
- JSON 파싱(parse)에 실패한 것인지
- 유스케이스(usecase)에서 실패한 것인지
- 룸(room)을 찾지 못한 것인지
- 플레이어 ID(playerID)가 틀린 것인지
- WebSocket 송신에 실패한 것인지
를 알 수 없게 됩니다.
AI에게 버그 수정을 부탁할 때도 로그나 에러 정보가 적으면 추측으로 수정할 수밖에 없습니다.
추측으로 수정하면 다른 부분이 망가지기 쉽습니다.
WebSocket에서는 클라이언트에게 보내는 페이로드(payload)가 많아집니다.
처음에는 그때그때 페이로드를 만들었습니다.
하지만 비슷한 페이로드 생성 로직이 여러 곳에 흩어져 있으면,
- 어떤 곳에서는 점수(score)를 넣고 있다
- 다른 곳에서는 콤보(combo)를 넣지 않는다
- 4인 대전에서는 필요한 필드가 누락된다
- 재접속 시에만 옛날 형식으로 보내버린다
와 같은 문제가 발생합니다.
이런 중복이나 어긋남은 AI도 수정하기 어렵습니다.
"비슷하지만 미묘하게 다른 처리"는 AI 코딩에서 상당히 위험하다고 느꼈습니다.
AI 코딩을 사용하며 느낀 점은,
AI에게 맡길수록 인간 측의 설계가 중요해진다
는 것입니다.
언뜻 보기에는 AI가 코드를 작성해 준다면 설계의 중요도가 낮아질 것처럼 보입니다.
하지만 실제로는 반대였습니다.
책임(Responsibility)이 뒤섞인 코드를 AI에게 전달하면, AI는 넓은 범위를 한꺼번에 수정하려고 합니다.
그러면 지금 고치고 싶은 버그와 관계없는 부분까지 바뀌어 버립니다.
반대로 레이어드 아키텍처(Layered Architecture)로 책임이 나누어져 있으면,
JoinRoomUseCase만 고치고
VerifyAnswerUseCase의 승패 판정만 보고
Room의 상태 관리만 수정하고
WebSocket의 송신 처리만 정리하는
식으로 AI에게 전달하는 범위를 좁힐 수 있습니다.
이것은 상당히 중요했습니다.
AI에게 너무 큰 파일을 통째로 건네며 "고쳐줘"라고 하면 수정 범위가 너무 넓어집니다.
하지만 계층(layer)마다 책임이 나누어져 있으면,
AI에게 전달하는 문맥(context)도 작게 만들 수 있어
결과적으로 수정 정밀도도 높이기 쉽습니다.
제 안에서는 레이어드 아키텍처가 인간을 위한 것뿐만 아니라,
AI가 망가뜨리기 어려운 코드를 만들기 위한 구조
이기도 하다고 느꼈습니다.
이번에 레이어드 아키텍처에 가깝게 구성함으로써 가독성이 상당히 좋아졌습니다.
특히 컸던 것은 WebSocket 핸들러(handler)를 "모든 것을 다 하는 곳"으로 만들지 않겠다는 의식입니다.
핸들러는 통신의 입구입니다.
즉, 핸들러의 주요 책임은,
- WebSocket으로부터 메시지를 받는다
- JSON을 페이로드(payload)로 읽는다
- 메시지 종류에 따라 유스케이스(usecase)를 호출한다
- 결과를 클라이언트에 반환한다
정도로 하고 싶습니다.
반대로 핸들러가 다음과 같은 내용까지 너무 많이 알게 되면 위험합니다.
- 점수 계산 방법
- 콤보 업데이트 조건
- 방해 효과 발동 조건
- 승패 판정의 세부 규칙
- 룸에 누가 있는지에 대한 내부 구조
- 게임 상태 생성 방법
이런 부분은 도메인(domain)이나 유스케이스(usecase) 쪽으로 모으는 것이 다루기 쉽습니다.
레이어드 아키텍처로 구성함으로써,
통신 버그는 핸들러(handler)를 보고
게임 규칙 버그는 도메인(domain)을 보고
처리 흐름 버그는 유스케이스(usecase)를 보고
저장·취득 버그는 인프라스트럭처(infrastructure)를 본다
는 식으로 문제의 분리가 쉬워졌습니다.
이것은 매우 큰 차이입니다.
이번에 가장 배운 점은,
작동하는 것과 망가지지 않는 것은 전혀 다르다
는 것입니다.
처음 WebSocket이 연결되었을 때는 상당히 감동했습니다.
상대의 선택이 실시간으로 반영된다.
점수가 동기화된다.
방해 효과가 날아간다.
게임으로서 움직이고 있는 것처럼 보인다.
하지만, 거기서부터 실제로 플레이할 수 있는 것으로 만들기 위해서는 상당히 많은 문제가 있었습니다.
- 연결 관리 (Connection Management)
- 재연결 (Reconnection)
- 퇴장 처리 (Exit Processing)
- 룸 관리 (Room Management)
- 플레이어 관리 (Player Management)
- 다인원 대응 (Multi-player Support)
- 배타적 제어 (Exclusive Control)
- heartbeat
- 송신 큐 (Send Queue)
- 클린업 (Cleanup)
- 에러 처리 (Error Handling)
- 레이어드 아키텍처 (Layered Architecture)에 의한 책임 분리
이런 부분들을 제대로 만들지 않으면, 실시간 대전 게임으로서는 금방 망가집니다.
역으로 말하면, 이 부분을 고민함으로써,
WebSocket을 사용한다는 것은, 연결 이후의 상태를 설계하는 것
이라는 것을 알게 되었습니다.
Go로 WebSocket 서버를 작성하며 좋았던 점도 많았습니다.
연결, 룸, 플레이어, 게임 상태 등을 struct로 정리할 수 있어서 상태를 어떻게 가질지 생각하기 쉬웠습니다.
예를 들어, WebSocket 메시지도 다음과 같이 Type과 Payload로 나눔으로써 프론트엔드와의 통신 형식을 정리하기 쉬워집니다.
type Message struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
...
}
실시간 통신에서는 메시지의 종류가 늘어나기 때문에, 이러한 형식으로 정리해 두는 것이 중요했습니다.
연결마다의 read / write / heartbeat와 같은 처리는 goroutine과 궁합이 좋았습니다.
단, goroutine을 사용한다고 해서 저절로 안전해지는 것은 아닙니다.
공유 상태를 다룰 경우에는 mutex나 채널 (Channel) 설계가 필요했습니다.
Go의 병행 처리 (Concurrency)는 편리하지만, 상태를 가진 게임 서버에서는 설계 없이 사용하면 그대로 망가집니다.
HTTP 서버나 시그널 핸들링 (Signal Handling) 등 표준 라이브러리로 꽤 많은 것을 작성할 수 있는 점은 좋았습니다.
한편, WebSocket 자체는 라이브러리를 사용했습니다.
Go는 가볍게 서버를 작성할 수 있는 반면, 상태 관리 설계는 제대로 고민할 필요가 있다고 느꼈습니다.
Go에서 어려웠던 점은 상태의 책임을 어디에 둘 것인가 하는 점입니다.
처음에는 WebSocket의 handler에 처리가 상당히 몰리는 경향이 있었습니다.
하지만 게임 로직, 룸 관리, 연결 관리, 메시지 송신이 전부 섞이면 금방 가독성이 나빠집니다.
특히 AI 코딩을 사용하여 개발하고 있으면, handler가 비대해질수록 AI도 문맥을 따라가기 어려워집니다.
그렇기 때문에,
- domain
- usecase
- infrastructure
- handler
와 같이 책임을 나누는 방향으로 기울였습니다.
실시간 통신에서는 handler에 전부 작성하면 순식간에 고통스러워집니다.
WebSocket handler는 어디까지나 통신의 입구이며, 게임의 규칙이나 상태 업데이트는 별도의 계층으로 나누는 것이 다루기 쉽다고 느꼈습니다.
이번 개발을 통해 WebSocket에서 중요하다고 생각한 점을 정리합니다.
WebSocket 연결과 게임상의 플레이어는 별개입니다.
새로고침이나 재연결을 고려한다면, clientID와 playerID와 sessionID는 나누어 생각하는 것이 좋습니다.
사용자는 평범하게 새로고침을 합니다.
네트워크도 순간적으로 끊깁니다.
따라서 끊긴 순간에 바로 퇴장 처리로 간주하면 사용자 경험이 망가집니다.
약간의 유예를 두는 설계가 필요했습니다.
실시간 통신에서는 브로드캐스트 (Broadcast)를 하면 되는 상황과, 수신자마다 payload를 바꿔야 하는 상황이 있습니다.
특히 다인원 대전에서는 모두에게 동일한 정보만 보내는 것으로는 부족합니다.
WebSocket에 직접 쓰는 것이 아니라, 연결마다 송신 큐를 준비하는 편이 다루기 쉽습니다.
송신이 막힌 클라이언트를 어떻게 다룰지도 생각해야 합니다.
연결이 남아있는 것처럼 보여도 실제로는 죽어있는 경우가 있습니다.
PING / PONG과 같은 heartbeat를 넣음으로써 살아있는 연결인지 판단하기 쉬워집니다.
게임은 승패가 결정되었다고 해서 끝이 아닙니다.
룸, 플레이어, 연결, 세션, 타이머 등을 올바르게 지워야 합니다.
클린업을 소홀히 하면 다음 게임에서 원인 불명의 버그가 됩니다.
WebSocket handler에 전부 작성하면 통신, 상태 관리, 게임 규칙, 알림 처리가 뒤섞이게 됩니다.
처음에는 빠르게 동작하지만, 나중에 한꺼번에 힘들어집니다.
domain / usecase / infrastructure / handler와 같이 책임을 분리함으로써 버그의 영향 범위를 좁힐 수 있습니다.
이번 개발을 통해 AI 코딩에서 중요하다고 생각한 점도 정리해 보겠습니다.
거대한 handler를 통째로 넘기며 "고쳐줘"라고 말하기보다,
이 usecase만 보고
이 domain의 상태 업데이트만 보고
이 payload 생성만 보고
와 같이 범위를 좁히는 것이 더 잘 풀릴 가능성이 높습니다.
AI는 코드에 적혀 있지 않은 전제를 읽어내는 데 서툽니다.
clientID, playerID, sessionID, roomID와 같이 의미가 다른 ID는 명확하게 구분하는 것이 좋습니다.
책임이 분리되어 있으면 AI에게 수정을 요청하기가 쉬워집니다.
반대로 통신, 상태 업데이트, 알림, 클린업(Cleanup)이 전부 뒤섞여 있으면 AI가 다른 곳을 망가뜨리기 쉽습니다.
AI는 동작하는 코드를 상당히 빠르게 만들어 줍니다.
하지만 나중에 고치기 어려운 구조로 되어 있으면, 수정할 때마다 버그가 늘어납니다.
AI 시대이기 때문에 더욱 처음부터 고치기 쉬운 구조를 의식할 필요가 있다고 느꼈습니다.
Go + WebSocket으로 실시간 대전 게임을 만들며 가장 강하게 느낀 것은,
"동작했다"는 것은 끝이 아니라 시작이었다
라는 점입니다.
WebSocket으로 메시지를 송수신하는 것만이라면 그렇게 어렵지 않습니다.
하지만 그것을 게임으로서 성립시키려고 하면, 한꺼번에 생각해야 할 것이 늘어납니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기