junyeokk
Blog
Auth·2025. 10. 14

Apple Sign-In form_post 처리

OAuth 소셜 로그인을 구현할 때, Google이나 GitHub 같은 대부분의 OAuth 제공자는 인증이 끝나면 redirect_uri로 GET 요청을 보낸다. 쿼리 파라미터에 code를 담아서 https://example.com/callback?code=abc123 이런 식으로 리다이렉트하는 거다. 이 방식에 익숙한 상태에서 Apple Sign-In을 구현하면 당황하게 된다. Apple은 GET이 아니라 POST 요청으로 콜백을 보내기 때문이다.

Apple이 form_post를 사용하는 이유

Apple의 OAuth 콜백은 response_mode=form_post라는 방식을 사용한다. 인증이 완료되면 Apple 서버가 사용자의 브라우저에 자동 제출되는 HTML 폼을 내려보내고, 이 폼이 redirect_uri로 POST 요청을 보내는 구조다.

html
<!-- Apple이 사용자 브라우저에 내려보내는 응답 (개념적) -->
<html>
<body onload="document.forms[0].submit()">
  <form method="POST" action="https://example.com/callback">
    <input type="hidden" name="code" value="abc123..." />
    <input type="hidden" name="state" value="xyz..." />
    <input type="hidden" name="id_token" value="eyJ..." />
  </form>
</body>
</html>

왜 이렇게 할까? 보안 때문이다. GET 방식은 인증 코드가 URL에 노출된다. URL은 브라우저 히스토리에 남고, 서버 액세스 로그에도 찍히고, Referer 헤더를 통해 외부 사이트에 유출될 수도 있다. POST 방식은 데이터가 요청 본문(body)에 담기기 때문에 이런 경로로 노출되지 않는다. 특히 Apple은 첫 로그인 시 사용자의 이름과 이메일을 user 파라미터에 담아 함께 전송하는데, 이런 개인정보가 URL에 노출되면 안 되니까 form_post가 필수적이다.

response_mode=form_postOAuth 2.0 Form Post Response Mode (RFC) 스펙에 정의된 표준이다. Apple만의 독자적인 방식이 아니라 OpenID Connect 생태계에서 인정된 방식이지만, 실제로 이걸 기본 모드로 사용하는 메이저 OAuth 제공자는 Apple이 거의 유일하다.

SPA/SSR 프레임워크에서의 문제

문제는 대부분의 프론트엔드 프레임워크가 이 방식을 자연스럽게 처리하지 못한다는 점이다.

일반적인 SPA(React, Vue 등)에서 라우팅은 클라이언트 사이드에서 일어난다. /callback 경로에 매칭되는 컴포넌트가 있고, 이 컴포넌트가 마운트될 때 URL의 쿼리 파라미터에서 code를 꺼내 처리한다. 그런데 Apple이 POST 요청을 보내면? SPA 라우터는 POST 요청의 body를 읽을 수 없다. 브라우저의 클라이언트 사이드 JavaScript에서는 "현재 페이지가 POST로 로드됐을 때의 form data"에 접근하는 표준 API가 없다.

Next.js 같은 SSR 프레임워크에서도 비슷한 문제가 있다. Next.js의 페이지 컴포넌트(page.tsx)는 GET 요청에 대해서만 렌더링된다. POST 요청이 들어오면 405 Method Not Allowed를 반환하거나, 아예 라우트 매칭이 되지 않을 수 있다.

Route Handler로 POST를 받아 GET으로 전환

해결 방법은 서버 사이드에서 POST 요청을 받아 처리한 뒤, GET 요청으로 리다이렉트하는 것이다. Next.js에서는 Route Handler(route.ts)가 이 역할을 한다.

typescript
// app/auth/provider.apple/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const code = formData.get('code') as string | null;
  const error = formData.get('error') as string | null;

  const origin = getOrigin(request);

  if (error) {
    return NextResponse.redirect(
      new URL(`/auth/provider.apple/continue?error=${encodeURIComponent(error)}`, origin),
      303,
    );
  }

  if (!code) {
    return NextResponse.redirect(
      new URL('/auth/provider.apple/continue?error=missing_code', origin),
      303,
    );
  }

  return NextResponse.redirect(
    new URL(`/auth/provider.apple/continue?code=${encodeURIComponent(code)}`, origin),
    303,
  );
}

