SURF 동아리 커뮤니티 서비스
카카오, Apple의 웹, 앱 로그인을 지원하고, Redis 기반 토큰 갱신의 동시성 문제를 개선한 동아리 커뮤니티 서비스
프로젝트 소개
SURF는 TAVE 동아리 구성원의 소통과 활동을 지원하는 커뮤니티 서비스입니다. 전체 11명, 백엔드 4명으로 구성된 팀에서 인증과 인가, 금칙어 마스킹을 담당했습니다. 팀은 서비스를 Google Play와 App Store에 출시했습니다.
기존 카카오 웹 로그인을 카카오와 Apple의 웹 로그인과 앱 로그인으로 유연한 구조로 확장하고 토큰 갱신 과정에서 발생하는 경쟁 조건과 정상적인 중복 요청을 처리하는 데 집중했습니다.
카카오 웹 로그인에서 네 가지 인증 흐름으로 확장
기존에는 컨트롤러가 카카오 토큰 교환부터 회원 조회와 저장, JWT 발급, 쿠키 설정까지 처리했습니다. 회원도 카카오 전용 ID로 식별하고 있어 Apple 로그인과 앱 로그인을 추가하려면 인증 구조를 함께 정리해야 했습니다.
로그인 흐름을 조율하는 Usecase와 외부 API 호출, 인증 로그 처리의 책임을 분리했습니다. 회원은 provider + providerId 조합으로 식별하고 복합 Unique 제약을 적용했습니다. 공통 토큰 발급기인 LoginTokenIssuer에는 JWT 발급과 응답 조립을 모았습니다.
요청 형식이 다른 웹과 앱은 로그인 URL을 분리했습니다. Refresh Token은 웹에 HttpOnly 쿠키로, 앱에 JSON 응답으로 전달하도록 구성했습니다.
Apple 인증에서는 Identity Token의 서명과 클레임을 검증하고 웹의 Service ID와 앱의 Bundle ID를 구분했습니다. 웹 콜백 검증에 필요한 state와 nonce는 Redis에 저장하고 Lua로 조회와 삭제를 한 번에 처리해 재사용을 막았습니다.
이렇게 제공자별 인증과 공통 회원 처리, 클라이언트별 토큰 전달을 분리해 카카오와 Apple 각각에 웹과 앱 로그인을 제공하는 네 가지 흐름을 지원했습니다.
같은 Refresh Token이 두 번 소비되는 문제 개선
기존 토큰 회전 로직은 Redis의 조회, 값 비교, 삭제를 각각 실행했습니다. 두 요청이 삭제 전에 같은 값을 읽으면 동일한 토큰으로 모두 검증을 통과할 수 있었습니다.
저장값 비교와 삭제를 Lua 스크립트로 묶어 같은 토큰을 소비하는 요청이 하나만 성공하도록 변경했습니다. 처리 결과도 소비 완료, 불일치, 미존재로 구분하고 인프라 오류는 별도로 처리했습니다.
하지만 토큰을 한 번만 소비하도록 만드는 것만으로는 충분하지 않았습니다. 앱의 여러 API가 동시에 만료 응답을 받으면 정상 사용자도 같은 Refresh Token으로 갱신을 요청할 수 있었습니다. 뒤늦은 요청을 재사용 공격으로 판단하면 다른 기기 세션까지 폐기되는 문제가 남았습니다.
이를 보완하기 위해 직전 토큰과 새 토큰을 30초 동안 보관하는 유예 키를 추가했습니다. 유예 키가 저장된 뒤 들어온 중복 요청은 제출한 토큰이 직전 토큰과 일치할 때 같은 새 토큰을 받습니다. 로그아웃할 때는 현재 토큰과 유예 키를 함께 삭제했습니다.
전체 세션을 폐기하는 처리도 KEYS에서 SCAN 커서 순회와 100개 단위 삭제로 변경했습니다.
검증 범위와 남은 과제
다른 기기 세션 보존, 직전 토큰 재시도, 로그아웃 후 유예 키 삭제, 여러 세션 키 삭제를 다루는 Redis Testcontainers 테스트 5개와 인프라 예외 단위 테스트 2개를 추가했습니다.
현재 원자적으로 처리하는 범위는 기존 토큰의 검증과 삭제입니다. 토큰을 소비한 직후 유예 키가 저장되기 전에는 중복 요청이 실패할 수 있습니다. 유예 키 저장 후 현재 토큰 저장이 실패하는 경우도 보완해야 합니다.
토큰 소비와 저장을 하나의 연산으로 묶고 요청의 실행 순서를 제어해 두 요청의 결과를 함께 확인하는 테스트를 후속 과제로 정리했습니다. 30초 유예 시간도 실제 중복 요청 간격을 측정해 조정할 필요가 있습니다.
운영자가 관리할 수 있는 금칙어 마스킹
게시글, 댓글, 쪽지, 프로필에 입력되는 텍스트를 서버에서 검사하도록 구현했습니다. Aho-Corasick 기반으로 원문과 정규화된 텍스트를 검사하고 탐지한 위치를 원문에 대응시켜 마스킹했습니다.
금칙어와 허용 표현은 DB에서 관리하고 관리자 API로 수정할 수 있도록 했습니다. 변경된 사전은 스냅숏을 교체하는 방식으로 반영하고 필요할 때 기능을 비활성화할 수 있는 설정도 마련했습니다.