열렬히.뛰기

딴짓천국 설계하기 (8) — 채팅 서버에 핸드셰이크와 유휴 종료를 붙이다

인제 RoomSessionRegistry 테스트를 마무리하는 것으로 시작해서, 실제 WebSocket 핸드셰이크·동시 전송 보호·유휴 연결 자동 종료까지 채팅 서버의 핵심 흐름을 완성했다. 그리고 프런트엔드로 넘어가기 전에 작업 흐름 자체를 먼저 점검했다. 이 글은 그 과정에서 무엇을 왜 그렇게 결정했는지, 어디서 막혔는지를 남긴다.

그리고 여기서 부터는 Ai의 활용빈도를 한번 확 높여보기 시작했다. 그 전까지 코드 타이핑에 집중해 보았다면, 인제는 반복되는 코드 타이핑도 Ai에게 맡기는 부분까지 왔다.

Registry 테스트의 마지막 교훈

전날 작성한 unregister·broadcast 테스트를 마무리하면서 리뷰에서 하나를 배웠다. 제거한 연결이 메시지를 받지 않는 것만 확인하면, 사실은 방 전체를 잘못 제거해 버리는 구현도 그 테스트를 통과할 수 있다. 그래서 "제거된 연결은 못 받는다"와 "남은 연결은 정상적으로 받는다"를 같이 확인해야 한다는 걸 테스트에 반영했다. 함께 확인한 결과 이 시점에 Registry 테스트는 2개가 통과한 상태였다.

방별 전달 격리와 전송 실패 처리

오후에는 다른 방으로 보낸 메시지가 엉뚱한 방에 전달되지 않는지 확인하는 테스트를 직접 추가했다.

처음 작성할 때 verify에 빈 인자를 남겨두고, 기대값도 이전 테스트의 문자열 그대로 남겨둔 실수가 있었다. AI가 이 부분과 메시지 본문을 캡처해서 비교하는 방식의 문제를 지적했고, 직접 바로잡았다.
verify는 새 메시지를 보내는 동작이 아니라 모의 객체에 기록된 호출을 검사하는 것이라는 점도 이때 다시 짚었다.

이어서 한 연결이 IOException으로 전송에 실패해도 같은 방의 다른 연결에는 정상 전달되고, 실패한 연결은 이후 방송 대상에서 빠지는지 확인하는 테스트를 추가했다. doThrow(new IOException(...)).when(alice).sendMessage(...)는 설정 순간이 아니라 실제 호출 시점에 예외를 던진다는 점, 실패한 호출도 mock 호출 횟수에는 포함된다는 점이 핵심이었다. 처음 작성한 테스트는 isOpen을 설정하지 않아 애초에 예외 분기에 들어가지도 못했고, verify 뒤에 검사할 메서드가 빠져 UnfinishedVerificationException으로 실패했다. AI가 이 두 문제를 짚어줬고, 직접 고치고 나서 4개 테스트가 모두 통과하는 것을 함께 확인했다. AI는 소스를 대신 고치지 않고 테스트를 실행하며 리뷰만 했다.

테스트에서 실제 연결로

Registry 테스트가 쌓이자 다음 질문이 나왔다. 이제 테스트 말고 실제로 뭔가를 만들어야 하는 거 아닌가? 답은 "둘 다"였다. 닫힌 연결 분기 검증 같은 건 뒤로 미뤄도 되고, 그걸 끝내야만 Handler를 만들 수 있는 건 아니었다. ChatWebSocketHandler 초안은 직접 작성했고, 이후 AI 설명을 받아가며 몇 가지 흔한 실수를 고쳤다.

  • 파일을 src/test/java에 만들어 운영 빌드에 포함되지 않았다 → src/main/java로 이동.
  • 접속·종료 메서드 안에서 각각 new RoomSessionRegistry()를 호출해서, 접속할 때 만든 목록과 종료할 때 만든 목록이 서로 다른 객체였다 → 생성자로 주입받는 하나의 Registry를 공유하도록 수정.
  • register(1, session)처럼 방 번호를 하드코딩해서 모든 연결이 방 1로 섞였다 → 읽어온 roomId를 그대로 쓰도록 수정.

