Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions content/posts/en/payment-system-redesign-sync-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,8 @@ The key is to **filter cases that should not be refunded first**.
catch (BaseException e) {
// Cases that do not need refund: already cancelled, not paid, duplicate processing
if (Set.of(CANCELLED_PAYMENT, PAYMENT_NOT_PAID, ALREADY_PROCESSING)
.contains(e.getPodoStatusCode())) {
log.warn("[{}] Skip refund. paymentId: {}", e.getPodoStatusCode(), paymentId);
.contains(e.getErrorCode())) {
log.warn("[{}] Skip refund. paymentId: {}", e.getErrorCode(), paymentId);
return;
}

Expand Down
42 changes: 21 additions & 21 deletions content/posts/ko/api-response-error-standardization.md

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions content/posts/ko/auth-authorize-callback-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ draft: false

핵심은 간단하다.

프론트엔드는 더 이상 로그인 상태를 직접 만들지 않는다. 보호된 페이지에 접근한 사용자를 백엔드 인증 시작점으로 보내고, 백엔드가 돌려준 콜백 결과를 확인한 뒤 받은 토큰을 브라우저 쿠키로 내려준다.
프론트엔드는 더 이상 로그인 상태를 직접 만들지 않는다. 보호된 페이지에 접근한 사용자를 백엔드 인증 시작점으로 보내고 백엔드가 돌려준 콜백 결과를 확인한 뒤 받은 토큰을 브라우저 쿠키로 내려준다.

```mermaid
sequenceDiagram
Expand Down Expand Up @@ -69,7 +69,7 @@ function redirectToAuthorize(request: Request) {

여기서 중요한 값은 `destination`이다. 사용자가 원래 가려던 경로를 잃지 않기 위해서다.

다만 이 값은 그대로 믿으면 안 된다. callback에서 다시 사용할 때 반드시 내부 경로인지 확인해야 한다. 외부 URL을 허용하면 open redirect 문제가 된다.
다만 이 값은 그대로 믿으면 안 된다. callback에서 다시 사용할 때 반드시 내부 경로인지 확인한다. 외부 URL을 허용하면 open redirect 문제가 된다.

---

Expand Down Expand Up @@ -129,7 +129,7 @@ public ResponseEntity<?> authorize(
}
```

서비스 계층에서는 provider를 해석하고, callback 이후 확인할 `state`를 만들고, 인증 제공자로 보낼 authorize URL을 구성한다.
서비스 계층에서는 provider를 해석하고 callback 이후 확인할 `state`를 만들고 인증 제공자로 보낼 authorize URL을 구성한다.

```java
public AuthorizeContext authorize(String providerValue, Map<String, String> queryParams) {
Expand All @@ -150,15 +150,15 @@ public AuthorizeContext authorize(String providerValue, Map<String, String> quer
}
```

여기서 `state`는 단순한 문자열이 아니다. provider, redirect URI, 원래 가려던 경로 같은 인증 흐름의 맥락을 이어주는 값이다. 그래서 보호 페이지에서 바로 로그인 화면으로 보내지 않고, 반드시 백엔드 authorize를 거치게 했다.
여기서 `state`는 단순한 문자열이 아니다. provider, redirect URI, 원래 가려던 경로 같은 인증 흐름의 맥락을 이어주는 값이다. 그래서 보호 페이지에서 바로 로그인 화면으로 보내지 않고 반드시 백엔드 authorize를 거치게 했다.

---

## 4. callback은 바로 로그인 성공이 아니다

백엔드가 callback으로 돌려준 값이 있다고 해서 바로 로그인 성공으로 처리하지 않았다.

callback은 "인증 결과를 확인해도 된다"는 신호다. 실제 구현에서는 백엔드가 인증 결과를 일회성 id로 저장하고, 프론트엔드는 그 id를 다시 백엔드에 전달해 서비스 토큰으로 교환했다.
callback은 "인증 결과를 확인해도 된다"는 신호다. 실제 구현에서는 백엔드가 인증 결과를 일회성 id로 저장하고 프론트엔드는 그 id를 다시 백엔드에 전달해 서비스 토큰으로 교환했다.

```ts
export async function callbackHandler(request: Request) {
Expand Down Expand Up @@ -193,15 +193,15 @@ export async function callbackHandler(request: Request) {
- 백엔드 exchange가 실패하면 로그인 화면으로 보낸다.
- destination이 외부 URL이면 기본 페이지로 보낸다.

인증 흐름에서는 실패를 대충 넘기면 안 된다. 실패를 성공처럼 처리하면 인증 우회가 되고, 실패마다 다른 화면으로 보내면 사용자는 흐름을 이해하기 어렵다.
인증 흐름에서는 실패를 대충 넘기면 안 된다. 실패를 성공처럼 처리하면 인증 우회가 되고 실패마다 다른 화면으로 보내면 사용자는 흐름을 이해하기 어렵다.

---

## 5. 백엔드는 callback 결과를 일회성 교환 값으로 바꾼다

백엔드 callback은 인증 제공자가 넘겨준 authorization code와 state를 검증하고, 서비스에서 사용할 토큰을 발급한다.
백엔드 callback은 인증 제공자가 넘겨준 authorization code와 state를 검증하고 서비스에서 사용할 토큰을 발급한다.

다만 브라우저를 바로 백엔드 응답으로 로그인시키지 않았다. 프론트엔드 callback으로 다시 돌아가야 했기 때문이다. 그래서 백엔드는 인증 결과를 Redis 같은 저장소에 짧게 보관하고, 프론트엔드에는 일회성 id만 넘겼다.
다만 브라우저를 바로 백엔드 응답으로 로그인시키지 않았다. 프론트엔드 callback으로 다시 돌아가야 했기 때문이다. 그래서 백엔드는 인증 결과를 Redis 같은 저장소에 짧게 보관하고 프론트엔드에는 일회성 id만 넘겼다.

```java
@GetMapping("/{provider}/callback")
Expand Down Expand Up @@ -250,7 +250,7 @@ public TokenResponse exchange(@RequestParam String sid) {
}
```

이 구조에서 `sid`는 로그인 토큰이 아니다. callback 결과를 한 번만 꺼내기 위한 교환 키다. 만료 시간이 짧고, 한 번 소비하면 삭제된다. 그래서 콜백 URL에 실제 access token이나 refresh token을 직접 싣지 않아도 된다.
이 구조에서 `sid`는 로그인 토큰이 아니다. callback 결과를 한 번만 꺼내기 위한 교환 키다. 만료 시간이 짧고 한 번 소비하면 삭제된다. 그래서 콜백 URL에 실제 access token이나 refresh token을 직접 싣지 않아도 된다.

---

Expand Down Expand Up @@ -310,6 +310,6 @@ authorize/callback 흐름을 프론트엔드에 붙일 때 중요한 것은 코
- 토큰 쿠키의 만료 시간은 백엔드 응답을 따른다.
- destination은 내부 경로만 허용한다.

이렇게 나누면 프론트엔드는 로그인 흐름을 이어주지만, 로그인 상태를 직접 만들지는 않는다.
이렇게 나누면 프론트엔드는 로그인 흐름을 이어주지만 로그인 상태를 직접 만들지는 않는다.

다음 글에서는 기존 토큰과 새 백엔드 토큰을 함께 받아야 할 때의 문제를 다룬다. 어떤 순서로 검증하고, redirect chain을 어떻게 확인했는지 정리한다.
다음 글에서는 기존 토큰과 새 백엔드 토큰을 함께 받아야 할 때의 문제를 다룬다. 어떤 순서로 검증하고 redirect chain을 어떻게 확인했는지 정리한다.
42 changes: 21 additions & 21 deletions content/posts/ko/auth-token-verification-migration.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "로그인 책임 분리 (3) - 기존 토큰과 새 백엔드 토큰을 함께 검증하기"
date: "2026-04-17"
description: "브라우저에는 토큰이 쿠키로 전달되지만, 기존 토큰과 새 백엔드 토큰의 검증 기준이 다를 때 순서와 실패 처리를 어떻게 나눴는지 정리합니다."
description: "브라우저에는 토큰이 쿠키로 전달되지만 기존 토큰과 새 백엔드 토큰의 검증 기준이 다를 때 순서와 실패 처리를 어떻게 나눴는지 정리합니다."
tags: ["authentication", "oauth", "jwt", "testing", "migration", "java", "spring"]
series: "로그인 책임 분리"
seriesOrder: 3
Expand All @@ -12,7 +12,7 @@ draft: false

인증 책임을 백엔드로 옮긴다고 해서 기존 로그인 토큰이 한 번에 사라지지는 않는다.

이미 로그인한 사용자가 있고, 프론트엔드가 발급한 토큰이 브라우저 쿠키에 남아 있고, 일부 API는 여전히 그 쿠키에서 토큰을 읽는다. 동시에 새 로그인 흐름으로 들어온 사용자는 백엔드가 발급한 토큰을 같은 쿠키 이름으로 받게 된다.
이미 로그인한 사용자가 있고 프론트엔드가 발급한 토큰이 브라우저 쿠키에 남아 있고 일부 API는 여전히 그 쿠키에서 토큰을 읽는다. 동시에 새 로그인 흐름으로 들어온 사용자는 백엔드가 발급한 토큰을 같은 쿠키 이름으로 받게 된다.

즉 한동안은 두 종류의 토큰 발급 경로가 같이 존재한다.

Expand All @@ -25,9 +25,9 @@ draft: false

## 기존 토큰은 서명만으로 검증되지 않았다

기존 프론트엔드 토큰은 JWT였지만, JWT 서명만 맞으면 끝나는 구조가 아니었다.
기존 프론트엔드 토큰은 JWT였지만 JWT 서명만 맞으면 끝나는 구조가 아니었다.

토큰을 만들 때 Redis에도 저장했고, 검증할 때는 두 가지를 함께 확인했다.
토큰을 만들 때 Redis에도 저장했고 검증할 때는 두 가지를 함께 확인했다.

```ts
async function verifyLegacyToken(token: string) {
Expand All @@ -44,9 +44,9 @@ async function verifyLegacyToken(token: string) {
}
```

이 구조에서는 토큰 문자열 자체가 Redis key 역할을 한다. 로그아웃하면 Redis에서 토큰을 지우고, 이후 같은 JWT가 들어와도 유효하지 않다.
이 구조에서는 토큰 문자열 자체가 Redis key 역할을 한다. 로그아웃하면 Redis에서 토큰을 지우고 이후 같은 JWT가 들어와도 유효하지 않다.

따라서 쿠키에서 읽은 기존 토큰을 처리할 때도 토큰을 단순히 decode하거나 서명만 확인하면 안 된다. 기존 토큰은 기존 방식으로 검증해야 한다.
쿠키에서 읽은 기존 토큰을 처리할 때도 토큰을 단순히 decode하거나 서명만 확인하면 안 된다. 기존 토큰은 기존 방식으로 검증한다.

---

Expand Down Expand Up @@ -91,7 +91,7 @@ async function verifyBackendToken(token: string, refreshToken?: string) {

프론트엔드가 백엔드 토큰을 직접 decode하지 않게 하려면, 백엔드에는 토큰 확인용 API가 필요하다.

여기서 중요한 것은 응답을 작게 유지하는 것이다. 프론트엔드는 토큰이 active인지, 어떤 사용자로 볼 수 있는지만 알면 된다. 서명 방식, key rotation, 저장 방식은 백엔드 내부 사정이다.
여기서 응답은 작게 유지한다. 프론트엔드는 토큰이 active인지, 어떤 사용자로 볼 수 있는지만 알면 된다. 서명 방식, key rotation, 저장 방식은 백엔드 내부 사정이다.

```java
@PostMapping("/introspect")
Expand Down Expand Up @@ -131,7 +131,7 @@ public Map<String, Object> introspect(@RequestBody TokenRequest request) {

## refresh token의 기준은 Redis에 둔다

access token은 짧게 만료되고, refresh token은 다시 access token을 발급받기 위해 사용된다. 이때 refresh token을 단순히 JWT처럼 해석하지 않고 Redis에 저장된 상태를 확인했다.
access token은 짧게 만료되고 refresh token은 다시 access token을 발급받기 위해 사용된다. 이때 refresh token을 단순히 JWT처럼 해석하지 않고 Redis에 저장된 상태를 확인했다.

```java
@PostMapping("/refresh")
Expand All @@ -148,7 +148,7 @@ public TokenResponse refresh(@RequestBody RefreshRequest request) {
}
```

저장소 역할은 단순하다. 발급할 때 저장하고, 갱신할 때 조회하고, 로그아웃이나 만료 처리에서는 삭제한다.
저장소 역할은 단순하다. 발급할 때 저장하고 갱신할 때 조회하고 로그아웃이나 만료 처리에서는 삭제한다.

```java
@Service
Expand Down Expand Up @@ -179,13 +179,13 @@ class RefreshTokenStore {
}
```

브라우저에는 refresh token이 쿠키로 실려오지만, 유효성의 기준은 쿠키가 아니다. Redis에 남아 있어야 하고, 저장된 사용자와 연결되어 있어야 한다. 그래서 토큰을 지우는 것은 단순히 브라우저 쿠키를 지우는 일과 다르다. 서버 저장소에서도 더 이상 갱신할 수 없게 만들어야 한다.
브라우저에는 refresh token이 쿠키로 실려오지만 유효성의 기준은 쿠키가 아니다. Redis에 남아 있어야 하고 저장된 사용자와 연결되어 있어야 한다. 그래서 토큰을 지우는 것은 단순히 브라우저 쿠키를 지우는 일과 다르다. 서버 저장소에서도 더 이상 갱신할 수 없게 만들어야 한다.

---

## 어떤 토큰부터 확인할지 정한다

두 종류의 토큰을 함께 받아야 하는 동안에는 검증 순서를 명확히 정해야 한다.
두 종류의 토큰을 함께 받는 동안에는 검증 순서를 명확히 정한다.

```ts
async function verifyToken(accessToken: string, refreshToken?: string) {
Expand All @@ -201,13 +201,13 @@ async function verifyToken(accessToken: string, refreshToken?: string) {

기존 토큰을 먼저 검증하면 기존 사용자의 세션을 유지하기 쉽다. 새 토큰은 기존 검증에서 실패한 뒤 백엔드 검증으로 넘어간다.

다만 이 방식은 오래 유지할 구조가 아니다. 기존 토큰을 새 인증 흐름과 함께 처리하기 위한 호환 레이어다. 새 인증 흐름이 충분히 안정화되면 기존 토큰 검증 경로는 제거해야 한다. 그렇지 않으면 프론트엔드는 계속 두 인증 체계를 알고 있어야 한다.
다만 이 방식은 오래 유지할 구조가 아니다. 기존 토큰을 새 인증 흐름과 함께 처리하기 위한 호환 레이어다. 새 인증 흐름이 충분히 안정화되면 기존 토큰 검증 경로는 제거한다. 그렇지 않으면 프론트엔드는 계속 두 인증 체계를 알고 있어야 한다.

---

## 페이지 흐름에서는 실패하면 토큰 쿠키를 지우고 인증 시작점으로 보낸다

토큰 검증이 실패했을 때 가장 중요한 것은 애매한 상태를 남기지 않는 것이다. API 검증 계층에서는 토큰 쿠키를 지우고 오류를 반환하고, 페이지 미들웨어는 그 실패 응답을 보고 인증 시작점으로 보낸다.
토큰 검증이 실패했을 때 가장 중요한 것은 애매한 상태를 남기지 않는 일이다. API 검증 계층에서는 토큰 쿠키를 지우고 오류를 반환하고 페이지 미들웨어는 그 실패 응답을 보고 인증 시작점으로 보낸다.

```ts
async function authMiddleware(request: Request) {
Expand All @@ -232,17 +232,17 @@ async function authMiddleware(request: Request) {

검증이 실패했는데 토큰 쿠키를 그대로 두면 다음 요청에서도 같은 실패가 반복된다. 사용자는 로그인 페이지와 보호 페이지 사이를 계속 오가거나, 화면은 열렸는데 API만 401이 나는 상태를 만날 수 있다.

그래서 실패 시에는 토큰 쿠키를 지우고, 같은 인증 시작점으로 보냈다.
그래서 실패 시에는 토큰 쿠키를 지우고 같은 인증 시작점으로 보냈다.

---

## redirect chain을 검증해야 한다
## redirect chain 검증

이 작업에서 가장 중요한 검증은 단위 테스트보다 redirect chain 확인이었다.

코드상으로는 인증 시작점으로 redirect한다고 작성했더라도, 실제 응답이 예전 로그인 페이지나 다른 경로로 떨어질 수 있다. 인증 흐름은 브라우저가 여러 응답을 따라가며 완성되기 때문이다.

확인해야 할 흐름은 단순하다.
확인할 흐름은 단순하다.

```text
보호 페이지
Expand All @@ -254,13 +254,13 @@ async function authMiddleware(request: Request) {
-> 원래 페이지
```

로컬에서는 `curl -I`로 첫 redirect 위치를 확인하고, 필요하면 브라우저에서 실제 쿠키와 Location 헤더를 함께 봤다.
로컬에서는 `curl -I`로 첫 redirect 위치를 확인하고 필요하면 브라우저에서 실제 쿠키와 Location 헤더를 함께 봤다.

```bash
curl -I http://localhost:3000/protected
```

기대하는 것은 보호 페이지가 로그인 화면으로 바로 떨어지는 것이 아니다. 프론트엔드 인증 시작 라우트로 이동하고, 그 라우트가 다시 백엔드 authorize로 이동해야 한다.
기대하는 것은 보호 페이지가 로그인 화면으로 바로 떨어지는 상태가 아니다. 프론트엔드 인증 시작 라우트로 이동하고 그 라우트가 다시 백엔드 authorize로 이어져야 한다.

---

Expand All @@ -275,7 +275,7 @@ curl -I http://localhost:3000/protected
| 기존 토큰 검증 실패, 백엔드 access token 유효 | 백엔드 검증 성공 |
| 백엔드 access token 비활성, refresh token 유효 | 백엔드 refresh 후 새 토큰 저장 |

이 테스트의 목적은 "로그인이 된다"를 확인하는 것이 아니다. 어떤 검증 경로를 탔는지 확인하는 것이다.
이 테스트의 목적은 "로그인이 된다"를 확인하는 데 있지 않다. 어떤 검증 경로를 탔는지 확인하는 데 있다.

기존 검증이 성공했다면 백엔드 introspection이 호출되면 안 된다. 백엔드 access token이 유효하다면 refresh API가 호출되면 안 된다. 이런 호출 순서가 맞아야 두 검증 경로를 함께 둔 코드를 신뢰할 수 있다.

Expand All @@ -285,8 +285,8 @@ curl -I http://localhost:3000/protected

어려운 부분은 새 흐름을 만드는 것보다 기존 토큰과 새 백엔드 토큰을 한동안 함께 받아야 하는 쪽이었다.

기존 토큰은 기존 방식으로 검증해야 하고, 백엔드 토큰은 백엔드에 물어봐야 한다. 실패하면 토큰 쿠키를 정리하고, 모든 미인증 진입은 같은 인증 시작점으로 보내야 한다.
기존 토큰은 기존 방식으로 검증해야 하고 백엔드 토큰은 백엔드에 물어봐야 한다. 실패하면 토큰 쿠키를 정리하고 모든 미인증 진입은 같은 인증 시작점으로 보내야 한다.

그리고 마지막에는 반드시 redirect chain을 확인해야 한다.
마지막에는 반드시 redirect chain을 확인한다.

인증은 함수 하나의 결과가 아니라 여러 HTTP 응답이 이어진 흐름이다. 테스트가 통과해도 실제 브라우저가 예전 로그인 경로를 타고 있으면 전환은 끝난 게 아니다.
Loading
Loading