14-1. 시큐리티 필터 체인 (실습)

박은서's avatar
Feb 25, 2026
14-1. 시큐리티 필터 체인 (실습)

1. 세팅 코드

1) handler 폴더

① Exception400.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\handler\ex\Exception400.java
package com.metacoding.springv2._core.handler.ex; public class Exception400 extends RuntimeException { public Exception400(String message) { super(message); } }

② Exception401.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\handler\ex\Exception401.java
package com.metacoding.springv2._core.handler.ex; public class Exception401 extends RuntimeException { public Exception401(String message) { super(message); } }

③ Exception403.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\handler\ex\Exception403.java
package com.metacoding.springv2._core.handler.ex; public class Exception403 extends RuntimeException { public Exception403(String message) { super(message); } }

④ Exception404.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\handler\ex\Exception404.java
package com.metacoding.springv2._core.handler.ex; public class Exception404 extends RuntimeException { public Exception404(String message) { super(message); } }

⑤ Exception500.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\handler\ex\Exception500.java
package com.metacoding.springv2._core.handler.ex; public class Exception500 extends RuntimeException { public Exception500(String message) { super(message); } }

⑥ GlobalExceptionHandler.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\handler\GlobalExceptionHandler.java
package com.metacoding.springv2._core.handler; import lombok.extern.slf4j.Slf4j; import org.springframework.http.*; import org.springframework.web.bind.annotation.*; import com.metacoding.springv2._core.handler.ex.*; import com.metacoding.springv2._core.util.Resp; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception400.class) public ResponseEntity<?> exApi400(Exception400 e) { log.warn("[WARN] 사용자 입력 유효성 실패: " + e.getMessage()); return Resp.fail(HttpStatus.BAD_REQUEST, e.getMessage()); } @ExceptionHandler(Exception401.class) public ResponseEntity<?> exApi401(Exception401 e) { log.warn("[WARN] 사용자 인증 실패: " + e.getMessage()); return Resp.fail(HttpStatus.UNAUTHORIZED, e.getMessage()); } @ExceptionHandler(Exception403.class) public ResponseEntity<?> exApi403(Exception403 e) { log.warn("[WARN] 사용자 권한 실패: " + e.getMessage()); return Resp.fail(HttpStatus.FORBIDDEN, e.getMessage()); } @ExceptionHandler(Exception404.class) public ResponseEntity<?> exApi404(Exception404 e) { log.warn("[WARN] 사용자 자원 찾기 실패: " + e.getMessage()); return Resp.fail(HttpStatus.NOT_FOUND, e.getMessage()); } @ExceptionHandler(Exception500.class) public ResponseEntity<?> exApi500(Exception500 e) { log.warn("[ERROR] 예상 가능한 서버 오류: " + e.getMessage()); return Resp.fail(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } // 해당 오류가 발생하면 직접 처리 혹은 Exception500으로 관리하는 것이 좋다. @ExceptionHandler(Exception.class) public ResponseEntity<?> exUnKnown(Exception e) { log.error("[SYSTEM] 예상 불가능한 서버 오류: " + e.getMessage()); e.printStackTrace(); return Resp.fail(HttpStatus.INTERNAL_SERVER_ERROR, "관리자에게 문의하세요"); } }

⑦ GlobalValidationHandler.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\handler\GlobalValidationHandler.java
package com.metacoding.springv2._core.handler; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.*; import org.springframework.stereotype.Component; import org.springframework.validation.*; import com.metacoding.springv2._core.handler.ex.Exception400; import java.util.List; @Aspect // 관점 관리 @Component public class GlobalValidationHandler { @Before("@annotation(org.springframework.web.bind.annotation.PostMapping) || @annotation(org.springframework.web.bind.annotation.PutMapping)") public void badRequestAdvice(JoinPoint jp) { // jp는 실행될 실제 메서드의 모든 것을 투영하고 있다. Object[] args = jp.getArgs(); // 메서드의 매개변수들 for (Object arg : args) { // 매개변수 개수만큼 반복 (어노테이션은 제외) if (arg instanceof Errors) { Errors errors = (Errors) arg; // 에러가 존재한다면!! if (errors.hasErrors()) { List<FieldError> fErrors = errors.getFieldErrors(); for (FieldError fieldError : fErrors) { throw new Exception400(fieldError.getField() + ":" + fieldError.getDefaultMessage()); } } } } } }

2) util 폴더

