열렬히.뛰기

딴짓천국 설계하기 (5) - 로그인 없이 기억하기

추석 연휴를 지내다 보니 좀 게을러진 면이 있었던 거 같다.
뭐 나름 코드를 쳐보려고 노력은 했는데...아무래도 집안일 할게 많다보니;;;

Room 도메인을 schema, API, 단위 테스트까지 마무리하고 나서, 다음 차례는 AnonymousUser였다. 로그인이 없는 서비스에서 같은 브라우저를 어떻게 최소한으로 식별할지, 그 정책부터 정해야 코드를 시작할 수 있었다.

정책: 토큰은 쿠키에, 해시는 DB에

후보로 잡은 방식은 간단하다. 서버가 예측하기 어려운 임의 토큰을 만들어 브라우저 cookie로 내려주고, 다음 요청에서 그 cookie가 있으면 같은 사용자를 다시 찾는다. 여기서 바로 막힌 질문은 토큰 원문을 DB에 저장할지, 해시를 저장할지였다.

답은 해시였다. 브라우저 식별 토큰은 사실상 비밀번호가 아니라 bearer token이다. 토큰을 훔친 쪽은 그 브라우저인 척 요청할 수 있으므로, DB에는 원문이 아니라 SHA-256 해시를 저장하기로 했다. 그러자 바로 다음 질문이 따라왔다. 느린 해시(Argon2, bcrypt)를 써야 하는가, 빠른 해시로 충분한가. 이 토큰은 사람이 기억하는 비밀번호가 아니라 서버가 만든 고엔트로피 난수이기 때문에, OWASP 세션 관리 지침에 따라 빠른 SHA-256으로 충분하다고 판단했다. 대신 토큰 자체는 CSPRNG로 충분히 길게(이 프로젝트에서는 256비트) 생성해야 한다는 전제를 붙였다.

그런데 해시로 저장하면 쿠키 탈취까지 막아주는가? 이건 별개의 질문이었고, 답은 '아니다'였다. 원문을 훔친 공격자는 만료 전까지 그 브라우저인 척할 수 있다. 그래서 원문은 HttpOnly 쿠키로만 전달하고 로그에 남기지 않으며, 운영 환경에서는 HTTPS와 Secure, SameSite 속성을 쓰기로 했다. 그럼 SameSite만으로 CSRF까지 해결되는가 하는 의문도 뒤따랐는데, 쿠키는 브라우저가 자동으로 붙이므로 상태를 바꾸는 요청에는 CSRF가 여전히 고려 대상이라고 보고, SameSite만으로 CSRF가 다 해결된다고 가정하지 않기로 했다.

마지막으로 짚어야 했던 건 이 식별이 사람의 신원을 확인하는 것인가였다. 답은 아니었다. 쿠키를 지우거나 다른 기기를 쓰면 얼마든지 새 AnonymousUser가 생긴다. 낮은 진입 장벽을 우선하는 서비스 성격상 이건 브라우저 단위의 best-effort 식별로 받아들이고, IP 추적이나 fingerprinting처럼 개인정보 침해와 복잡성을 키우는 수단은 실제 악용이 확인되기 전까지는 넣지 않기로 했다.

코드를 어떻게 나눌지 막막할 때는 흐름부터 그린다

Entity, Repository, DTO, Service, Controller 중 어디서부터 손대야 하는지 막막했다. 코드 구조가 떠오르지 않을 때는 HTTP 요청에서 DB까지 한 흐름을 먼저 그려봤다.

브라우저 요청 + cookie
  → Controller가 cookie 확인
  → Service가 사용자 찾기 또는 생성 결정
  → Repository가 PostgreSQL 조회·저장
  → Entity가 AnonymousUser 상태 표현
  → Response DTO가 필요한 값만 반환

이 흐름을 보고 구현 순서를 Entity → Repository → DTO → Service → Controller로 잡았다. Controller가 런타임에 가장 먼저 호출되는 건 맞지만, 작성 순서까지 호출 순서를 따라야 하는가 하면 그럴 필요는 없다는 결론이었다. Service 메서드의 입력·출력이 정해지지 않으면 Controller도 연결할 대상이 없기 때문이다.

DB 스키마는 한 번에 완성되지 않았다

V5: enum과 기본 테이블