afterConnectionEstablished/afterConnectionClosed에 roomId 파라미터가 왜 없냐는 질문도 AI에게 했다. 이 메서드들은 직접 설계한 게 아니라 WebSocketHandler 계약에 따라 Spring이 호출하는 콜백이라 시그니처가 이미 정해져 있다는 설명을 들었다. 방 ID는 대신 접속 URL을 핸드셰이크 단계에서 검증해 session.getAttributes()에 담아두고, 콜백에서 꺼내 쓰는 식으로 전달해야 한다는 것도 그때 들었다.

JSON 변환과 "payload는 어디에 쓰는가"

처음 직접 작성한 handleTextMessage는 받은 TextMessage를 그대로 registry.broadcast에 넘기는 코드였다. payload를 읽어오긴 했지만 쓰지는 않았다. 이건 Echo랑 다를 게 없는 상태였다 — 서버가 검증하고 저장한 결과가 아니라 클라이언트가 보낸 원문을 그대로 돌려보내는 것이기 때문이다.

이 구멍을 메우려고 AI가 ChatMessageCodec을 만들었다. decode(payload)는 JSON 문자열을 기존 MessageCreateRequest로 바꾸면서 문법 오류·content 누락 같은 건 400 ResponseStatusException으로 표현한다. encode(saved)는 서비스가 저장한 ChatMessage를 응답 DTO와 JSON, TextMessage로 바꾼다. Jackson 3.1.5와 Spring Boot의 JsonMapper 자동 설정을 확인하고 JsonMapper를 생성자로 주입받는 구조로 만든 것도, 단독 테스트를 작성한 것도 AI였다. 나는 직접 작성한 Handler가 이 decode·encode를 호출하도록 연결했다.

Codec 단독 테스트를 돌리려는데 Handler의 create() 호출이 아직 인자가 없어서 컴파일이 안 됐다. 저장소 설정을 바꾸는 대신 임시 Gradle 초기화 스크립트로 이번 실행에서만 Handler 소스를 빼고 돌려서, 함께 확인한 결과 17개가 통과했다. 전체 컴파일이 되는 상태를 검증한 건 아니었다는 걸 분명히 해뒀다.

이 흐름은 roomId·rawToken 읽기, Codec 주입과 decode, ChatService.create 호출, 저장 결과의 encode→broadcast 연결까지 AI의 단계별 힌트를 받아가며 직접 작성했다. 최종적으로 handleTextMessage의 흐름을 정리하면 이렇다.

  1. Spring이 콜백을 호출한다 (void라서 반환값으로 응답하지 않는다).
  2. session.attributes에서 roomId를 읽는다 (Object → Integer 캐스팅, DB 조회 아님).
  3. message.getPayload()로 JSON 문자열 전체를 읽는다.
  4. attributes에서 rawToken을 읽는다 (쿠키에서 서버가 미리 준비한 값, 요청 JSON 필드 아님).
  5. Codec.decode가 JSON을 검증해 MessageCreateRequest를 만든다.
  6. ChatService.create가 토큰 해시 조회, 차단·만료 검사, 방 운영시간·본문 검증을 거쳐 저장하고 ChatMessage를 반환한다. 트랜잭션 커밋이 끝나야 반환값을 받는다.
  7. Codec.encode가 저장 결과를 TextMessage로 바꾼다.
  8. Registry.broadcast가 같은 방의 열린 연결마다 전송한다.

검증·저장이 실패하면 encode·broadcast는 실행되지 않고, 저장 커밋 후 전송이 실패해도 이미 커밋된 DB 행이 취소되지는 않는다.

핸드셰이크를 실제 접속으로 TDD하기

TDD로 핸드셰이크를 만들자고 했을 때 처음엔 "뭘 어떻게 하라는 건지 모르겠다"는 반응이 돌아왔다. 다시 풀어서 말하면 이런 뜻이었다. "실제 Spring 서버에 WebSocket으로 접속하는 테스트를 만들고, 쿠키가 없으면 401로 거절되는지부터 확인하자." 테스트 경계는 실제 /ws/rooms/{roomId} 접속과 수신 응답으로 잡고, Interceptor 내부 호출이나 private 메서드는 검사하지 않기로 했다.

  • 쿠키 없는 접속: 기대는 401인데 채팅 URL 자체가 없어서 404가 나왔다. URL·Bean·Interceptor를 연결하고 나서 401로 통과.
  • 쿠키 있는 정상 접속: 모든 접속을 거절하던 중간 구현이라 처음엔 실패. 쿠키 검증과 속성 전달을 추가한 뒤 메시지 왕복까지 통과.
  • 운영 종료 정각 접속 거절: 연결이 수락돼서 실패 → 운영 시간 검사를 추가해 403으로 거절하도록 수정.
  • 잘못된 토큰 401, 정확히 7일 지난 사용자 401, 차단 사용자 403, 없는 방 404, 숫자가 아닌 방 번호 400도 회귀 검증으로 추가.