① JwtProvider.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\util\JwtProvider.java
package com.metacoding.springv2._core.util; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import com.metacoding.springv2.user.User; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @Component public class JwtProvider { // Bearer JWT -> JWT만 추출하기 public String resolveToken(HttpServletRequest request) { return null; } // 토큰을 검증하고 Authentication 반환 public Authentication getAuthentication(String token) { return null; } // 토큰이 유효한지 단순 체크 public boolean validateToken(String token) { return false; } }

② JwtUtil.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\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) { return null; } // JWT 토큰 검증 및 디코딩 public static User verify(String jwt) { return null; } }

③ Resp.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\util\Resp.java
package com.metacoding.springv2._core.util; import lombok.Data; import org.springframework.http.*; @Data public class Resp<T> { private Integer status; private String msg; private T body; public Resp(Integer status, String msg, T body) { this.status = status; this.msg = msg; this.body = body; } public static <B> ResponseEntity<Resp<B>> ok(B body) { Resp<B> resp = new Resp<>(200, "성공", body); return new ResponseEntity<>(resp, HttpStatus.OK); // body, header를 응답할 수 있는 클래스 } public static ResponseEntity<?> fail(HttpStatus status, String msg) { Resp<?> resp = new Resp<>(status.value(), msg, null); return new ResponseEntity<>(resp, status); } }

④ RespFilter.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\util\RespFilter.java
package com.metacoding.springv2._core.util; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import java.io.*; @Slf4j public class RespFilter { private static ObjectMapper om = new ObjectMapper(); public static void fail(HttpServletResponse response, int status, String msg) throws IOException { response.setStatus(status); response.setContentType("application/json;charset=utf-8"); Resp<?> resp = new Resp<>(status, msg, null); String responseBody = null; try { responseBody = om.writeValueAsString(resp); } catch (JsonProcessingException e) { log.error("JSON 변환 실패", e); responseBody = """ {"status":500, "msg":"서버 내부 오류", "body":null} """; ; } PrintWriter out = response.getWriter(); out.println(responseBody); out.flush(); } }

3) auth 폴더

① AuthController.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\auth\AuthController.java
package com.metacoding.springv2.auth; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class AuthController { @GetMapping("/health") public String healthCheck() { return "health ok"; } }

② AuthRequest.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\auth\AuthRequest.java
package com.metacoding.springv2.auth; import com.metacoding.springv2.user.User; import jakarta.validation.constraints.*; public class AuthRequest { public record JoinDTO( @Size(min = 4, max = 20, message = "유저네임은 4자 이상 20자 이하로 입력해주세요") @NotEmpty(message = "유저네임을 입력해주세요") String username, @NotBlank(message = "비밀번호를 입력해주세요") @Size(min = 4, max = 12, message = "비밀번호는 4~12자여야 합니다") String password, @Email(message = "이메일 형식이 올바르지 않습니다") String email) { public User toEntity(String encPassword) { return User.builder() .username(username) .password(encPassword) .email(email) .roles("USER") .build(); } } public record LoginDTO( @NotEmpty(message = "유저네임을 입력해주세요") String username, @NotBlank(message = "비밀번호를 입력해주세요") String password) { } }

③ AuthResponse.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\auth\AuthResponse.java
package com.metacoding.springv2.auth; import com.metacoding.springv2.user.User; public class AuthResponse { public record DTO(Integer id, String username, String email, String roles) { public DTO(User user) { this(user.getId(), user.getUsername(), user.getEmail(), user.getRoles()); } } }

4) board 폴더

