diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 9f7d369..9bfe0f3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -51,14 +51,14 @@ jobs: token: ${{ secrets.GIT_ACTION_TOKEN }} submodules: true - - name: Copy docker-compose.yml to Server + - name: Copy compose & prometheus.yml to Server uses: appleboy/scp-action@master with: host: ${{ secrets.SSH_HOST }} port: ${{ secrets.SSH_PORT }} username: ${{ secrets.SSH_USERNAME }} key: ${{ secrets.SSH_PRIVATE_KEY }} - source: "docker-compose.yml" + source: "docker-compose.yml,prometheus.yml" target: "~/app" - name: Deploy on server diff --git a/build.gradle b/build.gradle index 3e4193d..e6470d5 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,10 @@ dependencies { // Flyway implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' + + // Grafana & Prometheus + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 02a009e..afc8691 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,6 +66,8 @@ services: depends_on: - app - dozzle + - grafana + - prometheus networks: - danchu-network restart: unless-stopped @@ -74,9 +76,66 @@ services: image: amir20/dozzle:latest container_name: dozzle volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro # 로그 접근을 위한 도커 소켓 마운트 + - /var/run/docker.sock:/var/run/docker.sock:ro # 로그 접근을 위한 도커 소켓 마운트 networks: - - danchu-network + - danchu-network + restart: unless-stopped + + grafana: + image: grafana/grafana + container_name: grafana + environment: + - GF_SERVER_ROOT_URL=https://grafana.danchu.site/ + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana-storage:/var/lib/grafana + networks: + - danchu-network + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + networks: + - danchu-network + restart: unless-stopped + + node-exporter: + image: prom/node-exporter + container_name: node-exporter + command: + - '--path.rootfs=/host' + volumes: + - '/:/host:ro,rslave' + networks: + - danchu-network + restart: unless-stopped + + mysqld-exporter: + image: prom/mysqld-exporter:v0.14.0 + container_name: mysqld-exporter + environment: + - DATA_SOURCE_NAME=exporter:${MYSQL_EXPORTER_PASSWORD}@(mysql:3306)/danchu + depends_on: + mysql: + condition: service_healthy + networks: + - danchu-network + restart: unless-stopped + + redis-exporter: + image: oliver006/redis_exporter:v1.67.0 + container_name: redis-exporter + environment: + - REDIS_ADDR=redis:6379 + depends_on: + redis: + condition: service_healthy + networks: + - danchu-network restart: unless-stopped networks: @@ -85,4 +144,5 @@ networks: volumes: mysql-data: - redis-data: \ No newline at end of file + redis-data: + grafana-storage: \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..5cbef93 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,49 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + scrape_timeout: 4s + +scrape_configs: + # Prometheus + - job_name: 'prometheus' + static_configs: + - targets: ['prometheus:9090'] + labels: + application: prometheus + service: prometheus + + # Spring Boot + - job_name: 'danchu' + metrics_path: /actuator/prometheus + static_configs: + - targets: ['app:8080'] + labels: + application: danchu + service: app + component: spring-boot + + # node-exporter + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + labels: + application: danchu + service: node-exporter + + # MySQL + - job_name: 'mysql' + static_configs: + - targets: ['mysqld-exporter:9104'] + labels: + application: danchu + service: mysql + component: exporter + + # Redis + - job_name: 'redis' + static_configs: + - targets: ['redis-exporter:9121'] + labels: + application: danchu + service: redis + component: exporter \ No newline at end of file diff --git a/src/main/java/com/likelion/danchu/domain/menu/controller/MenuController.java b/src/main/java/com/likelion/danchu/domain/menu/controller/MenuController.java index 78136c3..1aabdbc 100644 --- a/src/main/java/com/likelion/danchu/domain/menu/controller/MenuController.java +++ b/src/main/java/com/likelion/danchu/domain/menu/controller/MenuController.java @@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -69,4 +70,13 @@ public ResponseEntity>> getMenus(@PathVariable L List responses = menuService.getMenus(storeId); return ResponseEntity.ok(BaseResponse.success("메뉴 전체 조회에 성공했습니다.", responses)); } + + @Operation(summary = "특정 메뉴 삭제", description = "storeId와 menuId로 특정 메뉴를 삭제합니다.") + @DeleteMapping("/{menuId}") + public ResponseEntity deleteMenu( + @Parameter(description = "가게 ID", example = "1") @PathVariable Long storeId, + @Parameter(description = "메뉴 ID", example = "3") @PathVariable Long menuId) { + menuService.deleteMenu(storeId, menuId); + return ResponseEntity.ok(BaseResponse.success("메뉴 삭제에 성공했습니다.")); + } } diff --git a/src/main/java/com/likelion/danchu/domain/menu/service/MenuService.java b/src/main/java/com/likelion/danchu/domain/menu/service/MenuService.java index be2f080..411e126 100644 --- a/src/main/java/com/likelion/danchu/domain/menu/service/MenuService.java +++ b/src/main/java/com/likelion/danchu/domain/menu/service/MenuService.java @@ -86,4 +86,43 @@ public List getMenus(Long storeId) { .map(menuMapper::toResponse) // priceFormatted 포함됨 .toList(); } + + /** + * 특정 메뉴 삭제 + * + * @param storeId 대상 가게 ID + * @param menuId 삭제할 메뉴 ID + * @throws CustomException {@code STORE_NOT_FOUND} - 가게가 존재하지 않는 경우 + * @throws CustomException {@code MENU_NOT_FOUND} - 해당 가게에 메뉴가 존재하지 않는 경우 + */ + public void deleteMenu(Long storeId, Long menuId) { + // 가게 존재 확인 + if (!storeRepository.existsById(storeId)) { + throw new CustomException( + com.likelion.danchu.domain.store.exception.StoreErrorCode.STORE_NOT_FOUND); + } + + // 메뉴 조회 (storeId 일치 여부 검증) + Menu menu = + menuRepository + .findById(menuId) + .orElseThrow(() -> new CustomException(MenuErrorCode.MENU_NOT_FOUND)); + + if (!menu.getStore().getId().equals(storeId)) { + throw new CustomException(MenuErrorCode.MENU_NOT_FOUND); // 다른 가게의 메뉴를 잘못 요청한 경우 + } + + // S3 이미지 삭제 (있을 경우) + String url = menu.getImageUrl(); // 엔티티 필드명 맞춰 사용 + if (url != null && !url.isBlank()) { + try { + s3Service.deleteByUrl(url); + } catch (Exception ignored) { + // 이미지 삭제 실패는 무시하고 메뉴 삭제 진행 + } + } + + // 메뉴 삭제 + menuRepository.delete(menu); + } } diff --git a/src/main/java/com/likelion/danchu/domain/openAI/dto/response/MissionRecommendResponse.java b/src/main/java/com/likelion/danchu/domain/openAI/dto/response/MissionRecommendResponse.java index 45f1684..1b17915 100644 --- a/src/main/java/com/likelion/danchu/domain/openAI/dto/response/MissionRecommendResponse.java +++ b/src/main/java/com/likelion/danchu/domain/openAI/dto/response/MissionRecommendResponse.java @@ -16,7 +16,7 @@ public class MissionRecommendResponse { private String title; @Schema(description = "미션 보상(이름/설명)", example = "사이다 1잔") - private String rewardName; + private String reward; @Schema(description = "가게 이름", example = "동방손칼국수") private String storeName; diff --git a/src/main/java/com/likelion/danchu/domain/openAI/service/MissionRecommendService.java b/src/main/java/com/likelion/danchu/domain/openAI/service/MissionRecommendService.java index 53fd7a1..778dbc7 100644 --- a/src/main/java/com/likelion/danchu/domain/openAI/service/MissionRecommendService.java +++ b/src/main/java/com/likelion/danchu/domain/openAI/service/MissionRecommendService.java @@ -195,7 +195,7 @@ private MissionRecommendResponse toDto(Mission m) { return MissionRecommendResponse.builder() .missionId(m.getId()) .title(m.getTitle()) - .rewardName(m.getReward()) + .reward(m.getReward()) .storeName(s != null ? s.getName() : null) .build(); } diff --git a/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java b/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java index 4c7c4e1..5be3d56 100644 --- a/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java +++ b/src/main/java/com/likelion/danchu/domain/store/controller/StoreController.java @@ -23,6 +23,7 @@ import com.likelion.danchu.domain.store.dto.request.StoreRequest; import com.likelion.danchu.domain.store.dto.response.PageableResponse; import com.likelion.danchu.domain.store.dto.response.StoreDistanceResponse; +import com.likelion.danchu.domain.store.dto.response.StoreListItemResponse; import com.likelion.danchu.domain.store.dto.response.StoreResponse; import com.likelion.danchu.domain.store.exception.StoreErrorCode; import com.likelion.danchu.domain.store.service.StoreHashtagService; @@ -56,7 +57,7 @@ public class StoreController { - 가게 이름: **1자 이상, 10자 이내** - 주소: **1자 이상, 50자 이내** - 가게 소개: **1자 이상, 200자 이내** - - 전화번호: **010-1234-5678** 또는 **02-345-6789** 형식 + - 전화번호: **010-1234-5678** / **02-345-6789** / **0507-1418-3557** 형식 - 오픈/마감 시간: **HH:mm** 형식 (예: 09:00, 21:00) - 인증 코드: **숫자 4자리 (예: 0123)** - 이미지 파일: **multipart/form-data** 형식 @@ -98,10 +99,11 @@ public ResponseEntity> createStore( - size : 페이지 당 보여줄 가게 수입니다. (기본값: 3) """) @GetMapping - public ResponseEntity>> getPaginatedStores( + public ResponseEntity>> getPaginatedStores( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "3") int size) { - PageableResponse storeResponses = storeService.getPaginatedStores(page, size); + PageableResponse storeResponses = + storeService.getPaginatedStores(page, size); return ResponseEntity.ok(BaseResponse.success("가게 페이징 조회에 성공했습니다.", storeResponses)); } diff --git a/src/main/java/com/likelion/danchu/domain/store/dto/request/StoreRequest.java b/src/main/java/com/likelion/danchu/domain/store/dto/request/StoreRequest.java index 98b192c..3b2720e 100644 --- a/src/main/java/com/likelion/danchu/domain/store/dto/request/StoreRequest.java +++ b/src/main/java/com/likelion/danchu/domain/store/dto/request/StoreRequest.java @@ -46,8 +46,8 @@ public class StoreRequest { @NotBlank(message = "전화번호는 필수입니다.") @Pattern( - regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", - message = "전화번호는 형식에 맞게 입력해주세요. (예: 010-1234-5678 / 02-345-6789)") + regexp = "^(?:\\d{2,3}-\\d{3,4}-\\d{4}|050\\d-\\d{3,4}-\\d{4})$", + message = "전화번호는 형식에 맞게 입력해주세요. (예: 010-1234-5678 / 02-345-6789 / 0507-1418-3557)") @Schema(description = "전화번호", example = "010-1234-5678") private String phoneNumber; diff --git a/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreListItemResponse.java b/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreListItemResponse.java new file mode 100644 index 0000000..555798a --- /dev/null +++ b/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreListItemResponse.java @@ -0,0 +1,18 @@ +package com.likelion.danchu.domain.store.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(title = "StoreListItemResponse", description = "목록 아이템(가게 정보를 store로 래핑)") +public class StoreListItemResponse { + + @Schema(description = "가게 정보", requiredMode = Schema.RequiredMode.REQUIRED) + private StoreResponse store; +} diff --git a/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreResponse.java b/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreResponse.java index 4492560..9b29712 100644 --- a/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreResponse.java +++ b/src/main/java/com/likelion/danchu/domain/store/dto/response/StoreResponse.java @@ -35,6 +35,9 @@ public class StoreResponse { @Schema(description = "마감 시간 (HH:mm)", example = "21:00") private String closeTime; + @Schema(description = "가게 인증 코드") + private String authCode; + @Schema(description = "대표 이미지 URL", example = "https://s3.amazonaws.com/bucket/image.jpg") private String mainImageUrl; diff --git a/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java b/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java index 9cbc3ec..76da72c 100644 --- a/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java +++ b/src/main/java/com/likelion/danchu/domain/store/mapper/StoreMapper.java @@ -10,6 +10,7 @@ import com.likelion.danchu.domain.hashtag.dto.response.HashtagResponse; import com.likelion.danchu.domain.menu.dto.response.MenuResponse; import com.likelion.danchu.domain.store.dto.request.StoreRequest; +import com.likelion.danchu.domain.store.dto.response.StoreListItemResponse; import com.likelion.danchu.domain.store.dto.response.StoreResponse; import com.likelion.danchu.domain.store.entity.Store; @@ -58,6 +59,7 @@ public StoreResponse toResponse( .phoneNumber(store.getPhoneNumber()) .openTime(store.getOpenTime()) .closeTime(store.getCloseTime()) + .authCode(store.getAuthCode()) .mainImageUrl(store.getMainImageUrl()) .hashtags(hashtags != null ? hashtags : List.of()) // null 방어 .isOpen(isOpen) @@ -72,4 +74,18 @@ public StoreResponse toResponse(Store store, List hashtags) { public StoreResponse toResponse(Store store) { return toResponse(store, List.of(), List.of()); } + + // StoreResponse를 store로 감싼 목록 아이템으로 변환 + public StoreListItemResponse toListItem( + Store store, List hashtags, List menus) { + return StoreListItemResponse.builder().store(toResponse(store, hashtags, menus)).build(); + } + + public StoreListItemResponse toListItem(Store store, List hashtags) { + return StoreListItemResponse.builder().store(toResponse(store, hashtags)).build(); + } + + public StoreListItemResponse toListItem(Store store) { + return StoreListItemResponse.builder().store(toResponse(store)).build(); + } } diff --git a/src/main/java/com/likelion/danchu/domain/store/repository/StoreRepository.java b/src/main/java/com/likelion/danchu/domain/store/repository/StoreRepository.java index 9c083f4..75f79e0 100644 --- a/src/main/java/com/likelion/danchu/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/likelion/danchu/domain/store/repository/StoreRepository.java @@ -56,6 +56,8 @@ interface StoreWithDistanceProjection { Double getLongitude(); Double getDistance_m(); // alias와 동일해야 함 + + String getAuth_Code(); } /** @@ -84,7 +86,8 @@ interface StoreWithDistanceProjection { (6371000 * ACOS(LEAST(1, COS(RADIANS(:lat)) * COS(RADIANS(s.latitude)) * COS(RADIANS(s.longitude) - RADIANS(:lng)) + SIN(RADIANS(:lat)) * SIN(RADIANS(s.latitude)) - ))) AS distance_m + ))) AS distance_m, + s.auth_code FROM store s WHERE (:radius IS NULL OR (6371000 * ACOS(LEAST(1, @@ -113,42 +116,43 @@ Page findNearby( @Query( value = """ - SELECT - s.id, - s.name, - s.address, - s.description, - s.phone_number, - s.open_time, - s.close_time, - s.main_image_url, - s.latitude, - s.longitude, - (6371000 * ACOS(LEAST(1, - COS(RADIANS(:lat)) * COS(RADIANS(s.latitude)) * - COS(RADIANS(s.longitude) - RADIANS(:lng)) + - SIN(RADIANS(:lat)) * SIN(RADIANS(s.latitude)) - ))) AS distance_m - FROM store s - JOIN store_hashtag sh ON sh.store_id = s.id - JOIN hashtag h ON h.id = sh.hashtag_id - WHERE LOWER(h.name) IN (:names) - GROUP BY s.id - HAVING COUNT(DISTINCT LOWER(h.name)) = :size - ORDER BY distance_m ASC - """, + SELECT + s.id, + s.name, + s.address, + s.description, + s.phone_number, + s.open_time, + s.close_time, + s.main_image_url, + s.latitude, + s.longitude, + (6371000 * ACOS(LEAST(1, + COS(RADIANS(:lat)) * COS(RADIANS(s.latitude)) * + COS(RADIANS(s.longitude) - RADIANS(:lng)) + + SIN(RADIANS(:lat)) * SIN(RADIANS(s.latitude)) + ))) AS distance_m, + s.auth_code + FROM store s + JOIN store_hashtag sh ON sh.store_id = s.id + JOIN hashtag h ON h.id = sh.hashtag_id + WHERE LOWER(h.name) IN (:names) + GROUP BY s.id + HAVING COUNT(DISTINCT LOWER(h.name)) = :size + ORDER BY distance_m ASC + """, countQuery = """ - SELECT COUNT(*) FROM ( - SELECT s.id - FROM store s - JOIN store_hashtag sh ON sh.store_id = s.id - JOIN hashtag h ON h.id = sh.hashtag_id - WHERE LOWER(h.name) IN (:names) - GROUP BY s.id - HAVING COUNT(DISTINCT LOWER(h.name)) = :size - ) t - """, + SELECT COUNT(*) FROM ( + SELECT s.id + FROM store s + JOIN store_hashtag sh ON sh.store_id = s.id + JOIN hashtag h ON h.id = sh.hashtag_id + WHERE LOWER(h.name) IN (:names) + GROUP BY s.id + HAVING COUNT(DISTINCT LOWER(h.name)) = :size + ) t + """, nativeQuery = true) Page findByAllHashtagsWithDistance( @Param("names") List names, diff --git a/src/main/java/com/likelion/danchu/domain/store/service/StoreHashtagService.java b/src/main/java/com/likelion/danchu/domain/store/service/StoreHashtagService.java index 9862e95..a092a7b 100644 --- a/src/main/java/com/likelion/danchu/domain/store/service/StoreHashtagService.java +++ b/src/main/java/com/likelion/danchu/domain/store/service/StoreHashtagService.java @@ -17,6 +17,10 @@ import com.likelion.danchu.domain.hashtag.exception.HashtagErrorCode; import com.likelion.danchu.domain.hashtag.mapper.HashtagMapper; import com.likelion.danchu.domain.hashtag.repository.HashtagRepository; +import com.likelion.danchu.domain.menu.dto.response.MenuResponse; +import com.likelion.danchu.domain.menu.entity.Menu; +import com.likelion.danchu.domain.menu.mapper.MenuMapper; +import com.likelion.danchu.domain.menu.repository.MenuRepository; import com.likelion.danchu.domain.store.dto.response.PageableResponse; import com.likelion.danchu.domain.store.dto.response.StoreDistanceResponse; import com.likelion.danchu.domain.store.dto.response.StoreResponse; @@ -42,6 +46,8 @@ public class StoreHashtagService { private final StoreRepository storeRepository; private final StoreHashtagRepository storeHashtagRepository; private final StoreMapper storeMapper; + private final MenuRepository menuRepository; + private final MenuMapper menuMapper; /** * 특정 가게에 해시태그를 생성/연결하는 서비스 메서드 @@ -178,13 +184,18 @@ public PageableResponse filterStoresByHashtags( storeHashtag -> hashtagMapper.toResponse(storeHashtag.getHashtag()), Collectors.toList()))); + // menusByStoreId 생성 + Map> menusByStoreId = loadMenusByStoreIds(pageStoreIds); + // 해시태그 포함 DTO로 변환 Page responsePage = storePage.map( store -> { StoreResponse storeResponse = storeMapper.toResponse( - store, hashtagsByStoreId.getOrDefault(store.getId(), List.of())); + store, + hashtagsByStoreId.getOrDefault(store.getId(), List.of()), + menusByStoreId.getOrDefault(store.getId(), List.of())); Double meters = null; if (lat != null @@ -224,4 +235,25 @@ public PageableResponse filterStoresByHashtags( // 좌표가 없으면: 기존 순서 그대로 반환 return PageableResponse.from(responsePage); } + + /** + * 주어진 가게 ID 목록에 해당하는 메뉴들을 한 번에 조회하여 storeId 기준으로 MenuResponse 리스트로 매핑합니다. + * + * @param storeIds 메뉴를 조회할 가게 ID 리스트 + * @return Map + */ + private Map> loadMenusByStoreIds(List storeIds) { + if (storeIds == null || storeIds.isEmpty()) { + return Map.of(); + } + // N+1 방지: 한 번에 조회 + List menus = menuRepository.findByStore_IdInOrderByIdAsc(storeIds); + + // storeId 기준 그룹핑 → DTO 매핑 + return menus.stream() + .collect( + Collectors.groupingBy( + m -> m.getStore().getId(), + Collectors.mapping(menuMapper::toResponse, Collectors.toList()))); + } } diff --git a/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java b/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java index bd0e8ec..ed3b958 100644 --- a/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java +++ b/src/main/java/com/likelion/danchu/domain/store/service/StoreService.java @@ -26,6 +26,7 @@ import com.likelion.danchu.domain.store.dto.request.StoreRequest; import com.likelion.danchu.domain.store.dto.response.PageableResponse; import com.likelion.danchu.domain.store.dto.response.StoreDistanceResponse; +import com.likelion.danchu.domain.store.dto.response.StoreListItemResponse; import com.likelion.danchu.domain.store.dto.response.StoreResponse; import com.likelion.danchu.domain.store.entity.Store; import com.likelion.danchu.domain.store.entity.StoreHashtag; @@ -128,13 +129,15 @@ public StoreResponse createStore(StoreRequest storeRequest, MultipartFile imageF * @param size 한 페이지에 포함될 가게 수 * @return 페이징된 가게 목록 응답 */ - public PageableResponse getPaginatedStores(int page, int size) { + public PageableResponse getPaginatedStores(int page, int size) { PageRequest pageRequest = PageRequest.of(page, size); // 페이지당 3개 Page storePage = storeRepository.findAll(pageRequest); List stores = storePage.getContent(); if (stores.isEmpty()) { - return PageableResponse.from(storePage.map(storeMapper::toResponse)); // 그대로 빈 페이지 반환 + Page empty = + storePage.map(s -> storeMapper.toListItem(s, List.of(), List.of())); // 메뉴 빈 리스트 + return PageableResponse.from(empty); } // 현재 페이지의 가게 ID들 수집 @@ -153,14 +156,19 @@ public PageableResponse getPaginatedStores(int page, int size) { storeHashtag -> hashtagMapper.toResponse(storeHashtag.getHashtag()), Collectors.toList()))); - // 각 가게 별로 해시태그 포함하여 DTO 변환 - Page storeResponsePage = + // 메뉴 응답 리스트도 한 번에 로딩하여 (N+1 방지) + Map> menusByStoreId = loadMenusByStoreIds(storeIds); + + // StoreListItemResponse(store로 래핑) + Page wrappedPage = storePage.map( store -> - storeMapper.toResponse( - store, hashtagsByStoreId.getOrDefault(store.getId(), List.of()))); + storeMapper.toListItem( + store, + hashtagsByStoreId.getOrDefault(store.getId(), List.of()), + menusByStoreId.getOrDefault(store.getId(), List.of()))); - return PageableResponse.from(storeResponsePage); + return PageableResponse.from(wrappedPage); } /** @@ -181,11 +189,17 @@ public PageableResponse searchStoresByKeyword( Page storePage = storeRepository.findByNameContainingIgnoreCase(keyword.trim(), pageable); List stores = storePage.getContent(); + // 가게 ID 목록 추출 + List storeIds = stores.stream().map(Store::getId).toList(); // 현재 페이지 가게들의 해시태그를 한 번에 조회 (N+1 방지) Map> hashtagsByStoreId = stores.isEmpty() ? Map.of() : loadHashtagsByStoreIds(stores); + // 메뉴 한 번에 로딩 + Map> menusByStoreId = + stores.isEmpty() ? Map.of() : loadMenusByStoreIds(storeIds); + /** * 검색된 가게를 StoreDistanceResponse 형태로 변환합니다. * @@ -201,7 +215,9 @@ public PageableResponse searchStoresByKeyword( store -> { StoreResponse sr = storeMapper.toResponse( - store, hashtagsByStoreId.getOrDefault(store.getId(), List.of())); + store, + hashtagsByStoreId.getOrDefault(store.getId(), List.of()), + menusByStoreId.getOrDefault(store.getId(), List.of())); Double meters = null; if (lat != null @@ -288,6 +304,12 @@ public PageableResponse getNearbyStores( Page projPage = storeRepository.findNearby(lat, lng, radiusMeters, pageable); + // projection에서 가게 ID만 추출 + List storeIds = + projPage.getContent().stream() + .map(StoreRepository.StoreWithDistanceProjection::getId) + .toList(); + // 현재 페이지 가게들의 해시태그를 한 번에 조회 Map> hashtagsByStoreId = projPage.getContent().isEmpty() @@ -297,6 +319,10 @@ public PageableResponse getNearbyStores( .map(p -> Store.builder().id(p.getId()).build()) .toList()); + // 메뉴 배치 로딩 추가 + Map> menusByStoreId = + projPage.getContent().isEmpty() ? Map.of() : loadMenusByStoreIds(storeIds); + Page mapped = projPage.map( p -> { @@ -313,12 +339,14 @@ public PageableResponse getNearbyStores( .mainImageUrl(p.getMain_Image_Url()) .latitude(p.getLatitude()) .longitude(p.getLongitude()) - .authCode("") // 응답 비노출 + .authCode(p.getAuth_Code()) .build(); StoreResponse storeRes = storeMapper.toResponse( - store, hashtagsByStoreId.getOrDefault(store.getId(), List.of())); + store, + hashtagsByStoreId.getOrDefault(store.getId(), List.of()), + menusByStoreId.getOrDefault(store.getId(), List.of())); double meters = p.getDistance_m(); return StoreDistanceResponse.builder() @@ -400,4 +428,17 @@ public void deleteStore(Long storeId) { storeRepository.delete(store); } + + /** 여러 가게(storeIds)에 속한 메뉴를 한 번에 조회하여 가게 ID -> 메뉴 리스트(Map) 형태로 변환합니다. */ + private Map> loadMenusByStoreIds(List storeIds) { + if (storeIds == null || storeIds.isEmpty()) { + return Map.of(); + } + + return menuRepository.findByStore_IdInOrderByIdAsc(storeIds).stream() + .collect( + Collectors.groupingBy( + m -> m.getStore().getId(), + Collectors.mapping(menuMapper::toResponse, Collectors.toList()))); + } } diff --git a/src/main/java/com/likelion/danchu/global/config/SecurityConfig.java b/src/main/java/com/likelion/danchu/global/config/SecurityConfig.java index 3cc9721..583ac37 100644 --- a/src/main/java/com/likelion/danchu/global/config/SecurityConfig.java +++ b/src/main/java/com/likelion/danchu/global/config/SecurityConfig.java @@ -56,6 +56,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**") .permitAll() + .requestMatchers("/actuator/health", "/actuator/info", "/actuator/prometheus") + .permitAll() .anyRequest() .authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/resources b/src/main/resources index deff0bc..b8a089b 160000 --- a/src/main/resources +++ b/src/main/resources @@ -1 +1 @@ -Subproject commit deff0bc2e5091b5c3d3bd3a7829373199e2c8a64 +Subproject commit b8a089b2f7846e90aae0f901887b5ea4ba983f75