Authentication
웹 애플리케이션에서의 인증(Authentication) 플로우는 보통 다음과 같은 세 단계로 진행됩니다.
- Credential 입력 수집 — 아이디, 비밀번호(또는 OAuth redirect URL)를 사용자에게 입력받습니다.
- 백엔드 Endpoint 호출 —
/login,/oauth/callback,/2fa등 로그인 관련 API endpoint로 request를 보냅니다. - Token 저장 — 응답으로 받은 token을 cookie 또는 store에 저장해, 이후 request에 자동으로 포함되도록 합니다.
1. Credential 입력 수집
이 단계에서는 사용자가 로그인에 필요한 정보를 입력할 수 있는 UI를 준비합니다.
OAuth 로그인만 사용한다면, 2단계(credential 전송) 에서 별도로 아이디/비밀번호를 보내지 않습니다.
이 경우 바로 token 저장 단계로 넘어갑니다.
1-1. 로그인 전용 페이지
웹 애플리케이션에서는 일반적으로 /login 같은 로그인 Form 전용 페이지를 만들어, 사용자가 사용자 이름 / 이메일, 비밀번호를 입력하도록 합니다.
이 페이지는 하는 일이 단순하기 때문에, 추가적인 decomposition(구조 분할) 이 크게 필요하지 않습니다.
대신, 로그인 폼과 회원가입 폼을 각각 하나의 컴포넌트로 만들어 두고 재사용하는 방식이 적합합니다.
- 📂 pages
- 📂 login
- 📂 ui
- 📄 LoginPage.tsx (or your framework's component file format)
- 📄 RegisterPage.tsx
- 📄 index.ts
- other pages…
LoginPage와 RegisterPage 컴포넌트는 서로 분리 된 컴포넌트로 구현하고, 다른 곳에서 사용할 필요가 있다면 index.ts에서 export 합니다.
각 컴포넌트는 form element와 form submit handler만 포함하도록 해서,
복잡한 비즈니스 로직은 다른 segment로 분리하고 UI는 단순하게 유지합니다.
1-2. 로그인 dialog 만들기
어떤 페이지에서든 공통으로 사용할 수 있는 로그인 dialog가 필요하다면, 이를 재사용 가능한 widget으로 구현하는 것이 좋습니다.
widget으로 구현하면 페이지마다 로그인 로직을 따로 만들 필요 없이, 필요한 곳에서 동일한 dialog를 불러와 사용할 수 있고,
구조를 과하게 쪼개지 않으면서도 재사용성을 확보할 수 있습니다.
- 📂 widgets
- 📂 login-dialog
- 📂 ui
- 📄 LoginDialog.tsx
- 📄 index.ts
- other widgets…
이후 설명은 로그인 전용 페이지 를 기준으로 진행하지만,
여기서 다루는 원칙은 login dialog widget에도 동일하게 적용됩니다.
1-3. Client-side Validation
회원가입 페이지에서 잘못된 입력을 즉시 알려주면 UX가 훨씬 좋아집니다.
이를 위해 client-side validation을 적용할 수 있습니다.
검증 규칙은 pages/login/model segment에 schema 형태로 정의하고,ui segment에서는 이 schema를 불러와 재사용합니다.
아래 예시는 Zod를 사용해 타입과 값을 동시에 검증하는 패턴입니다.
import { z } from "zod";
export const registrationData = z.object({
email: z.string().email(),
password: z.string().min(6),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다",
path: ["confirmPassword"],
});
그런 다음, ui segment에서 이 schema를 사용해 form으로부터 받은 데이터를 검증할 수 있습니다:
import { registrationData } from "../model/registration-schema";
function validate(formData: FormData) {
const data = Object.fromEntries(formData.entries());
try {
registrationData.parse(data);
} catch (error) {
// TODO: Show error message to the user
}
}
export function RegisterPage() {
return (
<form onSubmit={(e) => validate(new FormData(e.target))}>
<label htmlFor="email">이메일</label>
<input id="email" name="email" required />
<label htmlFor="password">비밀번호 (최소 6자)</label>
<input id="password" name="password" type="password" required />
<label htmlFor="confirmPassword">비밀번호 확인</label>
<input id="confirmPassword" name="confirmPassword" type="password" required />
</form>
)
}
2. Send credentials
이 단계에서는 사용자가 입력한 credentials(e-mail, password 등)를
백엔드 endpoint로 전송하는 request 함수를 만듭니다.
이 함수는 다음과 같은 곳에서 호출할 수 있습니다.
- Zustand
- Redux Toolkit
- TanStack Query의 useMutation
- 기타 state 관리/요청 로직
즉, 어디에서나 재사용 가능한 로그인 요청 함수 를 만든다고 보면 됩니다.
2-1. 함수 placement
| 목적 | 권장 위치 | 이유 |
|---|---|---|
| 전역 재사용 | shared/api | 모든 slice에서 import 가능 |
| 로그인 전용 | pages/login/api | slice 내부 capsule 유지 |
shared/api에 저장하기
로그인뿐 아니라 모든 API request를 shared/api에 모아두고,
각 요청을 endpoint별로 그룹화하는 방식입니다.
- 📂 shared
- 📂 api
- 📂 endpoints
- 📄 login.ts
- other endpoint functions…
- 📄 client.ts
- 📄 index.ts
📄 client.ts는 원시 request 함수(fetch 등)를 감싼 공용 API client로,
기본 URL, 공통 헤더, request/response 직렬화 등을 처리합니다.
import { POST } from "../client";
export function login({ email, password }: { email: string, password: string }) {
return POST("/login", { email, password });
}
export { login } from "./endpoints/login";
page의 api segment에 저장하기
로그인 request가 로그인 페이지에서만 사용된다면,
해당 페이지의 api segment에 login 함수를 두는 것도 가능합니다.
- 📂 pages
- 📂 login
- 📂 api
- 📄 login.ts
- 📂 ui
- 📄 LoginPage.tsx
- 📄 index.ts
- other pages…
import { POST } from "shared/api";
export function login({ email, password }: { email: string, password: string }) {
return POST("/login", { email, password });
}
이 함수는 로그인 페이지 내부에서만 사용하므로,
index.ts에서 다시 export할 필요는 없습니다.
Two-Factor Auth (2FA)
2단계 인증(2FA)을 사용하는 경우에는 로그인 플로우에 한 단계가 더 추가됩니다.
/login응답에has2FA플래그가 있으면,/login/2fa페이지로 redirect 합니다.- 2FA 페이지와 관련 API들은 모두
pages/loginslice에 함께 둡니다. /2fa/verify와 같이 별도의 endpoint를 호출하는 함수는shared/api또는pages/login/api에 배치합니다.
이렇게 하면, 일반 로그인과 2FA 관련 로직을 login slice 내부에 모아둘 수 있습니다.
Authenticated Requests를 위한 token 저장
로그인, 비밀번호 변경, OAuth, 2단계 인증 등 어떤 방법으로 인증을 하든,
인증 API 호출의 응답(response) 으로 보통 token이 함께 내려옵니다.
이 token을 어딘가에 저장해 두면,
이후 모든 인증이 필요한 API 요청(request) 에 token을 자동으로 포함시켜 백엔드 인증을 통과할 수 있습니다.
웹 애플리케이션에서 token을 저장하는 방법 중 가장 권장되는 방식은 cookie입니다.
cookie를 사용하면, 브라우저가 요청마다 token을 자동으로 넣어 주기 때문에
프론트엔드에서 token을 직접 관리할 필요가 거의 없습니다.
따라서 프론트엔드 아키텍처 차원에서 신경 쓸 부분이 크게 줄어듭니다.
사용 중인 프레임워크가 서버 사이드 기능을 제공한다면(예: Remix),
서버 측 cookie 관련 로직을 shared/api에 두는 것을 권장합니다.
Remix에서의 구현 예시는 튜토리얼의 Authentication 섹션을 참고하면 됩니다.
하지만 cookie를 사용할 수 없는 환경도 있습니다.
이 경우에는 token을 클라이언트에서 직접 저장하고, token 만료를 감지하고,
refresh token을 사용해 새 token을 발급받고 기존 요청을 다시 실행하는 등의 로직을 함께 구현해야 합니다.
FSD에서는 여기서 한 가지 추가 고민이 필요합니다.
token을 어느 layer 또는 어느 segment에 저장할지,
그렇게 저장한 token을 앱 전역에서 어떻게 사용할 수 있게 할지에 따라 전체 구조가 달라지기 때문입니다.
3-1. Shared
Shared layer에 token을 두는 방식은 shared/api에 정의된 공용 API 클라이언트와 자연스럽게 결합되는 패턴입니다.
token을 module scope나 어떤 reactive store에 저장해 두면,
인증이 필요한 다른 API 함수에서 이 token을 그대로 참조해 사용할 수 있습니다.
token 자동 재발급(refresh)은 API client의 middleware에서 담당합니다.
- 로그인 시 access token, refresh token을 저장합니다.
- 인증이 필요한 request를 보냅니다.
- 응답에서 token 만료 코드를 받으면, refresh token으로 새 token을 발급해 저장한 뒤 실패한 request을 동일하게 다시 시도합니다.
Token 관리 분리 전략
-
전담 segment 부재
token 저장과 재발급 로직이 request 로직과 같은 파일에 뒤섞여 있으면, 코드가 많아질수록 유지보수가 점점 어려워집니다.
이런 경우에는 request 함수와 client는shared/api에 두고,
token 관리 로직은shared/authsegment로 분리하는 방식을 권장합니다. -
token과 사용자 정보를 함께 받는 경우
백엔드가 token과 동시에 현재 사용자 정보를 반환하는 API를 제공하는 경우도 있습니다.
이때는 다음 두 가지 방식 중 하나로 처리할 수 있습니다.- 별도 store에 함께 저장하거나
/me·/users/current같은 endpoint를 따로 호출해 user 정보를 가져올 수 있습니다.
3-2. Entities
FSD 프로젝트에서는 보통 User entity(또는 Current User entity)를 두는 경우가 많습니다.
두 entity를 하나로 합쳐서 사용하는 것도 전혀 문제 없습니다.
Current User는 viewer 또는 me라고 부르기도 합니다.
이는 권한과 개인 정보가 있는 현재 로그인한 단일 사용자와,
공개적으로 표시되는 여러 사용자 목록을 구분하기 위해 쓰는 이름입니다.
Token을 User Entities에 저장하기
User entity의 model segment에 reactive store를 만들고,
이곳에 token과 user 객체를 함께 보관할 수 있습니다.
이렇게 하면: 현재 로그인한 사용자 정보 와 그 사용자가 가진 token을 한 곳에서 관리할 수 있어서,
인증과 관련된 비즈니스 로직을 작성할 때 구조를 이해하기 쉬워집니다.
다만 API client는 보통 shared/api에 정의되거나,
여러 entity에 분산되어 있는 경우가 많습니다.
따라서 layer의 import 규칙(import rule on layers)을 지키면서도 다른 request에서 이 token을 안전하게 사용할 수 있어야 합니다.
Layer 규칙 — Slice의 module은 자기보다 아래 layer의 Slice만 import할 수 있습니다.
해결 방법
-
request마다 token을 직접 넘기기
- 구현은 단순하지만 코드가 반복되기 쉽고, 타입 안전성이 없으면 실수 가능성이 커집니다.
- shared/api에 middleware pattern을 적용하기도 어렵습니다.
-
앱 전역(Context / localStorage)에 노출
- token key는 shared/api에 두고, 실제 token 값이 담긴 store는 User entity에서 export 합니다.
- Context Provider는 App layer에 배치합니다.
- 설계 자유도가 높지만, 상위 layer에 암묵적 의존성이 생깁니다.
⇒ Context나 localStorage가 누락된 경우 명확한 에러를 내도록 처리하는 것이 좋습니다.
-
token이 바뀔 때마다 API 클라이언트에 업데이트
- store subscription으로 "token 변경 → 클라이언트 상태 업데이트”를 수행합니다.
- 방법 2와 마찬가지로 암묵적 의존성이 있으나,
- 방법 2는 필요할 때 값을 가져오는(pull) 방식이고,
- 방법 3은 변경될 때 값을 밀어넣는(push) 방식입니다.
token을 이렇게 외부에서 사용할 수 있도록 노출한 뒤에는
model segment에 비즈니스 로직을 더 추가할 수 있습니다.
예를 들면, token 만료 시간에 맞춰 자동으로 갱신하거나,
일정 시간이 지나면 token을 자동으로 무효화하도록 만들 수 있습니다.
실제 백엔드 호출은 User entity의 api segment 또는 shared/api에서 수행합니다.
3-3. Pages / Widgets — 권장하지 않음
다음과 같은 이유로 page layer나 widget layer에 token을 저장하는 것은 권장하지 않습니다.
page, widget layer에 token을 두면 전역에서 이 token에 의존하게 되는데,
이렇게 되면 다른 slice에서 재사용하기 어렵고, 구조가 쉽게 얽힙니다.
따라서 token 저장 위치는 Shared 또는 Entities 중 하나로 결정하는 것을 권장합니다.
4. Logout & Token Invalidation
로그아웃과 token 무효화
대부분의 애플리케이션에는 로그아웃 전용 페이지는 따로 두지 않습니다.
대신, 어느 화면에서든 호출할 수 있는 로그아웃 기능을 두는 것이 일반적입니다.
로그아웃은 일반적으로 다음 두 단계로 이루어집니다.
- 백엔드에 인증된 로그아웃 request 보내기 (예:
POST /logout) - token store reset (access token / refresh token 모두 제거)
모든 API request을 shared/api에 모아 관리하고 있다면,
로그아웃 API는 login() 근처, 예를 들어 shared/api/endpoints/logout.ts에 두는 것이 자연스럽습니다.반대로 특정 UI(예: Header)에만 로그아웃 버튼이 있고,
그곳에서만 이 API를 호출한다면 widgets/header/api/logout.ts처럼
버튼이 위치한 widget 근처에 두는 것도 가능합니다.
token store reset은 실제로 로그아웃 버튼을 가진 UI에서 트리거됩니다.
로그아웃 request와 store reset을 같은 widget의 model segment에 함께 두어도 됩니다.
자동 로그아웃
다음과 같은 경우에는 반드시 token store를 초기화해야 합니다.
- 로그아웃 request가 실패했을 때
- 로그인 token 갱신(
/refresh)이 실패했을 때
이 상황에서 token이 그대로 남아 있으면,
화면 상으로는 로그인된 것처럼 보이지만 실제로는 대부분의 요청이 실패하는 애매한 상태가 될 수 있습니다.
token을 Entities(User)에 보관했다면,
해당 entity의 model segment에 token 초기화 코드를 두는 것이 좋습니다.
Shared layer에서 token을 관리한다면, shared/auth segment로 분리해 두는 것도 좋은 선택입니다.