6장 · 보안 & 인증
이 장은 "누가 요청했는가"를 가려내는 부분입니다. 스프링 시큐리티?가 매 요청을 어떻게 검사하는지, 비밀번호는 BCrypt?로 어떻게 안전하게 저장하는지, 그리고 로그인 후 받은 JWT? 토큰으로 매번 본인을 증명하는 흐름을 따라갑니다.
인증 vs 인가, 그리고 STATELESS
보안은 두 단어로 갈립니다. 인증(Authentication)은 "너 누구야?"를 확인하는 일(로그인), 인가(Authorization)는 "그래서 이건 해도 돼?"를 판단하는 일(권한)입니다. 이 6장은 주로 인증을 다루고, 인가(역할 기반 권한)는 7장에서 이어집니다.
전통적인 웹은 로그인하면 서버가 세션을 만들어 "이 사람 로그인했음"을 서버 메모리에 기억합니다. 이 프로젝트는 반대로 세션을 전혀 안 만듭니다(STATELESS). 서버는 아무것도 기억하지 않고, 클라이언트가 매 요청마다 JWT? 토큰을 들고 와서 본인을 증명합니다.
서버가 세션을 기억하지 않으면 서버를 여러 대로 늘려도 "어느 서버가 내 세션을 가졌나" 걱정이 없습니다. 토큰 자체에 사용자 정보가 서명되어 들어 있어, 서버는 서명만 검증하면 됩니다. 대신 "로그아웃 즉시 무효화"가 어렵다는 약점이 있는데, 이 프로젝트는 뒤에서 다룰 DB 리프레시 토큰으로 그 약점을 보완합니다.
SecurityFilterChain — 보안 규칙의 본부
모든 HTTP 요청은 컨트롤러에 닿기 전에 필터?들의 줄을 통과합니다.
그 줄과 규칙을 정의하는 빈?이 SecurityFilterChain입니다(WebSecurityConfig.java).
// config/WebSecurityConfig.java
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 규칙 적용
.csrf(AbstractHttpConfigurer::disable) // CSRF 끔 (토큰 방식이라 불필요)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 안 만듦
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/**", "/api/version").permitAll() // 공개
.requestMatchers("/assets/**").permitAll()
.requestMatchers("/api/**").authenticated() // 그 외 /api/** 는 인증 필요
.anyRequest().permitAll()
)
// JWT 필터를 기본 로그인 필터보다 "앞에" 끼워 넣는다
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.httpBasic(AbstractHttpConfigurer::disable); // 브라우저 기본 인증창 끔
return http.build();
}핵심 결정 네 가지를 표로 정리합니다.
| 설정 | 의미 |
|---|---|
STATELESS | 서버가 세션을 만들지 않음 — 매 요청 토큰으로 판단 |
csrf disable | CSRF 공격은 주로 쿠키·세션 기반. 토큰 방식이라 끔 |
permitAll 경로 | /api/public/**, /api/auth/**, /api/version, /assets/** 은 토큰 없이 통과 |
/api/** = authenticated | 나머지 모든 API는 인증된 사용자만 |
addFilterBefore(...) | 우리가 만든 JwtAuthenticationFilter를 표준 필터보다 먼저 실행 |
/api/auth/**(로그인·리프레시 등)가 permitAll 인 건 당연합니다. 토큰을 받기 전 단계라 토큰을 요구할 수 없으니까요. 닭이 알을 낳으려면 먼저 닭장에 들어가야 하죠.
CORS — 어떤 출처를 믿을지
브라우저는 보안상 다른 출처(도메인·포트가 다른 곳)에서 오는 요청을 기본 차단합니다. 서버가 "이 출처는 허용한다"고 명시해야 통과하는데, 그 화이트리스트가 CORS? 설정입니다.
// config/WebSecurityConfig.java — corsConfigurationSource()
configuration.setAllowedOrigins(List.of(
"http://localhost:5174", // 로컬 개발 프론트
"http://192.168.1.151:7080", // 사내 서버
"http://app.example.com", // 운영 도메인
"https://app.example.com"
// ... 그 외 사내 IP / dev 도메인
));
configuration.setAllowedMethods(
Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));Vite 개발 서버가 /api 요청을 백엔드로 프록시해주기 때문에 브라우저 입장에선 같은 출처로 보입니다. 그래서 로컬 개발에서는 CORS 오류가 잘 안 나고, 화이트리스트는 주로 배포 환경에서 의미가 큽니다.
비밀번호 — BCrypt로 되돌릴 수 없게
비밀번호를 평문으로 DB에 저장하면, DB가 유출되는 순간 끝입니다. 그래서 BCrypt?로
되돌릴 수 없는 해시로 바꿔 저장합니다. 이 프로젝트는 스프링 기본 인코더 대신 PasswordEncoder를 직접 구현했습니다.
// config/SecurityConfig.java
@Bean
public PasswordEncoder passwordEncoder() {
return new PasswordEncoder() {
@Override
public String encode(CharSequence rawPassword) {
// gensalt(10): 매번 다른 salt + 강도 10 으로 해시
return BCrypt.hashpw(rawPassword.toString(), BCrypt.gensalt(10));
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
// 입력값을 같은 방식으로 비교 (평문 비교 아님)
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
};
}checkpw) 다짐육끼리 비교합니다.매번 다른 salt가 섞이므로, 똑같은 비밀번호라도 저장된 값은 사용자마다 달라집니다. "같은 비번이면 같은 해시"라는 약점을 막아줍니다.
AuthenticationManager & UserDetailsService
실제 "아이디·비번이 맞는가" 판정은 AuthenticationManager가 합니다.
이 프로젝트는 DaoAuthenticationProvider에 커스텀 UserDetailsService와 위 인코더를 끼워 만듭니다.
// config/SecurityConfig.java
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService); // 사용자 조회 방법
authProvider.setPasswordEncoder(passwordEncoder()); // 비번 비교 방법
return new ProviderManager(authProvider);
}
// core/service/UserDetailsService.java — DB에서 사용자 한 명 찾기
@Override
public UserDetails loadUserByUsername(String username) {
return userRepo.findByUserid(username)
.orElseThrow(() -> new UsernameNotFoundException(username + " not exist in users"));
}loadUserByUsername이 돌려주는 타입은 UserDetails입니다. 이 프로젝트의 User 엔티티?가 UserDetails를 직접 구현해서, DB의 사용자 행이 그대로 "로그인 사용자" 역할을 겸합니다. 별도 변환 클래스가 없어요.
JwtAuthenticationFilter — 매 요청의 첫 관문
로그인 이후의 모든 요청은 이 필터?를 가장 먼저 통과합니다(@Order(0), OncePerRequestFilter).
헤더의 토큰을 꺼내 검증하고, 통과하면 "이 요청은 이 사용자가 보냈다"는 정보를 SecurityContext에 심어 둡니다.
아래에서 단계별로 따라가 봅시다.
1. 토큰 꺼내기 (resolveToken) — 요청 헤더 Authorization: Bearer <토큰>에서
"Bearer " 뒤의 토큰 문자열만 잘라냅니다. 토큰이 없으면 이 필터는 아무것도 안 하고 다음으로 넘깁니다.
resolveToken
2. 검증 (validateToken) — 서명이 우리 비밀키로 만들어진 게 맞는지, 형식이 올바른지 확인합니다.
서명이 위조됐거나 형식이 깨졌으면 false, 통과면 true. (만료는 특별 취급 — 4단계 참고)
validateToken
3. 사용자 조회 → 인증정보 저장 — 토큰 안의 사용자 이름(subject)으로
loadUserByUsername을 호출해 DB에서 사용자를 가져오고,
UsernamePasswordAuthenticationToken을 만들어 SecurityContextHolder에 저장합니다.
이제 컨트롤러가 @AuthenticationPrincipal로 이 사용자를 꺼내 쓸 수 있어요.
loadUserByUsername
4. 만료된 토큰 (ExpiredJwtException) — 토큰이 만료됐고 경로가 /api/auth/가 아니면,
필터가 직접 401과 {"message":"토큰이 만료되었습니다..."}를 응답하고 체인을 중단합니다.
(로그인·리프레시 경로는 예외라 그냥 통과시킵니다.)
토큰 만료 메시지
return
5. 통과 → 컨트롤러로 — 위 검증을 모두 지나면 filterChain.doFilter()로
요청을 다음 단계(다른 필터 → 컨트롤러)로 넘깁니다. 인증정보가 컨텍스트에 들어 있으니
/api/** 보호 경로도 통과합니다.
// web/filter/JwtAuthenticationFilter.java (발췌)
String token = jwtTokenProvider.resolveToken(request);
String path = request.getRequestURI();
if (token != null) {
try {
if (jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUserNameFromJwtToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
var authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken); // 인증정보 심기
}
} catch (ExpiredJwtException e) {
if (!path.contains("/api/auth/")) { // 만료 + 인증 경로 아님
response.setStatus(HttpStatus.UNAUTHORIZED.value()); // 401
response.getWriter().write(
"{\"message\":\"토큰이 만료되었습니다. 새로운 토큰이 필요합니다.\"}");
return; // 체인 중단
}
}
}
filterChain.doFilter(request, response); // 통과JwtTokenProvider — 토큰을 굽고 검사하기
토큰을 만들고 검증하는 일은 JwtTokenProvider가 맡습니다.
비밀키는 Keys.hmacShaKeyFor(secret)로 만든 HMAC-SHA 키이고, 이 키로 서명·검증합니다.
| 메서드 | 하는 일 |
|---|---|
generateToken(authentication) | 로그인 성공 시 액세스 토큰 발급 (claim type=access, 만료 = jwtExpirationMs) |
generateTokenFromUsername(username) | 리프레시 시 username만으로 새 액세스 토큰 발급 |
validateToken(token) | 서명·형식 검증. 만료는 예외를 다시 던지고(re-throw), 그 외 문제는 false |
resolveToken(request) | Authorization: Bearer 헤더에서 토큰 추출 |
서명 위조·깨진 토큰은 그냥 "인증 안 됨"(false)으로 조용히 넘기면 됩니다. 하지만 만료는 "원래 유효했지만 시간이 지난" 정상 사용자일 가능성이 높아서, 필터가 이를 잡아 "토큰을 갱신하라"는 명확한 401을 줄 수 있도록 예외를 위로 올려보냅니다.
로그인 · 리프레시 · 로그아웃 (end-to-end)
인증 관련 엔드포인트는 모두 AuthEndpoint에 모여 있습니다.
로그인 POST /api/auth/login
// web/endpoint/AuthEndpoint.java (발췌)
Authentication authentication = authenticationManager.authenticate( // 아이디/비번 판정
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
String jwt = jwtTokenProvider.generateToken(authentication); // 액세스 토큰(JWT)
RefreshToken refreshToken = refreshTokenService.createRefreshToken(req.username()); // 리프레시(DB UUID)
authLogService.recordAuthAttempt(...); // 인증 로그 기록
LoginResp resp = new LoginResp(jwt, "Bearer", getCurrentUser());
resp.setRefreshToken(refreshToken.getToken());
return ActionResult.ok(resp);
// 최종 응답 JSON은 ActionResult로 한 번 감싸짐:
// { ok:true, type, message, data: { token, type:"Bearer", user, refreshToken } }갱신 POST /api/auth/refresh
액세스 토큰이 만료되면, 클라이언트는 가지고 있던 리프레시 토큰으로 새 액세스 토큰을 받아옵니다.
// 리프레시 토큰을 DB에서 찾고 → 만료검증 → 새 액세스 토큰 발급
return refreshTokenService.findByToken(requestRefreshToken)
.map(refreshTokenService::verifyExpiration) // 만료면 삭제+예외, 유효면 만료시간 연장
.map(RefreshToken::getUserid)
.map(userid -> {
String accessToken = jwtTokenProvider.generateTokenFromUsername(userid);
return ActionResult.ok(new TokenRefreshResp(accessToken, requestRefreshToken));
})
.orElseThrow(() -> new TokenRefreshException(requestRefreshToken,
"리프레시 토큰이 데이터베이스에 존재하지 않습니다"));로그아웃 POST /api/auth/logout · 현재 사용자 GET /api/auth/current
로그아웃은 DB의 리프레시 토큰을 삭제하고 SecurityContextHolder.clearContext()를 호출합니다.
/api/auth/current는 공개 경로라 토큰이 있으면 실제 사용자를, 없으면 익명 사용자를 돌려줍니다.
핵심 설계 — JWT + DB 하이브리드
액세스 토큰 = 서명된 JWT (stateless) — 서버는 저장하지 않고 서명만 검증.
리프레시 토큰 = DB에 저장된 UUID (stateful) — UUID.randomUUID() 문자열을 테이블에 저장, 만료 7일.
왜 섞었을까요? 순수 JWT는 빠르지만 로그아웃 즉시 무효화가 안 됩니다(서버가 기억하는 게 없으니 만료 전까지 유효). 그래서 재발급의 열쇠인 리프레시 토큰만 DB에 두고, 로그아웃 시 DB에서 삭제해 재발급을 차단합니다. JWT의 속도와 DB의 통제력을 둘 다 취한 절충안입니다.
// core/service/RefreshTokenService.java (발췌)
public RefreshToken createRefreshToken(String userid) {
refreshTokenRepo.deleteByUserid(userid); // 기존 토큰 정리(1인 1토큰)
RefreshToken t = new RefreshToken();
t.setToken(UUID.randomUUID().toString()); // JWT가 아니라 무작위 UUID
t.setUserid(userid);
t.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs)); // 만료 7일
return refreshTokenRepo.save(t);
}
public RefreshToken verifyExpiration(RefreshToken token) {
if (token.getExpiryDate().compareTo(Instant.now()) < 0) {
refreshTokenRepo.delete(token); // 만료면 삭제 후
throw new TokenRefreshException(...); // 예외 → 다시 로그인 필요
}
token.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs)); // 쓸 때마다 연장(sliding)
return refreshTokenRepo.save(token);
}JWT 설정값
# application.properties (발췌) app.jwt.secret=...(HMAC 서명용 비밀키) app.jwt.expirationMs=43200000 # 액세스 토큰 12시간 app.jwt.refreshExpirationMs=604800000 # 리프레시 토큰 7일
이 프로젝트의 액세스 토큰 만료는 12시간입니다. 실무에서 액세스 토큰은 보통 수 분~1시간으로 짧게 두고, 길게 사는 리프레시 토큰으로 자주 재발급받습니다. 액세스 토큰이 길면 그만큼 탈취됐을 때 위험 구간이 길어집니다(서버가 저장 안 해 즉시 무효화도 어려움). 학습용이라 길게 잡았다는 점을 기억하세요.
- "401이 떠요" — JWT 필터(만료/서명/누락)를 먼저 의심. 만료면 리프레시로 재발급, 누락이면 프론트가 헤더를 안 실은 것
- "로그인은 되는데 다른 API가 막혀요" —
/api/**는authenticated(). 토큰을 매 요청에 싣는지 확인 - "비밀번호가 DB에 이상하게 저장돼요" — BCrypt 해시는 원래 매번 다른 문자열. 정상입니다
- "로그아웃해도 토큰이 계속 먹혀요" — 액세스 토큰은 만료 전까지 유효(stateless). 재발급만 DB로 차단됨
- "CORS 오류" — 배포 출처가 화이트리스트에 있는지 확인. 로컬은 Vite 프록시로 회피
정리
- 인증 vs 인가 — 누구인지(이 장) vs 무엇을 할 수 있는지(7장)
- STATELESS — 서버는 세션을 안 만들고, 매 요청 JWT로 본인 증명
- SecurityFilterChain — 공개/인증 경로, CSRF off, JWT 필터 선삽입을 한곳에서 정의
- BCrypt — 비밀번호를 되돌릴 수 없게 해시, salt로 같은 비번도 다르게 저장
- JwtAuthenticationFilter — 추출 → 검증 → 사용자 조회 → 컨텍스트 저장 (만료면 직접 401)
- 하이브리드 — 액세스=JWT(stateless), 리프레시=DB UUID(stateful), 로그아웃 시 DB 삭제로 차단
→ 이 인증 흐름을 프론트(로그인 화면·토큰 저장)까지 이어 단계별로 보기: 연동 2장 · 로그인 · 토큰