From be86d7af9f7dce7b07c1f201439feb9a73ada294 Mon Sep 17 00:00:00 2001 From: KimGiHyeon Date: Mon, 14 Sep 2026 21:10:44 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[refactor]=20@Async=20=ED=92=80=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC,=20=EA=B1=B0=EB=B6=80=EC=A0=95=EC=B1=85,=20MDC=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=EB=A1=9C=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EA=B2=A9=EB=A6=AC=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: @Async 풀을 성격별로 분리하고 거부정책 적용 외부 I/O와 내부 DB 작업을 별도 스레드 풀로 분리해 상호 간섭을 막는다. - Discord는 externalExecutor, 내부 작업(분석·조회수·응모)은 generalExecutor로 격리 - 풀 포화 시 예외가 요청으로 전파되지 않도록 거부정책 지정 - Hikari 기본 커넥션 풀(10)을 웹 요청과 나눠 쓰도록 general을 max 4로 제한 - 종료 시 진행 중 작업을 기다리도록 graceful shutdown 설정 * feat: async 스레드로 MDC(traceId) 전파 @Async는 작업을 다른 풀 스레드로 넘겨 ThreadLocal인 MDC가 끊긴다. 부모 스레드의 MDC를 복사·복원해 async 로그에도 traceId가 남게 한다. - MdcTaskDecorator를 general·external 풀에 적용 - 풀 스레드 재사용 시 이전 요청 컨텍스트가 남지 않도록 실행 후 MDC 정리 * refactor: async 풀 이름을 상수로 추출 * refactor: external 풀도 유실 시 로그를 남기도록 수정 * style: public, private 가독성을 위한 공백 추가 --- .../event/RequestReviewEventListener.java | 3 +- .../analytics/AnalyticsEventListener.java | 3 +- .../common/config/AsyncConfiguration.java | 58 ++++++++++++---- .../infra/discord/DiscordWebhookSender.java | 3 +- .../common/logging/MdcTaskDecorator.java | 26 +++++++ .../place/service/ViewCountService.java | 3 +- .../common/config/AsyncConfigurationTest.java | 37 ++++++++++ .../common/logging/MdcTaskDecoratorTest.java | 68 +++++++++++++++++++ 8 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/matzip/common/logging/MdcTaskDecorator.java create mode 100644 src/test/java/com/matzip/common/config/AsyncConfigurationTest.java create mode 100644 src/test/java/com/matzip/common/logging/MdcTaskDecoratorTest.java diff --git a/src/main/java/com/matzip/admin/event/RequestReviewEventListener.java b/src/main/java/com/matzip/admin/event/RequestReviewEventListener.java index e93d9fe..7f2af74 100644 --- a/src/main/java/com/matzip/admin/event/RequestReviewEventListener.java +++ b/src/main/java/com/matzip/admin/event/RequestReviewEventListener.java @@ -3,6 +3,7 @@ import com.matzip.common.analytics.AnalyticsRecorder; import com.matzip.common.analytics.domain.EventType; import com.matzip.common.analytics.domain.TargetType; +import com.matzip.common.config.AsyncConfiguration; import com.matzip.common.exception.BusinessException; import com.matzip.common.exception.code.ErrorCode; import com.matzip.lottery.service.LotteryEventService; @@ -28,7 +29,7 @@ public RequestReviewEventListener(LotteryEventService lotteryEventService, Place this.analyticsRecorder = analyticsRecorder; } - @Async + @Async(AsyncConfiguration.GENERAL_EXECUTOR) @TransactionalEventListener(condition = "#event.reviewStatus().name() == 'APPROVED'") public void enterCurrentEventOnApproval(RequestReviewEvent event) { Place place = placeRepository.findById(event.placeId()) diff --git a/src/main/java/com/matzip/common/analytics/AnalyticsEventListener.java b/src/main/java/com/matzip/common/analytics/AnalyticsEventListener.java index c5c3664..e65dc67 100644 --- a/src/main/java/com/matzip/common/analytics/AnalyticsEventListener.java +++ b/src/main/java/com/matzip/common/analytics/AnalyticsEventListener.java @@ -2,6 +2,7 @@ import com.matzip.common.analytics.domain.entity.EventLog; import com.matzip.common.analytics.repository.EventLogRepository; +import com.matzip.common.config.AsyncConfiguration; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -24,7 +25,7 @@ public AnalyticsEventListener(EventLogRepository eventLogRepository) { this.eventLogRepository = eventLogRepository; } - @Async + @Async(AsyncConfiguration.GENERAL_EXECUTOR) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void on(AnalyticsEvent event) { try { diff --git a/src/main/java/com/matzip/common/config/AsyncConfiguration.java b/src/main/java/com/matzip/common/config/AsyncConfiguration.java index 9a78aee..58b920e 100644 --- a/src/main/java/com/matzip/common/config/AsyncConfiguration.java +++ b/src/main/java/com/matzip/common/config/AsyncConfiguration.java @@ -1,30 +1,64 @@ package com.matzip.common.config; -import org.springframework.boot.task.ThreadPoolTaskExecutorBuilder; +import com.matzip.common.logging.MdcTaskDecorator; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.task.TaskExecutor; +import org.springframework.core.task.TaskDecorator; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; - +import java.util.concurrent.RejectedExecutionHandler;@Slf4j @EnableAsync @Configuration public class AsyncConfiguration implements AsyncConfigurer { + public static final String GENERAL_EXECUTOR = "generalExecutor"; + public static final String EXTERNAL_EXECUTOR = "externalExecutor"; + + private static final int SHUTDOWN_AWAIT_SECONDS = 15; + @Override public Executor getAsyncExecutor() { - return asyncTaskExecutor(); + return generalExecutor(); + } + + @Bean(GENERAL_EXECUTOR) + public ThreadPoolTaskExecutor generalExecutor() { + // Hikari 기본 커넥션 풀(10)을 웹 요청과 나눠 쓰도록 max 4로 제한 + return buildExecutor("async-", 3, 4, 200, loggingDiscardPolicy(GENERAL_EXECUTOR)); + } + + @Bean(EXTERNAL_EXECUTOR) + public ThreadPoolTaskExecutor externalExecutor() { + return buildExecutor("ext-", 1, 2, 30, loggingDiscardPolicy(EXTERNAL_EXECUTOR)); + } + + private ThreadPoolTaskExecutor buildExecutor(String threadNamePrefix, int corePoolSize, int maxPoolSize, + int queueCapacity, RejectedExecutionHandler rejectedHandler) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix(threadNamePrefix); + executor.setRejectedExecutionHandler(rejectedHandler); + executor.setTaskDecorator(mdcTaskDecorator()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(SHUTDOWN_AWAIT_SECONDS); + executor.initialize(); + return executor; + } + + private TaskDecorator mdcTaskDecorator() { + return new MdcTaskDecorator(); } - @Bean - public TaskExecutor asyncTaskExecutor() { - return new ThreadPoolTaskExecutorBuilder() - .corePoolSize(10) - .maxPoolSize(50) - .queueCapacity(100) - .threadNamePrefix("Async-") - .build(); + // ViewCountService가 REQUIRES_NEW라 CallerRunsPolicy는 커넥션 데드락 위험이 있어 log & discard 사용 + private RejectedExecutionHandler loggingDiscardPolicy(String poolName) { + return (rejected, executor) -> + log.warn("[비동기 작업 유실] {}} 풀 포화로 작업을 버립니다. activeThreads={}, queueSize={}", + poolName, executor.getActiveCount(), executor.getQueue().size()); } } diff --git a/src/main/java/com/matzip/common/infra/discord/DiscordWebhookSender.java b/src/main/java/com/matzip/common/infra/discord/DiscordWebhookSender.java index 0fe8536..9c50fa4 100644 --- a/src/main/java/com/matzip/common/infra/discord/DiscordWebhookSender.java +++ b/src/main/java/com/matzip/common/infra/discord/DiscordWebhookSender.java @@ -1,5 +1,6 @@ package com.matzip.common.infra.discord; +import com.matzip.common.config.AsyncConfiguration; import com.matzip.common.config.DiscordWebhookProperties; import java.util.Map; import lombok.extern.slf4j.Slf4j; @@ -21,7 +22,7 @@ public DiscordWebhookSender(WebClient.Builder webClientBuilder, this.discordWebhookProperties = discordWebhookProperties; } - @Async + @Async(AsyncConfiguration.EXTERNAL_EXECUTOR) public void sendAsync(String content) { String webhookUrl = discordWebhookProperties.getWebhookUrl(); if (!StringUtils.hasText(webhookUrl)) { diff --git a/src/main/java/com/matzip/common/logging/MdcTaskDecorator.java b/src/main/java/com/matzip/common/logging/MdcTaskDecorator.java new file mode 100644 index 0000000..ba84850 --- /dev/null +++ b/src/main/java/com/matzip/common/logging/MdcTaskDecorator.java @@ -0,0 +1,26 @@ +package com.matzip.common.logging; + +import org.slf4j.MDC; +import org.springframework.core.task.TaskDecorator; + +import java.util.Map; + +public class MdcTaskDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + Map parentContext = MDC.getCopyOfContextMap(); + return () -> { + if (parentContext != null) { + MDC.setContextMap(parentContext); + } else { + MDC.clear(); + } + try { + runnable.run(); + } finally { + MDC.clear(); // 풀 스레드 재사용 시 이전 요청 컨텍스트가 남지 않도록 정리 + } + }; + } +} diff --git a/src/main/java/com/matzip/place/service/ViewCountService.java b/src/main/java/com/matzip/place/service/ViewCountService.java index 295d963..a3b1d78 100644 --- a/src/main/java/com/matzip/place/service/ViewCountService.java +++ b/src/main/java/com/matzip/place/service/ViewCountService.java @@ -1,5 +1,6 @@ package com.matzip.place.service; +import com.matzip.common.config.AsyncConfiguration; import com.matzip.place.domain.DailyViewCount; import com.matzip.place.repository.DailyViewCountRepository; import com.matzip.place.repository.PlaceRepository; @@ -18,7 +19,7 @@ public class ViewCountService { private final PlaceRepository placeRepository; private final DailyViewCountRepository dailyViewCountRepository; - @Async + @Async(AsyncConfiguration.GENERAL_EXECUTOR) @Transactional(propagation = Propagation.REQUIRES_NEW) public void incrementAllCounts(Long placeId) { placeRepository.incrementViewCount(placeId); diff --git a/src/test/java/com/matzip/common/config/AsyncConfigurationTest.java b/src/test/java/com/matzip/common/config/AsyncConfigurationTest.java new file mode 100644 index 0000000..fd9890b --- /dev/null +++ b/src/test/java/com/matzip/common/config/AsyncConfigurationTest.java @@ -0,0 +1,37 @@ +package com.matzip.common.config; + +import com.matzip.AbstractMatzipApplicationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import static org.assertj.core.api.Assertions.assertThat; + +class AsyncConfigurationTest extends AbstractMatzipApplicationTest { + + @Autowired + @Qualifier("generalExecutor") + private ThreadPoolTaskExecutor generalExecutor; + + @Autowired + @Qualifier("externalExecutor") + private ThreadPoolTaskExecutor externalExecutor; + + @Test + void 내부작업_풀과_외부IO_풀이_분리되어_등록된다() { + assertThat(generalExecutor).isNotSameAs(externalExecutor); + } + + @Test + void generalExecutor는_Hikari_커넥션을_고려한_크기로_구성된다() { + assertThat(generalExecutor.getCorePoolSize()).isEqualTo(3); + assertThat(generalExecutor.getMaxPoolSize()).isEqualTo(4); + } + + @Test + void externalExecutor는_저빈도_외부IO용_작은_크기로_구성된다() { + assertThat(externalExecutor.getCorePoolSize()).isEqualTo(1); + assertThat(externalExecutor.getMaxPoolSize()).isEqualTo(2); + } +} diff --git a/src/test/java/com/matzip/common/logging/MdcTaskDecoratorTest.java b/src/test/java/com/matzip/common/logging/MdcTaskDecoratorTest.java new file mode 100644 index 0000000..e6537d4 --- /dev/null +++ b/src/test/java/com/matzip/common/logging/MdcTaskDecoratorTest.java @@ -0,0 +1,68 @@ +package com.matzip.common.logging; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class MdcTaskDecoratorTest { + + private static final String TRACE_ID = "traceId"; + + private final MdcTaskDecorator decorator = new MdcTaskDecorator(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void 부모_스레드의_MDC를_실행_스레드로_복사한다() throws Exception { + MDC.put(TRACE_ID, "abc123"); // 제출(부모) 스레드에서 데코레이트 + AtomicReference seenDuringRun = new AtomicReference<>(); + Runnable decorated = decorator.decorate(() -> seenDuringRun.set(MDC.get(TRACE_ID))); + + // 다른 스레드에서 실행 → ThreadLocal이라 데코레이터 없이는 전파되지 않음 + runAndJoin(new Thread(decorated)); + + assertThat(seenDuringRun.get()).isEqualTo("abc123"); + } + + @Test + void 실행_후_스레드의_MDC를_정리한다() throws Exception { + MDC.put(TRACE_ID, "abc123"); + Runnable decorated = decorator.decorate(() -> {}); + + // 실행이 끝난 뒤 워커 스레드에 값이 남으면 재사용 시 다음 작업을 오염시킨다 + AtomicReference afterRun = new AtomicReference<>("SENTINEL"); + runAndJoin(new Thread(() -> { + decorated.run(); + afterRun.set(MDC.get(TRACE_ID)); + })); + + assertThat(afterRun.get()).isNull(); + } + + @Test + void 부모_MDC가_없으면_워커에_남은_잔여값을_지운다() throws Exception { + MDC.clear(); // 부모(제출) 스레드에 컨텍스트 없음 + AtomicReference seenDuringRun = new AtomicReference<>("SENTINEL"); + Runnable decorated = decorator.decorate(() -> seenDuringRun.set(MDC.get(TRACE_ID))); + + // 재사용된 워커 스레드에 이전 작업의 값이 남아있는 상황을 모사 + runAndJoin(new Thread(() -> { + MDC.put(TRACE_ID, "STALE"); + decorated.run(); + })); + + assertThat(seenDuringRun.get()).isNull(); + } + + private void runAndJoin(Thread worker) throws InterruptedException { + worker.start(); + worker.join(); + } +} From a6b834b642bad05232b1d8507b6deaa6f2caeeb8 Mon Sep 17 00:00:00 2001 From: KimGiHyeon Date: Tue, 15 Sep 2026 00:22:26 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[docs]=20README.md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 154 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 114 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 37e5e12..ffdc133 100644 --- a/README.md +++ b/README.md @@ -2,64 +2,138 @@ > 캠퍼스 주변의 맛집들을 지금 바로 찾아보세요! -**[🔗 배포 링크](https://knu-matzip.vercel.app/)** +**[🔗 서비스 바로가기](https://knu-matzip.vercel.app/)** -Frame 836 +KNU-Matzip -## 프로젝트 소개 +
-### 🗓️ 기간 +## 📖 프로젝트 소개 -2025.09 ~ 현재 [운영 중] +**KNU-Matzip 서버**는 공주대학교 학생들을 위한 캠퍼스 맛집 큐레이션 서비스의 API 서버입니다. -### ⚙️ 기술 스택 +- **기간**: 2025.09 ~ 현재 (운영 중) +- **역할**: 백엔드 설계 및 개발 -#### Back-end +#### 문제 정의 -![React](https://go-skill-icons.vercel.app/api/icons?i=java,spring,mysql,aws) +> 학교 주변 맛집 정보는 네이버 지도·검색으로 쉽게 찾을 수 있지만, 광고·협찬성 콘텐츠가 섞여 신뢰하기 어렵습니다. 그래서 학생들은 실제 경험 기반 후기를 얻으려 에브리타임을 찾지만, 후기는 시간이 지나면 밀려 사라지고 여러 게시글에 흩어져 있어 원하는 맛집 하나를 찾기 위해 매번 반복 검색해야 합니다. +> +> 문제 정의 -#### Front-end +
-![React](https://go-skill-icons.vercel.app/api/icons?i=ts,nextjs,zustand,reactquery,tailwind,storybook,pnpm,turborepo,googleanalytics,sentry&perline=5) +## ⚙️ 기술 스택 +**Back-end** -### 🥶 Back-end Architecture -knu-matzip excalidraw +![Back-end](https://go-skill-icons.vercel.app/api/icons?i=java,spring,mysql,aws) +- **Language / Framework**: Java 17, Spring Boot 3.4 (Web, Data JPA, Security, Validation) +- **Auth**: Kakao OAuth2 + Stateless JWT (`jjwt`) +- **DB**: MySQL (운영) / H2 (테스트) +- **관측성**: Spring Boot Actuator, Prometheus, Grafana +- **알림**: Discord Webhook +- **Infra**: AWS EC2, Nginx +
+Front-end 스택 (별도 저장소) -### 📱 주요 기능 +![Front-end](https://go-skill-icons.vercel.app/api/icons?i=ts,nextjs,zustand,reactquery,tailwind,storybook,pnpm,turborepo,googleanalytics,sentry&perline=5) -#### 1. 맛집 탐색 +
-> 카테고리나 지도로 캠퍼스 근처 맛집을 쉽게 찾을 수 있습니다. -IMG_2508-portrait 1 -IMG_2514-portrait 1 -IMG_2512-portrait 1 -IMG_2511-portrait 1 -IMG_2536-portrait +
-#### 2. 맛집 등록 -> 캠퍼스, 식당, 추천 메뉴, 소개, 태그, 카테고리를 단계 별로 입력해 새로운 맛집을 등록 신청합니다. -IMG_2521-portrait 1 -IMG_2523-portrait 1 -IMG_2524-portrait 1 -IMG_2525-portrait 1 -IMG_2526-portrait 1 +## 🏛️ 아키텍처 -#### 3. 럭키 드로우 이벤트 -> 맛집 등록으로 얻은 응모권을 통해 기프티콘 이벤트에 참여할 수 있습니다. -IMG_2529-portrait 1 -IMG_2530-portrait 1 -IMG_2532-portrait 1 -IMG_2533-portrait 1 +Back-end Architecture -#### 4. 오늘 뭐 먹지 -> 랜덤으로 카테고리를 추천해줍니다. -IMG_2513-portrait 1 +### 패키지 구조 -#### 5. 내 정보 조회 (등록 현황, 기프티콘) -IMG_2516-portrait 1 -IMG_2517-portrait 1 -IMG_2519-portrait 1 +기능이 늘어날수록 계층 우선(`controller/`, `service/` …) 구조는 하나의 변경이 여러 패키지에 흩어지는 문제가 있습니다. +그래서 **도메인 단위로 먼저 나누고**, 각 도메인 내부를 **3계층 레이어드**로 통일했습니다. +```text +com.matzip +├── auth # 카카오 로그인 / JWT 발급·재발급 +├── user # 프로필 +├── place # 맛집 등록·조회·찜·검색 +├── lottery # 럭키 드로우 응모 이벤트 +├── admin # 맛집 승인·거절 +└── common # 응답·예외·보안·설정·로깅·분석 등 공통 + +# 각 도메인 내부 +controller → service → repository → DB + ↘ client (외부 API·캐시 어댑터) +``` + +## 📱 주요 기능 + +| 기능 | 설명 | +| --- |-----------------------------------------| +| **맛집 탐색** | 카테고리·지도로 캠퍼스 근처 맛집 조회, 찜, 키워드 검색 | +| **맛집 등록** | 캠퍼스·식당·추천 메뉴·태그·카테고리를 단계별로 입력해 등록 신청 | +| **관리자 승인** | 등록 신청된 맛집을 관리자가 승인·거절 (승인 시 Discord 알림) | +| **럭키 드로우** | 맛집 등록으로 얻은 응모권으로 기프티콘 이벤트 참여, 스케줄러 추첨 | +| **오늘 뭐 먹지** | 랜덤 카테고리 추천 | +| **내 정보** | 등록 현황·기프티콘 조회 | + + +## 📸 화면 스크린샷 보기 + +**맛집 탐색** +

+탐색1 +탐색2 +탐색3 +탐색4 +

+ +**맛집 등록** +

+등록1 +등록2 +등록3 +등록4 +

+ +**럭키 드로우 이벤트** +

+이벤트1 +이벤트2 +이벤트3 +이벤트4 +

+ +**오늘 뭐 먹지 / 내 정보** +

+추천 +내정보1 +내정보2 +내정보3 +

+ +
+ +## 🚀 로컬 실행 + +```bash +# 1. 로컬 MySQL 준비 (localhost:3306/knu_matzip) + +# 2. 환경 변수 설정 +export DB_USER=... DB_PASS=... JWT_SECRET_KEY=... KAKAO_CLIENT_ID=... STATE_SECRET_KEY=... + +# 3. 실행 (local 프로파일) +SPRING_PROFILES_ACTIVE=local ./gradlew bootRun +``` + +**검증** + +```bash +./gradlew build # 빌드 + 테스트 +./gradlew test # 전체 테스트 (H2 인메모리 + 더미 시크릿으로 외부 의존 없이 동작) +``` + +- 스키마 반영: `local`은 `ddl-auto: update`, `prod`는 `validate` +- 배포: `main` 브랜치 push 시 EC2 자동 배포 From fe37a27c6e961c0bc7ce4456a2dd0ce43c0faeca Mon Sep 17 00:00:00 2001 From: KimGiHyeon Date: Tue, 15 Sep 2026 01:05:09 +0900 Subject: [PATCH 3/6] docs: Add ERD section with image to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index ffdc133..1942618 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,10 @@ controller → service → repository → DB ↘ client (외부 API·캐시 어댑터) ``` +## ERD +knu-matzip-erd + + ## 📱 주요 기능 | 기능 | 설명 | From 90869cc72e6e400d7a8b4dc242f03f517dc56edd Mon Sep 17 00:00:00 2001 From: KimGiHyeon Date: Tue, 15 Sep 2026 17:37:02 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20OpenAPI(Swagger)=20API=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=8F=84=EC=9E=85=20(#170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ .../matzip/common/config/OpenApiConfig.java | 30 ++++++++++++++++ .../jwt/filter/JwtAuthenticationFilter.java | 4 ++- src/main/resources/application-prod.yml | 9 ++++- src/main/resources/application.yml | 11 +++++- .../common/config/OpenApiSmokeTest.java | 36 +++++++++++++++++++ 6 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/matzip/common/config/OpenApiConfig.java create mode 100644 src/test/java/com/matzip/common/config/OpenApiSmokeTest.java diff --git a/build.gradle b/build.gradle index fc2a1d7..673ca9d 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' + + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' } tasks.named('test') { diff --git a/src/main/java/com/matzip/common/config/OpenApiConfig.java b/src/main/java/com/matzip/common/config/OpenApiConfig.java new file mode 100644 index 0000000..7598802 --- /dev/null +++ b/src/main/java/com/matzip/common/config/OpenApiConfig.java @@ -0,0 +1,30 @@ +package com.matzip.common.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + private static final String SECURITY_SCHEME_NAME = "bearerAuth"; + + @Bean + public OpenAPI matzipOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("KNU-Matzip API") + .description("공주대 맛집 서비스 API 문서") + .version("v1")) + .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)) + .components(new Components().addSecuritySchemes(SECURITY_SCHEME_NAME, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/src/main/java/com/matzip/common/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/matzip/common/security/jwt/filter/JwtAuthenticationFilter.java index 7038c4b..bec7983 100644 --- a/src/main/java/com/matzip/common/security/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/matzip/common/security/jwt/filter/JwtAuthenticationFilter.java @@ -36,7 +36,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { "/api/v1/categories", "/api/v1/events", "/admin/api", - "/actuator/prometheus" + "/actuator/prometheus", + "/swagger-ui", + "/v3/api-docs" ); private final JwtProvider jwtProvider; diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e81e27c..06355ec 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -45,4 +45,11 @@ management: include: prometheus metrics: tags: - application: ${spring.application.name} \ No newline at end of file + application: ${spring.application.name} + +# 운영 환경에서는 API 문서/Swagger UI 비활성화 +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a2f71a6..2e0136f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -21,4 +21,13 @@ server: app: discord: - webhook-url: ${DISCORD_WEBHOOK_URL:} \ No newline at end of file + webhook-url: ${DISCORD_WEBHOOK_URL:} + +springdoc: + swagger-ui: + path: /swagger-ui.html + operations-sorter: method + tags-sorter: alpha + persist-authorization: true + # 로컬 테스트용 토큰 발급 API는 문서에서 제외 + paths-to-exclude: /api/v1/auth/dev/** \ No newline at end of file diff --git a/src/test/java/com/matzip/common/config/OpenApiSmokeTest.java b/src/test/java/com/matzip/common/config/OpenApiSmokeTest.java new file mode 100644 index 0000000..b7866b2 --- /dev/null +++ b/src/test/java/com/matzip/common/config/OpenApiSmokeTest.java @@ -0,0 +1,36 @@ +package com.matzip.common.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = "springdoc.paths-to-exclude=/api/v1/auth/dev/**") +class OpenApiSmokeTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void apiDocs_토큰없이_200_반환하고_경로포함() { + ResponseEntity response = restTemplate.getForEntity("/v3/api-docs", String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("/api/v1/places"); + assertThat(response.getBody()).contains("bearerAuth"); + assertThat(response.getBody()).doesNotContain("/api/v1/auth/dev/token"); + } + + @Test + void swaggerUi_토큰없이_401아님() { + ResponseEntity response = restTemplate.getForEntity("/swagger-ui/index.html", String.class); + + assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.UNAUTHORIZED); + } +} From 5a4d5140602cc7d9e227109d727d13be8f1118d7 Mon Sep 17 00:00:00 2001 From: KimGiHyeon Date: Tue, 15 Sep 2026 21:40:10 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[docs]=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=99=94=20(#172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 컨트롤러에 Swagger 어노테이션 추가 * fix: 에러 응답만 선언된 엔드포인트의 성공 응답 문서 누락 수정 * docs: 맛집 응답 DTO에 Swagger 예시 값 추가 * docs: lottery, user 응답 DTO에 Swagger 예시 값 추가 --- .../admin/controller/AdminController.java | 8 +++++ ...PlaceRegisterRequestDetailResponseDto.java | 19 ++++++++---- .../PlaceRegisterRequestsResponseDto.java | 9 ++++-- .../auth/controller/AuthController.java | 6 ++++ .../auth/controller/AuthControllerV2.java | 4 +++ .../controller/LotteryEventController.java | 13 ++++++++ .../dto/response/ApplyEventResponseDto.java | 7 +++-- .../response/EventEntryResultResponseDto.java | 15 +++++----- .../LotteryEventAnonymousResponseDto.java | 9 ++++-- .../dto/response/LotteryEventResponseDto.java | 14 +++++++-- .../ParticipatedEventResponseDto.java | 9 +++--- .../place/controller/CategoryController.java | 5 ++++ .../place/controller/PlaceController.java | 7 +++++ .../place/controller/PlaceLikeController.java | 12 ++++++++ .../place/controller/PlaceReadController.java | 8 +++++ .../controller/PlaceRequestController.java | 9 ++++++ .../com/matzip/place/dto/CategoryDto.java | 7 +++++ .../com/matzip/place/dto/LocationDto.java | 4 +++ .../java/com/matzip/place/dto/PhotoDto.java | 7 +++++ .../java/com/matzip/place/dto/TagDto.java | 6 ++++ .../dto/response/MapSearchResponseDto.java | 7 +++++ .../place/dto/response/MenuResponseDto.java | 8 +++++ .../dto/response/PlaceCheckResponseDto.java | 13 ++++++++ .../dto/response/PlaceCommonResponseDto.java | 8 +++++ .../dto/response/PlaceDetailResponseDto.java | 11 +++++++ .../response/PlaceMenuSearchResponseDto.java | 6 ++++ .../response/PlaceRegisterResponseDto.java | 7 +++++ .../PlaceRegisterStatusDetailResponseDto.java | 17 +++++++++++ .../PlaceRegisterStatusResponseDto.java | 9 ++++++ .../dto/response/PlaceSearchResponseDto.java | 6 ++++ .../user/controller/UserController.java | 6 ++++ .../dto/response/UserProfileResponseDto.java | 6 ++++ .../common/config/OpenApiSmokeTest.java | 30 +++++++++++++++++++ 33 files changed, 286 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/matzip/admin/controller/AdminController.java b/src/main/java/com/matzip/admin/controller/AdminController.java index 234c751..0e5dee7 100644 --- a/src/main/java/com/matzip/admin/controller/AdminController.java +++ b/src/main/java/com/matzip/admin/controller/AdminController.java @@ -9,6 +9,8 @@ import com.matzip.common.response.ApiResponse; // import com.matzip.common.security.UserPrincipal; import com.matzip.place.repository.PlaceRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; // import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,6 +21,7 @@ import java.util.List; +@Tag(name = "어드민", description = "맛집 등록 요청 승인/거절 관리 API") @RequestMapping("/admin/api") @RestController public class AdminController { @@ -31,6 +34,7 @@ public AdminController(PlaceRepository placeRepository, AdminPlaceRegisterReques this.adminPlaceRegisterRequestService = adminPlaceRegisterRequestService; } + @Operation(summary = "맛집 등록 요청 목록 (어드민)", description = "승인 대기(PENDING) 상태인 맛집 등록 요청 목록을 조회한다.") @GetMapping("/requests/places") public ApiResponse> findPlaceRegisterRequests() { List data = placeRepository.findPendingPlaces() @@ -41,6 +45,9 @@ public ApiResponse> findPlaceRegisterRequ return ApiResponse.success(data); } + @Operation(summary = "맛집 등록 요청 상세 (어드민)", description = "특정 맛집 등록 요청의 상세 정보를 조회한다.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "맛집을 찾을 수 없습니다.") @GetMapping("/requests/places/{placeId}") public ApiResponse findPlaceRegisterRequestDetail( @PathVariable("placeId") Long placeId @@ -52,6 +59,7 @@ public ApiResponse findPlaceRegisterReque return ApiResponse.success(data); } + @Operation(summary = "맛집 등록 요청 승인/거절 (어드민)", description = "맛집 등록 요청을 승인(APPROVED) 또는 거절(REJECTED)한다. 거절 시 사유를 함께 전달한다.") @PostMapping("/requests/places/{placeId}/review") public ApiResponse reviewPlaceRegisterRequest( @PathVariable("placeId") Long placeId, diff --git a/src/main/java/com/matzip/admin/dto/response/PlaceRegisterRequestDetailResponseDto.java b/src/main/java/com/matzip/admin/dto/response/PlaceRegisterRequestDetailResponseDto.java index d6ff6fa..4631812 100644 --- a/src/main/java/com/matzip/admin/dto/response/PlaceRegisterRequestDetailResponseDto.java +++ b/src/main/java/com/matzip/admin/dto/response/PlaceRegisterRequestDetailResponseDto.java @@ -6,21 +6,28 @@ import com.matzip.place.dto.LocationDto; import com.matzip.place.dto.PhotoDto; import com.matzip.place.dto.TagDto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.time.LocalDate; import java.util.List; @Builder -public record PlaceRegisterRequestDetailResponseDto(Long placeId, String placeName, LocalDate requestDate, List photos, - String address, LocationDto location, String description, - List menus, List categories, List tags) { +public record PlaceRegisterRequestDetailResponseDto( + @Schema(description = "맛집 ID", example = "15") Long placeId, + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") String placeName, + @Schema(description = "등록 요청일", example = "2025-08-12") LocalDate requestDate, + List photos, + @Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") String address, + LocationDto location, + @Schema(description = "맛집 설명", example = "직원이 엄청 친절해요! 근데 화장실에 갔고 냄새나요 ㅠㅠㅠ 그래도 맛은 있어서 괜찮아요") String description, + List menus, List categories, List tags) { @Builder public record MenusResponse( - String name, - Integer price, - Boolean isRecommended + @Schema(description = "메뉴 이름", example = "숙성우대갈비") String name, + @Schema(description = "가격(원)", example = "32000") Integer price, + @Schema(description = "대표 메뉴 여부", example = "true") Boolean isRecommended ) { public static MenusResponse from(Menu menu) { return MenusResponse.builder() diff --git a/src/main/java/com/matzip/admin/dto/response/PlaceRegisterRequestsResponseDto.java b/src/main/java/com/matzip/admin/dto/response/PlaceRegisterRequestsResponseDto.java index 69b6c97..ac693c1 100644 --- a/src/main/java/com/matzip/admin/dto/response/PlaceRegisterRequestsResponseDto.java +++ b/src/main/java/com/matzip/admin/dto/response/PlaceRegisterRequestsResponseDto.java @@ -4,14 +4,19 @@ import com.matzip.place.domain.entity.Place; import com.matzip.place.dto.CategoryDto; import com.matzip.place.dto.TagDto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.time.LocalDate; import java.util.List; @Builder -public record PlaceRegisterRequestsResponseDto(Long placeId, String placeName, LocalDate requestDate, Campus campus, - List categories, List tags) { +public record PlaceRegisterRequestsResponseDto( + @Schema(description = "맛집 ID", example = "15") Long placeId, + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") String placeName, + @Schema(description = "등록 요청일", example = "2025-08-12") LocalDate requestDate, + @Schema(description = "캠퍼스", example = "SINGWAN") Campus campus, + List categories, List tags) { public static PlaceRegisterRequestsResponseDto from(Place place) { return PlaceRegisterRequestsResponseDto.builder() diff --git a/src/main/java/com/matzip/auth/controller/AuthController.java b/src/main/java/com/matzip/auth/controller/AuthController.java index d745fa8..76bca57 100644 --- a/src/main/java/com/matzip/auth/controller/AuthController.java +++ b/src/main/java/com/matzip/auth/controller/AuthController.java @@ -13,6 +13,8 @@ import com.matzip.common.exception.BusinessException; import com.matzip.common.exception.code.ErrorCode; import com.matzip.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; @@ -24,6 +26,7 @@ import java.time.Duration; +@Tag(name = "인증", description = "카카오 로그인 및 JWT 토큰 발급 API") @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor @@ -39,6 +42,7 @@ public class AuthController { private final StatelessStateSigner stateSigner; private final KakaoAuthorizeUrlBuilder kakaoAuthorizeUrlBuilder; + @Operation(summary = "카카오 인가 URL 리다이렉트", description = "허용된 clientOrigin을 검증하고 카카오 로그인 페이지(302)로 리다이렉트한다.") @GetMapping("/authorize") public ResponseEntity authorize(@RequestParam("clientOrigin") String clientOrigin) { @@ -66,6 +70,7 @@ public ResponseEntity authorize(@RequestParam("clientOrigin") String clien * @param body * @return */ + @Operation(summary = "액세스 토큰 재발급", description = "쿠키 또는 요청 본문의 리프레시 토큰으로 액세스 토큰을 재발급하고, 회전된 리프레시 토큰을 쿠키로 설정한다.") @PostMapping("/token") public ResponseEntity> reissue( @CookieValue(value = RT_COOKIE_NAME, required = false) String rtCookie, @@ -106,6 +111,7 @@ private ResponseCookie buildRtCookie(String rt, long maxAgeMs) { .build(); } + @Operation(summary = "카카오 로그인 콜백", description = "카카오 인가 코드를 받아 로그인을 처리하고, 성공/실패에 따라 프론트 페이지로 리다이렉트(302)한다.") @GetMapping("/callback") public ResponseEntity kakaoCallback( @RequestParam("code") @NotBlank String code, diff --git a/src/main/java/com/matzip/auth/controller/AuthControllerV2.java b/src/main/java/com/matzip/auth/controller/AuthControllerV2.java index 4b6d641..181fb1d 100644 --- a/src/main/java/com/matzip/auth/controller/AuthControllerV2.java +++ b/src/main/java/com/matzip/auth/controller/AuthControllerV2.java @@ -4,6 +4,8 @@ import com.matzip.auth.dto.response.LoginResponseDto; import com.matzip.auth.service.AuthService; import com.matzip.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; @@ -14,6 +16,7 @@ import java.time.Duration; +@Tag(name = "인증", description = "카카오 로그인 및 JWT 토큰 발급 API") @RequestMapping("/api/v2/auth") @RestController public class AuthControllerV2 { @@ -24,6 +27,7 @@ public AuthControllerV2(AuthService authService) { this.authService = authService; } + @Operation(summary = "카카오 로그인 (v2)", description = "인가 코드와 redirectUri로 로그인하고, 액세스 토큰을 본문으로, 리프레시 토큰을 쿠키로 반환한다.") @GetMapping("/oauth2") public ResponseEntity> kakaoLogin(@RequestParam("code") String code, @RequestParam("redirectUri") String redirectUri) { diff --git a/src/main/java/com/matzip/lottery/controller/LotteryEventController.java b/src/main/java/com/matzip/lottery/controller/LotteryEventController.java index 3e137ab..18e2241 100644 --- a/src/main/java/com/matzip/lottery/controller/LotteryEventController.java +++ b/src/main/java/com/matzip/lottery/controller/LotteryEventController.java @@ -8,6 +8,8 @@ import com.matzip.lottery.dto.response.LotteryEventView; import com.matzip.lottery.dto.response.ParticipatedEventResponseDto; import com.matzip.lottery.service.LotteryEventService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "이벤트", description = "추첨 이벤트 조회 및 응모 API") @RequestMapping("/api/v1/events") @RestController public class LotteryEventController { @@ -29,6 +32,7 @@ public LotteryEventController(LotteryEventService lotteryEventService) { this.lotteryEventService = lotteryEventService; } + @Operation(summary = "현재 진행 중인 이벤트 조회", description = "진행 중인 추첨 이벤트를 조회한다. 비로그인 시 익명 응답, 로그인 시 사용 티켓 수 등이 포함된다.") @GetMapping public ApiResponse findEvent(@AuthenticationPrincipal UserPrincipal user) { Long userId = (user != null) ? user.getUserId() : null; @@ -36,12 +40,18 @@ public ApiResponse findEvent(@AuthenticationPrincipal UserPrin return ApiResponse.success(data); } + @Operation(summary = "내가 응모한 이벤트 목록", description = "인증된 사용자가 응모한 이벤트 목록을 조회한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증이 필요합니다.") @GetMapping("/entries") public ApiResponse> getParticipatedEvents(@AuthenticationPrincipal UserPrincipal user) { List data = lotteryEventService.getParticipatedEvents(user.getUserId()); return ApiResponse.success(data); } + @Operation(summary = "이벤트 응모 결과 조회", description = "특정 이벤트에 대한 응모/당첨 결과를 조회한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증이 필요합니다.") @GetMapping("/{eventId}/entries") public ApiResponse getEntryResult(@PathVariable Long eventId, @AuthenticationPrincipal UserPrincipal user) { @@ -49,6 +59,9 @@ public ApiResponse getEntryResult(@PathVariable Lon return ApiResponse.success(data); } + @Operation(summary = "이벤트 응모", description = "연락처와 약관 동의 정보로 이벤트에 응모한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "응모 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증이 필요합니다.") @PostMapping("/{eventId}/apply") public ApiResponse applyForPrize(@PathVariable Long eventId, @Validated @RequestBody ApplyEventRequestDto request, diff --git a/src/main/java/com/matzip/lottery/dto/response/ApplyEventResponseDto.java b/src/main/java/com/matzip/lottery/dto/response/ApplyEventResponseDto.java index 4896d63..707754e 100644 --- a/src/main/java/com/matzip/lottery/dto/response/ApplyEventResponseDto.java +++ b/src/main/java/com/matzip/lottery/dto/response/ApplyEventResponseDto.java @@ -1,16 +1,19 @@ package com.matzip.lottery.dto.response; import com.matzip.lottery.domain.WinnerContact; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Builder public record ApplyEventResponseDto( - String phoneNumber, + @Schema(description = "연락처", example = "010-1234-5678") String phoneNumber, AgreementsResponse agreements ) { - public record AgreementsResponse(boolean termsAgreed, boolean privacyAgreed) { + public record AgreementsResponse( + @Schema(description = "이용약관 동의 여부", example = "true") boolean termsAgreed, + @Schema(description = "개인정보 수집 동의 여부", example = "true") boolean privacyAgreed) { } public static ApplyEventResponseDto from(WinnerContact contact) { diff --git a/src/main/java/com/matzip/lottery/dto/response/EventEntryResultResponseDto.java b/src/main/java/com/matzip/lottery/dto/response/EventEntryResultResponseDto.java index 9b100fc..be8aa5a 100644 --- a/src/main/java/com/matzip/lottery/dto/response/EventEntryResultResponseDto.java +++ b/src/main/java/com/matzip/lottery/dto/response/EventEntryResultResponseDto.java @@ -1,20 +1,21 @@ package com.matzip.lottery.dto.response; import com.matzip.lottery.domain.LotteryEvent; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.time.LocalDateTime; @Builder public record EventEntryResultResponseDto( - Long eventId, + @Schema(description = "이벤트 ID", example = "3") Long eventId, LotteryEventResponseDto.PrizeResponse prize, - int totalWinnersCount, - int participantsCount, - int usedTicketsCount, - boolean isWinner, - LocalDateTime eventEndDate, - boolean isPhoneSubmitted + @Schema(description = "총 당첨자 수", example = "3") int totalWinnersCount, + @Schema(description = "참여자 수", example = "27") int participantsCount, + @Schema(description = "사용한 응모권 수", example = "3") int usedTicketsCount, + @Schema(description = "당첨 여부", example = "true") boolean isWinner, + @Schema(description = "이벤트 종료 일시", example = "2025-08-21T00:00:00") LocalDateTime eventEndDate, + @Schema(description = "연락처 제출 여부", example = "true") boolean isPhoneSubmitted ) { public static EventEntryResultResponseDto of(LotteryEvent event, int participantsCount, int usedTicketsCount, diff --git a/src/main/java/com/matzip/lottery/dto/response/LotteryEventAnonymousResponseDto.java b/src/main/java/com/matzip/lottery/dto/response/LotteryEventAnonymousResponseDto.java index 7548704..933ca71 100644 --- a/src/main/java/com/matzip/lottery/dto/response/LotteryEventAnonymousResponseDto.java +++ b/src/main/java/com/matzip/lottery/dto/response/LotteryEventAnonymousResponseDto.java @@ -1,13 +1,18 @@ package com.matzip.lottery.dto.response; import com.matzip.lottery.domain.LotteryEvent; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.time.LocalDateTime; @Builder -public record LotteryEventAnonymousResponseDto(Long eventId, LotteryEventResponseDto.PrizeResponse prize, - int totalWinnersCount, int participantsCount, LocalDateTime eventEndDate) +public record LotteryEventAnonymousResponseDto( + @Schema(description = "이벤트 ID", example = "3") Long eventId, + LotteryEventResponseDto.PrizeResponse prize, + @Schema(description = "총 당첨자 수", example = "3") int totalWinnersCount, + @Schema(description = "참여자 수", example = "27") int participantsCount, + @Schema(description = "이벤트 종료 일시", example = "2025-08-21T00:00:00") LocalDateTime eventEndDate) implements LotteryEventView { public static LotteryEventAnonymousResponseDto empty() { diff --git a/src/main/java/com/matzip/lottery/dto/response/LotteryEventResponseDto.java b/src/main/java/com/matzip/lottery/dto/response/LotteryEventResponseDto.java index d365cc2..795c50c 100644 --- a/src/main/java/com/matzip/lottery/dto/response/LotteryEventResponseDto.java +++ b/src/main/java/com/matzip/lottery/dto/response/LotteryEventResponseDto.java @@ -2,13 +2,19 @@ import com.matzip.lottery.domain.LotteryEvent; import com.matzip.lottery.domain.Prize; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.time.LocalDateTime; @Builder -public record LotteryEventResponseDto(Long eventId, PrizeResponse prize, int totalWinnersCount, int participantsCount, - int usedTicketsCount, LocalDateTime eventEndDate) +public record LotteryEventResponseDto( + @Schema(description = "이벤트 ID", example = "3") Long eventId, + PrizeResponse prize, + @Schema(description = "총 당첨자 수", example = "3") int totalWinnersCount, + @Schema(description = "참여자 수", example = "27") int participantsCount, + @Schema(description = "사용한 응모권 수", example = "3") int usedTicketsCount, + @Schema(description = "이벤트 종료 일시", example = "2025-08-21T00:00:00") LocalDateTime eventEndDate) implements LotteryEventView { public static LotteryEventResponseDto empty() { @@ -28,7 +34,9 @@ public static LotteryEventResponseDto of(LotteryEvent lotteryEvent, int particip } @Builder - record PrizeResponse(String description, String imageUrl) { + record PrizeResponse( + @Schema(description = "경품 설명", example = "BHC 뿌링클 치킨 기프티콘 1장") String description, + @Schema(description = "경품 이미지 URL", example = "https://example.com/images/bhc_bburinkle_chicken.png") String imageUrl) { public static PrizeResponse from(Prize prize) { return PrizeResponse.builder() diff --git a/src/main/java/com/matzip/lottery/dto/response/ParticipatedEventResponseDto.java b/src/main/java/com/matzip/lottery/dto/response/ParticipatedEventResponseDto.java index 5b6ba93..d5761e7 100644 --- a/src/main/java/com/matzip/lottery/dto/response/ParticipatedEventResponseDto.java +++ b/src/main/java/com/matzip/lottery/dto/response/ParticipatedEventResponseDto.java @@ -1,17 +1,18 @@ package com.matzip.lottery.dto.response; import com.matzip.lottery.domain.LotteryEvent; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import java.time.LocalDateTime; @Builder public record ParticipatedEventResponseDto( - Long eventId, + @Schema(description = "이벤트 ID", example = "3") Long eventId, LotteryEventResponseDto.PrizeResponse prize, - int totalWinnersCount, - int participantsCount, - LocalDateTime eventEndDate + @Schema(description = "총 당첨자 수", example = "3") int totalWinnersCount, + @Schema(description = "참여자 수", example = "27") int participantsCount, + @Schema(description = "이벤트 종료 일시", example = "2025-08-21T00:00:00") LocalDateTime eventEndDate ) { public static ParticipatedEventResponseDto of(LotteryEvent event, int participantsCount) { diff --git a/src/main/java/com/matzip/place/controller/CategoryController.java b/src/main/java/com/matzip/place/controller/CategoryController.java index 36bfdb7..a45c734 100644 --- a/src/main/java/com/matzip/place/controller/CategoryController.java +++ b/src/main/java/com/matzip/place/controller/CategoryController.java @@ -4,6 +4,8 @@ import com.matzip.place.dto.response.PlaceCommonResponseDto; import com.matzip.place.service.PlaceReadService; import com.matzip.place.domain.Campus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import com.matzip.place.service.CategoryService; @@ -14,6 +16,7 @@ import java.util.List; +@Tag(name = "카테고리", description = "카테고리 및 카테고리별 맛집 조회 API") @RestController @RequestMapping("/api/v1/categories") @RequiredArgsConstructor @@ -22,6 +25,7 @@ public class CategoryController { private final PlaceReadService placeReadService; private final CategoryService categoryService; + @Operation(summary = "카테고리별 맛집 목록", description = "특정 카테고리에 속한 캠퍼스별 맛집 목록을 조회한다.") @GetMapping("/{categoryId}/places") public ApiResponse> getPlacesByCategory( @PathVariable Long categoryId, @@ -31,6 +35,7 @@ public ApiResponse> getPlacesByCategory( return ApiResponse.success(places); } + @Operation(summary = "전체 카테고리 목록", description = "등록에 사용할 수 있는 전체 카테고리 목록을 조회한다.") @GetMapping public ApiResponse> getAllCategories() { List categories = categoryService.getAllCategories(); diff --git a/src/main/java/com/matzip/place/controller/PlaceController.java b/src/main/java/com/matzip/place/controller/PlaceController.java index 2d7188d..aceb3bb 100644 --- a/src/main/java/com/matzip/place/controller/PlaceController.java +++ b/src/main/java/com/matzip/place/controller/PlaceController.java @@ -11,6 +11,8 @@ import com.matzip.place.service.PlaceReadService; import com.matzip.place.service.PlaceService; import com.matzip.place.domain.Campus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -18,6 +20,7 @@ import java.util.List; +@Tag(name = "맛집", description = "맛집 등록·검색 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/places") @@ -27,6 +30,7 @@ public class PlaceController { private final PlaceReadService placeReadService; // 프리뷰 + @Operation(summary = "맛집 등록 프리뷰", description = "카카오 장소 ID로 등록 전 미리보기 정보를 조회한다.") @GetMapping("/preview") public ApiResponse preview(@Valid @ModelAttribute PlaceCheckRequestDto req) { PlaceCheckResponseDto data = placeService.preview(req); @@ -34,6 +38,7 @@ public ApiResponse preview(@Valid @ModelAttribute PlaceCh } // 등록 + @Operation(summary = "맛집 등록", description = "맛집 등록을 요청한다. 로그인 시 등록자 정보가 함께 저장된다.") @PostMapping public ApiResponse register( @Valid @RequestBody PlaceRequestDto req, @@ -48,6 +53,7 @@ public ApiResponse register( } // 가게 이름으로 검색 + @Operation(summary = "가게 이름으로 맛집 검색", description = "키워드와 캠퍼스로 가게 이름을 검색한다.") @GetMapping("/search") public ApiResponse> search( @RequestParam String keyword, @@ -57,6 +63,7 @@ public ApiResponse> search( } // 메뉴 이름으로 가게 검색 + @Operation(summary = "메뉴 이름으로 맛집 검색", description = "키워드와 캠퍼스로 메뉴 이름을 검색해 해당 메뉴를 파는 가게를 조회한다.") @GetMapping("/search/menu") public ApiResponse> searchByMenu( @RequestParam String keyword, diff --git a/src/main/java/com/matzip/place/controller/PlaceLikeController.java b/src/main/java/com/matzip/place/controller/PlaceLikeController.java index 9043057..6af4ab3 100644 --- a/src/main/java/com/matzip/place/controller/PlaceLikeController.java +++ b/src/main/java/com/matzip/place/controller/PlaceLikeController.java @@ -5,6 +5,8 @@ import com.matzip.place.dto.response.PlaceLikeResponseDto; import com.matzip.place.dto.response.PlaceCommonResponseDto; import com.matzip.place.service.PlaceLikeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.RestController; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -12,6 +14,7 @@ import java.util.List; +@Tag(name = "맛집 찜", description = "맛집 찜 추가·취소·목록 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/places") @@ -20,6 +23,9 @@ public class PlaceLikeController { private final PlaceLikeService placeLikeService; + @Operation(summary = "맛집 찜 추가", description = "맛집을 찜 목록에 추가한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "찜 추가 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증이 필요합니다.") @PostMapping("/{placeId}/like") public ApiResponse addLike( @PathVariable Long placeId, @@ -29,6 +35,9 @@ public ApiResponse addLike( return ApiResponse.success(response); } + @Operation(summary = "맛집 찜 취소", description = "맛집을 찜 목록에서 제거한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "찜 취소 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증이 필요합니다.") @DeleteMapping("/{placeId}/like") public ApiResponse removeLike( @PathVariable Long placeId, @@ -38,6 +47,9 @@ public ApiResponse removeLike( return ApiResponse.success(response); } + @Operation(summary = "내가 찜한 맛집 목록", description = "인증된 사용자가 찜한 맛집 목록을 조회한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증이 필요합니다.") @GetMapping("/like") public ApiResponse> getMyLikedPlaces( @AuthenticationPrincipal UserPrincipal userPrincipal) { diff --git a/src/main/java/com/matzip/place/controller/PlaceReadController.java b/src/main/java/com/matzip/place/controller/PlaceReadController.java index 281a451..926239b 100644 --- a/src/main/java/com/matzip/place/controller/PlaceReadController.java +++ b/src/main/java/com/matzip/place/controller/PlaceReadController.java @@ -10,12 +10,15 @@ import com.matzip.place.service.PlaceReadService; import com.matzip.place.domain.Campus; import com.matzip.place.domain.SortType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; +@Tag(name = "맛집", description = "맛집 조회 API") @RestController @RequestMapping("/api/v1/places") @RequiredArgsConstructor @@ -23,6 +26,9 @@ public class PlaceReadController { private final PlaceReadService placeReadService; + @Operation(summary = "맛집 상세 조회", description = "맛집 상세 정보를 조회한다. 로그인 시 찜 여부(isLiked)가 함께 내려간다.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "맛집을 찾을 수 없습니다.") @GetMapping("/{placeId}") public ApiResponse getPlaceDetail( @PathVariable Long placeId, @@ -34,6 +40,7 @@ public ApiResponse getPlaceDetail( return ApiResponse.success(placeDetail); } + @Operation(summary = "지도 범위 내 맛집 조회", description = "지도 좌표 범위(bounds) 내의 맛집 목록을 조회한다.") @GetMapping public ApiResponse> getPlacesInMap( @Valid @ModelAttribute MapSearchRequestDto requestDto) { @@ -42,6 +49,7 @@ public ApiResponse> getPlacesInMap( return ApiResponse.success(places); } + @Operation(summary = "맛집 랭킹 조회", description = "캠퍼스별로 정렬 기준(좋아요/조회수/최신)에 따른 맛집 랭킹을 조회한다.") @GetMapping("/ranking") public ApiResponse> getRanking( @RequestParam SortType sort, diff --git a/src/main/java/com/matzip/place/controller/PlaceRequestController.java b/src/main/java/com/matzip/place/controller/PlaceRequestController.java index 92a0cd8..3de670f 100644 --- a/src/main/java/com/matzip/place/controller/PlaceRequestController.java +++ b/src/main/java/com/matzip/place/controller/PlaceRequestController.java @@ -8,6 +8,8 @@ import com.matzip.place.dto.response.PlaceRegisterStatusDetailResponseDto; import com.matzip.place.dto.response.PlaceRegisterStatusResponseDto; import com.matzip.place.service.PlaceReadService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -17,6 +19,7 @@ import java.util.List; +@Tag(name = "맛집 등록 요청", description = "내 맛집 등록 요청 현황 조회 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/requests/places") @@ -24,6 +27,9 @@ public class PlaceRequestController { private final PlaceReadService placeReadService; + @Operation(summary = "내 맛집 등록 요청 목록", description = "인증된 사용자가 등록 요청한 맛집들의 승인 상태 목록을 조회한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "로그인이 필요합니다.") @GetMapping public ApiResponse> getMyPlaceRequests( @AuthenticationPrincipal UserPrincipal userPrincipal @@ -36,6 +42,9 @@ public ApiResponse> getMyPlaceRequests( return ApiResponse.success(response); } + @Operation(summary = "내 맛집 등록 요청 상세", description = "인증된 사용자의 특정 맛집 등록 요청 상세와 승인/거절 사유를 조회한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "로그인이 필요합니다.") @GetMapping("/{placeId}") public ApiResponse getMyPlaceRequestDetail( @PathVariable Long placeId, diff --git a/src/main/java/com/matzip/place/dto/CategoryDto.java b/src/main/java/com/matzip/place/dto/CategoryDto.java index 62b9f41..fdf1e43 100644 --- a/src/main/java/com/matzip/place/dto/CategoryDto.java +++ b/src/main/java/com/matzip/place/dto/CategoryDto.java @@ -1,14 +1,21 @@ package com.matzip.place.dto; import com.matzip.place.domain.entity.Category; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @Getter @Builder public class CategoryDto { + + @Schema(description = "카테고리 ID", example = "5") private Long id; + + @Schema(description = "카테고리 이름", example = "고기·구이") private String name; + + @Schema(description = "아이콘 키", example = "Meat") private String iconKey; public static CategoryDto from(Category category) { diff --git a/src/main/java/com/matzip/place/dto/LocationDto.java b/src/main/java/com/matzip/place/dto/LocationDto.java index 9a6c679..6fd1c3a 100644 --- a/src/main/java/com/matzip/place/dto/LocationDto.java +++ b/src/main/java/com/matzip/place/dto/LocationDto.java @@ -1,5 +1,6 @@ package com.matzip.place.dto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @@ -7,7 +8,10 @@ @Builder public class LocationDto { + @Schema(description = "위도", example = "37.625") private double latitude; + + @Schema(description = "경도", example = "127.151") private double longitude; public static LocationDto of(double latitude, double longitude) { diff --git a/src/main/java/com/matzip/place/dto/PhotoDto.java b/src/main/java/com/matzip/place/dto/PhotoDto.java index 8ebd16b..d851ee4 100644 --- a/src/main/java/com/matzip/place/dto/PhotoDto.java +++ b/src/main/java/com/matzip/place/dto/PhotoDto.java @@ -1,14 +1,21 @@ package com.matzip.place.dto; import com.matzip.place.domain.entity.Photo; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @Getter @Builder public class PhotoDto { + + @Schema(description = "사진 ID", example = "101") private Long photoId; + + @Schema(description = "사진 URL", example = "https://example.com/spots/photo1.jpg") private String photoUrl; + + @Schema(description = "노출 순서(0부터)", example = "0") private Integer displayOrder; public static PhotoDto from(Photo photo) { diff --git a/src/main/java/com/matzip/place/dto/TagDto.java b/src/main/java/com/matzip/place/dto/TagDto.java index 198f245..88d99d4 100644 --- a/src/main/java/com/matzip/place/dto/TagDto.java +++ b/src/main/java/com/matzip/place/dto/TagDto.java @@ -1,6 +1,7 @@ package com.matzip.place.dto; import com.matzip.place.domain.entity.Tag; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -8,8 +9,13 @@ @Builder public class TagDto { + @Schema(description = "태그 ID", example = "3") private Long id; + + @Schema(description = "태그 이름", example = "혼밥하기 좋은") private String name; + + @Schema(description = "아이콘 키", example = "fingerUp") private String iconKey; public static TagDto from(Tag tag) { diff --git a/src/main/java/com/matzip/place/dto/response/MapSearchResponseDto.java b/src/main/java/com/matzip/place/dto/response/MapSearchResponseDto.java index 12b640c..7e92340 100644 --- a/src/main/java/com/matzip/place/dto/response/MapSearchResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/MapSearchResponseDto.java @@ -8,6 +8,7 @@ import com.matzip.place.domain.entity.Photo; import com.matzip.place.domain.entity.Place; import com.matzip.place.domain.entity.Tag; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -18,9 +19,15 @@ @Builder public class MapSearchResponseDto { + @Schema(description = "맛집 ID", example = "15") private Long placeId; + + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private String placeName; + + @Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") private String address; + private LocationDto location; private List photos; private List categories; diff --git a/src/main/java/com/matzip/place/dto/response/MenuResponseDto.java b/src/main/java/com/matzip/place/dto/response/MenuResponseDto.java index c5d99cc..2a8fc00 100644 --- a/src/main/java/com/matzip/place/dto/response/MenuResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/MenuResponseDto.java @@ -2,16 +2,24 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.matzip.place.domain.entity.Menu; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @Getter @Builder public class MenuResponseDto { + + @Schema(description = "메뉴 ID", example = "202") private Long menuId; + + @Schema(description = "메뉴 이름", example = "숙성우대갈비") private String name; + + @Schema(description = "가격(원)", example = "32000") private int price; + @Schema(description = "대표 메뉴 여부", example = "true") @Getter(onMethod_ = @JsonProperty("isRecommended")) private boolean isRecommended; diff --git a/src/main/java/com/matzip/place/dto/response/PlaceCheckResponseDto.java b/src/main/java/com/matzip/place/dto/response/PlaceCheckResponseDto.java index 35461fb..56b76a9 100644 --- a/src/main/java/com/matzip/place/dto/response/PlaceCheckResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/PlaceCheckResponseDto.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.matzip.place.dto.LocationDto; import com.matzip.place.dto.PhotoDto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -12,10 +13,15 @@ @Builder public class PlaceCheckResponseDto { + @Schema(description = "이미 등록된 가게인지 여부", example = "false") private Boolean alreadyRegistered; // 우리 서비스에 이미 등록된 가게인지 여부 + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private String placeName; + + @Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") private String address; + private LocationDto location; // 미등록 프리뷰: 실제 사진/메뉴로 채움 @@ -26,10 +32,17 @@ public class PlaceCheckResponseDto { @Getter @Builder public static class MenuItem { + + @Schema(description = "메뉴 ID", example = "202") private Long menuId; + + @Schema(description = "메뉴 이름", example = "숙성우대갈비") private String name; + + @Schema(description = "가격(원)", example = "32000") private int price; + @Schema(description = "대표 메뉴 여부", example = "true") @Getter(onMethod_ = @JsonProperty("isRecommended")) private boolean isRecommended; // 프리뷰 기본값: false } diff --git a/src/main/java/com/matzip/place/dto/response/PlaceCommonResponseDto.java b/src/main/java/com/matzip/place/dto/response/PlaceCommonResponseDto.java index ba462bd..f797c42 100644 --- a/src/main/java/com/matzip/place/dto/response/PlaceCommonResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/PlaceCommonResponseDto.java @@ -5,6 +5,7 @@ import com.matzip.place.domain.entity.Category; import com.matzip.place.domain.entity.Place; import com.matzip.place.domain.entity.Tag; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -14,9 +15,16 @@ @Getter @Builder public class PlaceCommonResponseDto { + + @Schema(description = "맛집 ID", example = "15") private final Long placeId; + + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private final String placeName; + + @Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") private final String address; + private final List categories; private final List tags; diff --git a/src/main/java/com/matzip/place/dto/response/PlaceDetailResponseDto.java b/src/main/java/com/matzip/place/dto/response/PlaceDetailResponseDto.java index 17f0235..8adcbfd 100644 --- a/src/main/java/com/matzip/place/dto/response/PlaceDetailResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/PlaceDetailResponseDto.java @@ -10,6 +10,7 @@ import com.matzip.place.dto.LocationDto; import com.matzip.place.dto.PhotoDto; import com.matzip.place.dto.TagDto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -20,16 +21,26 @@ @Builder public class PlaceDetailResponseDto { + @Schema(description = "맛집 ID", example = "15") private Long placeId; + + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private String placeName; + + @Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") private String address; + private LocationDto location; private List photos; + + @Schema(description = "맛집 설명", example = "직원이 엄청 친절해요! 근데 화장실에 갔고 냄새나요 ㅠㅠㅠ 그래도 맛은 있어서 괜찮아요") private String description; + private List menus; private List tags; private List categories; + @Schema(description = "로그인 사용자의 찜 여부", example = "true") @Getter(onMethod_ = @JsonProperty("isLiked")) private boolean isLiked; diff --git a/src/main/java/com/matzip/place/dto/response/PlaceMenuSearchResponseDto.java b/src/main/java/com/matzip/place/dto/response/PlaceMenuSearchResponseDto.java index 108577b..11301b6 100644 --- a/src/main/java/com/matzip/place/dto/response/PlaceMenuSearchResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/PlaceMenuSearchResponseDto.java @@ -1,6 +1,7 @@ package com.matzip.place.dto.response; import com.matzip.place.domain.entity.Menu; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -8,8 +9,13 @@ @Builder public class PlaceMenuSearchResponseDto { + @Schema(description = "맛집 ID", example = "15") private Long placeId; + + @Schema(description = "메뉴 이름", example = "숙성우대갈비") private String menuName; + + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private String placeName; public static PlaceMenuSearchResponseDto from(Menu menu) { diff --git a/src/main/java/com/matzip/place/dto/response/PlaceRegisterResponseDto.java b/src/main/java/com/matzip/place/dto/response/PlaceRegisterResponseDto.java index c21afe3..ca6aa37 100644 --- a/src/main/java/com/matzip/place/dto/response/PlaceRegisterResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/PlaceRegisterResponseDto.java @@ -5,6 +5,7 @@ import com.matzip.place.domain.entity.Category; import com.matzip.place.domain.entity.Place; import com.matzip.place.domain.entity.Tag; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -15,9 +16,15 @@ @Builder public class PlaceRegisterResponseDto { + @Schema(description = "맛집 ID", example = "15") private Long placeId; + + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private String placeName; + + @Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") private String address; + private List categories; private List tags; diff --git a/src/main/java/com/matzip/place/dto/response/PlaceRegisterStatusDetailResponseDto.java b/src/main/java/com/matzip/place/dto/response/PlaceRegisterStatusDetailResponseDto.java index 1bf2782..e49bc58 100644 --- a/src/main/java/com/matzip/place/dto/response/PlaceRegisterStatusDetailResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/PlaceRegisterStatusDetailResponseDto.java @@ -9,6 +9,7 @@ import com.matzip.place.dto.LocationDto; import com.matzip.place.dto.PhotoDto; import com.matzip.place.dto.TagDto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -21,17 +22,33 @@ @Builder public class PlaceRegisterStatusDetailResponseDto { + @Schema(description = "맛집 ID", example = "15") private Long placeId; + + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private String placeName; + + @Schema(description = "등록 요청일", example = "2025-08-12") private LocalDate requestDate; + private List photos; + + @Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") private String address; + private LocationDto location; + + @Schema(description = "맛집 설명", example = "직원이 엄청 친절해요! 근데 화장실에 갔고 냄새나요 ㅠㅠㅠ 그래도 맛은 있어서 괜찮아요") private String description; + private List menus; private List categories; private List tags; + + @Schema(description = "등록 상태", example = "REJECTED") private String registerStatus; + + @Schema(description = "거절 사유(거절 시)", example = "메뉴 정보가 부족합니다.") private String rejectedReason; public static PlaceRegisterStatusDetailResponseDto from(Place place, diff --git a/src/main/java/com/matzip/place/dto/response/PlaceRegisterStatusResponseDto.java b/src/main/java/com/matzip/place/dto/response/PlaceRegisterStatusResponseDto.java index 64e8036..10e6e31 100644 --- a/src/main/java/com/matzip/place/dto/response/PlaceRegisterStatusResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/PlaceRegisterStatusResponseDto.java @@ -2,6 +2,7 @@ import com.matzip.place.domain.entity.Place; import com.matzip.place.dto.CategoryDto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -14,10 +15,18 @@ @Builder public class PlaceRegisterStatusResponseDto { + @Schema(description = "맛집 ID", example = "15") private Long placeId; + + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private String placeName; + + @Schema(description = "등록 요청일", example = "2025-08-12") private LocalDate requestDate; + private List categories; + + @Schema(description = "등록 상태", example = "PENDING") private String registerStatus; public static PlaceRegisterStatusResponseDto from(Place place) { diff --git a/src/main/java/com/matzip/place/dto/response/PlaceSearchResponseDto.java b/src/main/java/com/matzip/place/dto/response/PlaceSearchResponseDto.java index c14e374..6a945db 100644 --- a/src/main/java/com/matzip/place/dto/response/PlaceSearchResponseDto.java +++ b/src/main/java/com/matzip/place/dto/response/PlaceSearchResponseDto.java @@ -1,6 +1,7 @@ package com.matzip.place.dto.response; import com.matzip.place.domain.entity.Place; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -8,8 +9,13 @@ @Builder public class PlaceSearchResponseDto { + @Schema(description = "맛집 ID", example = "15") private Long placeId; + + @Schema(description = "맛집 이름", example = "우돈탄 다산본점") private String placeName; + + @Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") private String address; public static PlaceSearchResponseDto from(Place place) { diff --git a/src/main/java/com/matzip/user/controller/UserController.java b/src/main/java/com/matzip/user/controller/UserController.java index 87744cf..d2a7d17 100644 --- a/src/main/java/com/matzip/user/controller/UserController.java +++ b/src/main/java/com/matzip/user/controller/UserController.java @@ -4,12 +4,15 @@ import com.matzip.common.security.UserPrincipal; import com.matzip.user.dto.response.UserProfileResponseDto; import com.matzip.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "사용자", description = "사용자 프로필 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/users") @@ -17,6 +20,9 @@ public class UserController { private final UserService userService; + @Operation(summary = "내 프로필 조회", description = "인증된 사용자의 프로필(닉네임/프로필 이미지/배경)을 조회한다. 인증 필요.") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증이 필요합니다.") @GetMapping("/me") public ApiResponse getMyProfile( @AuthenticationPrincipal UserPrincipal userPrincipal) { diff --git a/src/main/java/com/matzip/user/dto/response/UserProfileResponseDto.java b/src/main/java/com/matzip/user/dto/response/UserProfileResponseDto.java index 2723886..0aa1869 100644 --- a/src/main/java/com/matzip/user/dto/response/UserProfileResponseDto.java +++ b/src/main/java/com/matzip/user/dto/response/UserProfileResponseDto.java @@ -1,6 +1,7 @@ package com.matzip.user.dto.response; import com.matzip.user.domain.User; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @@ -8,8 +9,13 @@ @Builder public class UserProfileResponseDto { + @Schema(description = "닉네임", example = "행복한 다람쥐") private String nickname; + + @Schema(description = "프로필 이미지 URL", example = "https://knu-matzip.co.kr/images/profiles/tiger.png") private String profileImageUrl; + + @Schema(description = "프로필 배경 색상 HEX 코드", example = "BEE1E6") private String profileBackgroundHexCode; public static UserProfileResponseDto from(User user, String imageBaseUrl) { diff --git a/src/test/java/com/matzip/common/config/OpenApiSmokeTest.java b/src/test/java/com/matzip/common/config/OpenApiSmokeTest.java index b7866b2..0bd9c81 100644 --- a/src/test/java/com/matzip/common/config/OpenApiSmokeTest.java +++ b/src/test/java/com/matzip/common/config/OpenApiSmokeTest.java @@ -2,6 +2,8 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -27,6 +29,34 @@ class OpenApiSmokeTest { assertThat(response.getBody()).doesNotContain("/api/v1/auth/dev/token"); } + @Test + void apiDocs_컨트롤러_어노테이션_반영() { + ResponseEntity response = restTemplate.getForEntity("/v3/api-docs", String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // @Tag: 도메인 한글 태그로 그룹화 + assertThat(response.getBody()).contains("\"맛집\""); + // @Operation: 엔드포인트 요약 + assertThat(response.getBody()).contains("맛집 상세 조회"); + } + + @Test + void 모든_엔드포인트에_200_응답_존재() throws Exception { + // 에러 @ApiResponse만 선언하면 springdoc가 기본 200을 대체해 성공 응답이 누락된다. + // 모든 오퍼레이션에 200이 남아있는지 회귀 방지로 검증한다. + ResponseEntity response = restTemplate.getForEntity("/v3/api-docs", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + JsonNode paths = new ObjectMapper().readTree(response.getBody()).get("paths"); + paths.fields().forEachRemaining(pathEntry -> + pathEntry.getValue().fields().forEachRemaining(opEntry -> { + JsonNode responses = opEntry.getValue().get("responses"); + assertThat(responses.has("200")) + .as("%s %s 에 200 응답이 있어야 한다", opEntry.getKey(), pathEntry.getKey()) + .isTrue(); + })); + } + @Test void swaggerUi_토큰없이_401아님() { ResponseEntity response = restTemplate.getForEntity("/swagger-ui/index.html", String.class); From 962f5a96adc080d7a3bed4d58dfca4afdeae4ea6 Mon Sep 17 00:00:00 2001 From: KimGiHyeon Date: Thu, 17 Sep 2026 00:41:25 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[refactor]=20=EC=84=B1=EA=B3=B5/=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=ED=83=80=EC=9E=85=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20data,=20error,=20timestamp=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC=20(#174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- .../exception/GlobalExceptionHandler.java | 42 ++++++++-------- .../matzip/common/response/ApiResponse.java | 48 ++----------------- .../matzip/common/response/ErrorResponse.java | 38 +++++++++++++++ .../jwt/filter/JwtAuthenticationFilter.java | 6 +-- 5 files changed, 67 insertions(+), 69 deletions(-) create mode 100644 src/main/java/com/matzip/common/response/ErrorResponse.java diff --git a/CLAUDE.md b/CLAUDE.md index dbe611c..08f323c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ controller -> service -> repository -> DB ### DTO -- 모든 DTO는 `Dto` 접미사를 붙인다. 유일한 예외는 응답 래퍼 `ApiResponse`. +- 모든 DTO는 `Dto` 접미사를 붙인다. 유일한 예외는 응답 래퍼 `ApiResponse`, `ErrorResponse`다. - HTTP 요청/응답 DTO는 `dto/request`·`dto/response`. 요청/응답이 공유하는 조각은 `dto/` 루트. 외부 API 응답 파싱 DTO는 해당 `client/.../dto`. - 프레임워크·엔티티 타입을 응답 DTO 대신 그대로 노출하지 않는다. diff --git a/src/main/java/com/matzip/common/exception/GlobalExceptionHandler.java b/src/main/java/com/matzip/common/exception/GlobalExceptionHandler.java index 1493077..d881444 100644 --- a/src/main/java/com/matzip/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/matzip/common/exception/GlobalExceptionHandler.java @@ -1,7 +1,7 @@ package com.matzip.common.exception; import com.matzip.common.exception.code.ErrorCode; -import com.matzip.common.response.ApiResponse; +import com.matzip.common.response.ErrorResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -25,7 +25,7 @@ public class GlobalExceptionHandler { * BaseException을 상속받는 모든 커스텀 예외 처리 */ @ExceptionHandler(BaseException.class) - protected ResponseEntity> handleBaseException(BaseException e) { + protected ResponseEntity handleBaseException(BaseException e) { HttpStatus status = e.getErrorCode().getStatus(); // 5xx(서버/외부연동 장애)만 스택트레이스와 함께 error, 4xx(예상된 규칙 위반)는 warn if (status.is5xxServerError()) { @@ -35,54 +35,54 @@ protected ResponseEntity> handleBaseException(BaseException e) } return ResponseEntity .status(status) - .body(ApiResponse.error(e.getErrorCode(), e.getMessage())); + .body(ErrorResponse.error(e.getErrorCode(), e.getMessage())); } /** * @Valid 검증 실패 예외 처리 */ @ExceptionHandler(MethodArgumentNotValidException.class) - protected ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { log.warn("MethodArgumentNotValidException: {}", e.getMessage()); String detail = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage(); return ResponseEntity .status(ErrorCode.VALIDATION_ERROR.getStatus()) - .body(ApiResponse.error(ErrorCode.VALIDATION_ERROR, detail)); + .body(ErrorResponse.error(ErrorCode.VALIDATION_ERROR, detail)); } /** * BindException 처리 */ @ExceptionHandler(BindException.class) - protected ResponseEntity> handleBindException(BindException e) { + protected ResponseEntity handleBindException(BindException e) { log.warn("BindException: {}", e.getMessage()); String detail = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage(); return ResponseEntity .status(ErrorCode.VALIDATION_ERROR.getStatus()) - .body(ApiResponse.error(ErrorCode.VALIDATION_ERROR, detail)); + .body(ErrorResponse.error(ErrorCode.VALIDATION_ERROR, detail)); } /** * HTTP 메서드 지원하지 않는 예외 처리 */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - protected ResponseEntity> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { + protected ResponseEntity handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) { log.warn("HttpRequestMethodNotSupportedException: {}", e.getMessage()); return ResponseEntity .status(ErrorCode.METHOD_NOT_ALLOWED.getStatus()) - .body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED)); + .body(ErrorResponse.error(ErrorCode.METHOD_NOT_ALLOWED)); } /** * 요청 파라미터 누락 예외 처리 */ @ExceptionHandler(MissingServletRequestParameterException.class) - protected ResponseEntity> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + protected ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { log.warn("MissingServletRequestParameterException: {}", e.getMessage()); String message = getMissingParameterMessage(e.getParameterName()); return ResponseEntity .status(ErrorCode.INVALID_INPUT_VALUE.getStatus()) - .body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE, message)); + .body(ErrorResponse.error(ErrorCode.INVALID_INPUT_VALUE, message)); } private String getMissingParameterMessage(String parameterName) { @@ -99,54 +99,54 @@ private String getMissingParameterMessage(String parameterName) { * HTTP 메시지 읽기 실패 예외 처리 */ @ExceptionHandler(HttpMessageNotReadableException.class) - protected ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { log.warn("HttpMessageNotReadableException: {}", e.getMessage()); return ResponseEntity .status(ErrorCode.INVALID_INPUT_VALUE.getStatus()) - .body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE, "잘못된 요청 본문입니다.")); + .body(ErrorResponse.error(ErrorCode.INVALID_INPUT_VALUE, "잘못된 요청 본문입니다.")); } /** * 메서드 인자 타입 불일치 예외 처리 */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) - protected ResponseEntity> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { + protected ResponseEntity handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) { log.warn("MethodArgumentTypeMismatchException: {}", e.getMessage()); return ResponseEntity .status(ErrorCode.INVALID_TYPE_VALUE.getStatus()) - .body(ApiResponse.error(ErrorCode.INVALID_TYPE_VALUE, e.getMessage())); + .body(ErrorResponse.error(ErrorCode.INVALID_TYPE_VALUE, e.getMessage())); } /** * 매핑/정적 리소스 모두 없을 때 */ @ExceptionHandler(NoResourceFoundException.class) - protected ResponseEntity> handleNoResourceFound(NoResourceFoundException e) { + protected ResponseEntity handleNoResourceFound(NoResourceFoundException e) { log.warn("NoResourceFoundException: {}", e.getMessage()); return ResponseEntity .status(ErrorCode.NOT_FOUND.getStatus()) - .body(ApiResponse.error(ErrorCode.NOT_FOUND, ErrorCode.NOT_FOUND.getMessage())); + .body(ErrorResponse.error(ErrorCode.NOT_FOUND, ErrorCode.NOT_FOUND.getMessage())); } /** * 핸들러를 찾을 수 없는 예외 처리 */ @ExceptionHandler(NoHandlerFoundException.class) - protected ResponseEntity> handleNoHandlerFoundException(NoHandlerFoundException e) { + protected ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException e) { log.warn("NoHandlerFoundException: {}", e.getMessage()); return ResponseEntity .status(ErrorCode.INVALID_INPUT_VALUE.getStatus()) - .body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE, "요청한 리소스를 찾을 수 없습니다.")); + .body(ErrorResponse.error(ErrorCode.INVALID_INPUT_VALUE, "요청한 리소스를 찾을 수 없습니다.")); } /** * 기타 모든 예외 처리 */ @ExceptionHandler(Exception.class) - protected ResponseEntity> handleException(Exception e) { + protected ResponseEntity handleException(Exception e) { log.error("Unexpected Exception: {}", e.getMessage(), e); return ResponseEntity .status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()) - .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR)); + .body(ErrorResponse.error(ErrorCode.INTERNAL_SERVER_ERROR)); } } diff --git a/src/main/java/com/matzip/common/response/ApiResponse.java b/src/main/java/com/matzip/common/response/ApiResponse.java index 23551d5..ce6433e 100644 --- a/src/main/java/com/matzip/common/response/ApiResponse.java +++ b/src/main/java/com/matzip/common/response/ApiResponse.java @@ -1,38 +1,28 @@ package com.matzip.common.response; -import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; -import com.matzip.common.exception.code.ErrorCode; import lombok.Builder; import lombok.Getter; -import java.time.LocalDateTime; - import static java.util.Collections.*; + @Getter @Builder @JsonInclude(JsonInclude.Include.NON_NULL) public class ApiResponse { - + private final String status; - - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private final LocalDateTime timestamp; - + @JsonInclude(JsonInclude.Include.ALWAYS) private final T data; - - @JsonInclude(JsonInclude.Include.NON_NULL) - private final ErrorInfo error; - + /** * 성공 응답 생성 */ public static ApiResponse success(T data) { return ApiResponse.builder() .status("OK") - .timestamp(LocalDateTime.now()) .data(data) .build(); } @@ -44,7 +34,6 @@ public static ApiResponse success(T data) { public static ApiResponse successWithoutData() { return ApiResponse.builder() .status("OK") - .timestamp(LocalDateTime.now()) // data 필드는 null로 유지 .build(); } @@ -55,36 +44,7 @@ public static ApiResponse successWithoutData() { public static ApiResponse successWithEmptyList() { return ApiResponse.builder() .status("OK") - .timestamp(LocalDateTime.now()) .data((T) emptyList()) // data 필드에 빈 리스트를 명시적으로 할당 .build(); } - - /** - * 실패 응답 생성 (ErrorCode 사용) - */ - public static ApiResponse error(ErrorCode errorCode) { - return error(errorCode, errorCode.getMessage()); - } - - /** - * 실패 응답 생성 (ErrorCode + 상세 메시지) - */ - public static ApiResponse error(ErrorCode errorCode, String detailMessage) { - return ApiResponse.builder() - .status("ERROR") - .timestamp(LocalDateTime.now()) - .error(ErrorInfo.builder() - .code(errorCode.name()) - .message(detailMessage) - .build()) - .build(); - } - - @Getter - @Builder - public static class ErrorInfo { - private final String code; - private final String message; - } } diff --git a/src/main/java/com/matzip/common/response/ErrorResponse.java b/src/main/java/com/matzip/common/response/ErrorResponse.java new file mode 100644 index 0000000..7971ff9 --- /dev/null +++ b/src/main/java/com/matzip/common/response/ErrorResponse.java @@ -0,0 +1,38 @@ +package com.matzip.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.matzip.common.exception.code.ErrorCode; +import lombok.Builder; +import lombok.Getter; + + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponse { + + private final String status; + + private final ErrorInfo error; + + public static ErrorResponse error(ErrorCode errorCode) { + return error(errorCode, errorCode.getMessage()); + } + + public static ErrorResponse error(ErrorCode errorCode, String detailMessage) { + return ErrorResponse.builder() + .status("ERROR") + .error(ErrorInfo.builder() + .code(errorCode.name()) + .message(detailMessage) + .build()) + .build(); + } + + @Getter + @Builder + public static class ErrorInfo { + private final String code; + private final String message; + } +} diff --git a/src/main/java/com/matzip/common/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/matzip/common/security/jwt/filter/JwtAuthenticationFilter.java index bec7983..589af25 100644 --- a/src/main/java/com/matzip/common/security/jwt/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/matzip/common/security/jwt/filter/JwtAuthenticationFilter.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.matzip.common.exception.code.ErrorCode; -import com.matzip.common.response.ApiResponse; +import com.matzip.common.response.ErrorResponse; import com.matzip.common.security.jwt.JwtProvider; import com.matzip.common.security.UserPrincipal; import io.jsonwebtoken.JwtException; @@ -124,7 +124,7 @@ private void handleJwtException(HttpServletResponse response, JwtProvider.TokenV response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); - ApiResponse apiResponse = ApiResponse.error(errorCode); + ErrorResponse apiResponse = ErrorResponse.error(errorCode); String jsonResponse = objectMapper.writeValueAsString(apiResponse); response.getWriter().write(jsonResponse); } @@ -133,7 +133,7 @@ private void handleUnauthorized(HttpServletResponse response) throws IOException response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); - ApiResponse apiResponse = ApiResponse.error(ErrorCode.UNAUTHORIZED); + ErrorResponse apiResponse = ErrorResponse.error(ErrorCode.UNAUTHORIZED); String jsonResponse = objectMapper.writeValueAsString(apiResponse); response.getWriter().write(jsonResponse); }