Google 로그인과 Redis 실시간 모듈에서 정한 신뢰 경계
인증과 실시간 기능은 서로 다른 문제처럼 보이지만, 내가 구현하면서 가장 많이 고민한 질문은 같았다. “어디까지 믿고, 어디에서 검증할 것인가?”였다.
Google 로그인에서는 브라우저가 보내는 사용자 정보를 그대로 믿을 수 없었다. 실시간 기능에서는 한 JVM 안에서 성공한 메시지 전달을 전체 replica의 성공으로 볼 수 없었다. 나는 두 기능 모두 명확한 신뢰 경계를 먼저 만들었다.
Google 로그인에서 브라우저의 이메일을 믿지 않았다
프론트엔드가 이메일과 이름을 보내고 백엔드가 그대로 가입시키는 방식은 간단하다. 하지만 공격자가 직접 요청을 만들면 다른 사람의 이메일도 쉽게 주장할 수 있다.
나는 Google Identity Services가 발급한 OpenID Connect ID Token 원문을 백엔드로 보내고, 서버에서 서명·issuer·audience·만료를 검증하도록 했다. 검증된 claim만 계정 생성과 로그인에 사용했다.
MeetBack에서는 Authorization Code 교환 대신 Google ID Token을 백엔드가 직접 검증하는 방식을 사용했다. Access Token이나 Google UserInfo API는 사용하지 않았다.
Google 로그인 버튼
→ 브라우저가 ID Token 획득
→ 백엔드로 ID Token 전달
→ 백엔드가 서명·issuer·audience·만료 검증
→ 검증된 sub와 email claim 사용
→ MeetBack 자체 인증 Token 또는 가입 단계 진행
소셜 계정의 영구 식별자는 이메일이 아니라 provider + sub로 잡았다. 이메일은 변경되거나 다른 공급자와 겹칠 수 있지만 sub는 Google이 사용자 식별을 위해 발급하는 안정적인 값이기 때문이다.
같은 이메일의 일반 계정이 있어도 자동으로 연결하지 않았다. 사용자가 기존 계정의 소유권을 별도로 증명하지 않았는데 이메일 문자열만 같다는 이유로 합치면 account takeover의 경계가 흐려진다.
운영에서 만난 OAuth 오류는 Secret 하나로 설명되지 않았다
운영 중 Kakao 로그인에서 KOE006과 KOE320을 차례로 만났다. 처음 화면만 보면 Client Secret이나 Docker Secret이 잘못됐다고 생각하기 쉽다.
그러나 두 오류의 성격은 달랐다.
KOE006은 등록된 redirect URI나 앱 설정이 실제 요청과 맞지 않을 때 확인해야 하는 오류였다.KOE320 invalid_grant는 이미 사용했거나 만료된 인가 코드를 다시 교환할 때 주로 발생한다.
나는 provider console의 등록 URI, 실제 요청의 redirect_uri, proxy가 전달한 scheme과 host, callback 중복 요청 여부를 나눠서 확인했다. 운영 Secret의 이름이 존재한다는 사실만으로 값이 맞다는 결론을 내리지 않았다.
또한 HTTPS 요청인데 애플리케이션이 http://.../home redirect를 만들고 JSESSIONID에 Secure가 붙지 않는 현상도 발견했다. 이는 OAuth provider 설정만의 문제가 아니라 forwarded header 처리와 Spring의 redirect·cookie 설정 문제였다.
Swarm VIP는 JVM 상태를 공유하지 않는다
애플리케이션을 두 replica로 늘리면 HTTP 요청은 Swarm VIP가 분산한다. 하지만 Spring SimpleBroker와 Presence map은 각 JVM 메모리에 따로 존재한다.
사용자 A가 replica 1에, 사용자 B가 replica 2에 연결되면 replica 1에서 보낸 STOMP 메시지는 replica 2 사용자에게 자동으로 전달되지 않는다. 나는 이 간극을 메우기 위해 Redis Pub/Sub 공통 계층을 만들었다.
업무 기능
→ RealtimeEventPublisher
→ 현재 JVM의 SimpleBroker
→ Redis Pub/Sub
→ 다른 JVM의 subscriber
→ 해당 JVM의 SimpleBroker
→ 연결된 사용자
Redis가 WebSocket을 대체하는 구조는 아니다. 브라우저 연결과 STOMP destination은 Spring이 계속 담당하고, Redis는 replica 사이에서 event를 fan-out한다.
local-first로 실패 범위를 나눴다
Publisher는 먼저 현재 JVM에 event를 전달하고 그다음 Redis에 publish한다. Redis가 잠시 끊겨도 현재 replica에 연결된 사용자까지 무조건 실패시키지 않기 위해서다.
Redis가 끊기면 현재 replica의 로컬 전달은 유지되지만 다른 replica 사용자는 event를 놓칠 수 있다. Pub/Sub은 복구 후 누락 메시지를 재생하지 않기 때문에 결과에서도 로컬 전달과 replica 간 전파 상태를 따로 반환했다.
자기 자신이 발행한 Redis 메시지를 다시 수신해 중복 전달하는 문제는 eventId와 sourceInstanceId로 줄였다. 다만 exactly-once나 전역 순서는 보장하지 않는다.
Presence 지연을 프록시 문제로 단정하지 않았다
사용자가 접속했는데 Presence가 바로 나타나지 않는다는 보고가 있었다. 먼저 두 외부 경로에서 /ws가 HTTP 101로 upgrade되는지 확인했다. Cloudflare Tunnel과 NPM 모두 101이었으므로 WebSocket 자체가 차단된 것은 아니었다.
그다음 애플리케이션 로그에서 STOMP CONNECT와 CONNECTED 비율을 비교했다. 누적 기준으로 성공 비율이 약 1~2%에 불과했다. 이 결과는 네트워크 upgrade 이후의 인증, reconnect loop, client state 또는 application Presence 처리에 더 가까운 문제라는 뜻이었다.
또 하나의 핵심은 Presence의 현재 상태가 여전히 JVM 메모리에 있다는 점이다. 접속·종료 event만 Redis로 퍼뜨려도 비정상 종료, 다중 탭, heartbeat 누락을 처리하지 않으면 전역 상태는 쉽게 어긋난다.
HTTP session도 현재는 각 JVM의 HttpSession을 사용한다. Redis가 배포돼 있다고 해서 session이 자동으로 공유되지는 않는다. 현재 코드에는 Spring Session Redis를 활성화한 근거가 없으므로 task 재배치나 proxy 경로 변화 때 로그인 상태가 유실될 가능성을 별도 문제로 남겼다.
후속 설계에서는 Redis set 또는 hash를 기준 상태로 두고 session TTL, heartbeat, instance별 session count, 비정상 종료 정리 정책을 함께 설계해야 한다.
현재 적용 범위와 다음 작업
Google 로그인에서는 Token 검증과 계정 식별 경계를 만들었고, Redis 모듈에서는 event 계약, JSON 변환, local dispatch, echo 억제와 장애 경로를 테스트했다.
현재 적용 범위를 기준으로 다음 작업이 남아 있다.
- 모든 실시간 업무 코드가 공통 publisher로 전환된 것은 아니다.
- Redis Pub/Sub은 메시지 영속성과 exactly-once를 제공하지 않는다.
- WebSocket session을 물리적으로 종료하는 공통 SPI의 실제 구현은 별도 연동이 필요하다.
- Presence의 전역 상태 모델과 부하 시험은 후속 과제다.
- OAuth callback 중복과 forwarded header 문제는 애플리케이션 수정과 회귀 테스트가 더 필요하다.
두 기능을 구현하면서 새로운 기술을 추가하는 일보다 입력과 상태를 어디에서 검증할지 먼저 정하는 습관이 생겼다. 다음 글에서는 이 애플리케이션이 올라갈 기반이 된 정적 IP 전환과 Docker Swarm 구축 과정을 정리한다.
실제 검증 화면
- 모바일에서 실제로 확인한
KOE006화면이다. 이 캡처를 기준으로 Kakao 콘솔의 redirect URI와 운영 요청 값을 하나씩 대조했다.