이 접속 URL·쿠키 검증, 연결 속성 전달, 공유 Bean·채팅 경로 설정은 AI가 구현하고 실제 접속 테스트도 AI가 작성·실행했다. 이후 연결 종료 시각을 세션 속성에 저장하는 부분, 즉 핸드셰이크 정상 경로에서 attributes.put("closesAt", room.currentClosingTime(now))를 추가하는 건 설명을 들은 뒤 직접 넣었다.

이 과정에서 ChatHandshakeInterceptor가 beforeHandshake에서 방 번호·쿠키를 검증하고 통과한 연결에만 attributes.put("roomId", ...), attributes.put("rawToken", ...)을 실행한다는 전체 그림은 AI 설명을 들으며 확인했다. true/false 반환값은 사용자에게 보내는 JSON이 아니라 Spring에게 "연결을 WebSocket으로 전환해도 되는가"를 알려주는 신호다.

이 단계에서 AI가 Handler/Registry에 @Component를 붙이고 설정에서 Echo/Chat 두 Handler Bean의 모호성을 해소했다. 함께 확인한 결과 ChatWebSocketHandshakeTest 8개, 연관 테스트 30개, 전체 78개가 모두 통과했다. 다만 두 클라이언트 사이의 실제 방송, 동시 전송 보호, 연결 후 오류 JSON, 운영 종료 시 기존 연결 안내는 아직 남아 있다는 걸 분명히 해뒀다.

실제 두 사용자로 방송·분리·재접속 확인

AI가 ChatWebSocketBroadcastTest를 작성해서 세 가지를 실제 네트워크로 확인했다.

  1. Alice와 Bob이 같은 방에 접속하면 Alice가 보낸 메시지를 둘 다 받는데, Bob이 받은 메시지의 작성자가 Alice로 표시돼야 한다. 이게 단순 Echo와 구분되는 지점이다.
  2. A방의 Alice·Bob, B방의 Carol을 연결했을 때 A방 메시지가 B방으로 새지 않는지 — 다만 이 미수신 검증은 관찰 시간(500ms) 안의 결과이지, 임의로 오래 지연된 전달까지 증명한 건 아니다.
  3. Bob이 정상 종료 후 같은 쿠키로 재접속해도 남은 연결과 새 연결이 함께 새 메시지를 받는지.

세 테스트 모두 기존 구현에서 바로 통과해서, 이건 새 기능의 red→green이 아니라 회귀 검증이었다. 함께 확인한 결과 전체 81개가 통과했다.

동시 전송 문제: synchronized는 Map이 아니라 세션에

리뷰에서 두 문제가 드러났다. 실제 Registry에 동시 호출을 거절하는 모의 세션을 붙여보니 IllegalStateException이 재현됐고, 잘못된 JSON을 보내면 오류 메시지 없이 그냥 연결이 1011로 끊겼다.

먼저 동시 전송 문제부터 손댔다. AI가 작성한 테스트는 첫 방송의 소켓 전송을 latch로 묶어둔 채 두 번째 스레드가 같은 방에 방송하는 식이었고, 전송을 해제하면 둘 다 성공해야 한다. 처음 실행하니 두 번째 Future에서 ExecutionException → IllegalStateException: 동일한 연결에 전송이 겹쳤습니다.가 나왔다 — 의도한 red였다.

AI가 실패를 재현하는 이 테스트와 함께 수정 위치까지 제시했고, RoomSessionRegistry.broadcast의 isOpen 검사·sendMessage·닫힌 연결 제거 구간에 직접 synchronized (s)를 추가했다. 문제를 발견하고 해결 방향을 잡은 걸 혼자 도출한 건 아니다. 여기서 중요한 건 잠글 대상이 공유 Map 구조가 아니라 소켓 객체 자체라는 점이다. 각 연결 객체의 모니터를 쓰기 때문에, 같은 세션을 대상으로 한 두 방송 중 하나가 전송 중이면 다른 하나는 기다린다. 컬렉션이나 등록 방식은 건드릴 필요가 없었다.

