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 인가 필터”
역할:
- 요청 헤더에서
Authorization값 읽기
- 없으면 그냥 통과 (비로그인 상태로 다음 필터/컨트롤러로)
- 있으면
Bearer제거 → 순수 JWT만 남김
JwtUtil.verify(jwt)로 검증 + 유저 정보 복원
UsernamePasswordAuthenticationToken을 만들어서
SecurityContextHolder.getContext().setAuthentication(authentication)로 로그인 상태를 시큐리티 컨텍스트에 세팅
- 다음 필터로 진행
토큰이 유효하면 “스프링이 알아먹는 로그인 객체(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):- username으로 유저 조회 (
UserRepository.findByUsername)
- 없으면
Exception401
- 입력 password와 DB의 해시 password를
BCryptPasswordEncoder.matches()로 비교
- 틀리면
Exception401
- 맞으면
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
- 클라이언트가
/login에{username, password}전송
SecurityConfig에서/login은 permitAll → 필터는 지나가지만 인증 없어도 접근 가능
AuthController.login()실행
AuthService.로그인()실행- username 조회
- bcrypt matches로 비밀번호 검증
- 성공하면
JwtUtil.create(user)로 JWT 생성
- 응답으로 토큰 문자열 내려줌
✅ 결과: 클라이언트는 JWT(accessToken) 을 보관 (보통 로컬스토리지/세션스토리지/앱 저장소 등)
B. 인증 필요한 API 호출 흐름: GET /api/good (예시)
- 클라이언트가 요청 보낼 때 헤더에 포함:
Authorization: Bearer {JWT}
- 요청이 서버에 들어오면 Spring Security Filter Chain이 먼저 실행
- 체인 중
JwtAuthorizationFilter가 실행됨 (addFilterBefore로 등록했으니까)
JwtAuthorizationFilter동작:- Authorization 헤더 확인
- 토큰이 있으면
JwtUtil.verify()로 검증 - 검증 성공 →
Authentication생성 SecurityContextHolder에 인증 정보 세팅
- 그 다음
authorizeHttpRequests규칙 검사: /api/**는 authenticated() 필요- 방금 SecurityContext에 인증이 세팅됐으니 통과
- 컨트롤러까지 도달 → “인증된 사용자”로 로직 수행 가능
✅ 결과:
/api/** 접근 성공C. 토큰 없이 /api/** 호출하면?
- Authorization 헤더 없음
JwtAuthorizationFilter에서jwt == null→ 그냥 체인 통과(인증 세팅 안 함)
- 나중에 권한 검사에서
/api/**는 authenticated() 요구
- 인증이 없으니 401/403 계열로 차단(설정/핸들러에 따라 응답은 달라질 수 있음)
4️⃣ “왜 필터에서 SecurityContextHolder에 넣는가?”
스프링 시큐리티는 최종적으로 SecurityContextHolder에 Authentication이 들어있으면 “로그인 된 요청”으로 생각
즉, JWT 방식에서는 세션 대신 매 요청마다 토큰 검증 → Authentication 만들기 → SecurityContext에 저장 → “로그인 처리”의 핵심 패턴
Share article