백엔드 · 6장 보안 · JWT

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 disableCSRF 공격은 주로 쿠키·세션 기반. 토큰 방식이라 끔
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"));
개발 중엔 보통 CORS를 안 겪는다

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"));
}
User 엔티티가 곧 로그인 사용자

loadUserByUsername이 돌려주는 타입은 UserDetails입니다. 이 프로젝트의 User 엔티티?UserDetails직접 구현해서, DB의 사용자 행이 그대로 "로그인 사용자" 역할을 겸합니다. 별도 변환 클래스가 없어요.

JwtAuthenticationFilter — 매 요청의 첫 관문

로그인 이후의 모든 요청은 이 필터?를 가장 먼저 통과합니다(@Order(0), OncePerRequestFilter). 헤더의 토큰을 꺼내 검증하고, 통과하면 "이 요청은 이 사용자가 보냈다"는 정보를 SecurityContext에 심어 둡니다. 아래에서 단계별로 따라가 봅시다.

1. 토큰 꺼내기 (resolveToken) — 요청 헤더 Authorization: Bearer <토큰>에서 "Bearer " 뒤의 토큰 문자열만 잘라냅니다. 토큰이 없으면 이 필터는 아무것도 안 하고 다음으로 넘깁니다.

헤더에서 토큰 추출
resolveToken
검증
사용자 조회
SecurityContext 저장
컨트롤러

2. 검증 (validateToken) — 서명이 우리 비밀키로 만들어진 게 맞는지, 형식이 올바른지 확인합니다. 서명이 위조됐거나 형식이 깨졌으면 false, 통과면 true. (만료는 특별 취급 — 4단계 참고)

토큰 추출
서명·형식 검증
validateToken
사용자 조회
SecurityContext 저장
컨트롤러

3. 사용자 조회 → 인증정보 저장 — 토큰 안의 사용자 이름(subject)으로 loadUserByUsername을 호출해 DB에서 사용자를 가져오고, UsernamePasswordAuthenticationToken을 만들어 SecurityContextHolder에 저장합니다. 이제 컨트롤러가 @AuthenticationPrincipal로 이 사용자를 꺼내 쓸 수 있어요.

토큰 추출
검증
DB에서 사용자
loadUserByUsername
SecurityContext 저장
컨트롤러

4. 만료된 토큰 (ExpiredJwtException) — 토큰이 만료됐고 경로가 /api/auth/가 아니면, 필터가 직접 401{"message":"토큰이 만료되었습니다..."}를 응답하고 체인을 중단합니다. (로그인·리프레시 경로는 예외라 그냥 통과시킵니다.)

401 응답
토큰 만료 메시지
필터에서 중단
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장 · 로그인 · 토큰