개발 slecs

Apple OAuth 콜백 오류와 세션 유실 문제 해결

목차

Apple OAuth를 붙이다가 구글/카카오에서는 없던 문제들을 한꺼번에 만났다. 결론부터 말하면 세 군데를 동시에 고쳐야 했고, 하나씩 조여가는 방식으로는 원인을 파악하기 쉽지 않았다.

구글이나 카카오는 콜백을 GET으로 내려준다. 파라미터가 쿼리스트링에 붙어서 돌아오니까 기존 필터 체인과 별 마찰이 없다. Apple은 다르다. response_mode=form_post를 강제하기 때문에 인증 결과가 POST body로 들어온다. 이게 발단이다. GET 콜백을 전제로 짜여 있던 서버 설정들이 POST 앞에서 줄줄이 걸린다.

왜 Apple이 form_post를 강제하는지는 공식 문서에서 딱 잘라 설명하지 않지만, code와 id_token을 URL에 노출하지 않으려는 보안 고려가 주된 이유로 꼽힌다. 브라우저 히스토리나 리퍼러 헤더에 민감한 토큰이 노출될 수 있는 GET 방식을 원천 차단하는 것. 이유는 납득이 가는데, 서버 구현 입장에서는 예상치 못한 곳에서 추가 작업이 생긴다.

form_post가 만들어내는 연쇄 문제

문제가 세 겹으로 겹쳐 있었다.

첫 번째는 봇 차단 필터. Apple의 OAuth 서버에서 POST 요청이 들어오는 순간, BotBlockFilter가 외부 서버발 POST를 봇 트래픽으로 판단해 403을 던졌다. 사람이 로그인 버튼을 누른 건데 Apple 서버 IP가 필터에 걸렸으니 요청이 죽어버림. 로그에 403이 찍히지만 사용자 쪽 증상은 그냥 "로그인이 안 된다"라서 원인을 특정하기 전까지 시간이 걸렸다.

봇 차단 필터에서 콜백 URL을 예외 처리하는 건 좀 찜찜한 부분이 있다. "왜 봇 차단에서 인증 콜백을 예외로 빼야 하냐"는 의문이 드는 건 자연스럽다. 그런데 OAuth 콜백 URL은 HTTPS로만 열려 있고, Apple Developer에 등록된 도메인으로만 요청이 들어오기 때문에 해당 경로만 패턴 매칭으로 예외 처리하는 건 리스크가 크지 않다고 판단했다. 더 빡빡하게 하려면 Apple의 IP 대역을 allowlist로 관리하는 방법도 있는데, Apple이 IP 대역을 고정 보장하지 않아서 운영 부담이 생기는 트레이드오프가 있다.

두 번째는 CORS. 콜백이 Apple 도메인에서 크로스오리진 POST로 들어오는 형태라 WebMvcConfig에 허용 URL이 명시돼 있지 않으면 400이 났다. 구글 콜백은 보통 같은 도메인 내 리다이렉트로 처리되는 경우가 많아서 CORS 설정에 OAuth 콜백 URL을 따로 넣는 경우가 드물다. Apple은 명시적으로 넣어줘야 한다.

세 번째가 세션 유실. 필터와 CORS를 다 통과했다 해도 POST 콜백 이후 세션이 끊기는 경우가 있었다. Spring Security 세션 관리 정책에 따라 OAuth 콜백 이후 세션이 새로 발급되면서 기존에 바인딩된 사용자 정보가 날아가는 시나리오다. 로그인 성공 처리는 됐는데 곧바로 다시 로그인 화면이 뜨는 증상이 이것.

세 가지가 동시에 작동하면 어디서 막히는지 순서대로 검증해야 한다. 로그 레벨을 올려서 어느 필터에서 요청이 멈추는지 먼저 확인한 다음, 필터 예외 처리 → CORS 허용 → 세션 재설정 순으로 하나씩 붙였다.