흐름을 정리하면 이렇다:

  1. 사용자가 "Apple로 로그인" 버튼 클릭
  2. Apple 인증 페이지로 이동 → 인증 완료
  3. Apple이 /auth/provider.apple/callback으로 POST 요청 전송 (form data에 code 포함)
  4. Route Handler가 POST를 받아서 form data에서 code를 추출
  5. /auth/provider.apple/continue?code=...303 리다이렉트 (GET으로 전환)
  6. continue 페이지 컴포넌트가 마운트되면서 쿼리 파라미터의 code로 토큰 교환 진행

핵심은 callback 경로(Route Handler, POST 수신)와 continue 경로(페이지 컴포넌트, GET 처리)를 분리하는 것이다.

303 See Other를 사용하는 이유

리다이렉트 상태 코드로 301이나 302가 아니라 303을 사용하는 이유가 있다.

상태 코드동작문제점
301 Moved Permanently영구 이동, 브라우저가 캐시함같은 URL로의 이후 요청도 리다이렉트됨
302 Found임시 이동, 원래 메서드 유지 가능일부 브라우저가 POST를 유지할 수 있음
303 See Other임시 이동, 반드시 GET으로 변환없음 ✓

HTTP 스펙상 302는 "원래 요청의 메서드를 유지해야 한다"고 되어 있다. 즉 POST 요청에 302로 응답하면 브라우저가 리다이렉트 대상에도 POST를 보낼 수 있다는 뜻이다. 실제로는 대부분의 브라우저가 302를 GET으로 바꿔서 보내긴 하지만, 스펙에 보장된 동작이 아니다.

303은 명확하게 "리다이렉트 대상은 GET으로 요청하라"고 지시하는 상태 코드다. POST → GET 전환이 목적이라면 303이 의미적으로 정확하고, 모든 브라우저에서 일관된 동작을 보장한다.

Origin 결정 로직

리다이렉트할 때 redirect_uri의 origin을 정확히 알아야 한다. 프록시나 로드 밸런서 뒤에서 동작하는 경우, request.url의 origin이 실제 클라이언트가 접근한 주소와 다를 수 있다.

typescript
function getOrigin(request: NextRequest): string {
  // 1순위: 리버스 프록시가 설정한 원래 호스트
  const forwardedHost = request.headers.get('x-forwarded-host');
  const forwardedProto = request.headers.get('x-forwarded-proto') || 'https';
  if (forwardedHost) {
    return `${forwardedProto}://${forwardedHost}`;
  }

  // 2순위: Host 헤더
  const host = request.headers.get('host');
  if (host) {
    const protocol = host.includes('localhost') ? 'http' : 'https';
    return `${protocol}://${host}`;
  }

  // 3순위: 요청 URL 자체
  return new URL(request.url).origin;
}

Nginx나 CloudFront 같은 리버스 프록시 뒤에 있으면 x-forwarded-hostx-forwarded-proto 헤더에 원래 요청 정보가 들어온다. 이걸 무시하고 request.url만 사용하면 내부 주소(예: http://localhost:3000)로 리다이렉트될 수 있다. 개발 환경에서는 localhost인지 확인해서 http를 사용하고, 프로덕션에서는 https를 기본으로 한다.

클라이언트 사이드: continue 페이지 처리

리다이렉트된 continue 페이지에서는 쿼리 파라미터의 code를 가져와 백엔드 API에 토큰 교환을 요청한다.

typescript
// useAppleOAuthCallback.ts
export const useAppleOAuthCallback = () => {
  const router = useRouter();
  const searchParams = useSearchParams();
  const { mutate, isPending, isSuccess, isError } = useContinueAppleLogin();
  const [errorMessage, setErrorMessage] = useState('');
  const [isProcessing, setIsProcessing] = useState(false);

  useEffect(() => {
    const code = searchParams.get('code');
    const error = searchParams.get('error');

    if (isProcessing) return;

    if (error) {
      setErrorMessage('Apple 로그인이 취소되었습니다.');
      setTimeout(() => router.push('/sign-in'), 3000);
      return;
    }

    if (!code) {
      setErrorMessage('Apple 로그인 중 오류가 발생했습니다.');
      setTimeout(() => router.push('/sign-in'), 3000);
      return;
    }

    setIsProcessing(true);
    mutate({ code }, {
      onError: () => {
        setErrorMessage('Apple 로그인에 실패했습니다.');
        setTimeout(() => router.push('/sign-in'), 3000);
      },
    });
  }, [mutate, router, searchParams, isProcessing]);

  return { isPending, isSuccess, isError, errorMessage };
};

주의할 점은 isProcessing 플래그다. React 18의 Strict Mode에서는 개발 환경에서 useEffect가 두 번 실행된다. 인증 코드는 대부분 일회용이라 두 번 사용하면 에러가 나므로, 중복 호출을 방지해야 한다.

useSearchParams()는 Next.js의 클라이언트 컴포넌트 훅인데, 이 훅을 사용하는 컴포넌트는 반드시 <Suspense> 경계 안에 있어야 한다. 빌드 시 정적 최적화를 하려면 searchParams를 읽는 시점을 런타임으로 미뤄야 하기 때문이다.

tsx
// page.tsx
export default function AppleOAuthCallback() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <AppleOAuthCallbackContent />
    </Suspense>
  );
}

