Heeyaa

기록

SNS 로그인 서버 중심으로 리팩토링

2026. 4. 2.

기존 프로젝트는 모바일 앱 전용 서비스였습니다. 앱에서는 카카오 SDK를 통해 클라이언트가 직접 소셜 토큰을 받아 서버에 전달하는 방식을 사용했고, 이는 모바일 환경에서 흔히 쓰이는 구조였습니다.

클라이언트(앱) → 카카오 SDK → social_id, email 획득
클라이언트     → POST /auth/oauth2/signin { social_id, phone, name, ... }
서버           → 검증 없이 바로 계정 생성/로그인

서비스가 웹으로 확장되면서 이 구조를 그대로 유지할 수 없게 됐습니다. 웹에는 네이티브 SDK가 없기 때문에 같은 방식을 쓰려면 클라이언트가 소셜 정보를 직접 서버에 넘겨야 하는데, 이 과정에서 다음과 같은 보안 취약점이 드러납니다.

  1. 소셜 토큰 서버 검증 없음 — 클라이언트가 전달하는 social_id를 서버가 그대로 신뢰하기 때문에 임의 값을 넣어 타인 계정에 접근하는 위조가 가능합니다.
  2. 개인정보 클라이언트 경유 — 이름, 전화번호 같은 민감한 정보가 요청 바디에 평문으로 노출됩니다.
  3. 서버 제어 불가 — 토큰 발급과 검증 흐름이 전부 클라이언트에 있어, 서버가 인증 과정을 통제할 수 없습니다.

목표

앱과 웹 모두에서 동작하는 서버 중심 OAuth 2.0 플로우를 설계하는 것이 목표였습니다.

단, 기존 앱 클라이언트는 수정 없이 계속 동작해야 했기 때문에, 레거시 API를 무영향(Non-Breaking) 으로 유지하면서 v2를 병렬 추가하는 방식을 선택했습니다.

AS-IS: POST /auth/oauth2/signin        (클라이언트 중심, 레거시 유지)
TO-BE: GET  /auth/v2/{provider}/start  → 신규
       GET  /auth/callback/{provider}  → v2 분기 3줄 추가 (최소 변경)
       POST /auth/v2/oauth2/token      → 신규
       POST /auth/v2/signup/complete   → 신규

변경 전/후 플로우 비교

기존 방식 (클라이언트 중심)

소셜 인증 정보가 클라이언트를 통해 전달되기 때문에, 서버는 해당 social_id가 실제로 카카오에서 발급된 것인지 검증할 방법이 없습니다.

변경 방식 (서버 중심)

서버가 카카오 API를 직접 호출해서 소셜 정보를 가져오기 때문에, 클라이언트가 social_id를 위조할 여지가 없습니다.


아키텍처 결정

1. 레거시 Callback 엔드포인트 재사용

처음에는 v2 전용 callback 엔드포인트(/auth/v2/callback/kakao)를 새로 만드는 방향을 생각했습니다.

하지만 카카오 OAuth에는 중요한 제약이 있습니다. authorize_url을 만들 때 넣는 redirect_uri와 이후 token 교환 요청의 redirect_uri정확히 일치해야 합니다. v2 전용 callback URL을 쓰려면 카카오 개발자 콘솔에 새 URL을 등록하고 환경변수도 추가해야 합니다.

대신 기존 /auth/callback/{provider} 엔드포인트에 3줄 분기만 추가하는 방식을 선택했습니다. state 값에 v2_ prefix가 있으면 v2 처리 함수로 넘기고, 없으면 기존 레거시 코드로 그대로 진행합니다.

# controller.py — 레거시 callback에 추가된 3줄
v2_result = await service.try_handle_v2_callback(
    provider=provider, code=code, state=state,
    error=error, error_description=error_description,
)
if v2_result is not None:
    return v2_result

# 레거시 코드 (한 줄도 변경 없음)
if error: ...

카카오 개발자 콘솔 변경 없이, 기존 KAKAO_REDIRECT_URI를 그대로 재사용할 수 있었습니다.

2. Redis Namespace 완전 분리

레거시와 v2가 같은 Redis를 사용하기 때문에, key 이름이 충돌하면 서로의 state나 code를 잘못 소비할 수 있습니다.

용도레거시 keyv2 key
OAuth stateoauth:state:{state}oauth:v2:state:{state}
Auth codeauth:code:{code}auth:v2:code:{code}

namespace를 분리해서 레거시 플로우와 v2 플로우가 서로의 데이터를 건드리지 않도록 했습니다.