1011에서 ERROR JSON으로

입력 오류 처리는 AI가 같은 패턴으로 진행했다. 잘못된 JSON·공백 본문·141자 입력을 실제 WebSocket 경계에서 테스트했을 때 셋 다 오류 JSON 없이 그냥 1011로 끊겼다 — red. decode/create에서 나오는 ResponseStatusException을 Handler가 잡아서 {"type":"ERROR","status":400,"message":"..."}로 바꾸도록 AI가 고치니 통과했다.

여기에 종료가 필요한 오류도 추가됐다. 만료(401)·차단(403)·방 종료(403)는 안내 후 방송 목록에서 제거하고 1008로 닫아야 한다. 입력 오류(400)는 같은 연결로 재입력할 수 있게 유지한다 — 이 둘의 차이를 테스트로 구분했다. try에서 오류를 보내고 finally에서 방송 목록 제거와 종료를 시도하는 구조는 AI가 Handler·Codec을 수정해 만든 것으로, 안내 전송 자체가 실패해도 정리는 이어지게 했다. 내가 작성한 정상 처리 흐름 전체를 AI가 새로 쓴 건 아니고, 오류·종료 경로만 이렇게 덧붙여졌다. 함께 확인한 결과 이 단계 끝에 전체 88개가 통과했다.

유휴 연결의 방 종료: Wayfinder로 결정하고 하나씩 구현

메시지를 보낸 사람만 종료 처리가 되는 건 절반짜리였다. 아무것도 안 보내고 가만히 있는 연결도 운영 구간이 끝나면 닫혀야 한다. 이 결정을 Wayfinder로 먼저 잡았다.

  • 1초마다 검사할지: 그렇게 하기로 했다. 다만 이건 엄격한 최대 지연 보장이 아니라 "다음 검사에서 처리한다"는 뜻이다.
  • 종료 직전 커밋된 메시지를 기다릴지: 방 종료가 우선이다. 저장이 성공해도 닫힌 구간의 연결에는 실시간 전달되지 않을 수 있다.

구현은 RoomSessionRegistry.closeExpiredSessions(now, notice)를 먼저 테스트로 만들었다. 정확히 종료 시각에 호출하면 안내를 보내고 1008로 닫아야 하는데, 처음엔 안내 전송이 호출되지 않아 WantedButNotInvoked로 실패했다 — 메서드가 비어 있었으니 당연했다.

여기서 몇 가지가 헷갈렸다.

  • closesAt을 try와 catch 중 어디에 넣는지 — 링크가 catch 줄을 가리켜서 혼동이 생겼는데, 실제로는 검증을 통과한 정상 흐름(try)에서 return true 직전에 넣는다. 검증에 실패한 연결에 종료 시각을 준비할 이유가 없다.
  • entrySet()을 호출만 하고 반환값을 쓰지 않으면 아무 일도 안 일어난다는 것. 이중 반복문(방별 → 연결별)으로 나눠서 생각하니 덜 어려웠다.
  • s.close()는 종료 시각을 바꾸는 메서드가 아니라 연결 자체를 닫는 메서드다. 먼저 속성에서 closesAt을 읽어야 한다 — (LocalDateTime) s.getAttributes().get("closesAt").
  • CloseStatus.POLICY_VIOLATION은 종료 시각이 아니라 종료 사유 코드를 전달하는 값이다.

이중 반복문, 종료 시각 읽기·비교, 안내·등록 해제·연결 종료는 직접 작성했다. 처음 작성한 구현은 sendMessage/close의 checked IOException을 처리하지 않아 컴파일이 안 됐고, 안내 전송이 실패하면 뒤의 정리를 건너뛰는 흐름도 문제였다. 예외 처리와 finally 배치는 여러 차례 AI의 설명·리뷰를 받으며 수정했다. 정리하면 세션 잠금 안에서 try로 안내를 보내고, finally에서 등록 해제 후 열려 있으면 close를 시도하고, 바깥 catch가 IOException을 잡아 한 연결의 실패가 다른 연결 처리를 막지 않게 하는 구조다. 이후 요청에 따라 AI가 실패 로그(SLF4J WARN, roomId·sessionId·예외만 기록하고 토큰·본문은 넣지 않음)를 추가했고, 직접 작성한 종료·예외 처리 흐름은 그대로 유지됐다. 함께 확인한 결과 전체 89개가 통과했다.