① Board.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\board\Board.java
package com.metacoding.springv2.board; import java.sql.Timestamp; import java.util.*; import org.hibernate.annotations.CreationTimestamp; import com.metacoding.springv2.reply.Reply; import com.metacoding.springv2.user.User; import jakarta.persistence.*; import lombok.*; @NoArgsConstructor @Getter @Entity @Table(name = "board_tb") public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(length = 30, nullable = false) private String title; @Column(length = 300, nullable = false) private String content; @ManyToOne(fetch = FetchType.LAZY) private User user; @CreationTimestamp private Timestamp createdAt; @OneToMany(mappedBy = "board", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) private List<Reply> replies = new ArrayList<>(); public void update(String title, String content) { this.title = title; this.content = content; } @Builder public Board(Integer id, String title, String content, User user, Timestamp createdAt) { this.id = id; this.title = title; this.content = content; this.user = user; this.createdAt = createdAt; } }

5) reply 폴더

① Reply.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\reply\Reply.java
package com.metacoding.springv2.reply; import java.sql.Timestamp; import org.hibernate.annotations.CreationTimestamp; import com.metacoding.springv2.board.Board; import com.metacoding.springv2.user.User; import jakarta.persistence.*; import lombok.*; @NoArgsConstructor @Getter @Entity @Table(name = "reply_tb") public class Reply { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(length = 100, nullable = false) private String comment; @ManyToOne(fetch = FetchType.LAZY) private User user; @ManyToOne(fetch = FetchType.LAZY) private Board board; @CreationTimestamp private Timestamp createdAt; @Builder public Reply(Integer id, String comment, User user, Board board, Timestamp createdAt) { this.id = id; this.comment = comment; this.user = user; this.board = board; this.createdAt = createdAt; } }

6) user 폴더

① User.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\user\User.java
package com.metacoding.springv2.user; import java.sql.Timestamp; import org.hibernate.annotations.CreationTimestamp; 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 { @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; } }

7) db 폴더

① data.sql

C:\workspace\spring_lab\spring-rest-start\src\main\resources\db\data.sql
insert into user_tb (username, password, email, roles,created_at) values ('ssar', '$2y$04$0yDwb5VSijD7z8Wj3lFlwu50bcZRkUwqZQWekol9g.h1eCEto02VK', 'ssar@metacoding.com', 'USER',now()); insert into user_tb (username, password, email, roles,created_at) values ('cos', '$2y$04$0yDwb5VSijD7z8Wj3lFlwu50bcZRkUwqZQWekol9g.h1eCEto02VK', 'cos@metacoding.com', 'USER,ADMIN',now()); insert into board_tb (title, content, user_id,created_at) values ('title1', 'content1', 1,now()); insert into board_tb (title, content, user_id,created_at) values ('title2', 'content2', 1,now()); insert into board_tb (title, content, user_id,created_at) values ('title3', 'content3', 1,now()); insert into board_tb (title, content, user_id,created_at) values ('title4', 'content4', 2,now()); insert into board_tb (title, content, user_id,created_at) values ('title5', 'content5', 2,now()); insert into reply_tb (comment, board_id, user_id,created_at) values ('comment1', 4, 2,now()); insert into reply_tb (comment, board_id, user_id,created_at) values ('comment2', 4, 2,now()); insert into reply_tb (comment, board_id, user_id,created_at) values ('comment3', 4, 2,now()); insert into reply_tb (comment, board_id, user_id,created_at) values ('comment4', 5, 1,now()); insert into reply_tb (comment, board_id, user_id,created_at) values ('comment5', 5, 1,now());

8) properties 파일

① application-dev.properties