3. 신규/기존 회원 분기

카카오 인증이 완료된 후, 해당 소셜 계정이 이미 가입된 회원인지 아닌지에 따라 다르게 처리합니다.

기존 회원 → auth_code 발급 → redirect_uri?code=xxx
신규 회원 → PendingUser 생성 → redirect_uri?session_id=yyy

신규 회원은 소셜 인증만으로 가입을 완료하지 않고, 본인인증(PortOne)을 추가로 거친 뒤 POST /auth/v2/signup/complete로 최종 회원가입을 마무리합니다. 이렇게 하면 실제 사람인지 확인하는 과정을 서버 측에서 강제할 수 있습니다.


핵심 구현

1. Redis GETDEL — 원자적 1회성 소비

OAuth의 stateauth_code딱 한 번만 사용되어야 합니다. 재사용을 막으려면 Redis에서 값을 읽은 뒤 즉시 삭제해야 하는데, 여기에 문제가 있습니다.

GETDELETE를 순서대로 실행하면, 두 요청이 거의 동시에 들어왔을 때 둘 다 GET에 성공한 다음 둘 다 처리로 넘어갈 수 있습니다. 이게 race condition입니다.

Redis의 GET과 DEL을 하나의 원자 연산으로 묶을 수 있습니다. Redis는 싱글 스레드로 단일 실행하기 때문에, 두 요청이 동시에 들어와도 한 쪽만 값을 가져가고 나머지는 nil을 받게 됩니다.

동시성 테스트 결과, 같은 state/code로 동시에 2회 요청을 보내면 1회만 성공하고 나머지는 401을 받았습니다.


2. redirect_uri Allowlist 정규화

OAuth에서 redirect_uri를 제대로 검증하지 않으면 Open Redirect 공격에 취약해집니다. 악의적인 공격자가 redirect_uri를 자신의 서버로 바꿔치기해서 인증 코드를 탈취할 수 있습니다.

단순 문자열 비교로 allowlist를 체크하면 다음과 같은 우회가 가능합니다.

  • https://example.com vs https://example.com/ (trailing slash)
  • https://example.com vs https://EXAMPLE.COM (대소문자)
  • https://example.com vs https://example.com:443 (기본 포트 명시)

이런 우회를 막기 위해 URI를 받는 즉시 정규화한 뒤 allowlist와 exact match 비교를 합니다.

@staticmethod
def normalize_uri(uri: str) -> str:
    parsed = urlparse(uri)
    scheme = parsed.scheme.lower()
    host = parsed.hostname or ""
    port = parsed.port
    default_port = {"https": 443, "http": 80}.get(scheme)
    # 기본 포트는 제거, 비기본 포트는 유지
    netloc = f"{host}:{port}" if port and port != default_port else host
    path = parsed.path or "/"
    if path != "/" and path.endswith("/"):
        path = path.rstrip("/")
    return urlunparse((scheme, netloc, path, "", "", ""))

def validate_redirect_uri_v2(self, uri: str) -> None:
    parsed = urlparse(uri)
    if parsed.query or parsed.fragment:     # query/fragment 포함 거부
        raise BadRequest(...)
    if parsed.username or parsed.password:  # userinfo 포함 거부
        raise BadRequest(...)
    if parsed.scheme != "https":            # https만 허용
        raise BadRequest(...)
    normalized = self.normalize_uri(uri)
    if normalized not in self._allowed_redirect_uris_v2:
        raise BadRequest(...)

allowlist도 서버 시작 시 같은 함수로 미리 정규화해서 frozenset에 올려두기 때문에, 요청이 들어올 때마다 정규화된 값끼리만 비교하면 됩니다.


3. URL 안전 Query Parameter 조립

인증 후 클라이언트의 redirect_uri로 리다이렉트할 때 ?code=xxx 같은 파라미터를 붙여야 합니다. 이때 문자열을 단순히 이어붙이면 문제가 생길 수 있습니다.

예를 들어 redirect_urihttps://app.example.com/login?from=sns처럼 이미 query string을 포함하고 있으면, f"{redirect_uri}?code=xxx"...?from=sns?code=xxx가 되어버립니다.

@staticmethod
def append_query(url: str, params: dict) -> str:
    parsed = urlparse(url)
    query = parse_qs(parsed.query)
    for k, v in params.items():
        query[k] = [str(v)]
    new_query = urlencode(query, doseq=True)
    return urlunparse(parsed._replace(query=new_query))

