17주차(분산 시스템) 이후 Claude가 임의로 구성한 학습 경로.
면접 거의 100% 출제 영역인 Spring Security와 인증/인가를 정복한다.
- Filter Chain 메커니즘 (15주차 Filter의 진짜 활용)
- 인증(Authentication) vs 인가(Authorization)
- Session 기반 vs Token 기반 (대규모 시스템의 결정)
- OAuth2와 JWT (현대 표준)
4년차 풀스택 개발자가 가장 자주 마주하지만 "제대로는 모르는" 영역.
1~17주차의 빈 공간:
| 영역 | 주차 | 상태 |
|---|---|---|
| Java/Spring/JPA | 1-12주차 | ✅ |
| DB | 13-14주차 | ✅ |
| Spring MVC | 15주차 | ✅ |
| 분산 시스템 | 16-17주차 | ✅ |
| Spring Security + 인증/인가 | 미등장 | ❌ |
왜 결정적인가:
ILIC 관점:
[Phase 1] 인증/인가의 본질 (Authentication vs Authorization)
↓
[Phase 2] Spring Security 아키텍처와 Filter Chain ◄ 정점 1
↓
[Phase 3] 인증 처리 흐름 (UserDetails, AuthenticationProvider)
↓
[Phase 4] 인가 처리 (URL 패턴, 메서드 보안, AOP)
↓
[Phase 5] Session 기반 vs Token 기반 ◄ 정점 2 (★ 면접 단골)
↓
[Phase 6] JWT 완전 정복 (구조, 검증, 보안 취약점)
↓
[Phase 7] OAuth2와 OpenID Connect
↓
[Phase 8] 보안 취약점과 방어 (CSRF, XSS, CORS)
총 8 Phase × 27 Unit — 면접 단골 정점 2개를 가진 단일 주차.
| 주차 | 주제 | 의미 |
|---|---|---|
| 1~17주차 | Java + Spring + JPA + DB + MVC + 분산 | 기능 구현 |
| 18주차 (지금) | Spring Security + 인증/인가 | 보안 영역 |
핵심 연결:
@PreAuthorize)| Day | Phase | 학습 목표 |
|---|---|---|
| 1일차 | Phase 1 + 2 | 인증/인가 본질 + Filter Chain (★) |
| 2일차 | Phase 3 | 인증 흐름 (UserDetails 등) |
| 3일차 | Phase 4 | 인가 (URL + 메서드) |
| 4일차 | Phase 5 | Session vs Token (★ 면접 단골) |
| 5일차 | Phase 6 | JWT 깊이 |
| 6일차 | Phase 7 | OAuth2/OIDC |
| 7일차 | Phase 8 + 종합 | CSRF/XSS/CORS + 자기 점검 |
여유 일정 (10일): Phase 2, 5에 +1일씩. 직접 디버거로 Filter Chain step-through 권장.
목표: 가장 자주 헷갈리는 두 개념을 정확히 분리한다.
선수 지식: 없음 (가장 기초)
핵심 정의
Authentication (인증) — "누구인가?":
"이 요청을 보낸 사람이 본인이 맞는가" 검증
Authorization (인가) — "무엇을 할 수 있는가?":
"인증된 사용자가 이 자원에 접근 가능한가" 검증
비유 — 회사 출입:
→ 인증 OK여도 인가는 NO일 수 있음
흐름:
[Request]
↓
[Authentication]
- 누구인가? (ID/PW, JWT, 인증서 등)
- 실패 → 401 Unauthorized
↓ (통과)
[Authorization]
- 권한이 있는가?
- 실패 → 403 Forbidden
↓ (통과)
[Business Logic]
HTTP 상태 코드 (15주차 복습) ⭐ :
401과 403의 차이는 면접 단골입니다.
ILIC 시나리오:
Authentication:
Authorization:
자기 점검
선수 지식: Unit 1.1
역사적 진화 ⭐ :
Authorization: Basic dXNlcjpwYXNzd29yZA== (Base64)
문제:
현재:
[Login]
↓ ID/PW 검증
[Server]
↓ Session 생성 + Session ID
[Browser] ← Cookie: SESSIONID=abc123
↓ 이후 요청마다 Cookie 자동 전송
[Server] → Session ID로 사용자 식별
장점: 단순, Spring 기본
단점: 서버 상태 보유 — 분산 환경에서 어려움 (Phase 5에서)
[Login]
↓ ID/PW 검증
[Server]
↓ JWT 생성 (서명)
[Browser] ← JWT 저장
↓ Authorization: Bearer eyJ...
[Server] → JWT 검증 (서명만 확인, 상태 없음)
장점: Stateless, 분산 환경 적합
단점: 토큰 폐기 어려움, 크기 ↑
[Client] → "Google로 로그인"
↓
[Google] (Authorization Server) → 사용자 동의
↓
[Client] ← Access Token + Refresh Token
↓
[Resource Server (API)] ← Access Token으로 인증
장점:
현재 표준:
ILIC 추정:
자기 점검
목표: 면접 단골 — Spring Security가 어떻게 동작하는지 Filter Chain을 통해 이해한다.
선수 지식: 15주차 Phase 5 (Filter)
핵심 그림 ⭐ :
[Client]
↓ HTTP Request
[Servlet Container (Tomcat)]
↓
[Filter Chain] ─────── ★ Spring Security가 여기 ★
├── DelegatingFilterProxy
│ └── FilterChainProxy (Spring Security)
│ ├── SecurityContextPersistenceFilter
│ ├── UsernamePasswordAuthenticationFilter
│ ├── BasicAuthenticationFilter
│ ├── ExceptionTranslationFilter
│ └── FilterSecurityInterceptor (인가)
↓
[DispatcherServlet] (15주차)
↓
[Controller]
핵심 통찰:
"Spring Security는 Servlet Filter 로 동작 → DispatcherServlet 도달 전에 차단/통과 결정"
왜 Filter인가 (15주차 복습):
DelegatingFilterProxy:
FilterChainProxy:
자기 점검
선수 지식: Unit 2.1
주요 Filter 순서 (실제로는 더 많지만 핵심만):
1. SecurityContextPersistenceFilter
↓ SecurityContext를 Session에서 복원
2. UsernamePasswordAuthenticationFilter
↓ /login POST 요청 처리
3. BasicAuthenticationFilter
↓ Basic Auth 헤더 처리
4. RememberMeAuthenticationFilter
↓ Remember-me 쿠키 처리
5. AnonymousAuthenticationFilter
↓ 익명 사용자 처리
6. ExceptionTranslationFilter
↓ 인증/인가 예외를 HTTP 응답으로
7. FilterSecurityInterceptor
↓ 최종 인가 결정
각 Filter의 역할:
/login 요청 가로챔AuthenticationException → 로그인 페이지 (또는 401)AccessDeniedException → 403 페이지hasRole, hasAuthority 등)JWT 사용 시:
UsernamePasswordAuthenticationFilter 대신JwtAuthenticationFilter 추가SessionCreationPolicy.STATELESS)@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
// ...
return http.build();
}
}
자기 점검
선수 지식: Unit 2.2, 4주차 ThreadLocal
핵심 개념
SecurityContext:
Authentication 객체 보유SecurityContextHolder:
// 어디서나 현재 사용자 접근 가능
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
왜 ThreadLocal?:
4주차 ThreadLocal 함정 재등장 ⚠️ :
@Async, CompletableFuture)에서 SecurityContext 손실DelegatingSecurityContextRunnable 등으로 전파 필요Authentication 인터페이스:
public interface Authentication extends Principal {
Collection<? extends GrantedAuthority> getAuthorities(); // 권한 목록
Object getCredentials(); // 비밀번호 (검증 후 보통 null)
Object getDetails(); // 추가 정보 (IP 등)
Object getPrincipal(); // 사용자 본체 (UserDetails)
boolean isAuthenticated();
}
구현체 예:
UsernamePasswordAuthenticationToken (form 로그인)JwtAuthenticationToken (JWT)OAuth2AuthenticationToken (OAuth2)Spring Boot에서 현재 사용자 가져오기 (실용):
방법 1 — SecurityContextHolder 직접:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
방법 2 — @AuthenticationPrincipal (권장) ⭐ :
@GetMapping("/me")
public User getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) {
return userService.findByUsername(userDetails.getUsername());
}
방법 3 — Authentication 직접 주입:
@GetMapping("/me")
public User getCurrentUser(Authentication authentication) {
return userService.findByUsername(authentication.getName());
}
자기 점검
목표: Spring Security가 ID/PW를 어떻게 검증하는지, 흐름을 따라간다.
선수 지식: Phase 2
핵심 인터페이스
UserDetails — 사용자 정보:
public interface UserDetails {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
구현 예시:
@Getter
public class CustomUserDetails implements UserDetails {
private final User user; // JPA 엔티티
@Override
public String getUsername() { return user.getEmail(); }
@Override
public String getPassword() { return user.getPasswordHash(); }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.toList();
}
@Override
public boolean isAccountNonLocked() { return !user.isLocked(); }
// ... 나머지
}
UserDetailsService — 사용자 조회:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
구현 예시:
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return new CustomUserDetails(user);
}
}
→ Spring Security가 자동으로 이 구현체를 사용
왜 인터페이스를 분리했나 (5주차 OCP, DI):
자기 점검
선수 지식: Unit 3.1
핵심 흐름
AuthenticationManager — 인증 책임자:
ProviderManager 구현체 사용AuthenticationProvider — 실제 검증:
전체 인증 흐름 ⭐ :
1. UsernamePasswordAuthenticationFilter
↓ 요청에서 ID/PW 추출
↓ UsernamePasswordAuthenticationToken 생성 (미인증)
2. AuthenticationManager.authenticate(token)
↓ ProviderManager가 Provider 목록에서 선택
3. DaoAuthenticationProvider (대표 Provider)
↓ UserDetailsService.loadUserByUsername() 호출
↓ DB에서 UserDetails 가져옴
↓ PasswordEncoder.matches(rawPassword, hashedPassword) 검증
↓ 성공 → UsernamePasswordAuthenticationToken (인증됨) 반환
4. SecurityContextHolder에 저장
5. 후속 요청에서 자동 인증
PasswordEncoder ⭐ :
대표 구현체:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // strength
}
// 사용
String hash = passwordEncoder.encode("rawPassword");
// → "$2a$12$abc..." 같은 형태
boolean match = passwordEncoder.matches("rawPassword", hash);
BCrypt의 특징:
커스텀 AuthenticationProvider:
@Component
public class CustomAuthProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication auth) {
// 커스텀 검증 로직 (예: 외부 API 호출)
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
ILIC 적용:
자기 점검
선수 지식: Unit 3.2
Form 로그인 (전통 웹):
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/login") // POST 처리 URL
.defaultSuccessUrl("/home")
.failureUrl("/login?error")
);
return http.build();
}
흐름:
1. GET /login → 로그인 페이지 표시
2. POST /login (ID/PW) → UsernamePasswordAuthenticationFilter 가로챔
3. 성공 → /home 리다이렉트 + Session 생성
4. 실패 → /login?error 리다이렉트
API 로그인 (REST + JWT):
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authManager;
private final JwtTokenProvider jwtProvider;
@PostMapping("/api/login")
public LoginResponse login(@RequestBody LoginRequest request) {
// 1. 인증 시도
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(), request.getPassword()
)
);
// 2. JWT 생성
String accessToken = jwtProvider.createAccessToken(auth);
String refreshToken = jwtProvider.createRefreshToken(auth);
return new LoginResponse(accessToken, refreshToken);
}
}
흐름:
1. POST /api/login (JSON ID/PW)
2. AuthenticationManager로 직접 인증
3. JWT 생성 후 응답
4. 클라이언트가 JWT 저장 (LocalStorage / HttpOnly Cookie)
5. 이후 요청에 Authorization: Bearer ...
ILIC 시나리오:
자기 점검
목표: URL 기반과 메서드 기반 인가의 차이와 활용을 마스터한다.
선수 지식: Phase 3
핵심 패턴
Spring Security 6 (현대):
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
return http.build();
}
주요 메서드:
| 메서드 | 의미 |
|---|---|
permitAll() | 누구나 접근 |
denyAll() | 모두 거부 |
authenticated() | 인증된 사용자 |
hasRole("ADMIN") | 특정 역할 |
hasAuthority("READ_PRODUCT") | 특정 권한 |
hasAnyRole("ADMIN", "MANAGER") | 여러 역할 중 하나 |
access("hasRole('ADMIN') and hasIpAddress('192.168.1.0/24')") | SpEL 복잡 표현 |
Role vs Authority ⭐ :
| Role | Authority | |
|---|---|---|
| 의미 | 역할 그룹 | 세부 권한 |
| 예 | ROLE_ADMIN, ROLE_USER | READ_PRODUCT, DELETE_USER |
| 메서드 | hasRole("ADMIN") | hasAuthority("READ_PRODUCT") |
| Prefix | 자동 ROLE_ 추가 | 그대로 |
핵심:
hasRole("ADMIN") ↔ DB에 ROLE_ADMIN 저장hasAuthority("ROLE_ADMIN") ↔ DB에 ROLE_ADMIN 저장 (같은 결과)ILIC 시나리오:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/fares/**").hasAnyRole("USER", "PARTNER", "ADMIN")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
순서가 중요 ⚠️ :
자기 점검
/api/admin/users 요청을 일반 사용자가 했다면 어떤 응답? (힌트: 403)/api/admin/users 요청을 인증 없이 했다면? (힌트: 401)선수 지식: Unit 4.1, 8-9주차 AOP
핵심 개념
메서드 보안:
활성화:
@Configuration
@EnableMethodSecurity
public class SecurityConfig { ... }
@PreAuthorize — 메서드 호출 전 검증:
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long userId) { ... }
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public User getUser(Long userId) { ... }
핵심:
#userId)authentication 변수로 현재 사용자@PostAuthorize — 메서드 호출 후 검증:
@PostAuthorize("returnObject.owner == authentication.principal.username")
public Document getDocument(Long id) { ... }
→ 반환된 객체 검사 후 권한 결정
@PreFilter / @PostFilter — 컬렉션 필터링:
@PostFilter("filterObject.owner == authentication.principal.username")
public List<Document> getMyDocuments() {
return documentRepository.findAll(); // 모두 가져온 후 필터
}
⚠️ 주의: 성능 — DB에서 모두 가져와서 필터링
→ 가능하면 쿼리 단계에서 필터링
메서드 보안의 본질 ⭐ :
@Around advice로 메서드 가로챔ILIC 활용:
@Service
public class FareService {
@PreAuthorize("hasRole('USER')")
public List<Fare> findAllByCurrentUser() { ... }
@PreAuthorize("hasRole('ADMIN')")
public void delete(Long id) { ... }
@PreAuthorize("@fareSecurity.canEdit(#fareId, authentication)")
public Fare update(Long fareId, FareDto dto) { ... }
}
@Component("fareSecurity")
public class FareSecurity {
public boolean canEdit(Long fareId, Authentication auth) {
// 복잡한 권한 로직 (소유자 또는 관리자)
}
}
자기 점검
목표: 면접에서 거의 100% 출제되는 비교를 명확히 잡는다.
선수 지식: Phase 2
핵심 흐름
1. [Login Request] POST /login (id, pw)
↓
2. [Server] 검증 → Session 생성
- Session ID (랜덤 문자열) 생성
- Server 메모리 또는 Redis에 Session 저장
- Session 데이터: { user_id: 42, role: "USER" }
↓
3. [Response] Set-Cookie: SESSIONID=abc123; HttpOnly; Secure
↓
4. [Browser] Cookie 자동 저장
↓
5. [Subsequent Request] Cookie: SESSIONID=abc123
↓
6. [Server] Session ID로 Session 조회 → 사용자 식별
Session 저장소:
1. 서버 메모리:
2. Redis (분산) ⭐ :
spring:
session:
store-type: redis
redis:
host: localhost
port: 6379
@EnableRedisHttpSession
@Configuration
public class SessionConfig { }
3. JDBC:
Cookie 보안 속성 ⭐ :
| 속성 | 의미 |
|---|---|
| HttpOnly | JavaScript에서 접근 X (XSS 방어) |
| Secure | HTTPS에서만 전송 |
| SameSite | 다른 도메인 요청 시 차단 (CSRF 방어) |
| Path | 적용 경로 |
| Max-Age | 만료 시간 |
모범 사례:
Set-Cookie: SESSIONID=abc; HttpOnly; Secure; SameSite=Lax; Max-Age=3600
Session의 장점 ⭐ :
1. 즉시 무효화 가능 — 서버에서 삭제하면 끝
2. 민감 정보 서버 보관 — Cookie에는 ID만
3. 단순 — Spring 기본
Session의 단점 ⚠️ :
1. 서버 상태 보유 (Stateful) — 분산 환경 부담
2. 수평 확장 어려움 — Sticky Session 또는 공유 저장소 필요
3. CSRF 공격 위험 (자동 Cookie 전송)
4. 모바일 앱과 부자연스러움
자기 점검
선수 지식: Unit 5.1
핵심 흐름
1. [Login Request] POST /api/login (email, pw)
↓
2. [Server] 검증 → JWT 생성
- Header + Payload + Signature
- Payload: { sub: 42, role: "USER", exp: 1700000000 }
- Signature: 서버 비밀키로 서명
↓
3. [Response] { "accessToken": "eyJhbGc..." }
↓
4. [Browser/App] 토큰 저장
- LocalStorage / SessionStorage / HttpOnly Cookie
↓
5. [Subsequent Request] Authorization: Bearer eyJhbGc...
↓
6. [Server] JWT 검증 (서명만 확인)
- 서버 메모리 조회 X (Stateless!)
- Payload에서 사용자 ID 추출
핵심 차이 — 서버 상태:
Session:
{ sessionId123: userInfo } 보관JWT:
JWT의 장점 ⭐ :
1. Stateless — 서버 확장 자유
2. MSA 친화 — 서비스 간 토큰 전달 쉬움
3. 다양한 클라이언트 — 모바일, IoT 등
4. SSO 가능 — 토큰을 여러 서비스에서 인정
JWT의 단점 ⚠️ :
1. 즉시 폐기 어려움 — 만료 전까지 유효
2. 토큰 크기 ↑ (Header에 매번 전송)
3. 보안 설정 어려움 — 저장 위치, 만료 등
4. 민감 정보 노출 — Payload는 Base64 (암호화 X)
즉시 폐기 문제 해결:
1. Short-lived Access Token + Refresh Token:
2. Token Blacklist:
3. 짧은 만료 시간 (15분 정도) + 비밀번호 변경 시 모든 토큰 무효:
자기 점검
선수 지식: Unit 5.1, 5.2
완전 비교 ⭐ :
| 측면 | Session | JWT |
|---|---|---|
| 상태 | Stateful (서버 보관) | Stateless |
| 저장 위치 | 서버 메모리/Redis | 클라이언트 |
| 확장성 | Sticky 또는 공유 저장소 | 자유 |
| 즉시 폐기 | ✅ 가능 | ❌ 어려움 |
| 공격 방어 | CSRF 위험 | XSS 위험 (저장 위치 따라) |
| 모바일 | 부자연스러움 | 자연스러움 |
| MSA | 부적합 | 적합 |
| 트래픽 | DB 조회 ↑ | 검증만 |
| 크기 | Cookie ID만 | 매 요청 토큰 전송 |
선택 가이드 ⭐ :
| 시나리오 | 추천 |
|---|---|
| 전통 웹 모놀리식, B2C | Session (단순) |
| SPA (React/Vue) + REST API | JWT |
| 모바일 앱 | JWT |
| MSA / 분산 시스템 | JWT |
| 금융 / 즉시 차단 필수 | Session 또는 짧은 JWT |
| SSO 필요 | JWT (또는 OAuth2) |
ILIC 시나리오 분석:
면접 모의 답변 (3분 답변 준비) ⭐ :
"Session과 JWT의 가장 큰 차이는 상태 보유 여부입니다.
Session은 서버에 인증 정보를 보관해서 즉시 폐기가 가능하지만, 분산 환경에서는 서버 간 공유를 위해 Redis 같은 외부 저장소가 필요합니다.
JWT는 토큰 자체에 사용자 정보가 담겨 서버는 서명만 검증하면 되는 Stateless 방식입니다. 확장성이 좋고 마이크로서비스에 적합하지만, 즉시 폐기가 어려워서 짧은 만료 시간 + Refresh Token 패턴을 보통 사용합니다.
저는 Vue SPA 환경에서는 JWT를, 전통 웹에서는 Session을 권장하지만, 즉시 차단이 중요한 결제 같은 영역은 JWT여도 만료를 매우 짧게 하거나 Token Blacklist를 두는 등 추가 설계가 필요 하다고 생각합니다."
자기 점검
목표: JWT의 구조부터 보안 취약점까지 깊이 있게 마스터한다.
선수 지식: Phase 5
핵심 구조:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiJ9.signature
└── Header ──┘.└── Payload ──┘.└── Signature ──┘
세 부분이 마침표(.) 로 구분.
{
"alg": "HS256",
"typ": "JWT"
}
알고리즘 종류:
대칭 vs 비대칭:
| 대칭 (HS256) | 비대칭 (RS256) | |
|---|---|---|
| 키 | 단일 비밀키 | 공개키 + 비밀키 |
| 발급/검증 | 같은 키 | 비밀키 발급, 공개키 검증 |
| 분산 환경 | 키 공유 어려움 | 검증자가 공개키만 |
| 적합 | 단일 서버 | MSA, OAuth2 |
→ MSA에서는 RS256 권장 ⭐
{
"sub": "42", // Subject — 사용자 ID
"name": "Alice",
"role": "USER",
"iat": 1700000000, // Issued At
"exp": 1700003600 // Expiration (만료)
}
표준 Claim ⭐ :
| Claim | 의미 |
|---|---|
| iss (Issuer) | 발급자 |
| sub (Subject) | 주체 (보통 사용자 ID) |
| aud (Audience) | 대상 |
| exp (Expiration) | 만료 시간 ⭐ |
| iat (Issued At) | 발급 시간 |
| nbf (Not Before) | 유효 시작 시간 |
| jti (JWT ID) | 고유 식별자 |
Custom Claim:
role, email 등 자유롭게 추가 가능⚠️ 주의 ⭐ :
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
역할 ⭐ :
핵심 통찰:
"JWT는 암호화 가 아닌 서명 이다. 누구나 내용을 볼 수 있지만, 변조하면 들킨다."
디코딩 도구:
자기 점검
선수 지식: Unit 6.1
라이브러리 — JJWT (가장 인기):
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
JwtTokenProvider 구현:
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long accessTokenValidity = 1000 * 60 * 60; // 1시간
private final long refreshTokenValidity = 1000 * 60 * 60 * 24 * 7; // 7일
public JwtTokenProvider(@Value("${jwt.secret}") String secret) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
// 토큰 생성
public String createAccessToken(Authentication auth) {
UserDetails userDetails = (UserDetails) auth.getPrincipal();
String authorities = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("authorities", authorities)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessTokenValidity))
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
}
// 토큰 검증 + Authentication 반환
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("authorities").toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
// 토큰 유효성 검사
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return true;
} catch (ExpiredJwtException e) {
log.info("Expired JWT");
} catch (JwtException | IllegalArgumentException e) {
log.error("Invalid JWT");
}
return false;
}
}
JwtAuthenticationFilter (커스텀 Filter):
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtProvider.validateToken(token)) {
Authentication auth = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
SecurityConfig 통합:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
핵심:
STATELESS — 세션 생성 Xcsrf().disable() — JWT는 CSRF 위험 낮음UsernamePasswordAuthenticationFilter 앞에자기 점검
OncePerRequestFilter를 상속? (힌트: 한 요청당 한 번만 — Filter 중복 호출 방지)선수 지식: Unit 6.2
문제:
해결 — Access + Refresh 패턴 ⭐ :
[Login]
↓
Access Token (15분) + Refresh Token (7일)
↓
Access 만료 시:
↓
Refresh Token으로 새 Access Token 발급
↓
Refresh 만료 시: 재로그인
구현:
1. 로그인 시 둘 다 발급:
@PostMapping("/api/auth/login")
public LoginResponse login(@RequestBody LoginRequest request) {
Authentication auth = authManager.authenticate(...);
String accessToken = jwtProvider.createAccessToken(auth);
String refreshToken = jwtProvider.createRefreshToken(auth);
// Refresh Token을 Redis에 저장 (즉시 폐기 가능)
refreshTokenRepository.save(
new RefreshToken(auth.getName(), refreshToken, Duration.ofDays(7))
);
return new LoginResponse(accessToken, refreshToken);
}
2. Access 만료 시 갱신:
@PostMapping("/api/auth/refresh")
public AccessTokenResponse refresh(@RequestBody RefreshRequest request) {
String refreshToken = request.getRefreshToken();
if (!jwtProvider.validateToken(refreshToken)) {
throw new InvalidTokenException();
}
String username = jwtProvider.getUsername(refreshToken);
// Redis 검증 (탈취된 토큰 방어)
if (!refreshTokenRepository.existsByUsernameAndToken(username, refreshToken)) {
throw new InvalidTokenException();
}
// 새 Access Token 발급
Authentication auth = jwtProvider.getAuthentication(refreshToken);
return new AccessTokenResponse(jwtProvider.createAccessToken(auth));
}
Refresh Token Rotation (보안 강화) ⭐ :
// refresh 시
String newRefresh = jwtProvider.createRefreshToken(auth);
refreshTokenRepository.delete(oldRefresh);
refreshTokenRepository.save(newRefresh);
저장 위치 결정 ⚠️ :
LocalStorage:
HttpOnly Cookie:
Memory (변수):
실무 패턴:
ILIC 권장:
자기 점검
선수 지식: Unit 6.1~6.3
주요 취약점 ⭐ :
"alg": "none" 로 변조방어:
// JJWT는 기본적으로 none 차단 (자동)
// 그러나 명시 권장
.parser()
.verifyWith(secretKey)
.requireIssuer("ilic")
.build()
방어:
"mysecret" 같은 짧은 키방어:
// 안전한 키 생성
SecretKey key = Jwts.SIG.HS256.key().build();
String secretString = Encoders.BASE64.encode(key.getEncoded());
방어:
방어:
.parser()
.clockSkewSeconds(60) // 60초 허용
.build()
방어:
보안 체크리스트 ⭐ :
exp) 항상 포함자기 점검
목표: 현대 인증의 표준인 OAuth2의 흐름을 이해한다.
선수 지식: Phase 6
문제:
"사용자가 매번 ID/PW 입력하지 않고, 다른 서비스의 인증을 활용 하고 싶다"
예: "Google로 로그인", "GitHub으로 로그인"
전통 방식의 위험:
OAuth2의 해결:
"비밀번호를 공유하지 않고 권한만 위임"
비유:
호텔 발렛 키 — 시동만 걸 수 있고 트렁크는 못 엶.
4가지 역할 ⭐ :
| 역할 | 의미 |
|---|---|
| Resource Owner | 사용자 (본인) |
| Client | 사용하려는 앱 (예: Spotify) |
| Authorization Server | 권한 발급 (예: Google) |
| Resource Server | 보호된 자원 (예: Google Drive API) |
시나리오 — Spotify에서 Google 로그인:
자기 점검
선수 지식: Unit 7.1
가장 흔한 OAuth2 흐름 (서버 앱):
1. [User] Spotify에서 "Google로 로그인" 클릭
↓
2. [Spotify] 사용자를 Google 로그인 페이지로 리다이렉트
GET https://accounts.google.com/o/oauth2/auth?
client_id=spotify_client_id&
redirect_uri=https://spotify.com/callback&
response_type=code&
scope=email profile
↓
3. [User] Google에 로그인 + 동의 화면
↓
4. [Google] Spotify로 리다이렉트 + Authorization Code
GET https://spotify.com/callback?code=AUTH_CODE
↓
5. [Spotify Server] Code로 Access Token 요청
POST https://oauth2.googleapis.com/token
{ code, client_id, client_secret, redirect_uri, grant_type }
↓
6. [Google] Access Token + (Refresh Token) 응답
↓
7. [Spotify] Access Token으로 Google API 호출
GET https://googleapis.com/userinfo
Authorization: Bearer ACCESS_TOKEN
↓
8. [Google] 사용자 정보 반환
↓
9. [Spotify] 사용자 등록/로그인 완료
핵심:
다른 Grant Type:
| Grant | 용도 |
|---|---|
| Authorization Code ⭐ | 일반 웹 앱 |
| Authorization Code + PKCE | SPA, 모바일 |
| Client Credentials | 서버 간 (사용자 X) |
| Resource Owner Password | 레거시 (권장 X) |
| Implicit | 옛날 방식 (권장 X) |
PKCE (Proof Key for Code Exchange):
자기 점검
선수 지식: Unit 7.2
문제:
OIDC (OpenID Connect):
"OAuth2 위에 인증 레이어를 표준화"
핵심 추가:
/userinfo)ID Token vs Access Token:
| ID Token | Access Token | |
|---|---|---|
| 용도 | 사용자 정보 | API 호출 권한 |
| 형식 | 항상 JWT | JWT 또는 불투명 문자열 |
| 검증 | Client가 검증 | Resource Server가 검증 |
| 내용 | 사용자 식별 | 권한 |
// ID Token Payload 예
{
"iss": "https://accounts.google.com",
"sub": "10769150350006150715113082367",
"email": "alice@example.com",
"name": "Alice",
"picture": "https://...",
"iat": 1700000000,
"exp": 1700003600
}
Spring Security OAuth2 Client:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: email,profile
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth -> oauth
.defaultSuccessUrl("/home")
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService) // 사용자 처리 커스텀
)
);
return http.build();
}
→ 자동으로 OAuth2/OIDC 흐름 처리
ILIC 시나리오:
자기 점검
목표: 웹 보안의 3대 위협 — CSRF, XSS, CORS — 를 깊이 이해한다.
선수 지식: Phase 5
핵심 개념
CSRF:
"다른 사이트에서 사용자의 인증 정보를 활용해 원치 않는 요청을 보내게 함"
시나리오:
1. 사용자가 Bank.com에 로그인 → Cookie 저장
2. 사용자가 Evil.com 방문
3. Evil.com에 숨겨진 폼:
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="hacker">
<input name="amount" value="1000000">
</form>
4. 자동 제출 → Bank.com에 요청
5. Bank.com: "사용자가 로그인되어 있네" → 송금 처리!
핵심:
방어 방법 ⭐ :
<form>
<input type="hidden" name="_csrf" value="abc123">
<!-- ... -->
</form>
Spring Security 기본 활성화:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
Set-Cookie: SESSIONID=abc; SameSite=Lax
| 값 | 의미 |
|---|---|
| Strict | 같은 사이트만 전송 (가장 안전) |
| Lax | GET 등 안전 메서드만 cross-site (기본) |
| None | 모든 cross-site (Secure 필수) |
JWT 사용 시:
http.csrf(AbstractHttpConfigurer::disable);
// JWT는 CSRF 위험 낮음
언제 CSRF 활성화/비활성화?:
자기 점검
선수 지식: Unit 8.1
핵심 개념
XSS:
"악의적 JavaScript 를 다른 사용자의 브라우저에서 실행"
3가지 유형:
1. 공격자: 게시글에 <script>fetch('/api/cookies?c=' + document.cookie)</script>
2. DB 저장
3. 다른 사용자가 게시글 조회 → 스크립트 실행
4. 사용자의 Cookie를 공격자 서버로 전송
URL: /search?q=<script>...</script>
서버가 검색어를 그대로 페이지에 노출 → 실행
방어 방법 ⭐ :
Thymeleaf (자동 이스케이프):
<p th:text="${userInput}"> <!-- 자동 이스케이프 ✅ -->
<p th:utext="${userInput}"> <!-- 이스케이프 X ⚠️ -->
JSP:
<c:out value="${userInput}"/> <!-- 자동 이스케이프 -->
React/Vue: 기본 이스케이프 적용 ({userInput} vs dangerouslySetInnerHTML)
@PostMapping("/comment")
public Comment create(@Valid @RequestBody CommentRequest request) {
// <, >, " 등 차단 또는 인코딩
}
Content-Security-Policy: default-src 'self'; script-src 'self'
→ 인라인 스크립트 차단
Set-Cookie: SESSIONID=abc; HttpOnly
JWT 저장과 XSS ⚠️ :
자기 점검
선수 지식: Phase 1
핵심 개념
Same-Origin Policy (브라우저 보안 기본):
"JS는 같은 origin(scheme + host + port) 의 리소스만 접근 가능"
예:
https://ilic.com:443 의 JS → https://ilic.com:443/api ✅https://ilic.com:443 의 JS → https://api.ilic.com:443 ❌ (다른 host)https://ilic.com:443 의 JS → http://ilic.com:443 ❌ (다른 scheme)왜 이 정책?:
문제 — 현대 웹의 현실:
localhost:3000) ↔ API (localhost:8080) → 다른 originCORS — Same-Origin 예외 허용:
"서버가 특정 다른 origin의 요청을 명시적으로 허용"
CORS 흐름 ⭐ :
Preflight 흐름:
1. [Browser] OPTIONS /api/users
Origin: https://ilic.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization
↓
2. [Server] 200 OK
Access-Control-Allow-Origin: https://ilic.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Credentials: true
↓
3. [Browser] PUT /api/users (실제 요청)
Spring Security CORS 설정:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// ...
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://ilic.com", "https://admin.ilic.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); // Cookie 전송 허용
config.setMaxAge(3600L); // Preflight 캐싱
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
* 와이드카드 vs 명시적 origin ⚠️ :
config.setAllowedOrigins(List.of("*")); // ❌ 위험
config.setAllowedOrigins(List.of("https://ilic.com")); // ✅ 안전
allowCredentials=true 일 때 * 사용 불가 (브라우저가 차단).
ILIC 시나리오:
localhost:3000 (Vue) ↔ localhost:8080 (Spring) → CORS 필요ilic.com ↔ api.ilic.com → CORS 필요자기 점검
/api)선수 지식: Unit 8.1~8.3
ILIC 보안 점검 리스트 ⭐ :
자기 점검
★★★ 면접 단골 (반드시):
★★ 매우 권장:
Phase 2 (Filter Chain):
Phase 5 (Session vs Token):
이번 주차는 반드시 작은 프로젝트를 직접 만들어보세요:
Spring Boot + JWT 인증 미니 프로젝트:
OAuth2 통합:
보안 취약점 실습:
이 3가지를 거치면 면접 답변이 자연스러워집니다.
이제 마무리 단계로 가고 있습니다:
| 영역 | 주차 | 깊이 |
|---|---|---|
| Java/Spring/JPA | 1-12 | ★★★ |
| DB | 13-14 | ★★★ |
| Spring MVC | 15 | ★★★ |
| 분산 시스템 | 16-17 | ★★★ |
| Spring Security | 18 | ★★★ |