14-3. 토큰 기반 인증 (Token-based Authentication)

박은서's avatar
Feb 25, 2026
14-3. 토큰 기반 인증 (Token-based Authentication)

1. 토큰 인증

1️⃣ 토큰 인증이란?

1) 핵심 아이디어

  • 서버가 로그인 성공 시 ‘이 사용자는 인증됨’을 나타내는 토큰 문자열을 발급
  • 이후 클라이언트는 요청마다 토큰을 실어 보내고,
  • 서버는 토큰을 검증해서 사용자 식별/권한 확인을 함

2) 세션 인증 vs 토큰 인증

  • 세션
    • 서버 메모리/DB에 세션 상태를 저장
    • 클라이언트는 세션ID만 들고 다님 (상태ful)
  • 토큰
    • 인증 정보(또는 그 참조)를 토큰으로 들고 다님
    • 서버는 상태를 안 들고도 가능 (상태less 가능)

2️⃣ 토큰 인증의 대표 유형 2가지

1) JWT(JSON Web Token) — 자기 포함(Self-contained)

  • 토큰 안에 클레임(사용자ID, 만료시간, 권한 등)이 들어있음
  • 서버는 서명(Signature) 검증으로 위변조 여부 확인
  • 장단점
    • 장점: 서버가 DB 조회 없이도 검증 가능(확장성)
    • 단점: 토큰이 탈취되면 만료 전까지 위험(폐기/차단 설계가 중요)
  • JWT 구조
    • header.payload.signature
    • payload는 보통 Base64URL 인코딩이라 암호화가 아니라 “보여도 됨”(민감정보 넣으면 안 됨)

2) Opaque Token(랜덤 토큰) — 참조형(Reference)

  • 토큰 자체는 의미 없는 랜덤 문자열
  • 서버/인증서버가 토큰 저장소(DB/Redis)에서 찾아서 유효성 확인(또는 introspection)
  • 장단점
    • 장점: 중앙에서 강제 폐기/회수 쉬움
    • 단점: 매 요청마다 저장소 조회 필요(캐시로 완화)

3️⃣ 동작원리(보안 관점에서 무엇을 검증하나)

서버가 보는 검증 포인트(일반적으로):
  1. 서명/무결성(JWT라면 signature 검증)
  1. 만료(exp)발급자(iss), 대상(aud) 같은 표준 클레임
  1. 권한/스코프(scope, role) 확인 (인가 Authorization 단계)
  1. (선택) 폐기 목록(blacklist), 토큰 버전, 리프레시 토큰 회전 여부

4️⃣ 스프링에서 JWT 토큰 인증 실행흐름

1) 로그인 /auth/login

  1. 클라이언트가 username/password 전송
  1. 서버가 AuthenticationManager로 인증
  1. 성공 시:
      • Access Token(JWT, 짧게) 발급
      • Refresh Token(랜덤, 길게) 발급 + DB/Redis 저장
  1. 클라이언트에 반환(보통 JSON)

2) API 호출

  1. 요청 헤더에 Authorization: Bearer <accessToken>
  1. JWT 인증 필터가 토큰을 파싱/검증
  1. 유효하면 SecurityContextAuthentication 세팅
  1. 컨트롤러/서비스는 @AuthenticationPrincipal 또는 SecurityContextHolder로 사용자 조회

3) Access 만료 → /auth/refresh

  1. Access 만료로 API에서 401
  1. 클라이언트가 refresh token으로 /auth/refresh
  1. 서버가 refresh token을 저장소에서 확인(유효/만료/폐기 여부)
  1. 새 access 발급(권장: refresh rotation도 함께)

5️⃣ 예시 코드

1) JWT 토큰 Provider

package com.example.demo.auth; import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import org.springframework.stereotype.Component; import java.security.Key; import java.time.Instant; import java.util.Date; import java.util.Map; @Component public class JwtTokenProvider { // 실습용. 운영에선 환경변수/Secret Manager + 충분히 긴 키 사용 private final Key key = Keys.hmacShaKeyFor( "dev-secret-dev-secret-dev-secret-dev-secret-1234".getBytes() ); private final String issuer = "my-api"; private final String audience = "my-client"; public String createAccessToken(String userId, String role, long ttlSeconds) { Instant now = Instant.now(); Instant exp = now.plusSeconds(ttlSeconds); return Jwts.builder() .issuer(issuer) .audience().add(audience).and() .subject(userId) // sub .claims(Map.of("role", role)) // 커스텀 클레임 .issuedAt(Date.from(now)) .expiration(Date.from(exp)) .signWith(key) .compact(); } public Jws<Claims> parseAndValidate(String token) { return Jwts.parser() .requireIssuer(issuer) .requireAudience(audience) .verifyWith((javax.crypto.SecretKey) key) .build() .parseSignedClaims(token); } }

2) JWT 인증 필터 (Bearer 토큰 처리)