urlparse로 기존 query를 파싱한 뒤 파라미터를 추가하고 다시 조립하면, 기존 query string이 있어도 안전하게 합칠 수 있습니다.


4. PendingUser upsert — partial unique index

신규 회원 플로우에서는 소셜 인증 완료 후 본인인증 전까지 임시로 PendingUser 레코드를 만들어둡니다. 동일한 소셜 계정으로 여러 번 인증을 시도하면 중복 레코드가 쌓일 수 있어 문제가 됩니다.

이를 막기 위해 PostgreSQL의 partial unique index를 활용했습니다. social_providersocial_id가 모두 NULL이 아닌 경우에만 unique 제약을 걸어서, 레거시 방식으로 생성된 NULL 포함 레코드와 충돌 없이 신규 레코드에만 제약을 적용합니다.

# Alembic 마이그레이션
op.create_index(
    "ux_pending_users_social_provider_social_id_not_null",
    "pending_users",
    ["social_provider", "social_id"],
    unique=True,
    postgresql_where=sa.text("social_provider IS NOT NULL AND social_id IS NOT NULL"),
)

충돌 시 upsert 처리는 emailexpires_at만 갱신하고, 본인인증 진행 중에 저장된 name, phone, birth, ci 필드는 절대 덮어쓰지 않습니다. 가입 세션이 진행 중인 사용자의 데이터를 reset하면 안 되기 때문입니다.

stmt = insert_stmt.on_conflict_do_update(
    index_elements=[PendingUser.social_provider, PendingUser.social_id],
    index_where=sa.text("social_provider IS NOT NULL AND social_id IS NOT NULL"),
    set_={
        "email": sa.func.coalesce(insert_stmt.excluded.email, PendingUser.email),
        "expires_at": sa.func.greatest(PendingUser.expires_at, insert_stmt.excluded.expires_at),
    },
).returning(PendingUser.id)

5. Callback 에러 처리 — 2-tier 정책

callback에서 에러가 발생했을 때, 에러를 어떻게 클라이언트에 전달할지를 state 유효성 여부에 따라 다르게 처리합니다.

state 없음 / v2_ prefix 아님  → None 반환 (레거시 fall-through)
state가 v2_인데 Redis miss    → 401 JSON
                               (redirect_uri를 알 수 없으므로 redirect 불가)
state 유효 + 이후 에러        → 302 redirect (redirect_uri?error=oauth_upstream_error)

state가 Redis에 없다는 건 만료됐거나 재사용 시도라는 뜻입니다. 이 경우 redirect_uri를 신뢰할 수 없기 때문에 redirect 대신 JSON 에러를 직접 반환합니다.

state가 유효한 경우에는 redirect_uri로 에러를 전달할 수 있지만, 이때도 upstream에서 받은 error_description(카카오 응답의 raw body)은 외부에 노출하지 않습니다. 내부 오류 메시지가 공격자에게 힌트를 줄 수 있기 때문입니다.


레거시 무영향 전략

검증 항목방법
기존 /auth/oauth2/signin 동작코드 일절 미수정
기존 callback 동작v2_ prefix 아닌 state → None 반환 → 기존 경로 그대로
Redis key 충돌namespace 분리 (oauth:state vs oauth:v2:state)
OAuthService 인스턴스state_prefix 파라미터화, 레거시 default 값 유지

에러 코드 매핑

시나리오HTTP응답 형태
미지원 provider400JSON
허용되지 않은 redirect_uri400JSON
state 만료/재사용401JSON
state provider 불일치401JSON
provider API 실패 (state 유효)302redirect + error=oauth_upstream_error
auth code 만료/재사용401JSON
pending 없음404JSON
본인인증 미완료422JSON
중복 회원(CI/phone/email)409JSON (충돌 필드는 서버 로그만)

마치며

이번 작업에서 가장 많이 고민한 건 "어떻게 하면 기존 코드를 최대한 건드리지 않을 수 있는가" 였습니다.

callback에 3줄만 추가해서 카카오 콘솔 변경 없이 v2 플로우를 붙였고, Redis namespace를 분리해서 레거시와 완전히 독립시켰습니다. GETDEL로 race condition을 애플리케이션 레벨에서 해결한 것도 이런 흐름이었습니다.

이상 앱 전용 서비스를 웹으로 확장하면서 인증 구조를 통째로 바꿔야 하는 상황에서, 기존 클라이언트에 영향을 주지 않으면서 보안도 함께 강화하는 방법을 찾는 과정이었습니다.