배경
이번 글은 이틀치 기록이다.
하루는 AnonymousUserSession을 마무리하고 학습 속도를 점검하는 데 썼고, 다른 하루는 거의 전부를 메시지를 어디에 저장할지 — DB 테이블로, 메모리로, 다시 DB로 — 오가는 데 썼다.
최종적으로는 PostgreSQL에 Message 테이블을 두는 쪽으로 돌아왔지만, 그 왕복 자체가 이번 글의 핵심이다.
문제 정의
Service가 Controller에 넘겨야 하는 값은 두 개였다.
HTTP 응답 DTO로 쓸 사용자 정보와 Set-Cookie에 넣을 쿠키 원문 토큰이다.
기존 AnonymousUserResponse에 원문 토큰을 같이 넣으면 JSON에 비밀값이 그대로 노출되니, 둘을 묶는 내부 전달 객체가 따로 필요했다.
한편 Message 쪽에서는 더 근본적인 질문이 있었다.
처음에는 ERD 초안에만 있던 Message를 실제 테이블로 만들려고 했다. Room과 AnonymousUser를 FK로 연결하고, content 140자 제한, 작성자는 쿠키 토큰 검증으로 서버가 결정하는 방식으로.
그런데 곧 질문이 생겼다. 우리 정책은 과거 메시지를 아예 안 남기는 거 아닌가? 그렇다면 Message 테이블이 왜 필요한가.
답은, 정책이 "전혀 저장 안 함"이 아니라 "방이 열린 동안만 보관하고 방이 닫히면 삭제"라는 거였다.
새로 들어온 사용자에게 현재 방의 메시지를 보여줘야 하고, 신고 대상 메시지를 id로 특정해야 한다.
WebSocket은 연결된 사용자에게 데이터를 전달하는 수단일 뿐 저장 수명이나 신고 대상을 관리해주지 않는다. 그러니 테이블은 필요하다 — 라는 데까지 정리됐다.
설계와 선택
AnonymousUserSession은 응답 DTO와 원문 토큰을 묶는 역할을 하는 record다.
session.user()는 응답 DTO를 만드는 데, session.rawToken()은 Set-Cookie에 쓴다. JPA Entity도, DB 테이블도, Servlet의 HttpSession도 아니다. 그냥 메서드 반환값 하나에 두 값을 묶기 위한 그릇이다.
record를 쓰면 생성자·접근자가 공짜로 생기고, toString이 원문 토큰을 가려줘서 로그에 찍힐 위험도 줄어든다. 내부 결과 객체와 외부 응답 DTO를 구분하는 것, 이게 이번 작업에서 남길 만한 결정이었다.
쿠키 관련해서는 HttpServletRequest를 Controller 파라미터로 받아 isSecure()로 HTTPS 여부를 확인하고 ResponseCookie의 Secure 속성을 정했다. 로컬 HTTP에서는 false, HTTPS 요청에서는 true가 된다.
다만 나중에 ALB 같은 프록시가 HTTPS를 종료하는 구조로 가면 전달 헤더와 신뢰 프록시 설정을 다시 봐야 isSecure가 제대로 동작한다는 점은 지금 단계에서 미리 적어뒀다.
Message는 늦게 입장한 사용자에게 과거 대화를 보여줄 요구가 실제로는 없다는 게 다시 확인되면서, DB에 Message 테이블을 두는 계획을 보류했다.
대신 제안된 건 일반 채팅은 서버 메모리에 짧게 유지하고, 신고가 들어오면 그 ID로 원본을 찾아 Report에 증거로 복사하는 구조였다.
구체적으로는 단일 Spring 서버 안에 ConcurrentHashMap<UUID, ChatMessage>를 두고, 메시지 ID는 DB 자동 생성이 아니라 서버가 UUID.randomUUID()로 만들고, 방·작성자·내용·작성 시각과 함께 담는 방식이다. Redis 같은 공유 저장소를 먼저 끌어올 필요는 없었다 — 아직 다중 서버가 아니니까.
정책은 이렇게 확정됐다: 메시지 최대 15분 또는 방 종료 시 만료(둘 중 빠른 쪽), 같은 신고자의 같은 메시지 중복 신고 금지, 신고 증거 30일 보관, 메모리 10,000개 상한.
이후 DB로 되돌리기로 하면서, grill-with-docs 방식으로 설계를 다시 좁혀나갔다. 주요 결정들만 추리면:
- 서버 재시작 후에도 원래 만료 기한까지는 신고를 허용한다.
- 메모리 저장의 10,000개 전역 상한은 DB로 옮기면서 제거한다. (RAM 보호 장치였을 뿐, DB에 그대로 이식할 이유가 없다.)
- 메시지 삭제는 신고 가능 기한과 실제 물리 삭제 시각을 분리한다. 신고 기한은
min(작성 + 15분, 해당 방 종료), 실제 삭제는 방 종료 + 10분. 사용자가 "그건 유지해줘"라고 답하면서 기존 15분 표현이 깨지지 않도록 두 기한으로 나누는 안이 확정됐다. - 물리 삭제는 1분 간격 Scheduled 작업으로 정리한다. 정확한 초 단위 삭제를 보장하는 게 아니라는 점은 명시했다.
- Message가 지워져도 신고 증거는 남아야 하니, Report.message_event_id는 UUID 복사값으로 추적하고 Message FK는 걸지 않는다.
- 본문을 String(varchar)으로 둘지 JSONB로 둘지도 물었는데, row 구조(방/작성자/본문/시각)가 일정하니 varchar(140)로 충분하다는 결론이었다.
이 과정에서 인터뷰 기록 방식 자체도 손봤다. 질문 번호·추천과 이유·사용자 답변·미결정 사항을 라운드별로 남기도록 개인 grill-with-docs 스킬을 갱신했는데, 이건 이 저장소 밖의 일이라 커밋에는 포함되지 않는다.
나의 의문점과 해결방안
이번에 반복해서 막힌 지점은 타입과 반환값과 제어 흐름을 동시에 추적하는 것이었다.
Optional<AnonymousUser>와 AnonymousUser를 헷갈리고, map이 반환값을 요구한다는 걸 놓치고, orElseThrow 뒤에 null 분기를 써놓고 왜 실행이 안 되는지 헤맸다.
Spring에서 요청 본문·쿠키·내부 결과 객체의 책임을 나누고 그걸 트랜잭션/영속 상태와 연결하는 경험이 아직 부족하다는 뜻이고, 그 이상도 이하도 아니다.
속도를 내려고 정한 방식은 간단하다. 메서드 입력·출력과 분기별 결과를 먼저 글로 적고, Optional은 조회 경계에서만 쓰고 내부 로직은 단순 if/return으로 짠다. 한 번에 한 흐름만 완성하고 컴파일·테스트로 바로 확인한다.
AI가 한 번에 새 개념과 정책을 여러 개 끼워 넣거나 여러 계층을 동시에 구현해주는 방식은 이해와 진행을 분리시켜서, 이번부터는 범위를 의도적으로 줄였다.
기존 AGENTS.md에도 설계 결정 대신하지 않기·힌트 우선 같은 원칙은 있었는데, 작은 기능으로 범위 제한하기·정상/실패/경계부터 정하기·구현 책임 구분 같은 걸 좀 더 구체적으로 보완했다. 반복 작업(토큰 생성 같은 것)은 AI에 맡기고, 전체 흐름을 설명하고 작은 변경을 직접 할 수 있는지로 이해를 확인하는 식이다.
다음 기능(Message)에 어떤 작업 도구를 쓸지도 미리 점검했다. grill-with-docs는 작은 범위에서 남은 정책만 짧게 확인할 때, to-spec/to-tickets는 여러 검증 가능한 단위로 쪼갤 때, wayfinder는 여러 세션에 걸친 큰 설계 결정을 추적할 때 쓰는 걸로 구분해뒀다. 이번 Message 작업은 작은 범위였으니 wayfinder는 필요 없었다.
구현된 코드를 읽으면서도 질문이 쏟아졌다. ChatController의 주소 매핑, registerSynchronization이 뭘 하는지, Report의 LAZY/JoinColumn이 어떤 의미인지, @Modifying이 언제 필요한지, ReportCleanup의 스케줄이 정확히 어떻게 동작하는지.
그 중 제일 까다로웠던 건 커밋 전 노출 문제였다. ChatService.create에서 Map에 메시지를 넣는 건 메서드 안에서 즉시 일어나는데, DB 커밋은 메서드가 반환된 뒤 트랜잭션 프록시가 처리한다. 그러니 Map에 메시지가 들어간 시점과 DB 커밋이 완료되는 시점 사이에 틈이 생긴다.
afterCommit으로 Map 저장을 미루면 이 틈은 막히지만, 그 시점에 Map 용량이 초과돼서 실패하면 이미 커밋된 lastSeenAt DB 변경은 되돌릴 수 없다. 단순히 저장 순서를 옮기는 걸로 DB와 Map이 원자적으로 묶이지는 않는다.
이 지점에서 "메모리 보관이 더 복잡해진 것 같다"는 피드백이 나왔다. DB와 Map이 별개의 저장 공간이라 롤백이 한쪽에만 걸린다는 개념부터 짚고, 설계를 바꿀지를 다시 물었다.
답은 — 첫 운영과 Spring/JPA 학습 목표를 생각하면 DB 임시 보관이 더 합리적이라는 쪽이었다. 과거 대화를 안 보여준다는 정책과 DB에 안 저장한다는 정책은 원래 별개였는데, 둘을 묶어서 생각했던 게 혼란의 원인이었던 셈이다.
구현과 실험
AnonymousUserSession 관련 테스트는 쿠키의 HttpOnly·SameSite·Path·7일 Max-Age·Secure, 닉네임 30/31자 경계, 응답 본문에 토큰이 없는지를 Controller 레벨에서, 신규 발급·방문 시각 UPDATE·차단 만료 복원을 PostgreSQL 레벨에서 확인했다. ./gradlew test 24개 전부 통과.
사용자가 자소서 작업 등으로 바빴던 타이밍에 Message 메모리 버전 구현 전체를 AI에게 맡겼다. V9는 Report만 만들고 Message 테이블은 없는 채로, Report의 대상·증거 내용은 메모리 원본에서 가져오는 식으로 구현됐다. 신고 원본이 없으면 410, 닫힌 방/차단은 403, 쿠키 없음/만료는 401. 테스트 39개 통과.
정책이 정리된 뒤 V10 migration 파일을 작성해 Flyway로 적용했다.
CHECK (btrim(content) <> '')
btrim은 양끝 공백을 제거한 결과를 돌려주는 PostgreSQL 함수다. NOT NULL만으로는 막지 못하는 빈 문자열이나 스페이스만 있는 내용을 이 CHECK로 추가로 거절한다.
다만 탭이나 줄바꿈 같은 모든 공백 문자를 기본 btrim이 처리해주는 건 아니라서, Java 쪽 isBlank/strip 검증은 그대로 유지했다. 비슷하게 expires_at > sent_at, delete_after > expires_at CHECK도 추가했는데, 이건 시간 순서만 보장할 뿐 15분/방 종료+10분 계산 자체는 Service가 담당한다.
V10을 Flyway로 적용하고 flyway_schema_history에서 version=10, success=true를 확인했다. 테스트 39개 통과 — 다만 이건 migration 적용과 기존 기능의 회귀 확인일 뿐, 실제 채팅 저장은 아직 Map을 쓰는 상태였다.
Message Entity를 만들면서 몇 가지가 확인됐다. UUID PK에는 DB DEFAULT가 없는 대신 GeneratedValue(GenerationType.UUID)로 Hibernate가 생성한다. findByIdAndExpiresAtGreaterThan은 기한이 정확히 도달한 순간엔 원본을 반환하지 않고, deleteReadyForCleanup은 delete_after를 기준으로 하는 벌크 JPQL DELETE라서 영속성 컨텍스트가 자동으로 동기화되지 않는다(테스트에서 clear 후 재조회해야 했다).
최종 ./gradlew test는 48개, 실패/오류/건너뜀 0. 실제 커밋 여부, 방문 시각/메시지 함께 롤백, 별도 트랜잭션에서 커밋 전 미조회, 만료 이후 신고 커밋 허용, 자정 전후 같은 운영 구간 삭제 시각 처리까지 테스트로 확인했다. 다만 실제 JVM 재시작이나 1분 스케줄 대기 자체를 테스트한 건 아니다.
실패와 개선
ChatService/ReportService를 Map에서 Repository로 바꾸는 작업은 이번에도 AI에게 맡겼다. 이때 리뷰하면서 발견한 두 가지 버그가 눈에 띈다.
하나는 나노초/마이크로초 반올림 문제다. Java의 나노초 정밀도가 PostgreSQL의 마이크로초로 반올림되면, 방 종료 직전에 작성된 메시지의 작성 시각과 만료 시각이 같아져서 expires_at > sent_at CHECK가 실패할 수 있었다. 작성/확인/정리 기준 시각을 전부 마이크로초로 절삭하고, 100나노초 경계 테스트를 추가해서 막았다.
다른 하나는 NUL 문자 문제다. PostgreSQL은 텍스트 컬럼에 NUL 문자를 저장할 수 없어서, 메시지 본문이나 신고 사유에 NUL이 들어오면 500 에러가 날 수 있었다. 이걸 명시적인 400 검증으로 바꾸고 테스트를 추가했다.
V10 migration도 처음부터 매끄럽게 되지 않았다. 처음엔 AI가 빈 파일만 만들고 내용은 사용자가 직접 썼는데, 첫 초안에는 Report용 컬럼(reporter_user_id, reason, evidence_content 등)과 integer id가 Message 컬럼과 섞여 들어가 있었다. Message와 Report가 서로 다른 테이블이라는 구분이 헷갈렸던 것이다.
거기에 더해, DBML의 increment가 정수 자동 생성값을 가리키는 개념이라 UUID PK인 Message.id에는 애초에 적용되지 않는다는 점, N:1 관계는 room_id/anonymous_user_id에 UNIQUE를 붙이면 오히려 1:N을 막아버린다는 점도 짚었다.
두 번째 수정에서 FK 문법과 세미콜론은 고쳐졌지만 Report 컬럼은 여전히 남아 있었다. 결국 이번 수정은 AI에게 맡기기로 하고, UUID PK·방/작성자 FK·nickname/content·sent_at/expires_at/delete_after로 정리했다.
회고
여기까지가 Message를 두고 벌어진 하루치 왕복이다.
테이블로 시작해서, 과거 저장 안 함이라는 정책 때문에 필요 없어 보였다가, 신고 증거 때문에 다시 필요해지고, 메모리로 가볍게 가려다가 트랜잭션 경계 문제로 복잡해지고, 결국 DB로 돌아왔다.
설계를 한 번에 맞히지 못했다는 게 아니라, 매번 막힌 지점에서 질문을 다시 던진 기록이다.
DB 전환을 마무리하면서 WebSocket 관련 질문 두 개가 남았다. 전송까지 WS로 할지 아니면 HTTP POST로 보내고 WS는 수신 전용으로 쓸지, 방 종료 시 연결 자체를 끊을지 아니면 연결은 유지하고 전송만 거절할지.
둘 다 추천안은 있었지만 — "웹소켓은 내가 직접 코딩하고 싶은 부분이라서" 결정을 다음으로 미뤘다.