전체 흐름 요약

text
사용자                    프론트엔드              Apple              백엔드
  |                         |                      |                  |
  |-- "Apple 로그인" 클릭 -->|                      |                  |
  |                         |-- 인증 URL 요청 ----->|                  |
  |<-- Apple 인증 페이지 ---|                       |                  |
  |-- 인증 완료 ----------->|                       |                  |
  |                         |<-- POST form_post ----|                  |
  |                         |   (code in body)      |                  |
  |                         |                       |                  |
  |                    [Route Handler]               |                  |
  |                    formData에서 code 추출        |                  |
  |                    303 redirect                  |                  |
  |                         |                       |                  |
  |<-- GET /continue?code=..                        |                  |
  |                         |                       |                  |
  |                    [Page Component]              |                  |
  |                    searchParams에서 code 추출    |                  |
  |                         |-- code → 토큰 교환 ---|---------------->|
  |                         |<-- 액세스 토큰 -------|-----------------|
  |<-- 로그인 완료 ---------|                       |                  |

다른 프레임워크에서의 처리

이 패턴은 Next.js에만 국한되지 않는다. Express라면 app.post('/callback', ...) 라우트에서 req.body를 읽고 res.redirect(303, ...) 하면 되고, 순수 SPA라면 별도 백엔드 서버에 POST 엔드포인트를 만들어야 한다.

javascript
// Express 예시
app.post('/auth/apple/callback', express.urlencoded({ extended: false }), (req, res) => {
  const { code, error } = req.body;
  if (error || !code) {
    return res.redirect(303, `/auth/apple/continue?error=${error || 'missing_code'}`);
  }
  res.redirect(303, `/auth/apple/continue?code=${encodeURIComponent(code)}`);
});

핵심은 동일하다: 서버에서 POST body를 받아 쿼리 파라미터로 옮긴 뒤 303으로 GET 리다이렉트. 프레임워크가 뭐든 이 패턴만 알면 Apple Sign-In의 form_post를 처리할 수 있다.

Google과의 비교

Google OAuth는 기본적으로 response_type=coderesponse_mode=query를 사용한다. 즉 인증 코드가 쿼리 파라미터로 온다:

text
GET /callback?code=4/0AfJohX...&scope=openid+email

프론트엔드 라우터에서 바로 처리할 수 있어서 별도의 서버 사이드 Route Handler가 필요 없다. Apple처럼 별도의 callback → continue 패턴 없이 하나의 callback 페이지에서 모든 처리가 가능하다.

Google도 response_mode=form_post를 지원하긴 한다. 하지만 기본값이 아니고 명시적으로 요청해야 하며, 대부분의 구현에서 사용하지 않는다. Apple은 form_post가 유일한 선택지라는 점이 다르다.

정리

  • Apple Sign-In은 보안상 form_post(POST body)로만 인증 코드를 전달하므로, SPA/SSR에서 GET 쿼리 파라미터 방식과 다른 처리가 필요하다
  • Route Handler에서 POST body를 받아 303 See Other로 GET 리다이렉트하는 callback→continue 분리 패턴이 핵심이다
  • React Strict Mode 중복 실행, Suspense 경계, 리버스 프록시 origin 결정 등 실전 엣지 케이스를 함께 처리해야 한다

관련 문서