package com.example.demo.auth; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.List; public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { this.jwtTokenProvider = jwtTokenProvider; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String auth = request.getHeader("Authorization"); if (auth != null && auth.startsWith("Bearer ")) { String token = auth.substring(7); try { Jws<Claims> jws = jwtTokenProvider.parseAndValidate(token); Claims claims = jws.getPayload(); String userId = claims.getSubject(); String role = claims.get("role", String.class); var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role)); var authentication = new UsernamePasswordAuthenticationToken(userId, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { // 토큰이 만료/위조/형식오류 등인 경우: SecurityContext 비우고 진행 SecurityContextHolder.clearContext(); // 보통은 여기서 바로 401로 끊기도 함(정책 선택) } } filterChain.doFilter(request, response); } }

3) Security 설정 (필터 체인 + 접근 규칙)

package com.example.demo.config; import com.example.demo.auth.JwtAuthenticationFilter; import com.example.demo.auth.JwtTokenProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http, JwtTokenProvider jwtTokenProvider) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } }

4) 실습용 사용자(인메모리) 설정

package com.example.demo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.userdetails.*; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class UserConfig { @Bean public UserDetailsService userDetailsService(PasswordEncoder encoder) { var user = User.withUsername("alice") .password(encoder.encode("pass123")) .roles("ADMIN") .build(); return new InMemoryUserDetailsManager(user); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }

5) Refresh Token 저장/검증 (실습용: 메모리 Map)

package com.example.demo.auth; import org.springframework.stereotype.Service; import java.security.SecureRandom; import java.time.Instant; import java.util.Base64; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Service public class RefreshTokenService { private final SecureRandom random = new SecureRandom(); // token -> record private final Map<String, Record> store = new ConcurrentHashMap<>(); public record Record(String userId, Instant expiresAt) {} public String issue(String userId, long ttlSeconds) { byte[] bytes = new byte[32]; random.nextBytes(bytes); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); store.put(token, new Record(userId, Instant.now().plusSeconds(ttlSeconds))); return token; } public Record validate(String token) { Record r = store.get(token); if (r == null) return null; if (r.expiresAt().isBefore(Instant.now())) { store.remove(token); return null; } return r; } // rotation: 기존 토큰 폐기 public void revoke(String token) { store.remove(token); } }

6) AuthController (로그인/리프레시)

package com.example.demo.auth; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.*; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.web.bind.annotation.*; import java.util.Map; @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; private final UserDetailsService userDetailsService; // TTL (실습용) private final long accessTtlSec = 60 * 10; // 10분 private final long refreshTtlSec = 60 * 60 * 24 * 14; // 14일 public record LoginRequest(String username, String password) {} public record RefreshRequest(String refreshToken) {} @PostMapping("/login") public Map<String, String> login(@RequestBody LoginRequest req) { var authToken = new UsernamePasswordAuthenticationToken(req.username(), req.password()); authenticationManager.authenticate(authToken); var userDetails = userDetailsService.loadUserByUsername(req.username()); String userId = userDetails.getUsername(); // 실습용: username을 userId로 사용 String role = userDetails.getAuthorities().stream() .findFirst().map(a -> a.getAuthority().replace("ROLE_", "")) .orElse("USER"); String accessToken = jwtTokenProvider.createAccessToken(userId, role, accessTtlSec); String refreshToken = refreshTokenService.issue(userId, refreshTtlSec); return Map.of( "accessToken", accessToken, "refreshToken", refreshToken ); } @PostMapping("/refresh") public Map<String, String> refresh(@RequestBody RefreshRequest req) { var record = refreshTokenService.validate(req.refreshToken()); if (record == null) { throw new BadCredentialsException("Invalid refresh token"); } // rotation refreshTokenService.revoke(req.refreshToken()); String newRefresh = refreshTokenService.issue(record.userId(), refreshTtlSec); // role은 실습 단순화를 위해 다시 조회(실무: DB/캐시) var userDetails = userDetailsService.loadUserByUsername(record.userId()); String role = userDetails.getAuthorities().stream() .findFirst().map(a -> a.getAuthority().replace("ROLE_", "")) .orElse("USER"); String newAccess = jwtTokenProvider.createAccessToken(record.userId(), role, accessTtlSec); return Map.of( "accessToken", newAccess, "refreshToken", newRefresh ); } }

7) 보호 API 예시 (현재 로그인 사용자 확인)

package com.example.demo.api; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController public class MeController { @GetMapping("/me") public Map<String, Object> me(Authentication authentication) { return Map.of( "userId", authentication.getName(), "authorities", authentication.getAuthorities() ); } }

8) Postman/curl로 테스트 흐름

  1. 로그인
    1. curl -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"alice","password":"pass123"}'
  1. 보호 API 호출
    1. curl http://localhost:8080/me \ -H "Authorization: Bearer <accessToken>"
  1. 만료되면 refresh
    1. curl -X POST http://localhost:8080/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refreshToken":"<refreshToken>"}'
 
Share article