기록
SNS 로그인 서버 중심으로 리팩토링
기존 프로젝트는 모바일 앱 전용 서비스였습니다. 앱에서는 카카오 SDK를 통해 클라이언트가 직접 소셜 토큰을 받아 서버에 전달하는 방식을 사용했고, 이는 모바일 환경에서 흔히 쓰이는 구조였습니다.
클라이언트(앱) → 카카오 SDK → social_id, email 획득
클라이언트 → POST /auth/oauth2/signin { social_id, phone, name, ... }
서버 → 검증 없이 바로 계정 생성/로그인
서비스가 웹으로 확장되면서 이 구조를 그대로 유지할 수 없게 됐습니다. 웹에는 네이티브 SDK가 없기 때문에 같은 방식을 쓰려면 클라이언트가 소셜 정보를 직접 서버에 넘겨야 하는데, 이 과정에서 다음과 같은 보안 취약점이 드러납니다.
- 소셜 토큰 서버 검증 없음 — 클라이언트가 전달하는
social_id를 서버가 그대로 신뢰하기 때문에 임의 값을 넣어 타인 계정에 접근하는 위조가 가능합니다. - 개인정보 클라이언트 경유 — 이름, 전화번호 같은 민감한 정보가 요청 바디에 평문으로 노출됩니다.
- 서버 제어 불가 — 토큰 발급과 검증 흐름이 전부 클라이언트에 있어, 서버가 인증 과정을 통제할 수 없습니다.
목표
앱과 웹 모두에서 동작하는 서버 중심 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를 잘못 소비할 수 있습니다.
| 용도 | 레거시 key | v2 key |
|---|---|---|
| OAuth state | oauth:state:{state} | oauth:v2:state:{state} |
| Auth code | auth: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의 state와 auth_code는 딱 한 번만 사용되어야 합니다. 재사용을 막으려면 Redis에서 값을 읽은 뒤 즉시 삭제해야 하는데, 여기에 문제가 있습니다.
GET → DELETE를 순서대로 실행하면, 두 요청이 거의 동시에 들어왔을 때 둘 다 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.comvshttps://example.com/(trailing slash)https://example.comvshttps://EXAMPLE.COM(대소문자)https://example.comvshttps://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_uri가 https://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_provider와 social_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 처리는 email과 expires_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 | 응답 형태 |
|---|---|---|
| 미지원 provider | 400 | JSON |
| 허용되지 않은 redirect_uri | 400 | JSON |
| state 만료/재사용 | 401 | JSON |
| state provider 불일치 | 401 | JSON |
| provider API 실패 (state 유효) | 302 | redirect + error=oauth_upstream_error |
| auth code 만료/재사용 | 401 | JSON |
| pending 없음 | 404 | JSON |
| 본인인증 미완료 | 422 | JSON |
| 중복 회원(CI/phone/email) | 409 | JSON (충돌 필드는 서버 로그만) |
마치며
이번 작업에서 가장 많이 고민한 건 "어떻게 하면 기존 코드를 최대한 건드리지 않을 수 있는가" 였습니다.
callback에 3줄만 추가해서 카카오 콘솔 변경 없이 v2 플로우를 붙였고, Redis namespace를 분리해서 레거시와 완전히 독립시켰습니다. GETDEL로 race condition을 애플리케이션 레벨에서 해결한 것도 이런 흐름이었습니다.
이상 앱 전용 서비스를 웹으로 확장하면서 인증 구조를 통째로 바꿔야 하는 상황에서, 기존 클라이언트에 영향을 주지 않으면서 보안도 함께 강화하는 방법을 찾는 과정이었습니다.