ERD의 user_status enum을 어떻게 DB에 반영할지가 첫 질문이었다. PostgreSQL native enum으로 만들기로 하고, 테이블 생성 전에 CREATE TYPE user_status AS ENUM ('ACTIVE', 'BANNED')를 먼저 선언했다. 그다음 질문은 Java 쪽에서 이 native enum을 어떻게 매핑하는가였는데, @Enumerated(EnumType.STRING)과 Hibernate의 @JdbcTypeCode(SqlTypes.NAMED_ENUM)을 함께 써야 native enum과 실제 JDBC 매핑이 맞는다는 걸 이때 정리했다. ORDINAL은 enum 순서가 바뀌면 깨지므로 처음부터 배제했다.

V5 초안에는 고칠 게 많았다. status ... default 'ACTIVE';에서 테이블 정의가 끝나기 전에 세미콜론이 들어가 문법 오류가 났고, id에 primary key·identity가 없었고, last_seen_at이 time(시각만)으로 돼 있어 ERD의 timestamp 의미와 달랐다. 테이블명도 "AnonymousUser"처럼 따옴표를 쓰면 대소문자 구분 이름이 만들어져 Hibernate 기본 naming과 충돌할 수 있어, anonymous_user로 명시적으로 소문자 snake_case를 썼다.

V6: 닉네임 길이

닉네임 최대 길이를 10자에서 30자로 늘려야 했다. 여기서 질문은 이미 로컬 DB에 적용된 V5를 고칠 것인가, 새 migration을 추가할 것인가였다. V5는 건드리지 않고, V6에서 varchar(30)으로 확장하는 별도 migration을 추가하는 쪽을 택했다. 이미 적용된 migration을 사후에 고치면 Flyway의 checksum 이력과 어긋난다는 걸 계속 의식했다.

V7: 토큰 해시 컬럼

재방문 식별을 구현하려면 쿠키 토큰의 해시를 저장할 unique 컬럼이 필요했다. 처음엔 id_hash라고 이름 붙였다가, 뭘 해시한 값인지 불분명하다는 지적을 받고 token_hash로 고쳤다. 문제는 여기서 실제로 한 번 터졌다. 이미 row가 존재하는 테이블에 NOT NULL 컬럼을 바로 추가했더니 SQLSTATE 23502가 났다. 컬럼 이름이나 타입 문제인가 싶었지만 원인을 보니 기존 데이터와 새 제약이 충돌한 것이었다. 로컬 개발 데이터라 보존할 필요가 없었으므로 기존 row를 비우고 V7을 다시 적용하는 쪽을 택했다.

V8: BANNED도 7일이면 끝난다

BANNED 상태를 영구로 둘지 임시로 둘지 정해야 했고, 결론은 7일 임시 차단이었다. 그러자 정확한 차단 종료 시점을 어떻게 계산할 것인가라는 질문이 남았는데, last_seen_at을 재사용할 수는 없다는 걸 알았다. 그래서 banned_until 컬럼을 ERD와 V8에 따로 추가하고, PostgreSQL CHECK 제약으로 ACTIVE는 null, BANNED는 시각이 있어야 한다고 강제했다.

리뷰에서 반복된 실수들

Entity와 Service, Controller를 만드는 과정에서 "왜 이게 안 되지" 싶은 순간들이 몇 가지 패턴으로 반복됐다. 원인과 고친 방식을 기록해둘 만하다.

  • 생성자와 메서드 혼동: AnonymousUser anonymousUser(...)처럼 반환 타입을 쓴 코드가 missing return statement 오류를 냈다. 원인을 보니 생성자가 아니라 반환 타입이 빠진 일반 메서드였다. 생성자는 클래스와 이름이 같고 반환 타입을 쓰지 않는다.
  • DB 컬럼과 필드 불일치: created_at인데 필드는 date, status인데 user_status로 지정하는 식의 어긋남이 여러 번 있었다. java.util.Date와 LocalTime도 실제 SQL 타입(date, timestamp)과 맞지 않아 LocalDate/LocalDateTime으로 고쳤다.
  • Setter를 전체에 열어두기: @Setter를 Entity 전체에 붙이면 id나 status처럼 불변이어야 할 값까지 어디서든 바뀐다는 게 문제였다. recordVisit(now), banForOneWeek(now)처럼 의미가 드러나는 도메인 메서드로 좁혔다.
  • Optional을 다루는 법: findByTokenHash(...).orElseThrow()를 먼저 호출해놓고 뒤에서 existingUser == null을 검사하는 코드가 있었는데, 왜 신규 생성 분기가 안 타는지 보니 사용자가 없을 때 이미 예외로 메서드가 끝나버린 것이었다. 존재 여부를 먼저 분기해야 했다.
  • Optional과 void 메서드: Optional<AnonymousUser>에서 .map(u -> u.getLastSeenAt())이 LocalDateTime이 아니라 Optional<LocalDateTime>을 반환한다는 걸 놓쳤다. 게다가 recordVisit(now)는 반환형이 void라서 애초에 map의 변환 함수로 쓸 수 없었다.
  • 조용한 return: 길이 초과 입력에 void 메서드가 조용히 return해버리면 호출자가 성공과 실패를 구분할 수 없다는 게 문제였다. DTO validation이나 명시적 예외로 바꿨다.
  • 예외 처리기를 너무 넓게 잡기: @RestControllerAdvice에서 모든 Exception을 잡아 printStackTrace()만 하는 초안이 있었는데, 이러면 버그나 DB 오류까지 검증 오류처럼 처리될 위험이 있다고 판단해 MethodArgumentNotValidException만 구체적으로 잡도록 좁혔다.

