개발 slecs

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

목차

Google이나 Kakao OAuth를 먼저 붙여본 경험이 있으면 Apple이 얼마나 다른지 첫날부터 느낀다. 나머지 provider들은 인가 코드를 GET 파라미터로 리다이렉트해주는데, Apple은 response_mode=form_post를 강제해서 콜백이 POST 바디로 들어온다. 이 차이 하나가 생각보다 여러 곳을 동시에 터뜨린다.

form_post를 강제하는 데는 Apple 나름의 이유가 있다. GET 리다이렉트 방식은 URL에 인가 코드가 노출되고 브라우저 히스토리나 서버 로그에 남을 가능성이 있다. POST 바디로 넘기면 그 위험이 줄어든다. 보안 측면에서는 더 낫지만, 서버 설정 면에서는 손댈 곳이 훨씬 많다.

form_post 하나가 터뜨린 세 가지

CORS. Apple 인증 흐름에서 POST를 실제로 날리는 건 사용자 브라우저지만, 서버 응답에 Apple 콜백 URL에 대한 허용 origin이 없으면 브라우저가 400으로 막는다. WebMvcConfig의 CORS 허용 URL 목록에 Apple 콜백 URL을 명시적으로 추가해야 통과된다. 이게 첫 번째 벽.

봇 차단 필터. 프로젝트에 BotBlockFilter가 붙어 있었는데, Apple OAuth 콜백 요청이 필터에 걸려 403이 됐다. Apple 서버 IP 대역이나 User-Agent를 수상한 요청으로 분류한 것. 콜백 경로를 필터 예외 목록에 추가하는 걸로 해결했다. 봇 차단 필터가 있는 서비스라면 Apple 연동 시 반드시 확인해야 할 포인트다.

세션 유실. form_post 방식에서는 리다이렉트 흐름이 GET과 달라 세션 쿠키 유지가 불안정할 수 있다. 콜백 처리 후 세션에 사용자 정보가 없는 상태가 됐고, OAuthController에서 처리 직후 세션을 명시적으로 재설정했다.

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

수정 순서가 중요했다. 필터부터 뚫지 않으면 콜백 자체가 안 들어오고, CORS가 열려 있지 않으면 400에서 막힌다. 세션 재설정은 그다음 문제다. 동시에 여러 증상이 나와서 원인 파악이 늦어질 뻔했는데, 필터 - CORS - 세션 순서로 하나씩 로그 보면서 풀었다.

BotBlockFilter  → Apple 콜백 URL 예외 추가 (403 해결)
WebMvcConfig    → CORS 허용 URL 추가 (400 해결)
OAuthController → 세션 재설정 로직 추가 (로그인 유실 해결)
버그 증상 해결책
봇 필터 차단 콜백 403 에러 BotBlockFilter 예외 처리
CORS 차단 콜백 400 에러 WebMvcConfig에 Apple URL 허용
세션 유실 로그인 실패 콜백 후 세션 강제 재설정

Apple만의 함정 - 이름과 이메일은 첫 번째 콜백이 마지막

OAuth provider를 여럿 붙이다 보면 사용자 이름이나 이메일을 가져오는 건 당연한 흐름이라고 생각하게 된다. Google이나 Kakao는 매 로그인마다 profile API를 호출해서 최신 정보를 가져올 수 있다. Apple은 완전히 다르다.

Apple은 최초 인증 시에만 이름과 이메일을 콜백 바디에 담아준다. 동일 Apple ID로 두 번째 로그인부터는 아무 정보도 오지 않는다. 사용자가 앱 연결을 해제했다가 다시 연결해도 마찬가지인데, 이미 한 번 인증한 계정이면 안 준다.

이걸 모르고 Google/Kakao 방식으로 "로그인마다 profile 갱신" 로직을 그대로 쓰면, 첫 로그인 이후 사용자 이름이 전부 null이 된다. 최초 콜백에서 반드시 저장해두고, 이후 콜백에서는 기존 DB 값을 그대로 써야 한다.

최초 여부 판단은 Apple이 직접 알려주는 게 아니라, DB에 해당 Apple sub 값으로 저장된 레코드가 있는지로 직접 판단해야 한다.

// Apple sub 기준으로 신규/기존 회원 분기
Optional<Member> existing = memberRepository.findByAppleSub(appleSub);

if (existing.isEmpty()) {
    // 최초 인증 - name/email이 콜백 바디에 있음
    Member newMember = new Member();
    newMember.setAppleSub(appleSub);
    newMember.setName(appleUser.getName());    // 이후 콜백엔 null
    newMember.setEmail(appleUser.getEmail());  // 이후 콜백엔 null
    memberRepository.save(newMember);
    member = newMember;
} else {
    // 재인증 - name/email은 null, 기존 레코드 그대로 사용
    member = existing.get();
}

테스트할 때도 주의할 게 있다. Apple 테스트 계정으로 처음 로그인해서 이름이 잘 들어오는 걸 확인하고 넘어갔다가, 같은 계정으로 다시 로그인하면 null이 되는 걸 뒤늦게 발견하는 패턴이 있다. 개발 중에 반드시 두 번째 로그인 시나리오를 확인해야 한다. Apple 개발자 콘솔에서 앱 연결 해제 후 재연결하면 최초 인증을 다시 시뮬레이션할 수 있으니 이걸 활용하면 된다.

provider별 분기는 피할 수 없다

결국 Google, Kakao, Apple 세 provider를 한 OAuthController에서 처리하는 구조는 분기 없이는 못 간다. response_mode, 이름/이메일 수신 방식, 세션 처리까지 전부 다르다. 전략 패턴으로 provider별 구현을 숨기면 장기적으로는 깔끔하다.

// 추상화한다면 이런 방향
public interface OAuthHandler {
    Member processCallback(HttpServletRequest request, HttpSession session);
    String getProvider();
}

public class AppleOAuthHandler implements OAuthHandler {
    @Override
    public Member processCallback(HttpServletRequest request, HttpSession session) {
        // form_post 처리, 최초 여부 판단, 세션 재설정
    }
}

provider가 세 개밖에 없는 지금은 이렇게까지 하면 오버엔지니어링이다. 지금 단계에서 인터페이스 설계에 시간 쓰는 것보다 콜백 처리 메서드 내부에서 provider 타입을 확인하는 분기로 일단 처리했다. provider가 더 늘어나면 그때 정리할 영역.

이번 작업의 교훈은 단순하다. SNS 로그인은 provider 공식 문서를 먼저 읽어야 한다. Google OAuth 경험만 가지고 Apple을 시작하면 CORS, 봇 필터, 세션, 이름 수신 방식 이 네 개를 전부 뒤늦게 발견한다. Apple Sign In 공식 문서에 form_post 강제 사항과 이름/이메일 단회 전달 정책이 명시돼 있다. 시작 전에 읽었으면 삽질이 절반으로 줄었을 것이다.

댓글 0

첫 댓글 달아줘.