3가지 핵심 요소가 서로 협력해서 인증을 처리해:
@Configuration → "이 앱의 보안 규칙은 이렇게 설정한다"Interceptor → "요청이 컨트롤러에 도달하기 전에 검사한다"JWT → "로그인 성공 증명서 (토큰)"JWT(JSON Web Token)는 3부분으로 구성된 문자열이야.
Header.Payload.Signature
클라이언트가 이 토큰을 가지고 있으면 → 로그인된 사람이라고 인정해 주는 방식.
@Configuration이 붙은 클래스는 앱이 시작될 때 딱 한 번 읽혀서 Spring 컨테이너에 설정을 등록해.
보안 설정에서 주로 하는 일:
/login, /register)/api/**)WebMvcConfigurer를 구현해서 인터셉터를 등록하거나, SecurityFilterChain을 빈으로 등록하는 게 이 클래스의 역할이야.
HandlerInterceptor를 구현한 클래스야. 요청 → 컨트롤러 사이에 끼어드는 문지기 역할.
3가지 시점에 개입할 수 있어:
| 메서드 | 실행 시점 | 주로 하는 일 |
|---|---|---|
preHandle | 컨트롤러 실행 전 | JWT 토큰 검증 ← 핵심 |
postHandle | 컨트롤러 실행 후 | 응답 가공 |
afterCompletion | 뷰 렌더링 완료 후 | 리소스 정리 |
JWT 인증에서는 preHandle만 주로 사용해. return true면 통과, return false면 요청 차단.
① 회원가입 (/join)
클라이언트 → 서버 수신 → @Configuration에서 "이 URL은 인증 불필요"
→ Interceptor 건너뜀 → Controller → DB 저장 → 200 OK
② 로그인 (/login)
클라이언트 → 서버 수신 → @Configuration에서 "이 URL은 인증 불필요"
→ Interceptor 건너뜀 → Controller → ID/PW 검증
→ 성공하면 JWT 토큰 생성해서 응답에 담아 반환
③ 인증이 필요한 요청 (/api/mypage)
클라이언트 (헤더에 토큰 담아서 전송)
→ Interceptor의 preHandle 실행
→ 헤더에서 토큰 꺼냄
→ 토큰 유효성 검증 (만료? 위변조?)
→ 실패 → 401 응답, 요청 차단 (return false)
→ 성공 → Controller로 통과 (return true)
클라이언트는 요청할 때 HTTP 헤더에 이렇게 담아:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx.yyy
Interceptor의 preHandle에서 이 헤더를 꺼내서 검증하는 거야.
| @Configuration | Interceptor | |
|---|---|---|
| 언제 | 앱 시작 시 1회 | 매 요청마다 |
| 하는 일 | 보안 규칙 설정, 인터셉터 등록 | 실제 토큰 검증 실행 |
| 비유 | 건물 출입 규칙 규정집 | 실제 경비원 |
브라우저(클라이언트)와 서버는 HTTP라는 텍스트 규약으로 통신해. 근데 이게 날것으로 오면 그냥 문자열 덩어리야.
GET /api/mypage HTTP/1.1
Host: localhost:8080
Authorization: Bearer eyJ...
Content-Type: application/json
이 텍스트를 Java 코드에서 쓰려면 파싱해서 객체로 만들어야 해. 그 역할을 Servlet 컨테이너(Tomcat)가 해주는 거야.
HTTP 텍스트 수신
↓
Tomcat이 파싱
↓
HttpServletRequest 객체 생성 ← "요청 정보를 담은 객체"
HttpServletResponse 객체 생성 ← "응답을 작성할 객체"
↓
Spring으로 넘김
즉, 이 두 객체는 Tomcat이 만들어서 Spring에게 전달하는 요청/응답 바구니야.
클라이언트가 보낸 모든 정보가 담겨있어.
| 꺼낼 수 있는 것 | 설명 |
|---|---|
getHeader("Authorization") | 헤더 값 꺼내기 → JWT 꺼낼 때 씀 |
getMethod() | GET, POST, PUT 등 |
getRequestURI() | /api/mypage 같은 경로 |
getParameter("id") | 쿼리 파라미터 |
getInputStream() | 요청 Body (JSON 등) |
getAttribute("userId") | 개발자가 직접 넣은 커스텀 값 |
getSession() | 세션 객체 |
getRemoteAddr() | 클라이언트 IP |
setAttribute / getAttribute 가 특히 중요해.
이게 뭐냐면 — 요청 객체를 레이어 간 데이터 전달 통로로 쓸 수 있어.
Interceptor에서 토큰 검증 후
→ request.setAttribute("userId", 1004) ← 데이터 심어두기
Controller에서
→ request.getAttribute("userId") ← 꺼내서 사용
토큰 검증은 Interceptor에서 하고, 그 결과(userId 등)를 Controller까지 안전하게 전달하는 용도로 씀.
서버가 클라이언트에게 돌려줄 응답을 직접 조립하는 객체야.
| 설정할 수 있는 것 | 설명 |
|---|---|
setStatus(401) | HTTP 상태코드 설정 |
setHeader("key", "value") | 응답 헤더 추가 |
setContentType("application/json") | 응답 타입 |
getWriter().write("...") | 응답 Body 직접 작성 |
Interceptor에서 토큰 검증 실패 시 Controller까지 안 가고 여기서 바로 응답을 써버리는 거야.
preHandle에서 토큰 검증 실패
→ response.setStatus(401)
→ response.getWriter().write("토큰이 유효하지 않습니다")
→ return false ← Controller 실행 안 됨
"이 요청을 처리할 자격이 있는가?" 를 검사하는 단계야.
여기서 하는 일들:
① 인증/인가 검사
토큰이 유효한지, 이 유저가 이 리소스에 접근 권한이 있는지 확인. 실패하면 return false로 즉시 차단.
② 요청 데이터 가공
토큰에서 userId를 꺼내서 request.setAttribute로 심어두는 것처럼, Controller가 쓸 데이터를 미리 준비해줌.
③ 로깅
어떤 유저가 언제 어떤 URL을 요청했는지 기록. 요청 시작 시각을 request.setAttribute("startTime", System.currentTimeMillis())로 저장해두면 나중에 처리 시간 계산 가능.
왜 Controller 안에서 하지 않나?
인증 로직을 모든 Controller에 넣으면 100개 API가 있으면 100곳에 같은 코드가 생겨. 변경사항이 생기면 100곳을 다 고쳐야 해. 공통 관심사는 한 곳에서 처리하는 게 핵심이야.
Controller가 정상 실행된 직후에 실행돼. 응답이 클라이언트에게 나가기 전 마지막으로 개입할 수 있어.
ModelAndView 객체를 파라미터로 받는데, 이건 Controller가 View에 넘길 데이터 덩어리야.
여기서 하는 일들:
① 응답 데이터 공통 가공
모든 API 응답에 공통 필드 (서버 시각, 버전 정보 등) 를 추가할 때.
② 공통 모델 데이터 주입
View를 렌더링하기 전에 모든 페이지에 공통으로 넣어야 할 데이터 (로그인 유저 정보, 메뉴 권한 등) 를 여기서 ModelAndView에 추가.
주의 — REST API (JSON 응답) 에서는 postHandle 쓸 일이 많지 않아. @ResponseBody나 @RestController는 Controller에서 이미 응답을 직렬화해버려서 postHandle에서 ModelAndView가 null로 들어와. 주로 서버사이드 렌더링 (Thymeleaf, JSP) 에서 의미있게 쓰여.
응답이 클라이언트에게 완전히 나간 뒤 실행돼. 예외가 발생해도 무조건 실행되는 게 핵심이야.
여기서 하는 일들:
① 리소스 정리
DB 커넥션, 파일 핸들 등 요청 처리 중 열어둔 자원 반납.
② 처리 시간 측정
preHandle에서 저장해뒀던 startTime을 꺼내서 현재시각 - startTime으로 API 응답 시간 기록.
③ 예외 로깅
파라미터로 Exception ex를 받아. Controller에서 예외가 터지면 여기서 잡아서 로그 남길 수 있어.
요청 수신
↓
preHandle ← 인증, 권한, 시작 로깅
↓ (true 반환 시)
Controller 실행
↓
postHandle ← 공통 데이터 추가, 응답 가공
↓
View 렌더링 / JSON 직렬화
↓
afterCompletion ← 리소스 정리, 시간 측정 (예외 발생해도 실행)
↓
응답 전송
Spring MVC의 핵심은 DispatcherServlet이야. 모든 요청을 한 곳에서 받아서 적절한 곳으로 위임하는 역할이야 (Front Controller 패턴).
문제는 요청을 처리하는 게 Controller 메서드만 있는 게 아니야.
GET /api/mypage → @Controller의 메서드
GET /images/logo.png → 정적 리소스 핸들러
GET /ws/connect → WebSocket 핸들러
이 다양한 처리 주체들을 Spring은 Handler라는 이름으로 추상화해. 즉:
Handler = "이 요청을 실제로 처리할 객체"의 추상적인 표현
우리가 짜는 @Controller 메서드는 Handler의 가장 대표적인 형태일 뿐이야.
DispatcherServlet이 요청을 받으면 가장 먼저 하는 일이 "이 URL을 누가 처리하지?" 를 찾는 거야. 그 역할이 HandlerMapping이야.
GET /api/mypage 요청 수신
↓
HandlerMapping: "이거 UserController.mypage() 메서드가 처리해"
↓
Handler 객체 반환
Handler를 찾았다고 바로 실행하는 게 아니야. Handler 종류마다 실행 방식이 달라. 그래서 Handler를 실제로 실행시키는 어댑터가 필요해.
Handler 결정
↓
HandlerAdapter: "이 Handler는 이렇게 실행하면 돼"
↓
Controller 메서드 실행
preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
세 번째 파라미터 handler가 바로 "이 요청을 처리할 Handler 객체" 야.
이게 왜 필요하냐면:
① 어떤 Controller의 어떤 메서드가 실행될지 알 수 있음
handler를 HandlerMethod로 캐스팅하면 실행될 메서드 정보를 꺼낼 수 있어.
HandlerMethod handlerMethod = (HandlerMethod) handler
handlerMethod.getMethod() → 실행될 메서드
handlerMethod.getBean() → 어떤 Controller인지
handlerMethod.getMethodAnnotations() → 메서드에 붙은 어노테이션 목록
② 어노테이션 기반 접근 제어 가능
예를 들어 @LoginRequired 같은 커스텀 어노테이션을 직접 만들어서, 해당 어노테이션이 붙은 메서드에만 인증 검사를 적용하는 패턴을 쓸 수 있어.
preHandle에서
→ handler에서 메서드 꺼냄
→ @LoginRequired 어노테이션이 붙어있는지 확인
→ 있으면 JWT 검증 실행
→ 없으면 그냥 통과 (공개 API)
이렇게 하면 excludePathPatterns로 URL을 일일이 나열하는 대신 어노테이션으로 선언적으로 인증 여부를 제어할 수 있어.
HTTP 요청 (텍스트)
↓
Tomcat → HttpServletRequest / HttpServletResponse 객체 생성
↓
DispatcherServlet 수신
↓
HandlerMapping → 어떤 Handler(Controller)가 처리할지 결정
↓
Interceptor.preHandle(request, response, handler)
→ request로 헤더/파라미터 읽기
→ handler로 어떤 메서드인지 확인
→ response로 실패 시 즉시 응답 작성
↓ (통과 시)
HandlerAdapter → Controller 메서드 실행
↓
Interceptor.postHandle(request, response, handler, modelAndView)
↓
Interceptor.afterCompletion(request, response, handler, exception)
↓
HTTP 응답 전송
세 개념이 결국 하나의 파이프라인이야. Request/Response는 그 파이프라인을 흐르는 데이터 바구니고, Interceptor는 파이프라인 중간에 끼어드는 검문소, Handler는 파이프라인 끝에서 실제로 처리하는 주체. 이 관계를 이해하면 Spring MVC 동작 원리 전체가 그려져.
웹 서비스에서 "이 사람이 로그인한 사람이 맞나?" 를 확인하는 방법이 필요해.
HTTP는 기본적으로 무상태(Stateless) 프로토콜이거든.
무상태란? 서버가 클라이언트의 이전 요청을 기억하지 못함.
로그인했어도 다음 요청에서 서버는 "이 사람 누구지?" 모름.
그래서 인증을 유지하는 별도 메커니즘이 필요한 거야.
1. 사용자 로그인 요청 (아이디/비번)
↓
2. 서버: 인증 성공 → HttpSession 객체 생성 → JSESSIONID 발급
↓
3. 클라이언트: JSESSIONID를 쿠키에 저장
↓
4. 재요청시: 쿠키에 JSESSIONID 자동 포함
↓
5. 서버: JSESSIONID로 HttpSession 찾아서 → 인증 확인
서버 메모리에 사용자 정보를 담아두는 임시 저장 공간이야.
// 예시: 세션에 사용자 정보 저장
HttpSession session = request.getSession();
session.setAttribute("loginUser", userInfo);
session.setAttribute("role", "ADMIN");
// 나중에 꺼낼 때
UserInfo user = (UserInfo) session.getAttribute("loginUser");
서버 메모리 안에 이런 구조가 생기는 거야:
서버 메모리
┌────────────────────────────────────┐
│ JSESSIONID: abc123 │
│ └─ loginUser: {id: "minji", ...} │
│ └─ role: "ADMIN" │
│ │
│ JSESSIONID: def456 │
│ └─ loginUser: {id: "hong", ...} │
│ └─ role: "USER" │
│ │
│ ... 사용자 수만큼 계속 쌓임 ... │
└────────────────────────────────────┘
사용자가 1만 명이면? → 메모리에 1만 개 올라감 → 부하!
쿠키는 같은 도메인에만 자동으로 전송돼.
shop.mysite.com → api.mysite.com ✅ (서브도메인, 설정에 따라 가능)
mysite.com → othersite.com ❌ (다른 도메인, 불가)
"서버가 아무것도 기억하지 않는다"
REST API는 각 요청이 완전히 독립적이어야 해. 서버가 이전 요청을 저장하지 않음.
그럼 인증 정보는? → 클라이언트(토큰)에 담아서 매 요청마다 들고 와.
세션 방식: 서버가 기억 (HttpSession)
JWT 방식: 클라이언트가 들고 다님 (Token)
1. 사용자 로그인 요청 (아이디/비번)
↓
2. 서버: 인증 성공 → JWT 토큰 생성 → 클라이언트에 발급
↓
3. 클라이언트: 토큰을 localStorage 또는 메모리에 저장
↓
4. 재요청시: HTTP 헤더에 토큰 포함
Authorization: Bearer eyJhbGc...
↓
5. 서버: 토큰 서명 검증 → 인증 확인 (DB/메모리 조회 없음!)
서버엔 저장 안 해. 클라이언트 측에 저장해.
| 저장 위치 | 특징 | 보안 |
|---|---|---|
| localStorage | 브라우저 저장소, 영구적 | XSS 취약점 주의 |
| sessionStorage | 브라우저 탭 닫으면 삭제 | XSS 취약점 주의 |
| HttpOnly 쿠키 | JS 접근 불가 | CSRF 주의, 비교적 안전 |
| 메모리 (변수) | 새로고침하면 사라짐 | 가장 안전 |
실무에서는 HttpOnly 쿠키 또는 메모리 + Refresh Token 조합을 많이 써.
JWT 토큰은 딱 봤을 때 이렇게 생겼어:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJtaW5qaSIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNjE2MjM5MDIyfQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
점(.)으로 3부분으로 나뉘어. 각각 Base64 인코딩된 JSON이야.
{
"alg": "HS256", // 서명 알고리즘 (HMAC SHA-256)
"typ": "JWT" // 토큰 타입
}
{
"sub": "minji", // subject: 토큰 주인 (사용자 ID)
"role": "USER", // 사용자 역할 (커스텀 클레임)
"iat": 1616239022, // issued at: 발급 시각
"exp": 1616242622 // expiration: 만료 시각
}
⚠️ 페이로드는 암호화가 아니야! Base64로 인코딩된 것뿐이라 누구나 디코딩해서 볼 수 있어. 그래서 비밀번호, 개인정보는 절대 담으면 안 돼.
HMACSHA256(
Base64(헤더) + "." + Base64(페이로드),
서버의 비밀키(Secret Key)
)
서버만 알고 있는 비밀키로 서명을 만들어.
누군가 토큰을 조작하면 → 서명이 맞지 않음 → 서버가 탐지해서 거부.
토큰 변조 시나리오:
해커: 페이로드의 role을 "USER" → "ADMIN" 으로 변조
↓
서버: 서명 재검증 → 비밀키가 없으니 서명 불일치
↓
서버: 401 Unauthorized → 거부!
| 세션 | JWT | |
|---|---|---|
| 인증 정보 위치 | 서버 메모리 | 클라이언트 토큰 |
| 상태 | Stateful | Stateless |
| 서버 메모리 | 사용자 수만큼 소모 | 거의 없음 |
| 멀티 서버 | ❌ 어려움 (세션 공유 필요) | ✅ 쉬움 |
| 토큰 강제 만료 | ✅ 세션 삭제로 즉시 | ❌ 만료 시각까지 유효 |
| 주요 사용처 | 전통적 웹 서비스 | REST API, MSA, SSO |
나중에 구현할 때 이런 구조가 돼:
클라이언트 요청
↓
JwtFilter (토큰 추출 및 검증)
↓
SecurityContext에 인증 정보 저장
↓
Controller → Service → DB
// 토큰에서 사용자 정보 추출 예시 (개념)
String token = request.getHeader("Authorization").substring(7); // "Bearer " 제거
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
String userId = claims.getSubject();
String role = claims.get("role", String.class);
정리하면: 세션은 서버가 기억, JWT는 클라이언트가 들고 다님.
REST API에서 JWT를 쓰는 핵심 이유는 Stateless 유지 + 멀티 서버 확장성 때문이야.