@Component와 생성자 주입을 자꾸 혼동했다. @Component는 클래스를 Spring 관리 대상(빈)으로 등록하는 표시고, 생성자 주입은 그 빈이 필요한 다른 빈(Registry·Codec·Clock)을 전달받는 방식이다. 둘은 대체 관계가 아니라 같이 쓰는 것이다. @Autowired는 주입 지점 표시일 뿐이고 생성자가 하나면 생략해도 된다. @EnableScheduling은 정기 실행 기능 자체를 켜는 것, @Scheduled는 실행 주기를 지정하는 것 — 세 층위가 다 다른 역할이다. 이 구분은 AI 설명을 들으며 조금씩 분명해졌다.

Clock이 LocalDateTime인 줄 알았던 것도 짚었다. Clock은 현재 시각을 얻는 기준(과 시간대)이고, LocalDateTime은 날짜·시간 값이다. LocalDateTime.now(clock)으로 그 시계 기준의 값을 얻는다. 테스트에서는 고정 Clock을 주입해서 운영 종료 정각을 재현할 수 있다.

RoomConnectionCloser의 의존성 주입과 1초 주기 호출은 직접 작성했다. 이 과정에서 encodeError에 문자열을 넘겨 컴파일이 깨졌다. 왜 ResponseStatusException을 받아야 하는지 막혀서 AI에게 물었다 — ChatMessageCodec.encodeError는 ResponseStatusException을 받도록 돼 있어서, HttpStatus.FORBIDDEN과 문구로 예외 객체를 만들어 넘겨야 했다. "WebSocket 오류인데 왜 HttpStatus를 쓰냐"고 물었더니, 이 설계가 좀 어색하다는 데는 AI도 동의했다 — 예외를 던지지도 않는 스케줄러에서 안내를 만들려고 예외 객체를 생성하는 건 이상했다.

컴파일을 고치고 나니 다른 문제가 드러났다. 방 종료 테스트에서 "아무것도 안 보낸 Bob에게는 이벤트가 없어야 한다"고 기대했던 부분이 깨졌다. 새 정기 작업이 생기면서 방이 닫히면 가만히 있던 Bob도 안내·종료 대상이 되기 때문이다. 즉 사용자 만료·차단은 그 사용자만 영향받지만, 운영 종료는 같은 운영 구간의 연결 전체에 영향을 준다는 차이를 테스트에서 구분해야 했다. 발신자 전용 오류 테스트와 방 종료 테스트를 나누고, 유휴 연결 두 개가 메시지 없이도 안내·1008을 받는 전용 테스트를 추가했다.

이 김에 어색했던 부분은 AI가 바꿨다. encodeError가 ResponseStatusException 대신 ChatErrorResponse 데이터 객체를 받도록 바꾸고, Handler는 실제로 잡은 예외에서 상태·문구를 꺼내 변환하고, RoomConnectionCloser는 예외 객체를 만들지 않고 ChatErrorResponse를 직접 생성하게 했다. HTTP 타입을 WebSocket 안내 생성 경계에서 걷어낸 것이다. 이 라운드 끝에 함께 확인한 결과 전체 92개가 통과했다.

방송 직전에도 종료 시각을 본다

1초 주기 검사와 별개로, 그 검사 사이의 틈에 만료된 연결에 새 메시지가 방송될 수 있다는 문제가 남아 있었다. Registry에 Clock을 추가하고, broadcast도 그 Clock을 받아 세션 잠금 안에서 전송 직전에 closesAt과 현재 시각을 비교하도록 직접 작성했다. 시각을 잠금 밖에서 먼저 읽으면, 잠금을 기다리는 동안 종료 시각을 지나도 오래된 시각으로 판단할 수 있어서 반드시 잠금을 얻은 뒤에 읽어야 한다. 만료된 연결은 방송만 건너뛰고 Registry에는 남겨둔다 — 여기서 등록까지 해제해 버리면 1초 종료 작업이 그 연결을 못 찾아서 안내·종료 자체를 누락할 수 있기 때문이다.

