
신입 3개월 차, AI와 함께 만든 '실시간 대전 타이핑 게임' ── 자체 제작 로마자 판정 엔진과 서버 권위 설계
요약
신입 엔지니어가 AI를 페어 프로그래밍 파트너로 활용하여 실시간 일본어 타이핑 대전 게임을 개발한 사례입니다. WebSocket을 이용한 실시간 동기화와 치트 방지를 위한 서버 권위(Server Authority) 설계, 그리고 라이브러리 없이 직접 구현한 로마자 판정 엔진의 핵심 로직을 다룹니다.
핵심 포인트
- AI를 활용한 설계 판단 중심의 페어 프로그래밍 실천
- 치트 방지를 위해 프론트와 백엔드 양측에 판정 로직을 구현한 서버 권위 설계
- WebSocket/STOMP를 이용한 실시간 양방향 통신 구현
- 일본어 특유의 복잡한 로마자 입력 패턴을 처리하는 자체 판정 엔진 개발
IT 기업에 신입으로 입사한 지 3개월 차인 엔지니어입니다. 연수 기간 중에, 실시간으로 온라인 대전이 가능한 일본어 타이핑 게임을 혼자서 풀스택 (Full-stack) 개발하여 실제 서비스로 공개하고 운영하고 있습니다.
「타이핑 게임을 만들었다」는 기사는 세상에 아주 많지만, 이 기사에서 쓰고 싶은 것은 그것이 아닙니다. ① 대전 상대와 실시간으로 동기화한다 (WebSocket), ② 치트를 방지하기 위해 서버 측에서도 승패를 판정한다, ③ 일본어 특유의 로마자 표기 흔들림을 라이브러리에 의존하지 않고 직접 판정한다 ── 이 세 가지를, 신입 3개월 차인 제가 AI를 페어 프로그래밍 (Pair Programming) 상대로 삼아 어떻게 설계하고 구현했는지에 대한 이야기입니다.
구현 가속화를 위해 AI (코딩 어시스턴트)를 전면적으로 사용했습니다. 다만, 설계 판단은 스스로 하고 있습니다. 그 판단의 내용을 코드와 「왜 그렇게 했는가」까지 포함하여 작성합니다. AI 개발에 도전하고 싶은 분들에게도, 실시간 통신이나 일본어 입력 판정에서 어려움을 겪고 있는 분들에게도 무언가 얻어갈 것이 있다면 기쁘겠습니다.
공개 중인 사이트 → https://typinggaming.com/
PC 키보드 입력과 스마트폰 플릭 (Flick) 입력을 모두 지원하는 실시간 일본어 타이핑 게임입니다.
| 모드 | 내용 |
|---|---|
| 🏆 레이트전 | 실력이 비슷한 상대와 자동 매칭. 승패에 따라 Elo 레이팅 (Rating)이 변동 (총 10문제) |
| ... | |
| 레이어 | 기술 |
| --- | --- |
| 프론트엔드 | React, TypeScript, Vite, Vanilla CSS |
| ... |
이 앱에서 가장 효과적이었던 설계 판단은, 로마자 판정 엔진을 TypeScript (프론트엔드)와 Java (백엔드) 양쪽에 구현한 것입니다. 언뜻 보면 DRY 원칙에 어긋나는 중복 구현이라 번거로워 보입니다. 그럼에도 이렇게 한 이유는 명확합니다 ──
대전 게임에서는 클라이언트가 신고하는 스코어를 신뢰할 수 없기 때문입니다. JavaScript는 브라우저 상에서 조작될 수 있으므로, 「모든 문제를 맞혔다」라고 거짓 신고를 보내는 것이 원리적으로 가능합니다. 따라서 승패의 최종 판정은 서버가 가져야 하며 (서버 권위, Server Authority), 결과적으로 프론트엔드와 동일한 판정 로직을 Java 측에도 다시 구현하게 되었습니다.
- 프론트엔드 측 엔진 … 1타건(Keystroke)마다의 즉시 피드백 (UX를 위해)
- Java 측 엔진 … 승패·레이팅의 확정 (공정성을 위해)
WebSocket / STOMP를 선택한 것도 같은 맥락입니다. 상대의 진행 상황을 한 글자 단위로 가시화하려면 양방향 통신이 필요하며, Java/Spring과의 친화성 때문에 STOMP를 채택했습니다.
타이핑 게임의 심장부인 로마자 입력 판정을 라이브러리를 사용하지 않고 직접 만들었습니다. 이 부분이 이 기사의 핵심입니다.
일본어 로마자 입력은 「가나 1글자 ↔ 로마자 1패턴」의 단순한 대응이 아닙니다. 하나의 가나에 여러 개의 올바른 입력 방식이 존재하며, 심지어 다음 글자에 따라 정답이 달라지기 때문입니다. 예를 들어:
- 「し」→
shi도 가능하고
si도 가능하고
ci도 정답 - 「ち」→
chi도 가능하고
ti도 정답 - 「っか」→
kka(자음 중첩) 도 가능하고
ltuka/xtuka(촉음 단독) 도 정답 - 「おう」→
ou도 가능하고
「おー」→o-도 가능하고
oo도 정답 - 「ん」→ 다음 글자에 따라
n1회로 확정되기도 하고
nn을 요구하기도 함
당초에는 한 문제의 주제 전체에 대해 허용되는 로마자 스펠링을 재귀적으로 조합하여, 사전에 모든 패턴을 열거하려고 생각했습니다. 하지만 「ん」, 「っ」, 「ー」나 표기 흔들림이 많은 글자가 연속되는 주제 (예: 「かんしんしゃー」 등)에서는, 단 몇 글자의 주제만으로도 허용 패턴이 수만 가지로 불어나는 「조합 폭발 (Combinatorial Explosion)」이 발생했습니다.
이 방식으로는 사전 열거 시의 메모리 소비도 많고, 동적으로 전방 일치 (Prefix Match)를 체크하는 퍼포먼스가 무너집니다.
그래서 사전 열거를 포기하고, 「가나의 최장 일치로 국소적인 유닛에 분할하고, 1타건마다 버퍼를 상태 전이(State Transition)시켜 나가는」 방식으로 설계를 전환했습니다.
최종적으로 채택한 방침은, 주제 텍스트를 TypingUnit이라는 최소 가나 단위로 분할하고, 각 유닛이 「허용 스펠링 목록」을 가지며, 1타건마다 버퍼를 상태 전이시키는 방식입니다.
TypeScript 측에서의 데이터 구조 정의는 다음과 같습니다.
export type TypingUnit = {
kana: string; // 표시용 ("し" "っか" "ん" 등)
spellings: string[]; // 허용 로마자 (전처리로 확정됨)
...
};
문제 "しっか(식카)"를 예로 들어, 1타건마다의 전이를 살펴보겠습니다.
먼저, 전처리 함수 build("しっか")에 의해 다음과 같이 두 개의 TypingUnit이 생성됩니다.
- Unit 0:
kana: "し",spellings: ["shi", "si", "ci"] - Unit 1:
kana: "っか",spellings: ["kka", "cca", "ltuka", "xtuka", "ltsuka", "xtsuka", ...]- ※ 「っ」의 직후는 모음이나 や(야)행 등을 제외하고, 뒤에 오는 「か」와 병합하여 자음 중첩 등의 스펠링을 사전에 생성합니다.
플레이어가 s → h → i → k → k → a라고 타건했을 때의 트레이스:
- 초기 상태:
unitIndex = 0,buffer = "" - 1타건째:
sbuffer + "s" = "s"가 됩니다. - Unit 0의spellings에"s"로 시작하는 요소가 있는지 검증합니다."shi"와"si"가 해당하므로, **입력은 수락(accept)**되며,buffer = "s"로 전이합니다. (이 시점에서는si/shi두 루트 모두 살아있습니다.)
- 2타건째:
hbuffer + "h" = "sh"가 됩니다. - Unit 0 중에서"sh"로 시작하는 것은"shi"뿐입니다. 전방 일치하므로 수락되며,buffer = "sh"가 됩니다. 이 시점에서"si"루트는 탈락합니다.
- 3타건째:
ibuffer + "i" = "shi"가 됩니다. - Unit 0의spellings에"shi"가 완전 일치합니다. 또한"shi"보다 긴 스펠링 후보는 존재하지 않기 때문에, 여기서 **유닛 확정(commit)**이 발생합니다. -committed[0] = "shi"가 기록되고,unitIndex = 1,buffer = ""로 전이합니다.
- 4타건째:
k- Unit 1에 대해,
buffer + "k" = "k"는"kka"등의 전방 일치이므로 수락되어,buffer = "k"가 됩니다.
- Unit 1에 대해,
- 5타건째:
k- Unit 1에 대해,
buffer + "k" = "kk"가 되어, 마찬가지로 전방 일치로 수락됩니다.
- Unit 1에 대해,
- 6타건째:
abuffer + "a" = "kka"가 되어 Unit 1에 완전 일치합니다. - 더 이상 긴 후보는 없으므로 확정되며, 마지막 유닛이 완료되었으므로 게임이 클리어(finish)됩니다.
만약 1타건째에 s, 2타건째에 i를 입력했다면, 2타건째 시점에 si가 완전 일치하여 불필요한 타건을 기다리지 않고 즉시 확정하여 Unit 1으로 진행합니다. 이러한 상태 전이 방식을 통해 표기 변화를 완전히 커버할 수 있습니다.
엔진 개발 중에서도 일본어 특유의 성질 때문에 특히 고생했던 부분은 다음 세 가지입니다.
「ん」의 확정 지연
뒤에 이어지는 글자가 모음(あ행)·や(야)행·な(나)행 이외라면, n 1회만으로 「ん」을 확정된 것으로 간주합니다. 그렇지 않으면 nn이나 n'을 요구합니다 (예: 「かんい(칸이)」는 kani라고 치면 「かに(카니)」가 되어버리므로 nn이 필요합니다). "다음 글자를 보기 전까지는 현재 글자를 확정할 수 없다"는 **선행 읽기 (lookahead)**가 필요하여, 1타건 단위의 단순한 상태 머신(state machine)에서 한 단계 더 나아가야 했습니다.
촉음 「っ」
다음 글자의 자음을 중첩하는 표기(「っか」 → kka)와 단독 표기(ltu / xtu)를 모두 허용합니다. 전자는 "다음 유닛의 선두 자음"에 의존하기 때문에, 유닛을 가로지르는 판정이 필요합니다.
장음 「ー」
하이픈 -뿐만 아니라, 직전 모음의 반복(「おー」 → oo)도 정답으로 받아들입니다.
「ん」의 선독(look-ahead) 처리를 구현했을 때, 문제가 「ん」으로 끝나는 경우(예: 「しん」) 후속 유닛인 rawUnits[i + 1]이 존재하지 않아 undefined의 프로퍼티(next.kana)를 읽으려 시도하면서 프론트엔드와 백엔드 양측 모두에서 예외 에러(exception error)가 발생하는 버그를 겪었습니다.
문장 끝의 「ん」은 뒤에 문자가 없으므로, 무조건적으로 nn이나 n'만을 요구하도록 if (!next) 예외 가드(exception guard)를 추가하여 해결했습니다.
프론트엔드와 동일한 판정 엔진을 Java 측에 갖춘 상태에서, 대전의 진행과 확정은 모두 서버가 제어(authority)하고 있습니다.
문제의 동기화 및 동시 도착 문제 해결
대전 중에 두 명의 플레이어가 거의 동시에 문제를 푸는 「동시 도착 문제」가 발생합니다.
서버 측의 GameController.java는 다음과 같이 배타 제어(mutual exclusion)를 수행하여, 가장 먼저 도착한 클리어 시그널(clear signal)만을 동기화 트리거(synchronization trigger)로 삼습니다.
@MessageMapping("/room/{roomId}/questionDone")
public void handleQuestionDone(@DestinationVariable String roomId, @Payload QuestionDoneMsg msg) {
GameRoom room = rooms.get(roomId);
...
첫 번째 사람의 클리어를 계기로 room.isAdvanceScheduled()를 true로 설정하고, 1초간의 버퍼(buffer)를 두어 전원을 일제히 다음 문제로 전이시킵니다. 두 번째 사람의 클리어 신호가 늦게 도착하더라도 이미 예약 플래그(reservation flag)가 세워져 있기 때문에 무시됩니다. 진행의 권위(authority)가 완전히 서버에 있기 때문에, 네트워크의 변동성(jitter)에 대해서도 견고하게 동기화할 수 있습니다.
접속 끊김 시의 구제와 레이팅(rating) 이중 적용 방지
대전 중에 플레이어가 접속을 끊을 경우, WebSocket의 SessionDisconnectEvent를 감지하여 남은 플레이어의 부전승으로 게임을 종료합니다. 이때 거의 동시에 「전원 완주에 의한 정상 종료」와 「접속 끊김 처리」라는 병렬 스레드(parallel thread)가 실행되어, 레이팅 계산이 이중으로 적용될 리스크가 있었습니다.
이에 대해, 방의 상태(status)에 ratingApplied 플래그를 두어, 계산 처리 시작 직전에 플래그를 검사 및 적용함으로써 이중 업데이트를 완벽하게 방지했습니다.
private List<Map<String, Object>> applyRankedRating(GameRoom room, String winnerId, boolean draw) {
if (!room.isRanked() || room.isRatingApplied()) return List.of();
room.setRatingApplied(true); // 처음에 적용 완료 플래그를 세워 후속 병렬 스레드를 차단
...
구현 속도는 AI 덕분에 극적으로 향상되었습니다. 다만, 단순히 맡기기만 해서는 위의 엔진을 절대 완성할 수 없었기에, 어떻게 사용했는지를 구체적으로 적겠습니다.
사양을 코드보다 먼저 텍스트로 확정했다
갑자기 구현하게 하는 것이 아니라, 먼저 "「ん」의 입력 사양을 후속 문자의 패턴별로 모두 망라한 표를 만들어줘"라고 **사양의 언어화(specification verbalization)**부터 시작했습니다. 사양이 문장으로 확정된 후 코드로 옮김으로써 재작업(rework)을 대폭 줄일 수 있었습니다. 이는 인간 사이의 설계 리뷰(design review)와 마찬가지로, AI를 상대로도 "애매한 상태로 쓰게 하지 않는다"는 원칙이 효과적이었습니다.
동일 로직의 언어 간 이식에 강하다
로마자 판정을 TS(TypeScript)에서 Java로 옮길 때, "이 TypeScript 클래스와 완전히 동일한 알고리즘으로 Java 버전을 작성해줘"라고 지시하니 상당히 정확하게 이식해 주었습니다. 서버 권위(server authority)를 위한 이중 구현과 궁합이 좋았던 부분입니다.
엣지 케이스(edge case)의 책임은 인간이 진다
AI는 동작하는 코드를 빠르게 내놓지만, WebSocket의 랙(lag)이나 동시 도착과 같은 병렬성의 엣지 케이스는 고려가 누락됩니다. 로컬에서 실행하며 로그를 보고, "이 입력에서 이렇게 망가졌으니 고쳐줘"라고 피드백을 돌리는 ── 이 검증 루프는 인간의 일이었습니다.
신입 3개월 차에 실시간 대전, 서버 권위, 자체 제작 판정 엔진까지 포함된 풀스택(full-stack) 앱을 실제 운영할 수 있는 것은, AI로 구현을 가속하면서도 설계의 고삐는 스스로 쥐었기 때문이라고 생각합니다.
이 엔진이나 동기화 관련 부분은 아직 다 채우지 못한 논점(동시 도착의 엄격화, 판정 엔진의 테스트 커버리지, CSR의 SEO 대책 등)이 남아 있어, 후속편에서 작성할 예정입니다.
설계나 구현에 있어 "이 부분은 이렇게 하는 것이 좋다"라거나 "우리는 이렇게 해결했다"와 같은 지적(tsukkomi)은 언제든 환영합니다. 댓글로 피드백을 주시면 감사하겠습니다.
끝까지 읽어주셔서 감사합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기