14-3. 토큰 기반 인증 (실습)

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

1. 실습

1) config 폴더

① SecurityConfig.java

package com.metacoding.springv2._core.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import com.metacoding.springv2._core.filter.JwtAuthorizationFilter; @Configuration public class SecurityConfig { @Bean public BCryptPasswordEncoder encode() { return new BCryptPasswordEncoder(); } // 시큐리티 필터 등록 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 인증/권한 주소 커스터마이징 http.authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/**").authenticated() .anyRequest().permitAll()); // 폼 로그인 비활성화 ( POST : x-www-form urlencoded : username, password ) http.formLogin(f -> f.disable()); // 베이직 인증 비활성화 (request 할 때마다 username, password를 요구) http.httpBasic(b -> b.disable()); // CSRF 비활성화 http.csrf(c -> c.disable()); // 인증 필터를 변경 (before/after 상관 없음!) http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }

2) filter 폴더

① JwtAuthorizationFilter.java

package com.metacoding.springv2._core.filter; import java.io.IOException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.filter.OncePerRequestFilter; import com.metacoding.springv2._core.util.JwtUtil; import com.metacoding.springv2.user.User; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; // 인가 필터 public class JwtAuthorizationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // localhost:8080/api/good // header -> Authorization : Bearer JWT토큰 String jwt = request.getHeader("Authorization"); if (jwt == null) { filterChain.doFilter(request, response); return; } jwt = jwt.replace("Bearer ", ""); User user = JwtUtil.verify(jwt); Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } }

3) util 폴더

① JwtUtil.java

package com.metacoding.springv2._core.util; import java.util.Date; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.interfaces.DecodedJWT; import com.metacoding.springv2.user.User; public class JwtUtil { public static final String HEADER = "Authorization"; // HTTP 헤더 이름 public static final String TOKEN_PREFIX = "Bearer "; // 토큰 접두사 public static final String SECRET = "메타코딩시크릿키"; // 토큰 서명에 사용될 비밀 키 (강력하게 변경 필요!) public static final Long EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7; // 토큰 유효기간 7일 // JWT 토큰 생성 public static String create(User user) { String accessToken = JWT.create() .withSubject(user.getUsername()) .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) .withClaim("id", user.getId()) .withClaim("roles", user.getRoles()) .sign(Algorithm.HMAC512(SECRET)); return accessToken; } // JWT 토큰 검증 및 디코딩 public static User verify(String jwt) { DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(SECRET)) .build() .verify(jwt); // 토큰 검증 Integer id = decodedJWT.getClaim("id").asInt(); String username = decodedJWT.getSubject(); String roles = decodedJWT.getClaim("roles").asString(); return User.builder().id(id).username(username).roles(roles).build(); } }

4) auth 폴더

① AuthController.java