C:\workspace\spring_lab\spring-rest-start\src\main\resources\application-dev.properties
# ===== Server ===== server.port=8080 spring.servlet.encoding.charset=UTF-8 spring.servlet.encoding.enabled=true spring.servlet.encoding.force=true # ===== H2 Datasource ===== spring.datasource.driver-class-name=org.h2.Driver spring.datasource.url=jdbc:h2:mem:test spring.datasource.username=sa spring.datasource.password= spring.h2.console.enabled=true # ===== JPA / Hibernate ===== spring.jpa.hibernate.ddl-auto=create spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.open-in-view=false # ===== SQL Init (data.sql) ===== spring.sql.init.data-locations=classpath:db/data.sql spring.jpa.defer-datasource-initialization=true # ===== Logging Level ===== logging.level.com.metacoding.springv2=DEBUG # ===== CORS Allow ===== app.cors.allowed-origins=* spring.output.ansi.enabled=always

② application-prod.properties

C:\workspace\spring_lab\spring-rest-start\src\main\resources\application-prod.properties
# ===== Server ===== server.port=8080 spring.servlet.encoding.charset=UTF-8 spring.servlet.encoding.enabled=true spring.servlet.encoding.force=true # ===== MySQL Datasource ===== spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.datasource.url=jdbc:mysql://${RDS_HOSTNAME}:${RDS_PORT}/${RDS_DB_NAME} spring.datasource.username=${RDS_USERNAME} spring.datasource.password=${RDS_PASSWORD} # ===== H2 console (사용 안 함) ===== spring.h2.console.enabled=false # ===== JPA / Hibernate ===== spring.jpa.hibernate.ddl-auto=none spring.jpa.open-in-view=false # ===== Logging Level ===== logging.level.com.metacoding.springv2=INFO # ===== CORS Allow ===== app.cors.allowed-origins=http://${FRONTEND_HOSTNAME}:${FRONTEND_PORT}

③ application.properties

C:\workspace\spring_lab\spring-rest-start\src\main\resources\application.properties
spring.profiles.active=dev

2. 실습 코드

1) config 폴더

① SecurityConfig.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\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.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import com.metacoding.springv2._core.filter.JwtAuthorizationFilter; @Configuration public class SecurityConfig { // 시큐리티 필터 등록 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 인증/권한 주소 커스터마이징 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()); // 인증 필터를 변경 (before/after 상관 없음!) http.addFilterBefore(new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } }

2) filter 폴더

① JwtAuthorizationFilter.java

C:\workspace\spring_lab\spring-rest-start\src\main\java\com\metacoding\springv2\_core\filter\JwtAuthorizationFilter.java
package com.metacoding.springv2._core.filter; import java.io.IOException; import org.springframework.web.filter.OncePerRequestFilter; 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 { System.out.println("~~~~~~~~~~~~~~~~~~JwtAuthorizationFilter~~~~~~~~~~~~~"); // localhost:8080/good?username=ssar&password=1234 String username = request.getParameter("username"); String password = request.getParameter("password"); if (username.equals("ssar") && password.equals("1234")) { filterChain.doFilter(request, response); } else { response.getWriter().println("get out"); } } }

3. 코드 이해하기

1️⃣ SecurityConfig 역할

SecurityConfig는 말 그대로 보안 정책 + 필터 체인 설정 담당

1) 핵심 코드