만료 정책: 7일, 그리고 rolling

쿠키 Max-Age 30일을 처음에 고려했는데, 이게 서버가 토큰을 거부하는 기준이 되는가부터 따져야 했다. 답은 아니었다. 그건 브라우저가 쿠키를 보관하는 기간일 뿐이고, 서버 쪽 만료는 별도로 정책을 정해야 하는 문제였다.

최종적으로는 마지막 방문 후 7일 동안 활동이 없으면 만료하는 쪽으로 정했다. 유효한 재방문에서는 last_seen_at과 쿠키 만료를 함께 갱신하는 rolling 방식이다. 다만 이 방식만으로 충분한가도 의문으로 남겨뒀다. 비활성 만료만 적용하면 탈취자가 주기적으로 토큰을 써서 유효기간을 계속 늘릴 수 있다는 한계가 있다. OWASP도 idle timeout과 absolute timeout을 구분해 설명하는데, 이 서비스에 absolute timeout이나 토큰 교체가 필요한지는 아직 별도로 판단할 문제로 남아 있다.

경계값 계산에서도 한 번 걸렸다. Duration.between(lastSeenAt, now).toDays() > 7이 7일 23시간도 정수 7일로 잘라버려 놓치는 걸 보고서야 문제를 알았다. Duration.ofDays(7)과 compareTo로 직접 비교하는 쪽이 의도를 명확히 한다는 결론을 냈다.

닉네임은 보안이 아니다

닉네임 생성에 처음엔 런타임 AI API 호출도 생각했다. 하지만 그럴 필요가 있는가를 되짚어보니 없다는 결론이었다. 수식어와 명사 목록을 조합하면 지연도 비용도 외부 장애 위험도 없이 일정한 정책으로 만들 수 있다. 초기 목록은 6개 수식어 × 7개 명사로 조합이 42개뿐이었는데, 이후 50개 × 40개에 000~999 숫자 접미사를 붙여 명목상 2,000,000가지 조합으로 늘렸다.

그다음 질문은 난수 생성에 SecureRandom이 필요한가였는데, 닉네임은 표시용 이름이지 인증 토큰이 아니므로 거기까지 쓸 필요는 없었다. 무작위 생성이 중복을 줄여주긴 해도 유일성을 보장하진 않는다는 점도 분명히 해뒀다 — 중복 허용 여부는 별도 제품 정책의 문제로 남겨뒀다.

지금까지 끝난 것과 남은 것

선택적 닉네임 입력, 미입력 시 자동 생성, PostgreSQL 저장, JSON 응답, 길이 검증, 그리고 쿠키 토큰 기반의 재방문 식별, 7일 비활성 만료, BANNED 7일 임시 차단까지 — ./gradlew test 전체가 통과하는 상태로 한 사이클이 마무리됐다. 최초 생성, 재방문 동일 사용자, 만료 후 새 토큰/사용자, 비활성 기간이 지나도 진행 중인 차단은 유지, 원문 토큰이 JSON 응답에 노출되지 않는지까지 테스트로 확인했다.

다만 이 BANNED 상태나 차단 로직이 아직 실제 Message/Report 같은 쓰기 기능에 연결된 건 아니다. 지금 끝난 범위는 "익명 사용자 생성·재방문 식별 API"이고, 실제로 이 식별을 Message 작성자 연결이나 신고 중복 방지에 쓰는 건 그 도메인을 만들 때의 몫으로 남아 있다.