개발 slecs

Apple OAuth 콜백 CORS 차단

목차

Apple OAuth를 처음 붙이던 날, 구글이랑 카카오는 30분도 안 걸렸는데 Apple만 반나절을 날렸다. 결국 세 군데를 동시에 고쳐야 했고, 각각의 버그가 서로 다른 HTTP 상태 코드로 나타나서 처음엔 원인 파악 자체가 헷갈렸다.

Apple OAuth가 다른 이유

OAuth 2.0 스펙상 authorization code를 콜백으로 전달하는 방법은 크게 두 가지다. 대부분의 provider는 response_mode=query를 기본으로 써서 GET /callback?code=... 형태로 redirect한다. Apple은 여기서 달리 간다. response_mode=form_post를 강제하는데, 이 경우 브라우저가 Apple 서버로부터 HTML form을 받아서 자동으로 POST 요청을 우리 서버에 날린다.

이게 왜 문제가 되느냐면, POST 요청은 브라우저의 CORS preflight 대상이 되는 조건을 더 쉽게 만족한다. Content-Type: application/x-www-form-urlencoded로 오기 때문에 단순 요청(simple request)처럼 보이지만, 실제로는 accounts.apple.com origin에서 우리 도메인으로 cross-origin POST가 날아오는 구조다. 서버 사이드에서 CORS 헤더를 제대로 안 열어주면 브라우저가 막아버린다.

거기다 Apple의 콜백은 Apple 서버 IP에서 직접 오는 게 아니라 사용자 브라우저가 리디렉션을 거쳐 POST를 날리는 방식이라, 요청 자체는 사용자 브라우저발이다. 그런데 User-Agent가 없거나 비표준인 경우 봇 차단 필터가 그 요청을 봇으로 판단해 403을 내뱉는다. 실제로 이게 처음 마주친 증상이었다. 콜백 URL 자체는 맞는데 403이 뜨니까 처음엔 인증 문제인 줄 알았다.

정리하면 세 개의 독립적인 버그가 연달아 숨어 있었다.

버그 증상 원인 해결책
봇 필터 차단 콜백 403 BotBlockFilter가 Apple 콜백 UA를 봇으로 판단 콜백 URL 패턴 예외 추가
CORS 차단 콜백 400 WebMvcConfig에 Apple 도메인 미포함 allowedOrigins에 accounts.apple.com 추가
세션 유실 로그인 실패 (리디렉션 루프) POST 콜백 후 세션 컨텍스트 재설정 누락 콜백 핸들러에서 세션 명시 재설정

403을 잡고 나니 400이 나왔고, 400을 잡고 나니 로그인은 되는 것 같은데 세션이 없어서 로그인 페이지로 다시 튕겼다. 한 단계씩 까는 느낌이었다.

수정 흐름과 코드

수정 순서는 요청이 서버에 도달하는 순서대로 따라갔다.

BotBlockFilter → Apple 콜백 URL 예외 추가
      ↓
WebMvcConfig  → CORS 허용 origin 추가
      ↓
OAuthController → 세션 재설정 로직 추가

BotBlockFilter는 URL 패턴 기준으로 예외를 처리했다. Apple 콜백 엔드포인트를 화이트리스트에 넣는 식이다. CORS 설정은 WebMvcConfigureraddCorsMappings에 Apple 콜백 경로와 origin을 추가했다.

세션 처리가 좀 손이 갔는데, form_post 방식은 기존 GET 기반 콜백과 달리 세션 컨텍스트가 POST 요청 처리 중에 초기화되는 타이밍 문제가 있었다. 그래서 OAuthController에서 콜백 처리 완료 후 명시적으로 세션에 로그인 정보를 다시 써줬다.

// Apple OAuth 콜백 처리 후 세션 재설정
session.setAttribute("loginUser", member);
session.setAttribute("sysId", member.getSysId());

다른 provider에서는 필요 없던 코드인데 Apple만 이게 필요했다. 뭔가 POST 처리 과정에서 Spring Security 세션 컨텍스트가 한 번 flush되는 것 같은데, 정확한 내부 동작까지는 더 파봐야 한다.

Apple의 최초 인증 데이터 문제

이번 작업에서 세션 버그보다 더 주의해야 했던 부분이 있다. Apple은 사용자 이름과 이메일을 최초 인증 때 딱 한 번만 넘겨준다. 사용자가 처음 "Apple로 로그인"을 승인할 때 id_token과 함께 user 파라미터로 이름/이메일이 온다. 그 이후 재인증부터는 user 파라미터가 아예 없다.

이게 Apple의 프라이버시 정책상 의도된 설계다. 앱이 사용자 정보를 과도하게 반복 수집하지 못하게 막는 거다. 개발자 입장에서는 첫 콜백 때 반드시 DB에 저장해야 한다는 의미다. 만약 그 타이밍을 놓치면 이후에 사용자 정보를 복구할 방법이 없고, 사용자가 앱 연동을 해제했다가 다시 연동해야 다시 받을 수 있다.

그래서 콜백 핸들러 안에서 user 파라미터 존재 여부를 체크하고, 있으면 저장, 없으면 기존 저장된 값 사용하는 분기를 넣었다.

// user 파라미터는 최초 인증 시에만 전달됨
String userJson = request.getParameter("user");
if (userJson != null && !userJson.isEmpty()) {
    // 최초 인증: 이름/이메일 파싱 후 저장
    AppleUser appleUser = objectMapper.readValue(userJson, AppleUser.class);
    member.setName(appleUser.getName().getFullName());
    member.setEmail(appleUser.getEmail());
    memberService.save(member);
}

구글/카카오는 매번 인증마다 프로필 정보를 갱신할 수 있어서 이런 처리가 불필요한데, Apple은 이 분기를 빠뜨리면 첫 연동 후 이름이 null로 저장되는 문제가 생긴다.

배운 것

SNS 로그인은 provider마다 동작 방식이 달라서 "공통 모듈로 만들겠다"는 생각을 너무 이르게 하면 안 된다. 구현하다 보면 Apple처럼 콜백 방식부터 다른 경우가 있어서, 공통 인터페이스 뒤에 provider별 구현을 두되 각 provider 특수 케이스를 억지로 추상화하려 들면 오히려 코드가 복잡해진다.

그리고 OAuth 디버깅은 로컬에서 하기가 까다롭다. Apple은 특히 localhost 콜백을 지원 안 하기 때문에 ngrok 같은 걸 써서 실제 도메인처럼 우회해야 한다. 이 환경 차이 때문에 로컬 테스트와 실서버 동작이 다를 때가 있어서, 처음부터 스테이징 환경에서 붙이는 게 낫다는 걸 이번에 다시 한번 확인했다.

댓글 0

첫 댓글 달아줘.