증상 HTTP 상태 원인 레이어 수정 위치
콜백 즉시 실패 403 BotBlockFilter Apple 콜백 URL 예외 추가
콜백 에러 400 CORS 정책 WebMvcConfig Apple URL 허용
로그인 후 재로그인 200 (세션 없음) 세션 재설정 누락 OAuthController 세션 강제 바인딩

수정 흐름을 단순하게 정리하면 이렇다.

BotBlockFilter  → Apple 콜백 URL 패턴 예외 처리
WebMvcConfig    → CORS 허용 origin/path 추가
OAuthController → 콜백 후 세션 강제 재설정

세션 재설정 코드 자체는 단순하다. 핵심은 콜백 처리가 끝난 직후, 기존 세션을 믿지 말고 명시적으로 다시 심는 것.

// OAuthController - Apple 콜백 후 세션 강제 재설정
session.setAttribute("loginUser", member);
session.setAttribute("sysId", member.getSysId());

세션을 invalidate하고 새로 발급할지, 기존 세션에 덮어쓸지는 구현에 따라 다르다. 여기선 기존 세션을 유지하면서 필요한 attribute만 재설정하는 방식을 택했다. 세션 무효화 후 재발급하면 CSRF 토큰도 같이 교체되기 때문에, 폼 기반 로그인이 혼재하는 환경에서는 사이드 이펙트가 생길 수 있어서다.

Apple이 이름·이메일을 한 번밖에 안 주는 문제

Apple은 최초 인증 시에만 사용자 이름과 이메일을 form_post body에 담아서 보낸다. 두 번째 로그인부터는 sub(고유 식별자)만 오고 이름과 이메일은 넘어오지 않는다. 이걸 모르고 매번 콜백에서 이름을 업데이트하는 로직을 짜면 두 번째 로그인부터 이름이 null로 덮이는 버그가 난다.

처리 방식은 크게 두 가지다.

  • 콜백에서 이름/이메일이 null이면 스킵, 값이 있을 때만 저장
  • isNew 플래그 기준으로 신규 가입 시에만 이름/이메일을 저장하고 이후 업데이트 차단
// Apple은 최초 인증 시만 name, email을 전달함
// null이면 기존 값 유지, 덮어쓰지 않음
if (appleEmail != null && !appleEmail.isBlank()) {
    member.setEmail(appleEmail);
}
if (appleName != null && !appleName.isBlank()) {
    member.setName(appleName);
}

이 특성 때문에 Apple로만 가입한 사용자는 회원 DB에 이름이 없는 채로 남는 경우도 생긴다. Apple 측에서 이미 앱에 연동된 계정은 이름 데이터를 다시 보내지 않기 때문인데, 이게 문제가 되면 온보딩 단계에서 이름을 따로 받는 화면을 설계해야 한다. SNS 연동 후 프로필 입력을 한 번 거치게 하는 게 가장 안전한 방법.

덧붙여서, Apple이 이메일을 릴레이 주소(@privaterelay.appleid.com)로 주는 경우도 있다. 사용자가 "내 이메일 숨기기" 옵션을 선택하면 실제 이메일 대신 Apple이 생성한 릴레이 주소가 오는데, 이걸 실제 이메일처럼 저장해두면 나중에 이메일 발송이 Apple 릴레이를 통해 실제 주소로 포워딩되는 구조가 된다. 릴레이가 끊기면 발송이 안 되니, 저장 시점에 릴레이 주소인지 확인하고 필요하면 사용자에게 실제 이메일을 추가로 받는 설계를 고려할 만하다.

결국 SNS 연동은 provider마다 조금씩 다른 동작을 전제하고 분기 처리하는 게 현실적이다. "공통 인터페이스로 추상화하면 되지 않냐"는 생각도 해봤는데, form_post 강제나 1회성 정보 제공 같은 Apple의 특이케이스들은 provider-specific 로직을 벗어나기 어렵다. 공통 흐름은 공통으로 묶되, Apple 전용 예외는 명시적으로 분리해두는 편이 나중에 디버깅하기도 편하고 다른 사람이 코드 볼 때도 파악하기 쉽다.

댓글 0

첫 댓글 달아줘.