package com.metacoding.springv2.auth; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import com.metacoding.springv2._core.util.Resp; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @RestController public class AuthController { private final AuthService authService; // 토큰 기반 인증 서버 @PostMapping("/login") public ResponseEntity<?> login(@RequestBody AuthRequest.LoginDTO reqDTO){ String accessToken = authService.로그인(reqDTO); return Resp.ok(accessToken); } @GetMapping("/health") public String healthCheck() { return "health ok"; } }

② AuthRequest.java

package com.metacoding.springv2.auth; import lombok.Data; public class AuthRequest { @Data public static class JoinDTO { private String username; private String password; private String email; } @Data public static class LoginDTO { private String username; private String password; } }

③ AuthService.java

package com.metacoding.springv2.auth; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import com.metacoding.springv2._core.handler.ex.Exception401; import com.metacoding.springv2._core.util.JwtUtil; import com.metacoding.springv2.auth.AuthRequest.LoginDTO; import com.metacoding.springv2.user.User; import com.metacoding.springv2.user.UserRepository; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Service public class AuthService { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; public String 로그인(LoginDTO reqDTO) { // 1. UserRepository에서 username 확인 User findUser = userRepository.findByUsername(reqDTO.getUsername()) .orElseThrow(() -> new Exception401("유저네임을 찾을 수 없어요")); // 2. password를 hash해서 비교검증 boolean isSamePassword = bCryptPasswordEncoder.matches(reqDTO.getPassword(), findUser.getPassword()); if (!isSamePassword) throw new Exception401("비밀번호가 틀렸어요"); return JwtUtil.create(findUser); } }

5) user 폴더

① User.java

package com.metacoding.springv2.user; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collection; import org.hibernate.annotations.CreationTimestamp; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @NoArgsConstructor @Getter @Entity @Table(name = "user_tb") public class User implements UserDetails { @GeneratedValue(strategy = GenerationType.IDENTITY) @Id private Integer id; @Column(unique = true, length = 20, nullable = false) private String username; @Column(length = 60, nullable = false) private String password; @Column(length = 30, nullable = false) private String email; private String roles; // 디폴트값은 USER @CreationTimestamp private Timestamp createdAt; @Builder public User(Integer id, String username, String password, String email, String roles, Timestamp createdAt) { this.id = id; this.username = username; this.password = password; this.email = email; this.roles = roles; this.createdAt = createdAt; } public void update(String email, String password) { this.email = email; this.password = password; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> as = new ArrayList<>(); String[] roleList = roles.split(","); for (String role : roleList) { as.add(() -> role); } return as; } }

② UserRepository.java

package com.metacoding.springv2.user; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; // findById, findAll, save, deleteById, count public interface UserRepository extends JpaRepository<User, Integer> { @Query("select u from User u where u.username = :username") public Optional<User> findByUsername(@Param("username") String username); }

2. 코드 이해하기

1️⃣ 핵심

  • /login에서 토큰을 발급하고
  • */api/**로 들어오는 요청은 JwtAuthorizationFilter가 토큰을 검증해서
  • 검증되면 SecurityContextHolder에 “로그인 상태(Authentication)”를 만들어 넣어주고
  • 그 다음 컨트롤러/서비스가 “인증된 사용자”로 처리하게 만드는 흐름

2️⃣ 각 코드의 역할

1) SecurityConfig

스프링 시큐리티의 전체 규칙(정책) + 필터 체인 구성
  • SessionCreationPolicy.STATELESS
    • 서버가 세션을 저장하지 않음. 매 요청마다 토큰으로만 인증.
  • authorizeHttpRequests
    • /api/**authenticated() → 인증(토큰) 필요
    • 그 외는 permitAll() → 토큰 없어도 접근 가능 (/login, /health 포함)
  • formLogin.disable(), httpBasic.disable()
    • 폼로그인/베이직 인증 같은 “기본 로그인 방식” 끔 (JWT 방식만 쓸거라서)
  • csrf.disable()
    • 주로 세션 기반에서 의미가 큰 CSRF 방어를 끔(REST+JWT 실습에서는 보통 끔)
  • addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
    • 인가(Authorization) 필터를 시큐리티 체인에 끼워 넣음
    • /api/** 요청에서 “인증 정보 만들기”를 이 필터가 담당

2) JwtAuthorizationFilter (OncePerRequestFilter)

매 요청마다 딱 1번 실행되는 “JWT 인가 필터”
역할:
  1. 요청 헤더에서 Authorization 값 읽기
  1. 없으면 그냥 통과 (비로그인 상태로 다음 필터/컨트롤러로)
  1. 있으면 Bearer 제거 → 순수 JWT만 남김
  1. JwtUtil.verify(jwt)로 검증 + 유저 정보 복원
  1. UsernamePasswordAuthenticationToken을 만들어서
  1. SecurityContextHolder.getContext().setAuthentication(authentication)로그인 상태를 시큐리티 컨텍스트에 세팅
  1. 다음 필터로 진행
토큰이 유효하면 “스프링이 알아먹는 로그인 객체(Authentication)”를 만들어서 꽂아주는 필터

3) JwtUtil

JWT 생성/검증 유틸
  • create(User user)
    • 토큰 생성
    • subject: username
    • claim: id, roles
    • expiresAt: 만료시간(7일)
    • sign: HMAC512 + SECRET
  • verify(String jwt)
    • 서명 검증 + 만료 검증(라이브러리가 처리)
    • claim, subject를 꺼내서 User.builder()로 User를 복원해서 리턴
💡 여기서 만든 User는 DB에서 가져온 완전한 User가 아니라, 토큰에 들어있는 정보로 만든 User(= 인증용 정보)

4) AuthController

로그인 API 제공(토큰 발급 엔드포인트)
  • POST /login
    • body로 username/password 받음
    • authService.로그인() 호출
    • 성공 시 accessToken을 응답으로 내려줌
  • GET /health
    • 헬스체크 (permitAll이라 토큰 필요 없음)

5) AuthRequest

요청 DTO 모음
  • LoginDTO: username/password 받는 용도
  • JoinDTO: 회원가입용 (현재 코드에서는 로그인만 쓰고 있음)

6) AuthService

실제 로그인 로직(자격 증명 검증 + 토큰 발급)
로그인(LoginDTO):
  1. username으로 유저 조회 (UserRepository.findByUsername)
  1. 없으면 Exception401
  1. 입력 password와 DB의 해시 password를 BCryptPasswordEncoder.matches()로 비교
  1. 틀리면 Exception401
  1. 맞으면 JwtUtil.create(findUser)로 토큰 생성 후 반환

7) User (Entity + UserDetails)

DB 유저 엔티티이면서, 스프링 시큐리티가 요구하는 “인증 주체(UserDetails)” 역할도 함
  • implements UserDetails
    • 스프링 시큐리티가 “로그인 사용자”로 다룰 수 있는 타입
  • getAuthorities()
    • roles 문자열을 ,로 split 해서 GrantedAuthority 컬렉션으로 변환
    • 예: "USER,ADMIN" → ROLE 목록 생성
이 코드에선 role prefix(ROLE_)를 강제하진 않지만, 실무에서는 "ROLE_USER" 같은 규칙을 맞추는 경우가 많아.

8) UserRepository

DB에서 유저 조회
  • findByUsername 커스텀 JPQL

3️⃣ 동작 순서(시나리오별)

A. 로그인 흐름: POST /login

  1. 클라이언트가 /login{username, password} 전송
  1. SecurityConfig에서 /login은 permitAll → 필터는 지나가지만 인증 없어도 접근 가능
  1. AuthController.login() 실행
  1. AuthService.로그인() 실행
      • username 조회
      • bcrypt matches로 비밀번호 검증
      • 성공하면 JwtUtil.create(user)로 JWT 생성
  1. 응답으로 토큰 문자열 내려줌
✅ 결과: 클라이언트는 JWT(accessToken) 을 보관 (보통 로컬스토리지/세션스토리지/앱 저장소 등)

B. 인증 필요한 API 호출 흐름: GET /api/good (예시)

  1. 클라이언트가 요청 보낼 때 헤더에 포함:
      • Authorization: Bearer {JWT}
  1. 요청이 서버에 들어오면 Spring Security Filter Chain이 먼저 실행
  1. 체인 중 JwtAuthorizationFilter가 실행됨 (addFilterBefore로 등록했으니까)
  1. JwtAuthorizationFilter 동작:
      • Authorization 헤더 확인
      • 토큰이 있으면 JwtUtil.verify()로 검증
      • 검증 성공 → Authentication 생성
      • SecurityContextHolder에 인증 정보 세팅
  1. 그 다음 authorizeHttpRequests 규칙 검사:
      • /api/** 는 authenticated() 필요
      • 방금 SecurityContext에 인증이 세팅됐으니 통과
  1. 컨트롤러까지 도달 → “인증된 사용자”로 로직 수행 가능
✅ 결과: /api/** 접근 성공

C. 토큰 없이 /api/** 호출하면?

  1. Authorization 헤더 없음
  1. JwtAuthorizationFilter에서 jwt == null → 그냥 체인 통과(인증 세팅 안 함)
  1. 나중에 권한 검사에서 /api/**는 authenticated() 요구
  1. 인증이 없으니 401/403 계열로 차단(설정/핸들러에 따라 응답은 달라질 수 있음)

4️⃣ “왜 필터에서 SecurityContextHolder에 넣는가?”

스프링 시큐리티는 최종적으로 SecurityContextHolder에 Authentication이 들어있으면 “로그인 된 요청”으로 생각
즉, JWT 방식에서는 세션 대신 매 요청마다 토큰 검증 → Authentication 만들기 → SecurityContext에 저장 → “로그인 처리”의 핵심 패턴
Share article