http.authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/**").authenticated() .anyRequest().permitAll() );
  • 해당 설정의 의미
    • 경로
      의미
      /api/**
      인증된 사용자만 접근 가능
      나머지 요청
      모두 허용
      ⬇️
      /api/users → 인증 필요 /home → 그냥 접근 가능

2) formLogin / httpBasic 비활성화

http.formLogin(f -> f.disable()); http.httpBasic(b -> b.disable());
➡️ 기본 로그인 방식 제거
  • formLogin → username/password 폼 로그인 제거
  • httpBasic → 요청마다 인증창 뜨는 방식 제거
➡️ 우리가 직접 필터로 인증 처리하겠다는 의미

3) 커스텀 필터 등록 (가장 중요)

http.addFilterBefore( new JwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class );
  • 의미
    • Spring Security 기본 인증 필터보다 먼저 JwtAuthorizationFilter 실행
      ⬇️
      요청 → JwtAuthorizationFilter → 기본 시큐리티 필터들

2️⃣ JwtAuthorizationFilter 역할

인가(Authorization) 필터처럼 동작하도록 만든 커스텀 필터
public class JwtAuthorizationFilter extends OncePerRequestFilter

1) OncePerRequestFilter 의미

요청당 딱 한 번만 실행되는 필터 (중복 실행 방지)

2) 실제 수행 로직

String username = request.getParameter("username"); String password = request.getParameter("password");
➡️ 요청 파라미터 추출
localhost:8080/api/test?username=ssar&password=1234

3) 인증 체크

if (username.equals("ssar") && password.equals("1234"))
  • 조건 만족하면
    • filterChain.doFilter(request, response);
      ➡️ 다음 필터로 넘김 (통과)
  • 조건 실패하면
    • response.getWriter().println("get out");
      ➡️ 요청 종료 (체인 중단)

3️⃣ 전체 동작 순서 (가장 중요한 부분)

브라우저 요청 발생
GET /api/test?username=ssar&password=1234

1) 요청 진입

클라이언트 요청 ↓ Spring Security Filter Chain 시작

2) JwtAuthorizationFilter 실행

왜 먼저 실행?
👉 addFilterBefore() 때문
JwtAuthorizationFilter
콘솔:
~~~~~~~~~~~~~~~~~~JwtAuthorizationFilter~~~~~~~~~~~~~

3) username / password 검사

✅ 성공 케이스
username=ssar password=1234
→ doFilter 호출
→ 다음 필터로 이동
❌ 실패 케이스
조건 불만족
→ "get out" 출력
→ 필터 체인 종료
→ 컨트롤러까지 못 감

4) Security 인증 체크

이제 Spring Security 정책 적용:
.requestMatchers("/api/**").authenticated()
문제 발생 가능 포인트 👇

⚠️ 중요한 개념적 문제점 (실습 코드의 핵심 이해 포인트)

1) 상황

  • 현재 필터
    • ✔ 통과 / 차단만 함
      ❌ SecurityContext에 인증 객체 저장 안 함
⬇️
  • Spring Security 입장
    • "이 사용자가 인증된 사용자라는 정보가 없음"
그래서 실제로는 아래와 같은 일 벌어짐

2) 문제

ssar / 1234 입력해도 필터는 통과시키지만 Spring Security는 인증 안 된 사용자로 판단
/api/** 접근 거부 가능

3) 문제가 생기는 이유

Spring Security가 인증 판단하는 기준
SecurityContextHolder.getContext().getAuthentication()
여기에 인증 객체가 있어야 함.
현재 코드에는 이 작업이 없음.

4️⃣ 실습 코드의 진짜 목적

  • 필터 체인 구조 이해
  • 필터 순서 이해
  • doFilter() 역할 이해
  • 체인 중단 메커니즘 이해

5️⃣ 동작 흐름 한 줄 요약

요청 발생 ↓ JwtAuthorizationFilter 실행 ↓ username/password 검사 ↓ 성공 → 다음 필터 실패 → 응답 종료 ↓ Spring Security 정책 검사 ↓ 컨트롤러 진입

✅ 핵심 포인트 정리

1) SecurityConfig

  • 보안 정책 정의
  • 필터 체인 구성
  • 커스텀 필터 위치 지정

2) JwtAuthorizationFilter

  • 요청 가로채기
  • 인증 조건 검사
  • 통과 / 차단 결정

3) filterChain.doFilter()

의미 “내 역할 끝났으니 다음 필터로 넘긴다”
Share article