diff --git a/README.md b/README.md
index 325110b0..9835cf70 100644
--- a/README.md
+++ b/README.md
@@ -5,26 +5,19 @@ Flyway는 소비자에게 합리적인 항공권 가격을 제공하기 위해
투명한 동적 가격 모델과 가격 변동 이력 기능을 구현한
실시간 항공권 예매 시스템입니다.
-또한 실제 서비스 수준에서 요구되는 핵심 문제인 **좌석 점유 동시성 제어**, **예약 상태 관리**, **결제 안정성 확보**를 중심으로 설계 및 구현했습니다.
+또한 실제 서비스 수준에서 요구되는 핵심 문제인 좌석 점유 동시성 제어, 예약 상태 관리, 결제 안정성 확보를 중심으로 설계 및 구현했습니다.
약 20만 건 규모의 대량 데이터를 직접 생성하여
운영 환경을 가정한 기능 검증과 시스템 테스트까지 수행했습니다.
-### 🚀 배포
-🔗 [https://flyway.kr](https://flyway.kr)
+**배포**: [https://flyway.kr](https://flyway.kr)
-
-
-### 🗓️ 개발 기간
-
-📌 **2026.01.07 ~ 진행 중**
-
-
+**개발 기간**: 2026.01.07 ~ 2026.02.13
-### 👥 팀원 소개
+### 팀원
@@ -33,9 +26,9 @@ Flyway는 소비자에게 합리적인 항공권 가격을 제공하기 위해
- 👩🏻💻 강희민 (@kkhhmm3103) |
- 👩🏻💻 김민서 (@minseokim0113) |
- 👩🏻💻 박수진 (@cl-o-lc) |
+ 강희민 (@kkhhmm3103) |
+ 김민서 (@minseokim0113) |
+ 박수진 (@cl-o-lc) |
@@ -69,9 +62,9 @@ Flyway는 소비자에게 합리적인 항공권 가격을 제공하기 위해
- 🧑🏻💻 오찬혁 (@ochanhyeok) |
- 👩🏻💻 [팀장] 이가은 (@gaeunnlee) |
- 🧑🏻💻 한재훈 (@hjh79gw) |
+ 오찬혁 (@ochanhyeok) |
+ [팀장] 이가은 (@gaeunnlee) |
+ 한재훈 (@hjh79gw) |
@@ -120,24 +113,19 @@ Flyway는 소비자에게 합리적인 항공권 가격을 제공하기 위해
2. 서비스 기획 배경
-### ✅ 문제 정의
-
-기존 항공권 예매 시장은 가격 산정 기준이 공개되지 않아
-사용자가 **가격 변동 이유와 현재 가격의 적정성**을 판단하기 어렵습니다.
-또한 좌석 점유율과 인기 항공편 정보가 명확한 근거 없이 제공되어
-사용자 의사결정 과정에서 **정보 비대칭 문제**가 발생합니다.
+### 문제 정의
-
+기존 항공권 예매 시장은 가격 산정 기준이 공개되지 않아 사용자가 **가격 변동 이유와 현재 가격의 적정성**을 판단하기 어렵습니다.
+또한 좌석 점유율과 인기 항공편 정보가 명확한 근거 없이 제공되어 사용자 의사결정 과정에서 **정보 비대칭 문제**가 발생합니다.
-### ✨ Flyway 차별점
+### Flyway 차별점
-Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하기 위해
-**가격 산정 기준을 공개하는 투명한 예매 서비스**를 제공합니다.
+Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하기 위해 **가격 산정 기준을 공개하는 투명한 예매 서비스**를 제공합니다.
가격은 다음 요소를 기반으로 산정됩니다.
-- **M_time** : 출발일까지 남은 시간
-- **M_load** : 좌석 점유율(수요)
-- **alpha** : 가격 변동 완화 계수
+- **M_time** : 출발일까지 남은 시간
+- **M_load** : 좌석 점유율(수요)
+- **alpha** : 가격 변동 완화 계수
또한 사용자가 합리적으로 예매할 수 있도록 다음 정보를 제공합니다.
@@ -150,8 +138,121 @@ Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하
+## 3. 주요 기능
+
+
+ | 회원 |
+ 관리자 |
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+## 4. 기술 스택 (Tech Stack)
+
+| 구분 | 기술 | 구분 | 기술 |
+| -------- | ----------------------------------------- | -------- | ----------------------------------------------------- |
+| Backend | Spring Framework 5.3.31
MyBatis 3.5.13 | Security | Spring Security 5.8.13
JWT (jjwt 0.12.6)
BCrypt |
+| Database | MariaDB (AWS RDS) | Frontend | JSP
Tailwind CSS
Chart.js |
+| 실시간 | Spring WebSocket + SockJS | 결제 | 토스페이먼츠 API |
+| 인증 | Kakao OAuth 2.0 | 배포 | AWS EC2
Tomcat |
+
+
+
+
+
+## 5. 시스템 아키텍처
+
+
+
+
+
+
+## 6. ERD
+
+
+ | 항공편 |
+ 공항 |
+ 관리자 |
+ 예약(1) |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+ | 예약(2) |
+ 항공편 가격 |
+ 회원 |
+ |
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+## 7. 개발 계획 및 협업
+* WBS 기반 일정 수립을 통해 전체 기능을 도메인 단위로 분해하고, 스프린트 단위로 개발 계획을 구체화
+* 평일 오전 9시 데일리 스크럼 작성 후 9시 30분 대면 회의 진행
+* Jira 이슈 기반 개발 프로세스를 적용하여 기능 단위를 명확히 정의하고, PR 단위로 작업을 관리
+* Notion을 통해 설계 문서, 트러블슈팅, 도메인 지식을 공유하며 팀 전체의 이해도를 통일
+* Discord 알림 연동을 통해 PR/이슈 상황을 실시간 공유하며 협업 속도 개선
+
+
+ | WBS(1) |
+ WBS(2) |
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+
-## 3. 주요 화면
+## 8. 주요 화면
| 메인 |
@@ -188,7 +289,7 @@ Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하
-
+
더보기
@@ -336,136 +437,16 @@ Flyway는 항공권 가격이 불투명하게 결정되는 문제를 해결하
-
-
-
-## 4. 주요 기능
-
-
- | 회원 |
- 관리자 |
-
-
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-## 5. 기술 스택 (Tech Stack)
-
-| 구분 | 기술 | 구분 | 기술 |
-| -------- | ----------------------------------------- | -------- | ----------------------------------------------------- |
-| Backend | Spring Framework 5.3.31
MyBatis 3.5.13 | Security | Spring Security 5.8.13
JWT (jjwt 0.12.6)
BCrypt |
-| Database | MariaDB (AWS RDS) | Frontend | JSP
Tailwind CSS
Chart.js |
-| 실시간 | Spring WebSocket + SockJS | 결제 | 토스페이먼츠 API |
-| 인증 | Kakao OAuth 2.0 | 배포 | AWS EC2
Tomcat |
-
-
-
-
-
-## 6. 시스템 아키텍처
-
-
-
-
-## 7. ERD
-
-
- | 항공편 |
- 공항 |
- 관리자 |
- 예약(1) |
-
-
-
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
- | 예약(2) |
- 항공편 가격 |
- 회원 |
- |
-
-
-
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-## 8. 개발 계획 및 협업
-* **WBS 기반 일정 수립**을 통해 전체 기능을 도메인 단위로 분해하고, 스프린트 단위로 개발 계획을 구체화
-* 평일 오전 9시 **데일리 스크럼** 작성 후 9시 30분 대면 회의 진행
-* **Jira 이슈 기반 개발 프로세스**를 적용하여 기능 단위를 명확히 정의하고, PR 단위로 작업을 관리
-* Notion을 통해 설계 문서, 트러블슈팅, 도메인 지식을 공유하며 팀 전체의 이해도를 통일
-* Discord 알림 연동을 통해 PR/이슈 상황을 실시간 공유하며 협업 속도 개선
-
-
- | WBS(1) |
- WBS(2) |
-
-
-
-
- |
-
-
- |
-
-
-
-
## 9. 핵심 설계
-Flyway는 항공권 예매 과정에서 발생하는 인증 보안 문제, 좌석 중복 예약 문제, 불투명한 가격 정책으로 인한 정보 비대칭 문제를 해결하기 위해 다음 4가지 핵심 설계를 중심으로 구현했습니다.
-
### 1) 인증/보안 설계 (JWT + Refresh + CSRF + Cookie)
-
-Flyway는 HttpOnly Cookie 기반 JWT 인증 방식을 적용하여 토큰 탈취 위험을 줄였습니다.
-
-또한 Refresh Token 회전(Rotation) 전략을 통해 Access Token 만료 시 자동 재발급이 가능하도록 구현하여 로그인 유지 경험을 개선했습니다.
-
-소셜 로그인 사용자를 고려하여 OAuth 2.0 기반 로그인(Kakao) 흐름을 적용하였으며, OAuth 인증 성공 이후에도 서비스 내부에서는 JWT를 발급하여 인증 방식을 일관되게 통합했습니다.
-
-API 요청은 JWT 기반으로 인증되며, 상태 변경 요청(POST/PUT/PATCH/DELETE)은 CSRF 토큰 검증을 통해 보호합니다.
+- HttpOnly Cookie 기반 JWT 인증 방식을 적용하여 토큰 탈취 위험을 줄였습니다.
+- Refresh Token 회전 전략을 통해 Access Token 만료 시 자동 재발급이 가능하도록 구현하여 로그인 유지 경험을 개선했습니다.
+- 소셜 로그인 사용자를 고려하여 OAuth 2.0 기반 로그인(Kakao) 흐름을 적용했습니다.
+- OAuth 인증 성공 이후에도 서비스 내부에서는 JWT를 발급하여 인증 방식을 일관되게 통합했습니다.
+- API 요청은 JWT 기반으로 인증되며, 상태 변경 요청은 CSRF 토큰 검증을 통해 보호합니다.
이를 통해 브라우저 환경에서의 안정성과 보안성을 강화하고, 확장 가능한 인증 구조를 구축했습니다.
@@ -481,18 +462,16 @@ API 요청은 JWT 기반으로 인증되며, 상태 변경 요청(POST/PUT/PATCH
OAuth 시퀀스 다이어그램
-
-
+
+
-### 2) 좌석 동시성 제어 (HOLD → PAYING → CONFIRMED)/>
-
+### 2) 좌석 동시성 제어 (HOLD → PAYING → CONFIRMED)
-Flyway는 동일 좌석에 대한 중복 예약을 방지하기 위해 좌석을 단순 조회 데이터가 아닌
-공유 자원(Concurrency Resource) 으로 정의하고, 상태 기반 점유 모델을 설계했습니다.
+동일 좌석에 대한 중복 예약을 방지하기 위해 좌석을 단순 조회 데이터가 아닌 공유 자원으로 정의하고, 상태 기반 점유 모델을 설계했습니다.
- 좌석 선택 시 `AVAILABLE → HELD`로 변경하여 임시 점유 처리
- 결제 진행 시 `HELD → PAYING → CONFIRMED` 상태 전이로 경쟁 상태 방지
@@ -503,29 +482,21 @@ Flyway는 동일 좌석에 대한 중복 예약을 방지하기 위해 좌석을
### 3) 동적 가격 투명성 모델 (M_time, M_load, alpha)
-기존 항공권 예매 서비스는 가격 변동 기준이 공개되지 않아 사용자가 가격의 적정성을 판단하기 어렵습니다.
+기존 항공권 예매 서비스는 가격 변동 기준이 공개되지 않아 사용자가 가격의 적정성을 판단하기 어렵습니다. Flyway는 이러한 정보 비대칭 문제를 해결하기 위해 **가격 산정 기준을 공개하는 동적 가격 모델**을 적용했습니다. 가격은 다음 요소를 기반으로 산정됩니다.
-Flyway는 이러한 정보 비대칭 문제를 해결하기 위해 **가격 산정 기준을 공개하는 동적 가격 모델**을 적용했습니다.
-
-가격은 다음 요소를 기반으로 산정됩니다.
-
-- **M_time** : 출발일까지 남은 시간
-- **M_load** : 좌석 점유율 기반 수요 지표
-- **alpha** : 가격 변동 완화 계수
+- M_time : 출발일까지 남은 시간
+- M_load : 좌석 점유율 기반 수요 지표
+- alpha : 가격 변동 완화 계수
또한 사용자에게 가격 변동 이력을 제공하여 합리적인 예매 판단이 가능하도록 설계했습니다.
### 4) 예약 상태 및 결제 흐름 설계 (예약 세그먼트/승객/부가서비스 + 동시성 연계)
-Flyway는 항공권 예매가 단순 결제 처리로 끝나지 않고,
-
-**예약 정보 + 탑승자 정보 + 부가서비스 + 결제 상태**가 유기적으로 연결되는 복합 도메인임을 고려하여
-
-예약 흐름을 상태 기반으로 관리했습니다. 트랜잭션과 상태 기반 관리를 통한 동시성 제어로 오버 부킹 상황을 사전에 방지했습니다.
+Flyway는 항공권 예매가 단순 결제 처리로 끝나지 않고, 예약 정보 + 탑승자 정보 + 부가서비스 + 결제 상태가 유기적으로 연결되는 복합 도메인임을 고려하여 예약 흐름을 상태 기반으로 관리했습니다. 트랜잭션과 상태 기반 관리를 통한 동시성 제어로 오버 부킹 상황을 사전에 방지했습니다.
-- 예약 상태 변경 과정에서 **좌석 동시성 제어 로직(HOLD/PAYING/CONFIRMED)** 이 함께 적용되어
-동일 좌석 중복 예약이 발생하지 않도록 설계했습니다.
+- 예약 상태 변경 과정에서 좌석 동시성 제어 로직(HOLD/PAYING/CONFIRMED)이 함께 적용되어
+ 동일 좌석 중복 예약이 발생하지 않도록 설계했습니다.
- 예약은 해당 항공편을 선택하여 동의 페이지로 이동하기 전 트랜잭션과 잔여석 행에 대한 비관적 락을 통해 잔여석을 인원수 만큼 차감하고 예약자의 상태 칼럼을 `HELD`로 변경합니다. 정보 입력 후 결제를 완료하면 `HELD → CONFIRMED` 흐름으로 상태가 변경되며, 결제 확정 처리됩니다.
- 예약 세그먼트(왕복/경유) 구조를 반영하여 다구간 예약을 관리할 수 있도록 설계했습니다.
- 탑승자(Passenger) 정보와 좌석(Seat) 선택이 예약과 강하게 연결되도록 구성했습니다.
@@ -549,7 +520,7 @@ Flyway는 관리자가 서비스 현황을 실시간으로 모니터링할 수
- **강희민**
어려운 일들이 많았지만 하나씩 해결해 가면서 많이 성장할 수 있었습니다! 이번 프로젝트를 통해 단순히 기능이 동작하는 것보다 현실적인 운영 구조를 데이터 모델에 어떻게 녹여내는지가 훨씬 중요하다는 점을 배웠습니다!
- **김민서**
- 다양한 변수로 일정이 지연되었으나, Jira로 계획의 우선순위를 조정하며 단계적으로 해결하였습니다. 이를 통해 계획의 유연성과 일정 관리의 중요성을 배웠습니다.
+ 다양한 변수로 일정이 지연되었으나, Jira로 계획의 우선순위를 조정하며 단계적으로 해결하였습니다. 이를 통해 계획의 유연성과 일정 관리의 중요성을 배웠습니다.
- **박수진**
초기에 시행착오를 겪으며, 우리 팀에게 필요한 소통 방식을 찾아가던 과정이 인상 깊습니다. 매일 9:30 데일리 스크럼을 진행한 것이 큰 도움이 되었습니다.
- **오찬혁**
diff --git a/src/main/java/com/flyway/security/config/SecurityConfigApi.java b/src/main/java/com/flyway/security/config/SecurityConfigApi.java
index 895c01a4..1c00e68b 100644
--- a/src/main/java/com/flyway/security/config/SecurityConfigApi.java
+++ b/src/main/java/com/flyway/security/config/SecurityConfigApi.java
@@ -70,7 +70,6 @@ protected void configure(HttpSecurity http) throws Exception {
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
- // 로그인 전/토큰 재발급 같은 엔드포인트는 "의도적으로" 예외 가능
.ignoringRequestMatchers(
new AntPathRequestMatcher("/api/auth/loginProc", "POST"),
new AntPathRequestMatcher("/api/auth/refresh", "POST"),
diff --git a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java
index ceb0c500..977d886b 100644
--- a/src/main/java/com/flyway/security/config/SecurityConfigWeb.java
+++ b/src/main/java/com/flyway/security/config/SecurityConfigWeb.java
@@ -159,6 +159,9 @@ protected void configure(HttpSecurity http) throws Exception {
.logout()
.logoutUrl("/auth/logout")
.addLogoutHandler(jwtCookieLogoutHandler())
+ .invalidateHttpSession(true)
+ .clearAuthentication(true)
+ .deleteCookies("JSESSIONID")
.logoutSuccessUrl("/login")
.permitAll()
.and()
diff --git a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java
index 19b8fb8c..21f4eb54 100644
--- a/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java
+++ b/src/main/java/com/flyway/security/filter/OriginRefererCheckFilter.java
@@ -10,6 +10,8 @@
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -122,10 +124,63 @@ private boolean isAllowedReferer(String referer) {
if (normalizedReferer.equals(allowedOrigin) || normalizedReferer.startsWith(allowedOrigin + "/")) {
return true;
}
+ if (isLocalhostSchemeVariant(normalizedReferer, allowedOrigin)) {
+ return true;
+ }
}
return false;
}
+ private boolean isLocalhostSchemeVariant(String refererUrl, String allowedOrigin) {
+ OriginParts refererOrigin = extractOrigin(refererUrl);
+ OriginParts allowed = extractOrigin(allowedOrigin);
+ if (refererOrigin == null || allowed == null) return false;
+
+ if (!isLocalhostHost(refererOrigin.host) || !isLocalhostHost(allowed.host)) return false;
+ if (refererOrigin.port != allowed.port) return false;
+
+ boolean refererHttp = "http".equals(refererOrigin.scheme);
+ boolean refererHttps = "https".equals(refererOrigin.scheme);
+ boolean allowedHttp = "http".equals(allowed.scheme);
+ boolean allowedHttps = "https".equals(allowed.scheme);
+ return (refererHttp && allowedHttps) || (refererHttps && allowedHttp);
+ }
+
+ private boolean isLocalhostHost(String host) {
+ if (!StringUtils.hasText(host)) return false;
+ String h = host.toLowerCase(Locale.ROOT);
+ return "localhost".equals(h) || "127.0.0.1".equals(h) || "::1".equals(h) || "[::1]".equals(h);
+ }
+
+ private OriginParts extractOrigin(String url) {
+ try {
+ URI uri = new URI(url);
+ String scheme = trimToNull(uri.getScheme());
+ String host = trimToNull(uri.getHost());
+ if (!StringUtils.hasText(scheme) || !StringUtils.hasText(host)) return null;
+
+ int port = uri.getPort();
+ if (port < 0) {
+ port = "https".equalsIgnoreCase(scheme) ? 443 : 80;
+ }
+ return new OriginParts(scheme.toLowerCase(Locale.ROOT), host.toLowerCase(Locale.ROOT), port);
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+
+ private static final class OriginParts {
+ private final String scheme;
+ private final String host;
+ private final int port;
+
+ private OriginParts(String scheme, String host, int port) {
+ this.scheme = scheme;
+ this.host = host;
+ this.port = port;
+ }
+ }
+
private boolean isIncludedPath(String path) {
for (String basePath : includeBasePaths) {
if (matchesBasePath(path, basePath)) return true;
diff --git a/src/main/java/com/flyway/template/controller/ErrorController.java b/src/main/java/com/flyway/template/controller/ErrorController.java
index e2cc58cb..6ab6d205 100644
--- a/src/main/java/com/flyway/template/controller/ErrorController.java
+++ b/src/main/java/com/flyway/template/controller/ErrorController.java
@@ -2,8 +2,8 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
-import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
@@ -13,7 +13,7 @@
@RequestMapping("/error")
public class ErrorController {
- @GetMapping
+ @RequestMapping
public String handleError(HttpServletRequest request) {
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
String requestUri = (String) request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
diff --git a/src/main/webapp/WEB-INF/views/common/header.jsp b/src/main/webapp/WEB-INF/views/common/header.jsp
index 0beb3956..f9d429e1 100644
--- a/src/main/webapp/WEB-INF/views/common/header.jsp
+++ b/src/main/webapp/WEB-INF/views/common/header.jsp
@@ -92,6 +92,7 @@
diff --git a/src/main/webapp/resources/common/js/csrfFetch.js b/src/main/webapp/resources/common/js/csrfFetch.js
index 9924051e..a762d5ed 100644
--- a/src/main/webapp/resources/common/js/csrfFetch.js
+++ b/src/main/webapp/resources/common/js/csrfFetch.js
@@ -104,12 +104,6 @@ function withCsrfHeader(init = {}) {
return { ...init, headers };
}
-/**
- * csrfFetch
- * - GET/HEAD/OPTIONS: 그냥 fetch
- * - POST/PUT/PATCH/DELETE: CSRF 쿠키 보장 + 헤더 자동첨부
- * - 401 처리/refresh/redirect 절대 안 함
- */
export async function csrfFetch(input, init = {}) {
const merged = { credentials: "same-origin", ...init };
diff --git a/src/test/java/com/flyway/security/filter/LogoutFlowTest.java b/src/test/java/com/flyway/security/filter/LogoutFlowTest.java
new file mode 100644
index 00000000..85f7c97e
--- /dev/null
+++ b/src/test/java/com/flyway/security/filter/LogoutFlowTest.java
@@ -0,0 +1,103 @@
+package com.flyway.security.filter;
+
+import com.flyway.auth.service.AuthTokenService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.security.web.authentication.logout.CookieClearingLogoutHandler;
+import org.springframework.security.web.authentication.logout.LogoutFilter;
+import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
+import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
+import org.springframework.security.web.csrf.CsrfFilter;
+import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import javax.servlet.http.Cookie;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class LogoutFlowTest {
+
+ private MockMvc mockMvc;
+ private AuthTokenService authTokenService;
+ private CookieCsrfTokenRepository csrfTokenRepository;
+
+ @BeforeEach
+ void setUp() {
+ authTokenService = mock(AuthTokenService.class);
+ csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
+
+ CsrfFilter csrfFilter = new CsrfFilter(csrfTokenRepository);
+
+ SecurityContextLogoutHandler contextLogoutHandler = new SecurityContextLogoutHandler();
+ contextLogoutHandler.setInvalidateHttpSession(true);
+ contextLogoutHandler.setClearAuthentication(true);
+
+ CookieClearingLogoutHandler cookieClearingLogoutHandler =
+ new CookieClearingLogoutHandler("JSESSIONID");
+
+ LogoutFilter logoutFilter = new LogoutFilter("/login",
+ (request, response, authentication) -> authTokenService.logout(request, response),
+ contextLogoutHandler,
+ cookieClearingLogoutHandler
+ );
+ logoutFilter.setLogoutRequestMatcher(new AntPathRequestMatcher("/auth/logout", "POST"));
+
+ mockMvc = MockMvcBuilders.standaloneSetup(new NoopController())
+ .addFilters(csrfFilter, logoutFilter)
+ .build();
+ }
+
+ @Test
+ @DisplayName("CSRF 토큰 없이 POST /auth/logout 요청하면 403을 반환한다")
+ void logout_withoutCsrfToken_returnsForbidden() throws Exception {
+ mockMvc.perform(post("/auth/logout"))
+ .andExpect(status().isForbidden());
+
+ verify(authTokenService, never()).logout(any(), any());
+ }
+
+ @Test
+ @DisplayName("유효한 CSRF 쿠키/헤더로 POST /auth/logout 요청하면 /login으로 리다이렉트되고 로그아웃 핸들러가 호출된다")
+ void logout_withValidCsrfToken_redirectsToLogin() throws Exception {
+ String token = newTokenValue();
+ MockHttpSession session = new MockHttpSession();
+
+ MvcResult result = mockMvc.perform(post("/auth/logout")
+ .session(session)
+ .cookie(new Cookie("XSRF-TOKEN", token))
+ .header("X-XSRF-TOKEN", token))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/login"))
+ .andReturn();
+
+ verify(authTokenService).logout(any(), any());
+ assertThat(session.isInvalid()).isTrue();
+
+ List setCookies = result.getResponse().getHeaders(HttpHeaders.SET_COOKIE);
+ assertThat(setCookies).anyMatch(v -> v.startsWith("JSESSIONID=") && v.contains("Max-Age=0"));
+ }
+
+ private String newTokenValue() {
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/auth/csrf");
+ CsrfToken token = csrfTokenRepository.generateToken(request);
+ return token.getToken();
+ }
+
+ /* 필터 체인 검증용 플레이스홀더(standaloneSetup은 최소 1개의 컨트롤러 필요) */
+ @Controller
+ static class NoopController {}
+}
diff --git a/src/test/java/com/flyway/template/controller/ErrorControllerTest.java b/src/test/java/com/flyway/template/controller/ErrorControllerTest.java
new file mode 100644
index 00000000..e89481ea
--- /dev/null
+++ b/src/test/java/com/flyway/template/controller/ErrorControllerTest.java
@@ -0,0 +1,44 @@
+package com.flyway.template.controller;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import javax.servlet.RequestDispatcher;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
+
+class ErrorControllerTest {
+
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(new ErrorController()).build();
+ }
+
+ @Test
+ @DisplayName("GET /error 요청에 403 상태가 전달되면 403 에러 페이지를 렌더링한다")
+ void getError_with403_rendersForbiddenPage() throws Exception {
+ mockMvc.perform(get("/error")
+ .requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 403)
+ .requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/auth/logout"))
+ .andExpect(status().isOk())
+ .andExpect(view().name("error/403"));
+ }
+
+ @Test
+ @DisplayName("POST /error 요청에 403 상태가 전달되어도 403 에러 페이지를 렌더링한다")
+ void postError_with403_rendersForbiddenPage() throws Exception {
+ mockMvc.perform(post("/error")
+ .requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 403)
+ .requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/auth/logout"))
+ .andExpect(status().isOk())
+ .andExpect(view().name("error/403"));
+ }
+}