Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 대신 그대로 노출하지 않는다.

Expand Down
158 changes: 118 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,64 +2,142 @@

> 캠퍼스 주변의 맛집들을 지금 바로 찾아보세요!

**[🔗 배포 링크](https://knu-matzip.vercel.app/)**
**[🔗 서비스 바로가기](https://knu-matzip.vercel.app/)**

<img width="1920" height="1080" alt="Frame 836" src="https://github.com/user-attachments/assets/ca63e59d-13b6-44bb-acca-d24f7feb3a88" />
<img width="1920" alt="KNU-Matzip" src="https://github.com/user-attachments/assets/ca63e59d-13b6-44bb-acca-d24f7feb3a88" />

## 프로젝트 소개
<br/>

### 🗓️ 기간
## 📖 프로젝트 소개

2025.09 ~ 현재 [운영 중]
**KNU-Matzip 서버**는 공주대학교 학생들을 위한 캠퍼스 맛집 큐레이션 서비스의 API 서버입니다.

### ⚙️ 기술 스택
- **기간**: 2025.09 ~ 현재 (운영 중)
- **역할**: 백엔드 설계 및 개발

#### Back-end
#### 문제 정의

![React](https://go-skill-icons.vercel.app/api/icons?i=java,spring,mysql,aws)
> 학교 주변 맛집 정보는 네이버 지도·검색으로 쉽게 찾을 수 있지만, 광고·협찬성 콘텐츠가 섞여 신뢰하기 어렵습니다. 그래서 학생들은 실제 경험 기반 후기를 얻으려 에브리타임을 찾지만, 후기는 시간이 지나면 밀려 사라지고 여러 게시글에 흩어져 있어 원하는 맛집 하나를 찾기 위해 매번 반복 검색해야 합니다.
>
> <img width="200" alt="문제 정의" src="https://github.com/user-attachments/assets/ad2cd326-e741-44a8-a89e-2d70655eee7a" />

#### Front-end
<br/>

![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
<img width="1265" height="1047" alt="knu-matzip excalidraw" src="https://github.com/user-attachments/assets/18840a9c-4d9d-4b55-9344-57d201d0a89e" />
![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

<details>
<summary>Front-end 스택 (별도 저장소)</summary>

### 📱 주요 기능
![Front-end](https://go-skill-icons.vercel.app/api/icons?i=ts,nextjs,zustand,reactquery,tailwind,storybook,pnpm,turborepo,googleanalytics,sentry&perline=5)

#### 1. 맛집 탐색
</details>

> 카테고리나 지도로 캠퍼스 근처 맛집을 쉽게 찾을 수 있습니다.
<img width="180" height="788" alt="IMG_2508-portrait 1" src="https://github.com/user-attachments/assets/48b5e7d1-9184-4ac6-99c7-cf1491028635" />
<img width="180" height="788" alt="IMG_2514-portrait 1" src="https://github.com/user-attachments/assets/24a1a29d-abb8-4136-9e09-4c869a73acaa" />
<img width="180" height="788" alt="IMG_2512-portrait 1" src="https://github.com/user-attachments/assets/a4fe04d5-0569-45b0-a657-7298e193e176" />
<img width="180" height="788" alt="IMG_2511-portrait 1" src="https://github.com/user-attachments/assets/d12e25c2-a218-49ae-bd5c-67f0aa59df40" />
<img width="180" height="2796" alt="IMG_2536-portrait" src="https://github.com/user-attachments/assets/38a65333-0def-4079-a661-56851edf8fac" />
<br/>

#### 2. 맛집 등록
> 캠퍼스, 식당, 추천 메뉴, 소개, 태그, 카테고리를 단계 별로 입력해 새로운 맛집을 등록 신청합니다.
<img width="180" height="788" alt="IMG_2521-portrait 1" src="https://github.com/user-attachments/assets/4f5f95f4-5de5-44c1-a79c-2f77498bf9a0" />
<img width="180" height="788" alt="IMG_2523-portrait 1" src="https://github.com/user-attachments/assets/09c1fe01-94d6-42ff-a2bc-63ee4a623dcd" />
<img width="180" height="788" alt="IMG_2524-portrait 1" src="https://github.com/user-attachments/assets/29786b0c-8037-4062-8e17-dd12e2a139bf" />
<img width="180" height="788" alt="IMG_2525-portrait 1" src="https://github.com/user-attachments/assets/88ee31e7-b8dc-4b68-a63e-1a94e69fc5d6" />
<img width="180" height="788" alt="IMG_2526-portrait 1" src="https://github.com/user-attachments/assets/552881ad-e02a-42e8-8fff-39eedce1926e" />
## 🏛️ 아키텍처

#### 3. 럭키 드로우 이벤트
> 맛집 등록으로 얻은 응모권을 통해 기프티콘 이벤트에 참여할 수 있습니다.
<img width="180" height="788" alt="IMG_2529-portrait 1" src="https://github.com/user-attachments/assets/50942ccc-9934-494f-b470-778de640b940" />
<img width="180" height="788" alt="IMG_2530-portrait 1" src="https://github.com/user-attachments/assets/3a9e223b-1dd8-47a4-96c7-52d7b7481937" />
<img width="180" height="788" alt="IMG_2532-portrait 1" src="https://github.com/user-attachments/assets/cf2fb97f-5b16-44c7-945e-690ed75d518a" />
<img width="180" height="788" alt="IMG_2533-portrait 1" src="https://github.com/user-attachments/assets/d83f89be-7ad5-4536-b594-8f4d07358399" />
<img width="1000" alt="Back-end Architecture" src="https://github.com/user-attachments/assets/18840a9c-4d9d-4b55-9344-57d201d0a89e" />

#### 4. 오늘 뭐 먹지
> 랜덤으로 카테고리를 추천해줍니다.
<img width="180" height="788" alt="IMG_2513-portrait 1" src="https://github.com/user-attachments/assets/0b4bd0f0-a0c7-4a01-a1eb-5429e2241ffb" />
### 패키지 구조

#### 5. 내 정보 조회 (등록 현황, 기프티콘)
<img width="180" height="788" alt="IMG_2516-portrait 1" src="https://github.com/user-attachments/assets/71f28830-0d27-4b7b-87e2-fb72772417f1" />
<img width="180" height="788" alt="IMG_2517-portrait 1" src="https://github.com/user-attachments/assets/7d5316b1-c7fc-4588-b0dd-66098cfe499c" />
<img width="180" height="788" alt="IMG_2519-portrait 1" src="https://github.com/user-attachments/assets/43c96503-7118-4001-a67d-edf5028ef507" />
기능이 늘어날수록 계층 우선(`controller/`, `service/` …) 구조는 하나의 변경이 여러 패키지에 흩어지는 문제가 있습니다.
그래서 **도메인 단위로 먼저 나누고**, 각 도메인 내부를 **3계층 레이어드**로 통일했습니다.

```text
com.matzip
├── auth # 카카오 로그인 / JWT 발급·재발급
├── user # 프로필
├── place # 맛집 등록·조회·찜·검색
├── lottery # 럭키 드로우 응모 이벤트
├── admin # 맛집 승인·거절
└── common # 응답·예외·보안·설정·로깅·분석 등 공통

# 각 도메인 내부
controller → service → repository → DB
↘ client (외부 API·캐시 어댑터)
```

## ERD
<img width="1000" alt="knu-matzip-erd" src="https://github.com/user-attachments/assets/912e44c8-7cd8-4363-aae3-a79cf1c747dc" />


## 📱 주요 기능

| 기능 | 설명 |
| --- |-----------------------------------------|
| **맛집 탐색** | 카테고리·지도로 캠퍼스 근처 맛집 조회, 찜, 키워드 검색 |
| **맛집 등록** | 캠퍼스·식당·추천 메뉴·태그·카테고리를 단계별로 입력해 등록 신청 |
| **관리자 승인** | 등록 신청된 맛집을 관리자가 승인·거절 (승인 시 Discord 알림) |
| **럭키 드로우** | 맛집 등록으로 얻은 응모권으로 기프티콘 이벤트 참여, 스케줄러 추첨 |
| **오늘 뭐 먹지** | 랜덤 카테고리 추천 |
| **내 정보** | 등록 현황·기프티콘 조회 |


## 📸 화면 스크린샷 보기

**맛집 탐색**
<p>
<img width="180" alt="탐색1" src="https://github.com/user-attachments/assets/48b5e7d1-9184-4ac6-99c7-cf1491028635" />
<img width="180" alt="탐색2" src="https://github.com/user-attachments/assets/24a1a29d-abb8-4136-9e09-4c869a73acaa" />
<img width="180" alt="탐색3" src="https://github.com/user-attachments/assets/a4fe04d5-0569-45b0-a657-7298e193e176" />
<img width="180" alt="탐색4" src="https://github.com/user-attachments/assets/d12e25c2-a218-49ae-bd5c-67f0aa59df40" />
</p>

**맛집 등록**
<p>
<img width="180" alt="등록1" src="https://github.com/user-attachments/assets/4f5f95f4-5de5-44c1-a79c-2f77498bf9a0" />
<img width="180" alt="등록2" src="https://github.com/user-attachments/assets/09c1fe01-94d6-42ff-a2bc-63ee4a623dcd" />
<img width="180" alt="등록3" src="https://github.com/user-attachments/assets/29786b0c-8037-4062-8e17-dd12e2a139bf" />
<img width="180" alt="등록4" src="https://github.com/user-attachments/assets/88ee31e7-b8dc-4b68-a63e-1a94e69fc5d6" />
</p>

**럭키 드로우 이벤트**
<p>
<img width="180" alt="이벤트1" src="https://github.com/user-attachments/assets/50942ccc-9934-494f-b470-778de640b940" />
<img width="180" alt="이벤트2" src="https://github.com/user-attachments/assets/3a9e223b-1dd8-47a4-96c7-52d7b7481937" />
<img width="180" alt="이벤트3" src="https://github.com/user-attachments/assets/cf2fb97f-5b16-44c7-945e-690ed75d518a" />
<img width="180" alt="이벤트4" src="https://github.com/user-attachments/assets/d83f89be-7ad5-4536-b594-8f4d07358399" />
</p>

**오늘 뭐 먹지 / 내 정보**
<p>
<img width="180" alt="추천" src="https://github.com/user-attachments/assets/0b4bd0f0-a0c7-4a01-a1eb-5429e2241ffb" />
<img width="180" alt="내정보1" src="https://github.com/user-attachments/assets/71f28830-0d27-4b7b-87e2-fb72772417f1" />
<img width="180" alt="내정보2" src="https://github.com/user-attachments/assets/7d5316b1-c7fc-4588-b0dd-66098cfe499c" />
<img width="180" alt="내정보3" src="https://github.com/user-attachments/assets/43c96503-7118-4001-a67d-edf5028ef507" />
</p>

<br/>

## 🚀 로컬 실행

```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 자동 배포
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +21,7 @@

import java.util.List;

@Tag(name = "어드민", description = "맛집 등록 요청 승인/거절 관리 API")
@RequestMapping("/admin/api")
@RestController
public class AdminController {
Expand All @@ -31,6 +34,7 @@ public AdminController(PlaceRepository placeRepository, AdminPlaceRegisterReques
this.adminPlaceRegisterRequestService = adminPlaceRegisterRequestService;
}

@Operation(summary = "맛집 등록 요청 목록 (어드민)", description = "승인 대기(PENDING) 상태인 맛집 등록 요청 목록을 조회한다.")
@GetMapping("/requests/places")
public ApiResponse<List<PlaceRegisterRequestsResponseDto>> findPlaceRegisterRequests() {
List<PlaceRegisterRequestsResponseDto> data = placeRepository.findPendingPlaces()
Expand All @@ -41,6 +45,9 @@ public ApiResponse<List<PlaceRegisterRequestsResponseDto>> 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<PlaceRegisterRequestDetailResponseDto> findPlaceRegisterRequestDetail(
@PathVariable("placeId") Long placeId
Expand All @@ -52,6 +59,7 @@ public ApiResponse<PlaceRegisterRequestDetailResponseDto> findPlaceRegisterReque
return ApiResponse.success(data);
}

@Operation(summary = "맛집 등록 요청 승인/거절 (어드민)", description = "맛집 등록 요청을 승인(APPROVED) 또는 거절(REJECTED)한다. 거절 시 사유를 함께 전달한다.")
@PostMapping("/requests/places/{placeId}/review")
public ApiResponse<?> reviewPlaceRegisterRequest(
@PathVariable("placeId") Long placeId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhotoDto> photos,
String address, LocationDto location, String description,
List<MenusResponse> menus, List<CategoryDto> categories, List<TagDto> 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<PhotoDto> photos,
@Schema(description = "주소", example = "경기 남양주시 다산중앙로82번길 25") String address,
LocationDto location,
@Schema(description = "맛집 설명", example = "직원이 엄청 친절해요! 근데 화장실에 갔고 냄새나요 ㅠㅠㅠ 그래도 맛은 있어서 괜찮아요") String description,
List<MenusResponse> menus, List<CategoryDto> categories, List<TagDto> 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CategoryDto> categories, List<TagDto> 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<CategoryDto> categories, List<TagDto> tags) {

public static PlaceRegisterRequestsResponseDto from(Place place) {
return PlaceRegisterRequestsResponseDto.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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())
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/matzip/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +26,7 @@
import java.time.Duration;


@Tag(name = "인증", description = "카카오 로그인 및 JWT 토큰 발급 API")
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
Expand All @@ -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<Void> authorize(@RequestParam("clientOrigin") String clientOrigin) {

Expand Down Expand Up @@ -66,6 +70,7 @@ public ResponseEntity<Void> authorize(@RequestParam("clientOrigin") String clien
* @param body
* @return
*/
@Operation(summary = "액세스 토큰 재발급", description = "쿠키 또는 요청 본문의 리프레시 토큰으로 액세스 토큰을 재발급하고, 회전된 리프레시 토큰을 쿠키로 설정한다.")
@PostMapping("/token")
public ResponseEntity<ApiResponse<TokenResponseDto>> reissue(
@CookieValue(value = RT_COOKIE_NAME, required = false) String rtCookie,
Expand Down Expand Up @@ -106,6 +111,7 @@ private ResponseCookie buildRtCookie(String rt, long maxAgeMs) {
.build();
}

@Operation(summary = "카카오 로그인 콜백", description = "카카오 인가 코드를 받아 로그인을 처리하고, 성공/실패에 따라 프론트 페이지로 리다이렉트(302)한다.")
@GetMapping("/callback")
public ResponseEntity<Void> kakaoCallback(
@RequestParam("code") @NotBlank String code,
Expand Down
Loading
Loading