문제점 선정 이유
프론트엔드에서 JWT 토큰을 디코딩하여 사용자 역할을 확인하고 페이지 라우팅을 처리하는 로직을 구현했습니다.하지만 현직자분과의 멘토링 과정에서 이 방식이 불필요할 뿐만 아니라 보안상 위험하다는 지적을 받았습니다.이 글에서는 JWT 디코딩이 왜 위험한지, 그리고 더 안전한 대안은 무엇인지 실제 프로젝트 코드를 통해 알아보겠습니다.
현재 로그인 로직은 어떻게 동작하는가?
소셜 로그인을 기반으로 사용자를 인증하고 있습니다.
전체 로그인 흐름 요약
- 사용자가 로그인 버튼을 클릭합니다.(고객용/디자이너용 구분)
- 프론트엔드에서 소셜 로그인 모달이 열리고, 해당 소셜 플랫폼으로 이동하여 인증을 시도합니다.
- 인증이 성공하면, 백엔드 서버는 accessToken과 refreshToken을 발급한 뒤, 해당 토큰을 쿼리 파라미터로 포함하여 프론트엔드로 리디렉션합니다.
- 프론트는 이 URL에서 쿼리 파라미터를 파싱해 두 토큰을 localStorage에 저장합니다.
- 저장된 accessToken을 decode하여 사용자 정보를 파악합니다. 이 때 사용자 role(예: ROLE_CUSTOMER, ROLE_DESIGNER 등)을 참고합니다.
- 사용자의 역할에 따라 /customer/home 또는 /designer/home 등의 대시보드 페이지로 이동시킵니다.
1단계: 로그인 버튼 클릭 → 소셜 로그인 모달 열기
<CustomButton
onClick={handleCustomerSignUp}>
일반 회원으로 시작하기
</CustomButton>
const handleCustomerSignUp = () => {
setIsModalVisible(true); // 모달 열기
setSigninType("customer"); // 어떤 사용자 타입인지 설정
};
사용자가 버튼을 누르면 SocialLoginModal이 뜨고,
카카오, 네이버, 구글 등의 소셜 로그인 버튼을 통해 인증을 요청하게 됩니다.
2단계: 소셜 인증 성공 후 백엔드에서 토큰 발급 → 프론트로 리디렉션
예시 URL
http://localhost:5173/sign-in?accessToken=eyJhbGciOi...&refreshToken=eyJhbGciOi...
인증이 끝나면, 백엔드는 토큰 2개를 쿼리 파라미터에 담아서 프론트로 보냅니다.
3단계: 프론트에서 토큰을 꺼내서 저장
function parseQueryParams() {
const params = new URLSearchParams(window.location.search);
return {
accessToken: params.get("accessToken") || "",
refreshToken: params.get("refreshToken") || "",
};
}
useEffect(() => {
const { accessToken, refreshToken } = parseQueryParams();
if (accessToken && refreshToken) {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
// ❌ 문제: JWT 디코딩으로 역할 확인
const decoded = jwtDecode<CustomJwtPayload>(accessToken);
const role = decoded.user.role;
if (role === "ROLE_CUSTOMER") {
navigate(ROUTE.customer.home);
} else if (role === "ROLE_DESIGNER") {
navigate(ROUTE.designer.home);
} else {
navigate(ROUTE.signIn);
}
}
}, []);
- URL에 담긴 accessToken, refreshToken을 꺼냅니다.
- 브라우저의 localStorage에 저장합니다.
- accessToken을 decode해서 누가 로그인했는지 파악합니다.
4단계: 사용자 role에 따라 다른 대시보드로 보내기
if (role === "ROLE_CUSTOMER") {
navigate(ROUTE.customer.home);
} else if (role === "ROLE_DESIGNER") {
navigate(ROUTE.designer.home);
}
로그인하자마자 해당 유저의 홈으로 리디렉션하는 구조입니다.
간략하게 정리하자면,
1.로그인 버튼 클릭
2.소셜 로그인 플랫폼으로 인증 요청
3.백엔드: 로그인 성공 → accessToken + refreshToken 발급
4.프론트: 토큰 파싱 후 localStorage 저장
5.accessToken decode → 사용자 role 확인
6.role 기반 페이지로 이동
토큰 왜 필요하고 각각 어떤 역할을 하는가?
웹 서비스에서 인증을 위해 가장 많이 사용하는 방식이 바로 JWT(Json Web Token) 입니다.
JWT는 유저의 정보를 안전하게 담아서, 서버와 클라이언트가 주고받을 수 있게 만들어주는 역할을 합니다.
accessToken 이란?
- 사용자의 신원을 증명하는 짧은 수명의 토큰입니다.
- 주로 API 요청 시, HTTP 헤더의 Authorization 필드에 담겨 전송됩니다.
Authorization: Bearer <accessToken>
- 내부에는 사용자의 고유 ID, 권한(role), 토큰 만료 시간(exp) 등의 정보가 담깁니다.
- 만료 시간이 짧은 이유는 혹시 유출되더라도 빠르게 무효화되도록 하기 위함입니다.
refreshToken 이란?
- accessToken이 만료되었을 때, 새로운 accessToken을 재발급 받기 위한 토큰입니다.
- 보통 수명이 깁니다 (며칠 ~ 몇 주)
- 중요한 점: refreshToken은 절대 브라우저에 노출시키지 않고, HTTPOnly 쿠키처럼 안전한 곳에 저장하는 것이 권장됩니다.
왜 굳이 두 개로 나눌까?
|
목적
|
이유
|
|
accessToken
|
요청 시 유저 인증 (빠르고 자주 쓰이기 때문에 짧은 수명)
|
|
refreshToken
|
accessToken이 만료됐을 때 재발급 요청 (노출 위험이 적도록 안전하게 보관)
|
토큰을 decode은 무슨 의미인가? 왜 문제가 될 수 있나?
프론트엔드에서는 종종 다음과 같은 코드로 accessToken을 디코딩해서 사용자의 정보를 파악합니다.
const decoded = jwtDecode<CustomJwtPayload>(accessToken);
이때 중요한 점은: 디코딩은 누구나 할 수 있다는 것입니다.
JWT는 암호화된 것이 아닙니다!
JWT는 Base64로 인코딩된 문자열일 뿐입니다. 즉 토큰을 복호화하면 누구나 그 안에 담긴 정보를 읽을 수 있습니다. 실제로 디코딩 해보면 아래와 같은 정보들이 나옵니다.
{
"user": {
"id": 7,
"role": "ROLE_CUSTOMER"
},
"exp": 1712345678
}
그럼 왜 위험한가요?
- 디코딩 자체는 문제는 아닙니다. JWT는 설계상 누구든 디코딩할 수 있게 만든 토큰이기 때문입니다.
- 하지만 중요한 정보(예: 이메일, 실명, 주소 등)는 절대 넣으면 안 됩니다.
- 토큰을 디코딩한 정보를 기준으로 UI를 바꾸는 것은 가능하지만, 절대 인가(권한 체크)는 프론트에서 하면 안 됩니다.
실제 백엔드 구조 분석: JWT 디코딩이 불필요한 이유
실제 프로젝트의 백엔드 코드를 살펴보면, 이미 API가 역할별로 완전히 분리되어 있습니다.
- 백엔드 API 구조
// Customer용 회원가입 응답
public record SignUpResult(
Long customerId,
String accessToken,
String refreshToken
) {
public static SignUpResult from(SignTokens signTokens, Customer customer) {
return new SignUpResult(
customer.getCustomerId(),
signTokens.accessToken(),
signTokens.refreshToken()
);
}
}
// Designer용 회원가입 응답
public record SignUpResult(
Long designerId,
String accessToken,
String refreshToken
) {
public static SignUpResult from(SignTokens signTokens, Designer designer) {
return new SignUpResult(
designer.getDesignerId(),
signTokens.accessToken(),
signTokens.refreshToken()
);
}
}
- 프론트엔드 API 호출
// Customer API
import { CustomerAPI } from "../../api";
export const signUp = async (data: SignUpRequest): Promise<SignUpResponse> => {
const res = await CustomerAPI.post<SignUpResponse>(`/v1/auth/sign-up`, data);
return res.data;
};
// Designer API
import { DesignerAPI } from "../../api";
export const signUp = async (data: SignUpRequest): Promise<SignUpResponse> => {
const res = await DesignerAPI.post<SignUpResponse>(`/v1/auth/sign-up`, data);
return res.data;
};
핵심 문제점 발견: 불필요한 JWT 디코딩
해당 코드에서 중요한 점을 발견할 수 있습니다:
1. API 엔드포인트가 이미 역할별로 분리되어 있습니다
- Customer: CustomerAPI.post('/v1/auth/sign-up')
- Designer: DesignerAPI.post('/v1/auth/sign-up')
2. 각 API는 해당 역할에 맞는 토큰만 발급합니다
- Customer API → Customer 전용 토큰
- Designer API → Designer 전용 토큰
3. 따라서 토큰의 출처를 이미 알고 있습니다
따라서 JWT 디코딩은 불필요한 작업이었습니다.
이미 백엔드 아키텍처에서 역할별 API 분리를 통해 충분히 구분 가능한 상황에서, 클라이언트에서 위험한 JWT 디코딩을 수행할 이유가 전혀 없었던 것입니다.
그렇다면 해당 문제를 어떻게 처리하면 좋을까?
JWT 디코딩에 대한 문제점을 해결하기 위해 2가지 해결방안을 생각해보았습니다.
대안 1: 상태 관리 활용
프론트엔드에서 로그인 버튼을 클릭할 때 사용자의 역할을 signinType이라는 상태로 저장해두고,로그인 인증이 완료된 뒤 그 상태값을 기준으로 라우팅하는 방식입니다.
// 개선된 방식
const [signinType, setSigninType] = useState<'customer' | 'designer' | null>(null);
// 로그인 버튼 클릭 시 역할 저장
const handleCustomerSignUp = () => {
setIsModalVisible(true);
setSigninType("customer");
};
// 토큰 받은 후 저장된 역할로 라우팅
useEffect(() => {
const { accessToken, refreshToken } = parseQueryParams();
if (accessToken && refreshToken) {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
// JWT 디코딩 없이 상태 활용
if (signinType === "customer") {
navigate(ROUTE.customer.home);
} else if (signinType === "designer") {
navigate(ROUTE.designer.home);
}
}
}, [signinType]);
대안 2: 백엔드에서 리다이렉트 URL 분리
더 안전한 방식은 아예 백엔드에서 역할에 따라 리다이렉트 경로를 분리하는 것입니다.
소셜 로그인 성공 시, 백엔드는 사용자의 유형에 따라 다른 URL로 리디렉션합니다.
// 더 안전한 방식: 엔드포인트 자체를 분리
// Customer 소셜 로그인
GET /api/customer/auth/social/callback → redirect to /customer/home
// Designer 소셜 로그인
GET /api/designer/auth/social/callback → redirect to /designer/home
결론
JWT를 디코딩해서 사용자 역할을 판단하고 라우팅을 처리하는 방식은, 처음엔 매우 직관적이고 효율적으로 느껴졌습니다. 하지만 이번 경험을 통해, 보안 위험과 역할의 책임을 명확히 나누는 설계의 중요성을 깊이 체감하게 되었습니다.
그동안 저는 프론트엔드 입장에서 '로그인 성공 후 어디로 보내줄지'만 고민했지만, 리팩토링을 계기로 백엔드 구조, 토큰 전달 방식, 그리고 사용자 역할에 따른 API 분리까지 이해하려고 노력했습니다. 특히 이 과정에서 중요하게 배운 개념이 책임 분리입니다. 이는 각 컴포넌트나 계층이 자신이 맡은 역할만 잘 수행하고, 나머지는 다른 계층에 위임하는 설계 방식을 의미합니다. 예를 들어, 프론트는 사용자 경험과 화면 처리에 집중하고, 인증과 권한 판단은 백엔드에 위임하는 것입니다. 이런 분리가 되어야 유지보수도 쉽고, 보안 책임이 명확히 나뉘어 더 안전한 구조를 만들 수 있습니다.
이번 리팩토링에서 배운점을 바탕으로 추후에 로그인 로직을 구현할 때, 단순히 동작 여부뿐만 아니라 사용자 역할 관리, 보안, 책임 분리 관점에서 구조적으로 설계할 수 있는 기준을 먼저 고민할 것입니다. 가능하다면 프론트엔드에서 역할을 직접 해석하지 않고, 백엔드에서 명확히 역할을 구분해 리다이렉트하거나, 프론트엔드는 단순히 전달받은 정보만 처리하는 구조를 우선적으로 고려할 계획입니다.
'refactor' 카테고리의 다른 글
| [반려견 미용 견적 서비스] refactor 3 - 상태관리 (4) | 2025.05.16 |
|---|---|
| [반려견 미용 견적 서비스] refactor 2 - 스켈레톤 UI (1) | 2025.04.28 |
| [반려견 미용 견적 서비스] refactor 1 - SEO 설정 (0) | 2025.04.19 |
| [반려견 미용 견적 서비스] PEAUTY 회고 및 리팩토링 방향성 (0) | 2025.04.16 |