이 변경으로 Registry 생성자에 인자 없이 호출하던 기존 테스트 6곳이 깨졌다. 이 테스트 준비는 AI가 수정했다 — 테스트용 MutableClock을 전달하고 모의 연결에 closesAt 속성을 채워 넣는 식이었다. 이후 종료 안내 전송이 실패해도 다른 연결의 정리가 계속되는 회귀 테스트까지 추가해서, 함께 확인한 결과 전체 97개 통과로 마무리했다.

하루의 끝: 백엔드 진행 지도

이날 끝에 백엔드가 어디까지 왔는지 정리했다. 방 운영 시간, 익명 사용자 생성·재방문, 메시지 임시 저장, 신고 증거 보존, 양방향 WebSocket과 방 종료 안내의 핵심 흐름은 구현·검증됐다. 신고 접수는 있지만 관리자 검토·자동 경고는 없고, Game은 ERD 단계고, 느린 수신자 대응이나 AWS·HTTPS 운영 설정은 후속 범위다. 전체 테스트 통과가 운영 준비 완료를 뜻하진 않는다는 걸 분명히 해뒀다.

프론트로 넘어가기 전

프런트로 넘어가기 전에, 정작 코드를 짜기 전에 스킬 사용 순서와 GitHub 이슈/PR 방식부터 정리했다. 사용자가 자러 가는 상황이라 답이 필요한 질문은 모아두고, 혼자 결정할 수 없는 부분은 확정하지 않은 채로 초안만 준비하는 방식으로 진행했다.

스킬 순서는 setup(스킬 적용 규칙 확인) → wayfinder(미결정 해결) → to-spec(명세 합성, 테스트 경계 확인) → to-tickets(작은 기능으로 분할, 크기·의존관계 승인) → implement-spec(통합 브랜치 구현) → pr → code-review 순이다. 설치 파일을 읽었다는 게 곧 적용했다는 뜻은 아니라는 걸 계속 구분했다 — setup의 "사용자가 먼저 편집하게 두라", to-spec의 "이 경계가 기대와 맞는지 확인하라", to-tickets의 "사용자가 분할안을 승인할 때까지 반복하라" 같은 원칙에 따라 실제 게시 전에서 멈췄다.

이슈당 PR 하나로 갈지, 통합 PR로 갈지도 짚었다. 기본은 작은 기능 이슈 하나 = PR 하나로, 기능별 diff와 검증 결과를 읽고 순서대로 머지할 수 있게 한다. 설치된 implement-spec은 티켓별 worktree를 하나의 통합 브랜치로 합쳐 전체 명세 PR을 만드는 방식이라 이 둘을 같은 거라고 뭉개지 않았다.

저장소 상태를 확인하니 Issues는 켜져 있지만 열린 이슈·PR이 0건, main은 보호 규칙이 없는 상태였다. 실제 AI가 참조하는 파일은 agents.md/claude.md였다.

프런트를 만들 때 놓치기 쉬운 서버 계약도 정리해뒀다 — 방 목록은 열린 방만 반환한다는 것, 익명 준비 응답이 BANNED 사용자에게도 200으로 올 수 있다는 것, 쿠키가 HttpOnly라 JS에서 못 읽는다는 것, 정상 채팅 응답에는 type 필드가 없고 오류에만 있다는 것, 닉네임/신고 사유는 UTF-16 길이 검사인데 메시지는 코드 포인트 검사라는 것, 서버 시각 응답에 offset이 없다는 것 등이다. 이런 건 코드를 짜기 시작하면 하나씩 다시 부딪힐 문제라 미리 적어뒀다.

질문 9개 중에서는 라벨 의미(needs-triage/needs-info/ready-for-agent/ready-for-human/wontfix), 연결 방식(한 도메인 아래서 Next.js/Spring으로 프록시하는 구성)에 대한 설명이 필요했고, 디자인 방향은 "커피 컵"에서 "탁 트인 창문이 보이는 라운지"로 수정됐다. 모바일 360px·데스크톱 1280px 기준, 작은 기능 분할 방향에는 동의를 받았다. 프런트 저장 위치는 기존 Spring 구조를 유지한 채 저장소 루트에 frontend 디렉터리를 추가하는 안으로 확정했다.

이날은 코드를 한 줄도 건드리지 않고 문서와 결정만 남겼다. 다음 시작은 이 질문들에 답하고 나서, 실제 열린 방 목록 화면부터 만드는 것이다.