From 3589f463011dfc768b3b230180c10f5d6838c09a Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Wed, 12 Aug 2026 23:44:28 +0900 Subject: [PATCH 01/66] =?UTF-8?q?feat:=20user-service=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=97=90=20AdminMemberController=20=EB=B0=8F=20AdminMemberServ?= =?UTF-8?q?ice=20=EB=BC=88=EB=8C=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMemberController에 사용자 목록/상세 조회, 인벤토리 수정 3개 엔드포인트를 501(Not Implemented)로 스텁 처리 - AdminMemberService/Impl 인터페이스와 메서드 뼈대 생성 (getUsers, getUserDetail, updateUserInventory), 몸통은 UnsupportedOperationException으로 스텁 처리 Co-Authored-By: Claude Sonnet 5 --- .../member/service/AdminMemberService.java | 19 ++++++ .../service/AdminMemberServiceImpl.java | 38 +++++++++++ .../controller/AdminMemberController.java | 63 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java create mode 100644 user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java new file mode 100644 index 0000000..b31c308 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java @@ -0,0 +1,19 @@ +package com.comatching.user.domain.member.service; + +import org.springframework.data.domain.Pageable; + +import com.comatching.common.dto.member.AdminUserProfileDto; +import com.comatching.common.dto.response.PagingResponse; + +/** + * TODO(refactor/admin): 반환 타입을 item-service 원본 스펙(AdminUserSummaryResponse/AdminUserDetailResponse, + * 티켓 수량 포함)으로 교체. 해당 DTO가 common-module로 이관된 뒤 적용. + */ +public interface AdminMemberService { + + PagingResponse getUsers(String keyword, Pageable pageable); + + AdminUserProfileDto getUserDetail(Long memberId); + + void updateUserInventory(Long adminId, Long memberId, Object request); +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java new file mode 100644 index 0000000..4e24e8d --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java @@ -0,0 +1,38 @@ +package com.comatching.user.domain.member.service; + +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.dto.member.AdminUserProfileDto; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminMemberServiceImpl implements AdminMemberService { + + private final MemberRepository memberRepository; + + @Override + public PagingResponse getUsers(String keyword, Pageable pageable) { + // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUsers 로직 이식 + ItemAdminClient 티켓 수량 합성 + throw new UnsupportedOperationException("not implemented yet"); + } + + @Override + public AdminUserProfileDto getUserDetail(Long memberId) { + // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUserDetail 로직 이식 + ItemAdminClient 티켓 수량 합성 + throw new UnsupportedOperationException("not implemented yet"); + } + + @Override + @Transactional + public void updateUserInventory(Long adminId, Long memberId, Object request) { + // TODO(refactor/admin): getUserDetail 선조회 -> ItemAdminClient.adjustInventory(memberId, adminId, request) 호출 + throw new UnsupportedOperationException("not implemented yet"); + } +} diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java new file mode 100644 index 0000000..69f33a8 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -0,0 +1,63 @@ +package com.comatching.user.infra.controller; + +import com.comatching.common.dto.response.PagingResponse; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + + +@Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리 (현재 미구현)") +@RestController +@RequestMapping("/api/admin/users") +public class AdminMemberController { + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "사용자 목록 조회/검색", description = "관리자가 이메일/닉네임/이름 키워드로 사용자 목록을 페이징 조회합니다.") + @GetMapping + public ResponseEntity>> getUsers( + @CurrentMember MemberInfo memberInfo, + @RequestParam(required = false) String keyword, + @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable + ) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "사용자 상세 조회", description = "관리자가 사용자 상세 정보와 보유 아이템 인벤토리를 조회합니다.") + @GetMapping("/{memberId}") + public ResponseEntity> getUserDetail( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long memberId + ) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "사용자 아이템 인벤토리 수정", description = "관리자가 특정 사용자 인벤토리에 아이템을 추가하거나 차감합니다.") + @PatchMapping("/{memberId}/items") + public ResponseEntity> updateUserInventory( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long memberId, + @RequestBody Object request + ) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } +} From d5d9c3e2a95838ebab930ff4eb620bdbdae36766 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Fri, 14 Aug 2026 18:46:58 +0900 Subject: [PATCH 02/66] =?UTF-8?q?feat(user/item):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A5=BC=20user-service=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - item-service에 내부 인벤토리 수량 조회 API 추가 (InternalAdminItemController, AdminItemQueryService) - user-service에 인벤토리 관련 DTO 이관 (AdminInventoryAction/Counts/UpdateRequest, AdminUserDetailResponse, AdminUserSummaryResponse) - user-service에 item-service 내부 API 호출용 ItemAdminClient(Feign) 신규 추가 - AdminMemberService/Impl을 domain/member에서 domain/admin으로 이동하고 인벤토리 수량 조회 로직 구현 - AdminMemberController.getUsers를 AdminMemberService에 연결 (기존 501 스텁 제거) - UserServiceApplication에 @EnableFeignClients, application.yml에 item-service.url 추가 - AdminMemberControllerTest, AdminMemberServiceTest 신규 작성 Co-Authored-By: Claude Sonnet 5 --- .../admin/service/AdminItemQueryService.java | 44 +++++ .../InternalAdminItemController.java | 44 +++++ .../user/UserServiceApplication.java | 2 + .../admin/dto/AdminInventoryAction.java | 6 + .../admin/dto/AdminInventoryCounts.java | 10 ++ .../dto/AdminInventoryUpdateRequest.java | 29 ++++ .../admin/dto/AdminUserDetailResponse.java | 15 ++ .../admin/dto/AdminUserSummaryResponse.java | 29 ++++ .../service/AdminMemberService.java | 5 +- .../admin/service/AdminMemberServiceImpl.java | 93 +++++++++++ .../service/AdminMemberServiceImpl.java | 38 ----- .../user/infra/client/ItemAdminClient.java | 29 ++++ .../controller/AdminMemberController.java | 10 +- .../src/main/resources/application.yml | 3 + .../admin/service/AdminMemberServiceTest.java | 158 ++++++++++++++++++ .../controller/AdminMemberControllerTest.java | 112 +++++++++++++ 16 files changed, 585 insertions(+), 42 deletions(-) create mode 100644 item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemQueryService.java create mode 100644 item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java rename user-service/src/main/java/com/comatching/user/domain/{member => admin}/service/AdminMemberService.java (72%) create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java delete mode 100644 user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java create mode 100644 user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java create mode 100644 user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java create mode 100644 user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemQueryService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemQueryService.java new file mode 100644 index 0000000..6bf64f2 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemQueryService.java @@ -0,0 +1,44 @@ +package com.comatching.item.domain.admin.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.item.domain.admin.dto.AdminInventoryCounts; +import com.comatching.item.domain.item.repository.ItemRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminItemQueryService { + private final ItemRepository itemRepository; + + public Map getInventoryCounts(List memberIds) { + if (memberIds == null || memberIds.isEmpty()) { + return Map.of(); + } + + Map countsByMemberId = new HashMap<>(); + for (ItemRepository.MemberItemQuantity quantity : itemRepository.sumUsableQuantityByMemberIds(memberIds)) { + long[] counts = countsByMemberId.computeIfAbsent(quantity.getMemberId(), ignored -> new long[2]); + if (quantity.getItemType() == ItemType.MATCHING_TICKET) { + counts[0] = quantity.getQuantity(); + } + if (quantity.getItemType() == ItemType.OPTION_TICKET) { + counts[1] = quantity.getQuantity(); + } + } + + Map result = new HashMap<>(); + countsByMemberId.forEach((memberId, counts) -> + result.put(memberId, new AdminInventoryCounts(counts[0], counts[1])) + ); + return result; + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java b/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java new file mode 100644 index 0000000..6d698ac --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java @@ -0,0 +1,44 @@ +package com.comatching.item.infra.controller; + +import java.util.List; +import java.util.Map; + +import com.comatching.item.domain.admin.service.AdminItemQueryService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.item.domain.admin.dto.AdminInventoryCounts; +import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/internal/admin/items") +@RequiredArgsConstructor +public class InternalAdminItemController { + private final AdminItemQueryService adminItemQueryService; + + @GetMapping + public ResponseEntity> getInventoryCounts( + @RequestParam("memberIds") List memberIds + ) { + return ResponseEntity.ok(adminItemQueryService.getInventoryCounts(memberIds)); + } + + @PatchMapping("/{memberId}") + public ResponseEntity adjustInventory( + @PathVariable Long memberId, + @RequestHeader("X-Admin-Id") Long adminId, + @RequestBody AdminInventoryUpdateRequest request + ) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } +} diff --git a/user-service/src/main/java/com/comatching/user/UserServiceApplication.java b/user-service/src/main/java/com/comatching/user/UserServiceApplication.java index 66327ba..3e1b411 100644 --- a/user-service/src/main/java/com/comatching/user/UserServiceApplication.java +++ b/user-service/src/main/java/com/comatching/user/UserServiceApplication.java @@ -4,6 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @@ -12,6 +13,7 @@ @SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class}) @ComponentScan(basePackages = {"com.comatching.user", "com.comatching.common"}) @EnableConfigurationProperties(ProfileImageProperties.class) +@EnableFeignClients(basePackages = "com.comatching.user") @EnableJpaAuditing public class UserServiceApplication { diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java new file mode 100644 index 0000000..32b846b --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java @@ -0,0 +1,6 @@ +package com.comatching.user.domain.admin.dto; + +public enum AdminInventoryAction { + ADD, + REMOVE +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java new file mode 100644 index 0000000..935bc9a --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java @@ -0,0 +1,10 @@ +package com.comatching.user.domain.admin.dto; + +public record AdminInventoryCounts( + long matchingTicketCount, + long optionTicketCount +) { + public static AdminInventoryCounts empty() { + return new AdminInventoryCounts(0, 0); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java new file mode 100644 index 0000000..4f46c65 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java @@ -0,0 +1,29 @@ +package com.comatching.user.domain.admin.dto; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.user.domain.admin.dto.AdminInventoryAction; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record AdminInventoryUpdateRequest( + @NotNull(message = "아이템 타입은 필수입니다.") + ItemType itemType, + + @Positive(message = "수량은 1 이상이어야 합니다.") + int quantity, + + @NotNull(message = "수정 액션은 필수입니다.") + AdminInventoryAction action, + + @NotBlank(message = "수정 사유는 필수입니다.") + @Size(max = 255, message = "수정 사유는 255자 이하여야 합니다.") + String reason +) { + public AdminInventoryUpdateRequest { + if (reason != null) { + reason = reason.trim(); + } + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java new file mode 100644 index 0000000..a9a30f2 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java @@ -0,0 +1,15 @@ +package com.comatching.user.domain.admin.dto; + +import com.comatching.common.domain.enums.Gender; + +public record AdminUserDetailResponse( + Long id, + String email, + String realName, + String nickname, + Gender gender, + String profileImageUrl, + long matchingTicketCount, + long optionTicketCount +) { +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java new file mode 100644 index 0000000..243a694 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java @@ -0,0 +1,29 @@ +package com.comatching.user.domain.admin.dto; + +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.dto.member.AdminUserProfileDto; +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; + +public record AdminUserSummaryResponse( + Long id, + String email, + String realName, + String nickname, + Gender gender, + String profileImageUrl, + long matchingTicketCount, + long optionTicketCount +) { + public static AdminUserSummaryResponse from(AdminUserProfileDto dto, AdminInventoryCounts inventoryCounts) { + return new AdminUserSummaryResponse( + dto.id(), + dto.email(), + dto.realName(), + dto.nickname(), + dto.gender(), + dto.profileImageUrl(), + inventoryCounts.matchingTicketCount(), + inventoryCounts.optionTicketCount() + ); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java similarity index 72% rename from user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java rename to user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java index b31c308..689daea 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java @@ -1,5 +1,6 @@ -package com.comatching.user.domain.member.service; +package com.comatching.user.domain.admin.service; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import org.springframework.data.domain.Pageable; import com.comatching.common.dto.member.AdminUserProfileDto; @@ -11,7 +12,7 @@ */ public interface AdminMemberService { - PagingResponse getUsers(String keyword, Pageable pageable); + PagingResponse getUsers(String keyword, Pageable pageable); AdminUserProfileDto getUserDetail(Long memberId); diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java new file mode 100644 index 0000000..1007c27 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java @@ -0,0 +1,93 @@ +package com.comatching.user.domain.admin.service; + +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.infra.client.ItemAdminClient; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.member.AdminUserProfileDto; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.member.entity.Member; +import com.comatching.user.domain.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminMemberServiceImpl implements AdminMemberService { + + private final MemberRepository memberRepository; + private final ItemAdminClient itemAdminClient; + + @Override + public PagingResponse getUsers(String keyword, Pageable pageable) { + String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null; + + Page userPage = memberRepository.searchMembersForAdmin( + MemberStatus.ACTIVE, + MemberRole.ROLE_USER, + normalizedKeyword, + pageable + ) + .map(this::toAdminUserProfileDto); + + List users = userPage.getContent(); + Map inventoryCountsByMemberId = itemAdminClient.getInventoryCounts( + users.stream() + .map(AdminUserProfileDto::id) + .toList() + ); + + List summaries = users.stream() + .map(user -> AdminUserSummaryResponse.from( + user, + inventoryCountsByMemberId.getOrDefault(user.id(), AdminInventoryCounts.empty()) + )) + .toList(); + + + return new PagingResponse<>( + summaries, + userPage.getNumber(), + userPage.getSize(), + userPage.getTotalElements(), + userPage.getTotalPages(), + userPage.hasNext(), + userPage.hasPrevious() + ); + } + + @Override + public AdminUserProfileDto getUserDetail(Long memberId) { + // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUserDetail 로직 이식 + ItemAdminClient 티켓 수량 합성 + throw new UnsupportedOperationException("not implemented yet"); + } + + @Override + @Transactional + public void updateUserInventory(Long adminId, Long memberId, Object request) { + // TODO(refactor/admin): getUserDetail 선조회 -> ItemAdminClient.adjustInventory(memberId, adminId, request) 호출 + throw new UnsupportedOperationException("not implemented yet"); + } + + private AdminUserProfileDto toAdminUserProfileDto(Member member) { + return new AdminUserProfileDto( + member.getId(), + member.getEmail(), + member.getRealName(), + member.getProfile().getNickname(), + member.getProfile().getGender(), + member.getProfile().getProfileImageUrl() + ); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java deleted file mode 100644 index 4e24e8d..0000000 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.comatching.user.domain.member.service; - -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.comatching.common.dto.member.AdminUserProfileDto; -import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.member.repository.MemberRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AdminMemberServiceImpl implements AdminMemberService { - - private final MemberRepository memberRepository; - - @Override - public PagingResponse getUsers(String keyword, Pageable pageable) { - // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUsers 로직 이식 + ItemAdminClient 티켓 수량 합성 - throw new UnsupportedOperationException("not implemented yet"); - } - - @Override - public AdminUserProfileDto getUserDetail(Long memberId) { - // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUserDetail 로직 이식 + ItemAdminClient 티켓 수량 합성 - throw new UnsupportedOperationException("not implemented yet"); - } - - @Override - @Transactional - public void updateUserInventory(Long adminId, Long memberId, Object request) { - // TODO(refactor/admin): getUserDetail 선조회 -> ItemAdminClient.adjustInventory(memberId, adminId, request) 호출 - throw new UnsupportedOperationException("not implemented yet"); - } -} diff --git a/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java new file mode 100644 index 0000000..6f3e13a --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java @@ -0,0 +1,29 @@ +package com.comatching.user.infra.client; + +import java.util.List; +import java.util.Map; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; + +@FeignClient(name = "item-service-admin", url = "${item-service.url}", path = "/api/internal/admin/items") +public interface ItemAdminClient { + + @GetMapping + Map getInventoryCounts(@RequestParam("memberIds") List memberIds); + + @PatchMapping("/{memberId}") + void adjustInventory( + @PathVariable Long memberId, + @RequestHeader("X-Admin-Id") Long adminId, + @RequestBody AdminInventoryUpdateRequest request + ); +} diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index 69f33a8..1279af0 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -19,25 +19,31 @@ import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.service.AdminMemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; @Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리 (현재 미구현)") @RestController @RequestMapping("/api/admin/users") +@RequiredArgsConstructor public class AdminMemberController { + private final AdminMemberService adminMemberService; + @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "사용자 목록 조회/검색", description = "관리자가 이메일/닉네임/이름 키워드로 사용자 목록을 페이징 조회합니다.") @GetMapping - public ResponseEntity>> getUsers( + public ResponseEntity>> getUsers( @CurrentMember MemberInfo memberInfo, @RequestParam(required = false) String keyword, @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable ) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + return ResponseEntity.ok(ApiResponse.ok(adminMemberService.getUsers(keyword, pageable))); } @RequireRole(MemberRole.ROLE_ADMIN) diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml index 3809a0f..06efdb0 100644 --- a/user-service/src/main/resources/application.yml +++ b/user-service/src/main/resources/application.yml @@ -12,6 +12,9 @@ server: client: url: ${CLIENT_URL:http://localhost:3000} +item-service: + url: ${ITEM_SERVICE_URL:http://localhost:9006} + auth: cookie: secure: ${AUTH_COOKIE_SECURE:false} diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java new file mode 100644 index 0000000..407993a --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java @@ -0,0 +1,158 @@ +package com.comatching.user.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.member.entity.Member; +import com.comatching.user.domain.member.entity.Profile; +import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.infra.client.ItemAdminClient; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminMemberServiceImpl 테스트") +class AdminMemberServiceTest { + + @InjectMocks + private AdminMemberServiceImpl adminMemberService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private ItemAdminClient itemAdminClient; + + @Test + @DisplayName("사용자 목록과 인벤토리 수량을 함께 조회한다") + void shouldReturnUsersWithInventoryCounts() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + Member member1 = createMemberWithProfile(1L, "user1@test.com", "홍길동", "닉네임1", Gender.FEMALE, "https://img1"); + Member member2 = createMemberWithProfile(2L, "user2@test.com", "김철수", "닉네임2", Gender.MALE, "https://img2"); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable)) + .willReturn(new PageImpl<>(List.of(member1, member2), pageable, 2)); + given(itemAdminClient.getInventoryCounts(anyList())) + .willReturn(Map.of(1L, new AdminInventoryCounts(3L, 1L))); + + // when + PagingResponse result = adminMemberService.getUsers(null, pageable); + + // then + assertThat(result.content()).hasSize(2); + assertThat(result.totalElements()).isEqualTo(2); + + AdminUserSummaryResponse first = result.content().get(0); + assertThat(first.id()).isEqualTo(1L); + assertThat(first.email()).isEqualTo("user1@test.com"); + assertThat(first.matchingTicketCount()).isEqualTo(3L); + assertThat(first.optionTicketCount()).isEqualTo(1L); + + AdminUserSummaryResponse second = result.content().get(1); + assertThat(second.id()).isEqualTo(2L); + assertThat(second.matchingTicketCount()).isZero(); + assertThat(second.optionTicketCount()).isZero(); + } + + @Test + @DisplayName("인벤토리 조회 결과에 없는 사용자는 수량 0으로 채운다") + void shouldFallbackToEmptyInventoryWhenMissing() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + Member member = createMemberWithProfile(5L, "user5@test.com", "이영희", "닉네임5", Gender.FEMALE, "https://img5"); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable)) + .willReturn(new PageImpl<>(List.of(member), pageable, 1)); + given(itemAdminClient.getInventoryCounts(anyList())).willReturn(Map.of()); + + // when + PagingResponse result = adminMemberService.getUsers(null, pageable); + + // then + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0).matchingTicketCount()).isZero(); + assertThat(result.content().get(0).optionTicketCount()).isZero(); + } + + @Test + @DisplayName("keyword 앞뒤 공백을 제거해서 저장소로 전달한다") + void shouldTrimKeywordBeforeQuery() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, "nickname", pageable)) + .willReturn(new PageImpl<>(List.of(), pageable, 0)); + + // when + adminMemberService.getUsers(" nickname ", pageable); + + // then + then(memberRepository).should() + .searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, "nickname", pageable); + } + + @Test + @DisplayName("빈 문자열 keyword는 null로 정규화해서 전달한다") + void shouldNormalizeBlankKeywordToNull() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable)) + .willReturn(new PageImpl<>(List.of(), pageable, 0)); + + // when + adminMemberService.getUsers(" ", pageable); + + // then + then(memberRepository).should() + .searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable); + } + + private static Member createMemberWithProfile( + Long id, + String email, + String realName, + String nickname, + Gender gender, + String imageUrl + ) { + Member member = Member.builder() + .email(email) + .role(MemberRole.ROLE_USER) + .status(MemberStatus.ACTIVE) + .build(); + member.updateRealName(realName); + + Profile profile = Profile.builder() + .member(member) + .nickname(nickname) + .gender(gender) + .profileImageUrl(imageUrl) + .build(); + + member.setProfile(profile); + ReflectionTestUtils.setField(member, "id", id); + return member; + } +} diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java new file mode 100644 index 0000000..a022471 --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -0,0 +1,112 @@ +package com.comatching.user.infra.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.service.AdminMemberService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class AdminMemberControllerTest { + + private MockMvc mockMvc; + + @Mock + private AdminMemberService adminMemberService; + + @InjectMocks + private AdminMemberController adminMemberController; + + private static final Long ADMIN_ID = 999L; + private static final String SUCCESS_CODE = "GEN-000"; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(adminMemberController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver(), new PageableHandlerMethodArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("GET /api/admin/users - 사용자 목록을 조회한다") + void getUsers_success() throws Exception { + // given + AdminUserSummaryResponse summary = new AdminUserSummaryResponse( + 1L, "user@test.com", "홍길동", "닉네임", Gender.FEMALE, "https://img", 3L, 1L + ); + PagingResponse response = + new PagingResponse<>(List.of(summary), 0, 20, 1, 1, false, false); + + given(adminMemberService.getUsers(eq(null), any(Pageable.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/admin/users") + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].id").value(1)) + .andExpect(jsonPath("$.data.content[0].email").value("user@test.com")) + .andExpect(jsonPath("$.data.content[0].matchingTicketCount").value(3)) + .andExpect(jsonPath("$.data.content[0].optionTicketCount").value(1)) + .andExpect(jsonPath("$.data.totalElements").value(1)); + + then(adminMemberService).should().getUsers(eq(null), any(Pageable.class)); + } + + @Test + @DisplayName("GET /api/admin/users?keyword= - 키워드를 서비스로 그대로 전달한다") + void getUsers_withKeyword() throws Exception { + // given + PagingResponse response = + new PagingResponse<>(List.of(), 0, 20, 0, 0, false, false); + + given(adminMemberService.getUsers(eq("nickname"), any(Pageable.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/admin/users") + .param("keyword", "nickname") + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)) + .andExpect(jsonPath("$.data.content.length()").value(0)); + + then(adminMemberService).should().getUsers(eq("nickname"), any(Pageable.class)); + } + + @Test + @DisplayName("GET /api/admin/users - 인증 헤더가 없으면 예외가 발생한다") + void getUsers_missingMemberIdHeader() throws Exception { + mockMvc.perform(get("/api/admin/users")) + .andExpect(status().isInternalServerError()); + + then(adminMemberService).shouldHaveNoInteractions(); + } +} From b97da7c06791691aca89a83fc0d6086f6556aec9 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Sun, 16 Aug 2026 20:35:28 +0900 Subject: [PATCH 03/66] =?UTF-8?q?feat(user):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20user-service=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMemberServiceImpl#getUserDetail 구현 (로컬 회원 조회 + ItemAdminClient로 인벤토리 수량 합성) - AdminUserDetailResponse#from 정적 팩토리 추가 - AdminMemberService/AdminMemberController 반환 타입을 AdminUserDetailResponse로 변경 - item-service AdminUserController Swagger 설명에 리팩토링 완료 표기 - gradlew 줄바꿈, chat-service/build.gradle 공백 정리 - .gitignore에 *.txt 추가 Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + chat-service/build.gradle | 2 +- .../infra/controller/AdminUserController.java | 2 +- .../admin/dto/AdminUserDetailResponse.java | 13 ++++++++++ .../admin/service/AdminMemberService.java | 10 +++----- .../admin/service/AdminMemberServiceImpl.java | 25 ++++++++++++++++--- .../controller/AdminMemberController.java | 5 ++-- 7 files changed, 44 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 5bfb258..fcf65ac 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ tools/perf/jmeter/results/ .pids/ logs/ cookies.txt +*.txt diff --git a/chat-service/build.gradle b/chat-service/build.gradle index 9f7c4ee..461cc86 100644 --- a/chat-service/build.gradle +++ b/chat-service/build.gradle @@ -1,4 +1,4 @@ -plugins { + plugins { id 'java' id 'org.springframework.boot' id 'io.spring.dependency-management' diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java index 5ef736d..30325e5 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java @@ -37,7 +37,7 @@ public class AdminUserController { private final AdminUserItemService adminUserItemService; @RequireRole(MemberRole.ROLE_ADMIN) - @Operation(summary = "사용자 목록 조회/검색", description = "관리자가 이메일/닉네임/이름 키워드로 사용자 목록을 페이징 조회합니다.") + @Operation(summary = "사용자 목록 조회/검색", description = "관리자가 이메일/닉네임/이름 키워드로 사용자 목록을 페이징 조회합니다. (리팩토링 완료)") @GetMapping public ResponseEntity>> getUsers( @CurrentMember MemberInfo memberInfo, diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java index a9a30f2..00897b3 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java @@ -1,6 +1,7 @@ package com.comatching.user.domain.admin.dto; import com.comatching.common.domain.enums.Gender; +import com.comatching.common.dto.member.AdminUserProfileDto; public record AdminUserDetailResponse( Long id, @@ -12,4 +13,16 @@ public record AdminUserDetailResponse( long matchingTicketCount, long optionTicketCount ) { + public static AdminUserDetailResponse from(AdminUserProfileDto dto, AdminInventoryCounts inventoryCounts) { + return new AdminUserDetailResponse( + dto.id(), + dto.email(), + dto.realName(), + dto.nickname(), + dto.gender(), + dto.profileImageUrl(), + inventoryCounts.matchingTicketCount(), + inventoryCounts.optionTicketCount() + ); + } } diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java index 689daea..19dfa80 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java @@ -1,20 +1,16 @@ package com.comatching.user.domain.admin.service; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import org.springframework.data.domain.Pageable; -import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; -/** - * TODO(refactor/admin): 반환 타입을 item-service 원본 스펙(AdminUserSummaryResponse/AdminUserDetailResponse, - * 티켓 수량 포함)으로 교체. 해당 DTO가 common-module로 이관된 뒤 적용. - */ public interface AdminMemberService { PagingResponse getUsers(String keyword, Pageable pageable); - AdminUserProfileDto getUserDetail(Long memberId); + AdminUserDetailResponse getUserDetail(Long memberId); void updateUserInventory(Long adminId, Long memberId, Object request); } diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java index 1007c27..d80d327 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java @@ -1,7 +1,11 @@ package com.comatching.user.domain.admin.service; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.global.exception.UserErrorCode; import com.comatching.user.infra.client.ItemAdminClient; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -17,10 +21,12 @@ import com.comatching.user.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.util.List; import java.util.Map; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -68,9 +74,19 @@ public PagingResponse getUsers(String keyword, Pageabl } @Override - public AdminUserProfileDto getUserDetail(Long memberId) { - // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUserDetail 로직 이식 + ItemAdminClient 티켓 수량 합성 - throw new UnsupportedOperationException("not implemented yet"); + public AdminUserDetailResponse getUserDetail(Long memberId) { + if (memberId == null || memberId <= 0) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE, "memberId는 1 이상의 값이어야 합니다."); + } + + Member member = memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_EXIST)); + AdminUserProfileDto user = toAdminUserProfileDto(member); + + Map inventoryCountsByMemberId = itemAdminClient.getInventoryCounts(List.of(memberId)); + AdminInventoryCounts inventoryCounts = inventoryCountsByMemberId.getOrDefault(memberId, AdminInventoryCounts.empty()); + + return AdminUserDetailResponse.from(user, inventoryCounts); } @Override @@ -90,4 +106,7 @@ private AdminUserProfileDto toAdminUserProfileDto(Member member) { member.getProfile().getProfileImageUrl() ); } + + + } diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index 1279af0..04d25b4 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -1,6 +1,7 @@ package com.comatching.user.infra.controller; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; @@ -49,11 +50,11 @@ public ResponseEntity>> get @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "사용자 상세 조회", description = "관리자가 사용자 상세 정보와 보유 아이템 인벤토리를 조회합니다.") @GetMapping("/{memberId}") - public ResponseEntity> getUserDetail( + public ResponseEntity> getUserDetail( @CurrentMember MemberInfo memberInfo, @PathVariable Long memberId ) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + return ResponseEntity.ok(ApiResponse.ok(adminMemberService.getUserDetail(memberId))); } @RequireRole(MemberRole.ROLE_ADMIN) From 8de989a30f58860aca2893a3fc3fb579c54644c1 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Mon, 17 Aug 2026 13:30:52 +0900 Subject: [PATCH 04/66] =?UTF-8?q?feat(user/item):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B8=EB=B2=A4=ED=86=A0=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20user-service=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - item-service: PATCH /api/internal/admin/items/{memberId} 내부 API 구현 (AdminItemCommandService) - item-service: dedupe/adjustment에 중복 구현돼 있던 검증 로직을 AdminInventoryRequestValidator로 통합해 요청당 한 번만 실행되도록 정리 - user-service: AdminMemberServiceImpl.updateUserInventory에서 ItemAdminClient.adjustInventory 호출 및 Feign 에러(409/400/기타) 매핑 추가 - user-service: AdminMemberController PATCH가 항상 501을 반환하던 버그 수정 - user-service: Feign 기본 클라이언트가 PATCH를 지원하지 않아 발생하던 500 오류 해결 (feign-hc5 추가) - user-service: 사용자 상세 조회 실패 코드를 MEM-001에서 구경로와 동일한 ITEM-004로 통일 - user-service: UserErrorCode에 item-service와 동일한 ITEM-001/004/005/006 추가 - 관련 서비스/컨트롤러 단위 테스트 추가 및 갱신 Co-Authored-By: Claude Sonnet 5 --- .../AdminInventoryAdjustmentService.java | 13 +- .../service/AdminInventoryDedupeService.java | 14 +- .../AdminInventoryRequestValidator.java | 21 +++ .../service/AdminItemCommandService.java | 20 +++ .../InternalAdminItemController.java | 8 +- .../AdminInventoryDedupeServiceTest.java | 3 + .../AdminInventoryRequestValidatorTest.java | 58 ++++++ user-service/build.gradle | 3 + .../admin/service/AdminMemberService.java | 3 +- .../admin/service/AdminMemberServiceImpl.java | 53 ++++-- .../user/global/exception/UserErrorCode.java | 6 + .../controller/AdminMemberController.java | 15 +- .../admin/service/AdminMemberServiceTest.java | 165 ++++++++++++++++++ .../controller/AdminMemberControllerTest.java | 71 ++++++++ 14 files changed, 405 insertions(+), 48 deletions(-) create mode 100644 item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidator.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemCommandService.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidatorTest.java diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryAdjustmentService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryAdjustmentService.java index 75b6565..6a04ad7 100644 --- a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryAdjustmentService.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryAdjustmentService.java @@ -5,11 +5,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import com.comatching.common.annotation.DistributedLock; import com.comatching.common.exception.BusinessException; -import com.comatching.common.exception.code.GeneralErrorCode; import com.comatching.item.domain.admin.dto.AdminInventoryAction; import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; import com.comatching.item.domain.item.entity.Item; @@ -26,15 +24,13 @@ public class AdminInventoryAdjustmentService { private static final LocalDateTime NO_EXPIRATION = LocalDateTime.of(2099, 12, 31, 23, 59, 59); - private static final int MAX_REASON_LENGTH = 255; private final ItemRepository itemRepository; private final ItemHistoryService historyService; + // request 검증은 이 파이프라인의 첫 단계인 AdminInventoryDedupeService.reserveOrThrow에서 이미 끝난 상태로 호출된다 @DistributedLock(key = "item:inventory", identifier = "#memberId + ':' + #request.itemType()", leaseTime = 10L) public void adjust(Long adminId, Long memberId, AdminInventoryUpdateRequest request) { - validateRequest(request); - if (request.action() == AdminInventoryAction.ADD) { addInventory(memberId, request); saveAdminHistory(adminId, memberId, request, request.quantity()); @@ -85,11 +81,4 @@ private void saveAdminHistory(Long adminId, Long memberId, AdminInventoryUpdateR "관리자 조정(adminId=" + adminId + "): " + request.reason() ); } - - private void validateRequest(AdminInventoryUpdateRequest request) { - if (request == null || request.itemType() == null || request.action() == null || request.quantity() <= 0 - || !StringUtils.hasText(request.reason()) || request.reason().length() > MAX_REASON_LENGTH) { - throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); - } - } } diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeService.java index c59f4d7..5316630 100644 --- a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeService.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeService.java @@ -5,10 +5,8 @@ import org.redisson.api.RBucket; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import com.comatching.common.exception.BusinessException; -import com.comatching.common.exception.code.GeneralErrorCode; import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; import com.comatching.item.global.exception.ItemErrorCode; @@ -19,13 +17,14 @@ public class AdminInventoryDedupeService { private static final long DEDUPE_TTL_SECONDS = 3L; - private static final int MAX_REASON_LENGTH = 255; private static final String DEDUPE_KEY_PREFIX = "admin:inventory:dedupe"; private final RedissonClient redissonClient; + private final AdminInventoryRequestValidator requestValidator; public void reserveOrThrow(Long memberId, AdminInventoryUpdateRequest request) { - validateRequest(request); + // 이 파이프라인의 첫 단계라 여기서만 검증한다 - adjust()는 항상 이 메서드 통과 후에만 호출된다 + requestValidator.validate(request); String key = String.join(":", DEDUPE_KEY_PREFIX, @@ -42,11 +41,4 @@ public void reserveOrThrow(Long memberId, AdminInventoryUpdateRequest request) { throw new BusinessException(ItemErrorCode.DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT); } } - - private void validateRequest(AdminInventoryUpdateRequest request) { - if (request == null || request.itemType() == null || request.action() == null || request.quantity() <= 0 - || !StringUtils.hasText(request.reason()) || request.reason().length() > MAX_REASON_LENGTH) { - throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); - } - } } diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidator.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidator.java new file mode 100644 index 0000000..7945472 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidator.java @@ -0,0 +1,21 @@ +package com.comatching.item.domain.admin.service; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; + +@Component +public class AdminInventoryRequestValidator { + + private static final int MAX_REASON_LENGTH = 255; + + public void validate(AdminInventoryUpdateRequest request) { + if (request == null || request.itemType() == null || request.action() == null || request.quantity() <= 0 + || !StringUtils.hasText(request.reason()) || request.reason().length() > MAX_REASON_LENGTH) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); + } + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemCommandService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemCommandService.java new file mode 100644 index 0000000..59b23a7 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemCommandService.java @@ -0,0 +1,20 @@ +package com.comatching.item.domain.admin.service; + +import org.springframework.stereotype.Service; + +import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminItemCommandService { + + private final AdminInventoryDedupeService adminInventoryDedupeService; + private final AdminInventoryAdjustmentService adminInventoryAdjustmentService; + + public void adjustInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request) { + adminInventoryDedupeService.reserveOrThrow(memberId, request); + adminInventoryAdjustmentService.adjust(adminId, memberId, request); + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java b/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java index 6d698ac..0dd7303 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java @@ -3,8 +3,6 @@ import java.util.List; import java.util.Map; -import com.comatching.item.domain.admin.service.AdminItemQueryService; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -17,6 +15,8 @@ import com.comatching.item.domain.admin.dto.AdminInventoryCounts; import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.item.domain.admin.service.AdminItemCommandService; +import com.comatching.item.domain.admin.service.AdminItemQueryService; import lombok.RequiredArgsConstructor; @@ -25,6 +25,7 @@ @RequiredArgsConstructor public class InternalAdminItemController { private final AdminItemQueryService adminItemQueryService; + private final AdminItemCommandService adminItemCommandService; @GetMapping public ResponseEntity> getInventoryCounts( @@ -39,6 +40,7 @@ public ResponseEntity adjustInventory( @RequestHeader("X-Admin-Id") Long adminId, @RequestBody AdminInventoryUpdateRequest request ) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + adminItemCommandService.adjustInventory(adminId, memberId, request); + return ResponseEntity.ok().build(); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeServiceTest.java b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeServiceTest.java index 793248e..ae170f1 100644 --- a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeServiceTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeServiceTest.java @@ -37,6 +37,9 @@ class AdminInventoryDedupeServiceTest { @Mock private RBucket bucket; + @Mock + private AdminInventoryRequestValidator requestValidator; + @Test @DisplayName("동일 조정 요청을 3초 TTL 키로 예약한다") void shouldReserveDedupeKeyWithTtl() { diff --git a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidatorTest.java b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidatorTest.java new file mode 100644 index 0000000..434ad81 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidatorTest.java @@ -0,0 +1,58 @@ +package com.comatching.item.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.admin.dto.AdminInventoryAction; +import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; + +@DisplayName("AdminInventoryRequestValidator 테스트") +class AdminInventoryRequestValidatorTest { + + private final AdminInventoryRequestValidator validator = new AdminInventoryRequestValidator(); + + @Test + @DisplayName("유효한 요청은 예외를 던지지 않는다") + void shouldPassWhenRequestIsValid() { + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 1, AdminInventoryAction.ADD, "보상 지급" + ); + + assertThatCode(() -> validator.validate(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("수량이 0 이하이면 예외가 발생한다") + void shouldThrowWhenQuantityIsNotPositive() { + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 0, AdminInventoryAction.ADD, "보상 지급" + ); + + assertThatThrownBy(() -> validator.validate(request)).isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("사유가 공백이면 예외가 발생한다") + void shouldThrowWhenReasonIsBlank() { + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 1, AdminInventoryAction.ADD, " " + ); + + assertThatThrownBy(() -> validator.validate(request)).isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("사유가 255자를 초과하면 예외가 발생한다") + void shouldThrowWhenReasonExceedsMaxLength() { + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 1, AdminInventoryAction.ADD, "가".repeat(256) + ); + + assertThatThrownBy(() -> validator.validate(request)).isInstanceOf(BusinessException.class); + } +} diff --git a/user-service/build.gradle b/user-service/build.gradle index 0729480..84fd28a 100644 --- a/user-service/build.gradle +++ b/user-service/build.gradle @@ -26,6 +26,9 @@ dependencies { // Kafka implementation 'org.springframework.kafka:spring-kafka' + // Feign - 기본 HttpURLConnection 클라이언트는 PATCH를 지원하지 않아 hc5로 교체 + implementation 'io.github.openfeign:feign-hc5' + // Mail implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java index 19dfa80..6a87a52 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Pageable; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; @@ -12,5 +13,5 @@ public interface AdminMemberService { AdminUserDetailResponse getUserDetail(Long memberId); - void updateUserInventory(Long adminId, Long memberId, Object request); + void updateUserInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request); } diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java index d80d327..1a27792 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java @@ -1,12 +1,8 @@ package com.comatching.user.domain.admin.service; -import com.comatching.common.exception.BusinessException; -import com.comatching.common.exception.code.GeneralErrorCode; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; -import com.comatching.user.global.exception.UserErrorCode; -import com.comatching.user.infra.client.ItemAdminClient; +import java.util.List; +import java.util.Map; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -17,15 +13,22 @@ import com.comatching.common.domain.enums.MemberStatus; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.global.exception.UserErrorCode; +import com.comatching.user.infra.client.ItemAdminClient; +import feign.FeignException; +import feign.codec.DecodeException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import java.util.List; -import java.util.Map; - @Slf4j @Service @RequiredArgsConstructor @@ -80,7 +83,7 @@ public AdminUserDetailResponse getUserDetail(Long memberId) { } Member member = memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER) - .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_EXIST)); + .orElseThrow(() -> new BusinessException(UserErrorCode.TARGET_USER_NOT_FOUND)); AdminUserProfileDto user = toAdminUserProfileDto(member); Map inventoryCountsByMemberId = itemAdminClient.getInventoryCounts(List.of(memberId)); @@ -91,9 +94,31 @@ public AdminUserDetailResponse getUserDetail(Long memberId) { @Override @Transactional - public void updateUserInventory(Long adminId, Long memberId, Object request) { - // TODO(refactor/admin): getUserDetail 선조회 -> ItemAdminClient.adjustInventory(memberId, adminId, request) 호출 - throw new UnsupportedOperationException("not implemented yet"); + public void updateUserInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request) { + // 존재하지 않는 대상 사용자의 인벤토리 수정 요청을 막기 위해 선조회 + memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER) + .orElseThrow(() -> new BusinessException(UserErrorCode.TARGET_USER_NOT_FOUND)); + + try { + itemAdminClient.adjustInventory(memberId, adminId, request); + } catch (DecodeException e) { + // item-service 응답 본문을 역직렬화하지 못한 경우(스펙 불일치 등) - 원인 파악이 불가능하므로 조회 실패로 처리 + log.warn("Admin inventory adjustment decode failed. memberId={}", memberId, e); + throw new BusinessException(UserErrorCode.USER_QUERY_FAILED); + } catch (FeignException e) { + if (e.status() == 409) { + // AdminInventoryDedupeService가 3초 내 동일 요청(memberId+itemType+action+quantity+reason)을 감지 - 재시도/중복클릭으로 재요청하면 안 됨 + throw new BusinessException(UserErrorCode.DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT); + } + if (e.status() == 400) { + // AdminInventoryAdjustmentService가 REMOVE 처리 중 보유 수량 부족을 감지 - 수량을 줄이거나 회원 보유량을 먼저 확인해야 함 + throw new BusinessException(UserErrorCode.NOT_ENOUGH_ITEM); + } + // 그 외(5xx, 타임아웃 등)는 item-service 쪽 원인을 특정할 수 없는 조정 실패 - 로그로 남기고 재시도 여부는 호출측 판단에 맡김 + log.warn("Admin inventory adjustment failed. memberId={}, status={}, body={}", + memberId, e.status(), e.contentUTF8(), e); + throw new BusinessException(UserErrorCode.USER_QUERY_FAILED); + } } private AdminUserProfileDto toAdminUserProfileDto(Member member) { diff --git a/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java b/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java index 57189a6..4357a16 100644 --- a/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java +++ b/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java @@ -32,6 +32,12 @@ public enum UserErrorCode implements ErrorCode { INVALID_NICKNAME("MEM-009", HttpStatus.BAD_REQUEST, "닉네임은 공백일 수 없습니다."), INVALID_PROFILE_TAG("MEM-010", HttpStatus.BAD_REQUEST, "유효하지 않은 장점 태그입니다."), INVALID_REAL_NAME("MEM-011", HttpStatus.BAD_REQUEST, "실명은 공백일 수 없습니다."), + + // 관리자 인벤토리 조정 에러 - item-service AdminUserController와 응답 스펙(코드/메시지/상태)을 동일하게 유지해야 하므로 ITEM-0xx 코드를 그대로 사용 + NOT_ENOUGH_ITEM("ITEM-001", HttpStatus.BAD_REQUEST, "아이템이 부족합니다."), + TARGET_USER_NOT_FOUND("ITEM-004", HttpStatus.BAD_REQUEST, "대상 사용자를 찾을 수 없습니다."), + USER_QUERY_FAILED("ITEM-005", HttpStatus.INTERNAL_SERVER_ERROR, "사용자 조회 중 오류가 발생했습니다."), + DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), ; private final String code; diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index 04d25b4..b332890 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -1,11 +1,8 @@ package com.comatching.user.infra.controller; -import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -20,15 +17,18 @@ import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import com.comatching.user.domain.admin.service.AdminMemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; - -@Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리 (현재 미구현)") +@Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리") @RestController @RequestMapping("/api/admin/users") @RequiredArgsConstructor @@ -63,8 +63,9 @@ public ResponseEntity> getUserDetail( public ResponseEntity> updateUserInventory( @CurrentMember MemberInfo memberInfo, @PathVariable Long memberId, - @RequestBody Object request + @Valid @RequestBody AdminInventoryUpdateRequest request ) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + adminMemberService.updateUserInventory(memberInfo.memberId(), memberId, request); + return ResponseEntity.ok(ApiResponse.ok()); } } diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java index 407993a..beeb398 100644 --- a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java @@ -1,12 +1,16 @@ package com.comatching.user.domain.admin.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,16 +24,26 @@ import org.springframework.test.util.ReflectionTestUtils; import com.comatching.common.domain.enums.Gender; +import com.comatching.common.domain.enums.ItemType; import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.domain.enums.MemberStatus; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.common.exception.BusinessException; +import com.comatching.user.domain.admin.dto.AdminInventoryAction; import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.entity.Profile; import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.global.exception.UserErrorCode; import com.comatching.user.infra.client.ItemAdminClient; +import feign.FeignException; +import feign.Request; +import feign.Response; +import feign.codec.DecodeException; + @ExtendWith(MockitoExtension.class) @DisplayName("AdminMemberServiceImpl 테스트") class AdminMemberServiceTest { @@ -129,6 +143,157 @@ void shouldNormalizeBlankKeywordToNull() { .searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable); } + @Test + @DisplayName("대상 사용자가 존재하면 인벤토리 조정을 item-service에 위임한다") + void shouldAdjustInventoryWhenMemberExists() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + + // when + assertThatCode(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .doesNotThrowAnyException(); + + // then + then(itemAdminClient).should().adjustInventory(memberId, adminId, request); + } + + @Test + @DisplayName("대상 사용자가 없으면 item-service를 호출하지 않고 예외를 던진다") + void shouldThrowWhenTargetMemberNotFound() { + // given + Long adminId = 900L; + Long memberId = 99L; + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.TARGET_USER_NOT_FOUND); + then(itemAdminClient).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("item-service가 409를 반환하면 중복 조정 예외로 변환한다") + void shouldThrowDuplicateWhenItemServiceReturns409() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + willThrow(feignException(409)).given(itemAdminClient).adjustInventory(memberId, adminId, request); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT); + } + + @Test + @DisplayName("item-service가 400을 반환하면 아이템 부족 예외로 변환한다") + void shouldThrowNotEnoughItemWhenItemServiceReturns400() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.REMOVE, "오지급 회수" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + willThrow(feignException(400)).given(itemAdminClient).adjustInventory(memberId, adminId, request); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.NOT_ENOUGH_ITEM); + } + + @Test + @DisplayName("item-service가 그 외 상태코드를 반환하면 조회 실패 예외로 변환한다") + void shouldThrowUserQueryFailedForOtherStatus() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + willThrow(feignException(500)).given(itemAdminClient).adjustInventory(memberId, adminId, request); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.USER_QUERY_FAILED); + } + + @Test + @DisplayName("item-service 응답 디코딩에 실패하면 조회 실패 예외로 변환한다") + void shouldThrowUserQueryFailedWhenDecodeFails() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + willThrow(decodeException()).given(itemAdminClient).adjustInventory(memberId, adminId, request); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.USER_QUERY_FAILED); + } + + private static FeignException feignException(int status) { + Response response = Response.builder() + .status(status) + .reason("error") + .request(testRequest()) + .headers(Map.of()) + .build(); + return FeignException.errorStatus("ItemAdminClient#adjustInventory", response); + } + + private static DecodeException decodeException() { + return new DecodeException(200, "decode failed", testRequest()); + } + + private static Request testRequest() { + return Request.create( + Request.HttpMethod.PATCH, "/api/internal/admin/items/1", Map.of(), Request.Body.empty(), null + ); + } + private static Member createMemberWithProfile( Long id, String email, diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java index a022471..a476a75 100644 --- a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -4,7 +4,9 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -19,15 +21,22 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.comatching.common.domain.enums.Gender; +import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.common.exception.BusinessException; import com.comatching.common.exception.handler.GlobalExceptionHandler; import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.user.domain.admin.dto.AdminInventoryAction; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import com.comatching.user.domain.admin.service.AdminMemberService; +import com.comatching.user.global.exception.UserErrorCode; import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) @@ -44,6 +53,8 @@ class AdminMemberControllerTest { private static final Long ADMIN_ID = 999L; private static final String SUCCESS_CODE = "GEN-000"; + private final ObjectMapper objectMapper = new ObjectMapper(); + @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup(adminMemberController) @@ -109,4 +120,64 @@ void getUsers_missingMemberIdHeader() throws Exception { then(adminMemberService).shouldHaveNoInteractions(); } + + @Test + @DisplayName("GET /api/admin/users/{memberId} - 사용자 상세와 인벤토리를 조회한다") + void getUserDetail_success() throws Exception { + // given + AdminUserDetailResponse detail = new AdminUserDetailResponse( + 1L, "user@test.com", "홍길동", "닉네임", Gender.FEMALE, "https://img", 3L, 1L + ); + given(adminMemberService.getUserDetail(1L)).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/admin/users/{memberId}", 1L) + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.matchingTicketCount").value(3)) + .andExpect(jsonPath("$.data.optionTicketCount").value(1)); + } + + @Test + @DisplayName("PATCH /api/admin/users/{memberId}/items - 인벤토리 조정에 성공하면 200을 반환한다") + void updateUserInventory_success() throws Exception { + // given + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + // when & then + mockMvc.perform(patch("/api/admin/users/{memberId}/items", 1L) + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)); + + then(adminMemberService).should().updateUserInventory(ADMIN_ID, 1L, request); + } + + @Test + @DisplayName("PATCH /api/admin/users/{memberId}/items - 대상 사용자가 없으면 ITEM-004를 반환한다") + void updateUserInventory_targetUserNotFound() throws Exception { + // given + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + willThrow(new BusinessException(UserErrorCode.TARGET_USER_NOT_FOUND)) + .given(adminMemberService).updateUserInventory(ADMIN_ID, 1L, request); + + // when & then + mockMvc.perform(patch("/api/admin/users/{memberId}/items", 1L) + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("ITEM-004")); + } } From af9227b3333e8a3c208e0883f0a99385bac72a28 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Wed, 19 Aug 2026 15:08:39 +0900 Subject: [PATCH 05/66] =?UTF-8?q?feat(user):=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B4=80=EB=A6=AC=20API=EB=A5=BC=20item-service?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - item-service의 notice 도메인(entity/repository/service/dto/controller)을 user-service domain/admin/notice로 옮기고 AdminNoticeService(Impl)로 리네임 - /api/admin/notices(목록/등록/수정/삭제), /api/notices/active 엔드포인트를 AdminNoticeController로 추가 - 기존 admin 패키지를 domain/admin/user로 재정리해 notice와 계층 구조를 통일 - item-service NoticeServiceImplTest를 AdminNoticeServiceImplTest로 이관(9개 케이스) Co-Authored-By: Claude Sonnet 5 --- .../notice/dto/ActiveNoticeResponse.java | 18 ++ .../admin/notice/dto/AdminNoticeResponse.java | 30 +++ .../admin/notice/dto/NoticeCreateRequest.java | 23 ++ .../admin/notice/dto/NoticeUpdateRequest.java | 23 ++ .../domain/admin/notice/entity/Notice.java | 48 ++++ .../notice/repository/NoticeRepository.java | 18 ++ .../notice/service/AdminNoticeService.java | 23 ++ .../service/AdminNoticeServiceImpl.java | 87 +++++++ .../{ => user}/dto/AdminInventoryAction.java | 2 +- .../{ => user}/dto/AdminInventoryCounts.java | 2 +- .../dto/AdminInventoryUpdateRequest.java | 3 +- .../dto/AdminUserDetailResponse.java | 2 +- .../dto/AdminUserSummaryResponse.java | 3 +- .../service/AdminMemberService.java | 8 +- .../service/AdminMemberServiceImpl.java | 10 +- .../user/infra/client/ItemAdminClient.java | 4 +- .../controller/AdminMemberController.java | 8 +- .../controller/AdminNoticeController.java | 80 ++++++ .../service/AdminNoticeServiceImplTest.java | 241 ++++++++++++++++++ .../admin/service/AdminMemberServiceTest.java | 9 +- .../controller/AdminMemberControllerTest.java | 10 +- 21 files changed, 621 insertions(+), 31 deletions(-) create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/ActiveNoticeResponse.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/AdminNoticeResponse.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeCreateRequest.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeUpdateRequest.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/entity/Notice.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/repository/NoticeRepository.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeService.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImpl.java rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminInventoryAction.java (50%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminInventoryCounts.java (78%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminInventoryUpdateRequest.java (87%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminUserDetailResponse.java (92%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminUserSummaryResponse.java (85%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/service/AdminMemberService.java (57%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/service/AdminMemberServiceImpl.java (93%) create mode 100644 user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java create mode 100644 user-service/src/test/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImplTest.java diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/ActiveNoticeResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/ActiveNoticeResponse.java new file mode 100644 index 0000000..ed872e2 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/ActiveNoticeResponse.java @@ -0,0 +1,18 @@ +package com.comatching.user.domain.admin.notice.dto; + + +import com.comatching.user.domain.admin.notice.entity.Notice; + +public record ActiveNoticeResponse( + Long noticeId, + String title, + String content +) { + public static ActiveNoticeResponse from(Notice notice) { + return new ActiveNoticeResponse( + notice.getId(), + notice.getTitle(), + notice.getContent() + ); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/AdminNoticeResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/AdminNoticeResponse.java new file mode 100644 index 0000000..6fbce5f --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/AdminNoticeResponse.java @@ -0,0 +1,30 @@ +package com.comatching.user.domain.admin.notice.dto; + + +import com.comatching.user.domain.admin.notice.entity.Notice; + +import java.time.LocalDateTime; + +public record AdminNoticeResponse( + Long noticeId, + String title, + String content, + LocalDateTime startTime, + LocalDateTime endTime, + boolean active +) { + public static AdminNoticeResponse from(Notice notice, LocalDateTime currentTime) { + return new AdminNoticeResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getStartTime(), + notice.getEndTime(), + isActive(notice, currentTime) + ); + } + + private static boolean isActive(Notice notice, LocalDateTime currentTime) { + return !notice.getStartTime().isAfter(currentTime) && !notice.getEndTime().isBefore(currentTime); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeCreateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeCreateRequest.java new file mode 100644 index 0000000..2382af0 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeCreateRequest.java @@ -0,0 +1,23 @@ +package com.comatching.user.domain.admin.notice.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public record NoticeCreateRequest( + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 200, message = "제목은 200자 이하로 입력해주세요.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + String content, + + @NotNull(message = "시작시간은 필수입니다.") + LocalDateTime startTime, + + @NotNull(message = "종료시간은 필수입니다.") + LocalDateTime endTime +) { +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeUpdateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeUpdateRequest.java new file mode 100644 index 0000000..f8f123d --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeUpdateRequest.java @@ -0,0 +1,23 @@ +package com.comatching.user.domain.admin.notice.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public record NoticeUpdateRequest( + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 200, message = "제목은 200자 이하로 입력해주세요.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + String content, + + @NotNull(message = "시작시간은 필수입니다.") + LocalDateTime startTime, + + @NotNull(message = "종료시간은 필수입니다.") + LocalDateTime endTime +) { +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/entity/Notice.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/entity/Notice.java new file mode 100644 index 0000000..f62d31e --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/entity/Notice.java @@ -0,0 +1,48 @@ +package com.comatching.user.domain.admin.notice.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "notice") +public class Notice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 200) + private String title; + + @Lob + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private LocalDateTime startTime; + + @Column(nullable = false) + private LocalDateTime endTime; + + @Builder + public Notice(String title, String content, LocalDateTime startTime, LocalDateTime endTime) { + this.title = title; + this.content = content; + this.startTime = startTime; + this.endTime = endTime; + } + + public void update(String title, String content, LocalDateTime startTime, LocalDateTime endTime) { + this.title = title; + this.content = content; + this.startTime = startTime; + this.endTime = endTime; + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/repository/NoticeRepository.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..1472e7d --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/repository/NoticeRepository.java @@ -0,0 +1,18 @@ +package com.comatching.user.domain.admin.notice.repository; + + +import com.comatching.user.domain.admin.notice.entity.Notice; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface NoticeRepository extends JpaRepository { + + List findAllByOrderByStartTimeDescIdDesc(); + + List findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc( + LocalDateTime currentTime, + LocalDateTime currentTime2 + ); +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeService.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeService.java new file mode 100644 index 0000000..3a57b89 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeService.java @@ -0,0 +1,23 @@ +package com.comatching.user.domain.admin.notice.service; + + + +import com.comatching.user.domain.admin.notice.dto.ActiveNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.AdminNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.NoticeCreateRequest; +import com.comatching.user.domain.admin.notice.dto.NoticeUpdateRequest; + +import java.util.List; + +public interface AdminNoticeService { + + void createNotice(NoticeCreateRequest request); + + void updateNotice(Long noticeId, NoticeUpdateRequest request); + + void deleteNotice(Long noticeId); + + List getActiveNotices(); + + List getAdminNotices(); +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImpl.java new file mode 100644 index 0000000..7258b0b --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImpl.java @@ -0,0 +1,87 @@ +package com.comatching.user.domain.admin.notice.service; + +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.user.domain.admin.notice.dto.ActiveNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.AdminNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.NoticeCreateRequest; +import com.comatching.user.domain.admin.notice.dto.NoticeUpdateRequest; +import com.comatching.user.domain.admin.notice.entity.Notice; +import com.comatching.user.domain.admin.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminNoticeServiceImpl implements AdminNoticeService { + + private final NoticeRepository noticeRepository; + + @Override + public void createNotice(NoticeCreateRequest request) { + validatePeriod(request.startTime(), request.endTime()); + + Notice notice = Notice.builder() + .title(request.title()) + .content(request.content()) + .startTime(request.startTime()) + .endTime(request.endTime()) + .build(); + + noticeRepository.save(notice); + } + + @Override + public void updateNotice(Long noticeId, NoticeUpdateRequest request) { + validatePeriod(request.startTime(), request.endTime()); + + Notice notice = findNoticeOrThrow(noticeId); + notice.update(request.title(), request.content(), request.startTime(), request.endTime()); + } + + @Override + public void deleteNotice(Long noticeId) { + Notice notice = findNoticeOrThrow(noticeId); + noticeRepository.delete(notice); + } + + @Override + @Transactional(readOnly = true) + public List getActiveNotices() { + LocalDateTime currentTime = LocalDateTime.now(); + return noticeRepository + .findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc(currentTime, currentTime) + .stream() + .map(ActiveNoticeResponse::from) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public List getAdminNotices() { + LocalDateTime currentTime = LocalDateTime.now(); + return noticeRepository.findAllByOrderByStartTimeDescIdDesc() + .stream() + .map(notice -> AdminNoticeResponse.from(notice, currentTime)) + .toList(); + } + + private Notice findNoticeOrThrow(Long noticeId) { + return noticeRepository.findById(noticeId) + .orElseThrow(() -> new BusinessException(GeneralErrorCode.NOT_FOUND, "공지사항을 찾을 수 없습니다.")); + } + + private void validatePeriod(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime == null || endTime == null) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE, "시작시간과 종료시간은 필수입니다."); + } + if (!startTime.isBefore(endTime)) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE, "시작시간은 종료시간보다 이전이어야 합니다."); + } + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryAction.java similarity index 50% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryAction.java index 32b846b..f0fbc2f 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryAction.java @@ -1,4 +1,4 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; public enum AdminInventoryAction { ADD, diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java similarity index 78% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java index 935bc9a..7f74d28 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java @@ -1,4 +1,4 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; public record AdminInventoryCounts( long matchingTicketCount, diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryUpdateRequest.java similarity index 87% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryUpdateRequest.java index 4f46c65..d12d6ef 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryUpdateRequest.java @@ -1,7 +1,6 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; import com.comatching.common.domain.enums.ItemType; -import com.comatching.user.domain.admin.dto.AdminInventoryAction; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserDetailResponse.java similarity index 92% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserDetailResponse.java index 00897b3..160d266 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserDetailResponse.java @@ -1,4 +1,4 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; import com.comatching.common.domain.enums.Gender; import com.comatching.common.dto.member.AdminUserProfileDto; diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserSummaryResponse.java similarity index 85% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserSummaryResponse.java index 243a694..d638414 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserSummaryResponse.java @@ -1,8 +1,7 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; import com.comatching.common.domain.enums.Gender; import com.comatching.common.dto.member.AdminUserProfileDto; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; public record AdminUserSummaryResponse( Long id, diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberService.java similarity index 57% rename from user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberService.java index 6a87a52..ff3b585 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberService.java @@ -1,11 +1,11 @@ -package com.comatching.user.domain.admin.service; +package com.comatching.user.domain.admin.user.service; import org.springframework.data.domain.Pageable; import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; public interface AdminMemberService { diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java similarity index 93% rename from user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java index 1a27792..d55f9bb 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java @@ -1,4 +1,4 @@ -package com.comatching.user.domain.admin.service; +package com.comatching.user.domain.admin.user.service; import java.util.List; import java.util.Map; @@ -15,10 +15,10 @@ import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; import com.comatching.common.exception.code.GeneralErrorCode; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.repository.MemberRepository; import com.comatching.user.global.exception.UserErrorCode; diff --git a/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java index 6f3e13a..c0d203a 100644 --- a/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java +++ b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java @@ -11,8 +11,8 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; @FeignClient(name = "item-service-admin", url = "${item-service.url}", path = "/api/internal/admin/items") public interface ItemAdminClient { diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index b332890..455a002 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -18,10 +18,10 @@ import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; -import com.comatching.user.domain.admin.service.AdminMemberService; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.service.AdminMemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java new file mode 100644 index 0000000..1ffed93 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java @@ -0,0 +1,80 @@ +package com.comatching.user.infra.controller; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.user.domain.admin.notice.dto.ActiveNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.AdminNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.NoticeCreateRequest; +import com.comatching.user.domain.admin.notice.dto.NoticeUpdateRequest; +import com.comatching.user.domain.admin.notice.service.AdminNoticeService; +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.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Notice API", description = "공지사항 등록/수정/삭제 및 조회") +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class AdminNoticeController { + private final AdminNoticeService adminNoticeService; + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "관리자 공지사항 목록 조회", description = "관리자가 등록된 전체 공지사항을 노출 시작시간 내림차순으로 조회합니다.") + @GetMapping("/admin/notices") + public ResponseEntity>> getAdminNotices( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(adminNoticeService.getAdminNotices())); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "공지사항 등록", description = "관리자가 제목, 내용, 시작시간, 종료시간으로 공지사항을 등록합니다.") + @PostMapping("/admin/notices") + public ResponseEntity> createNotice( + @CurrentMember MemberInfo memberInfo, + @RequestBody @Valid NoticeCreateRequest request + ) { + adminNoticeService.createNotice(request); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "공지사항 수정", description = "관리자가 기존 공지사항의 제목, 내용, 노출 기간을 수정합니다.") + @PatchMapping("/admin/notices/{noticeId}") + public ResponseEntity> updateNotice( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long noticeId, + @RequestBody @Valid NoticeUpdateRequest request + ) { + adminNoticeService.updateNotice(noticeId, request); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "공지사항 삭제", description = "관리자가 공지사항을 삭제합니다.") + @DeleteMapping("/admin/notices/{noticeId}") + public ResponseEntity> deleteNotice( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long noticeId + ) { + adminNoticeService.deleteNotice(noticeId); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @RequireRole({MemberRole.ROLE_USER, MemberRole.ROLE_ADMIN}) + @Operation(summary = "활성 공지사항 조회", description = "현재 시각 기준으로 노출 기간에 포함된 공지사항 목록을 조회합니다.") + @GetMapping("/notices/active") + public ResponseEntity>> getActiveNotices( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(adminNoticeService.getActiveNotices())); + } +} diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImplTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImplTest.java new file mode 100644 index 0000000..41f6959 --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImplTest.java @@ -0,0 +1,241 @@ +package com.comatching.user.domain.admin.notice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.user.domain.admin.notice.dto.ActiveNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.AdminNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.NoticeCreateRequest; +import com.comatching.user.domain.admin.notice.dto.NoticeUpdateRequest; +import com.comatching.user.domain.admin.notice.entity.Notice; +import com.comatching.user.domain.admin.notice.repository.NoticeRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminNoticeServiceImpl 테스트") +class AdminNoticeServiceImplTest { + + @InjectMocks + private AdminNoticeServiceImpl adminNoticeService; + + @Mock + private NoticeRepository noticeRepository; + + @Test + @DisplayName("관리자 공지사항 등록 시 내용을 그대로 저장한다") + void shouldSaveNoticeWithOriginalContent() { + // given + String content = "첫째 줄\n둘째 줄\n셋째 줄"; + NoticeCreateRequest request = new NoticeCreateRequest( + "점검 안내", + content, + LocalDateTime.of(2026, 3, 12, 14, 0), + LocalDateTime.of(2026, 3, 13, 2, 0) + ); + given(noticeRepository.save(any(Notice.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + adminNoticeService.createNotice(request); + + // then + ArgumentCaptor noticeCaptor = ArgumentCaptor.forClass(Notice.class); + then(noticeRepository).should().save(noticeCaptor.capture()); + Notice savedNotice = noticeCaptor.getValue(); + assertThat(savedNotice.getTitle()).isEqualTo("점검 안내"); + assertThat(savedNotice.getContent()).isEqualTo(content); + assertThat(savedNotice.getStartTime()).isEqualTo(request.startTime()); + assertThat(savedNotice.getEndTime()).isEqualTo(request.endTime()); + } + + @Test + @DisplayName("시작시간이 종료시간보다 같거나 늦으면 등록에 실패한다") + void shouldThrowWhenStartTimeIsNotBeforeEndTime() { + // given + LocalDateTime startTime = LocalDateTime.of(2026, 3, 12, 10, 0); + NoticeCreateRequest request = new NoticeCreateRequest( + "잘못된 공지", + "내용", + startTime, + startTime + ); + + // when & then + assertThatThrownBy(() -> adminNoticeService.createNotice(request)) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("시작시간 또는 종료시간이 null이면 등록에 실패한다") + void shouldThrowWhenPeriodIsNull() { + // given + NoticeCreateRequest request = new NoticeCreateRequest( + "공지", + "내용", + null, + LocalDateTime.of(2026, 3, 12, 11, 0) + ); + + // when & then + assertThatThrownBy(() -> adminNoticeService.createNotice(request)) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("현재 시각에 활성화된 공지사항 id, 제목, 내용을 그대로 반환한다") + void shouldReturnActiveNotices() { + // given + Notice notice = Notice.builder() + .title("공지 제목") + .content("한 줄\n두 줄") + .startTime(LocalDateTime.of(2026, 3, 12, 0, 0)) + .endTime(LocalDateTime.of(2026, 3, 20, 23, 59)) + .build(); + ReflectionTestUtils.setField(notice, "id", 10L); + + given(noticeRepository.findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc( + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(List.of(notice)); + + // when + List responses = adminNoticeService.getActiveNotices(); + + // then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).noticeId()).isEqualTo(10L); + assertThat(responses.get(0).title()).isEqualTo("공지 제목"); + assertThat(responses.get(0).content()).isEqualTo("한 줄\n두 줄"); + } + + @Test + @DisplayName("관리자 공지사항 목록 조회 시 전체 공지사항과 활성 여부를 반환한다") + void shouldReturnAdminNotices() { + // given + LocalDateTime now = LocalDateTime.now(); + Notice activeNotice = Notice.builder() + .title("활성 공지") + .content("활성 내용") + .startTime(now.minusDays(1)) + .endTime(now.plusDays(1)) + .build(); + ReflectionTestUtils.setField(activeNotice, "id", 11L); + + Notice expiredNotice = Notice.builder() + .title("종료 공지") + .content("종료 내용") + .startTime(now.minusDays(3)) + .endTime(now.minusDays(2)) + .build(); + ReflectionTestUtils.setField(expiredNotice, "id", 12L); + + given(noticeRepository.findAllByOrderByStartTimeDescIdDesc()) + .willReturn(List.of(activeNotice, expiredNotice)); + + // when + List responses = adminNoticeService.getAdminNotices(); + + // then + assertThat(responses).hasSize(2); + assertThat(responses).extracting(AdminNoticeResponse::noticeId).containsExactly(11L, 12L); + assertThat(responses).extracting(AdminNoticeResponse::title).containsExactly("활성 공지", "종료 공지"); + assertThat(responses).extracting(AdminNoticeResponse::active).containsExactly(true, false); + } + + @Test + @DisplayName("관리자 공지사항 수정 시 제목, 내용, 노출 기간을 변경한다") + void shouldUpdateNotice() { + // given + Notice notice = Notice.builder() + .title("기존 제목") + .content("기존 내용") + .startTime(LocalDateTime.of(2026, 3, 10, 0, 0)) + .endTime(LocalDateTime.of(2026, 3, 11, 0, 0)) + .build(); + ReflectionTestUtils.setField(notice, "id", 7L); + given(noticeRepository.findById(7L)).willReturn(Optional.of(notice)); + + NoticeUpdateRequest request = new NoticeUpdateRequest( + "수정 제목", + "수정 내용", + LocalDateTime.of(2026, 3, 12, 9, 0), + LocalDateTime.of(2026, 3, 13, 9, 0) + ); + + // when + adminNoticeService.updateNotice(7L, request); + + // then + assertThat(notice.getTitle()).isEqualTo("수정 제목"); + assertThat(notice.getContent()).isEqualTo("수정 내용"); + assertThat(notice.getStartTime()).isEqualTo(request.startTime()); + assertThat(notice.getEndTime()).isEqualTo(request.endTime()); + } + + @Test + @DisplayName("존재하지 않는 공지사항 수정 시 예외가 발생한다") + void shouldThrowWhenUpdatingMissingNotice() { + // given + NoticeUpdateRequest request = new NoticeUpdateRequest( + "수정 제목", + "수정 내용", + LocalDateTime.of(2026, 3, 12, 9, 0), + LocalDateTime.of(2026, 3, 13, 9, 0) + ); + given(noticeRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminNoticeService.updateNotice(999L, request)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("관리자 공지사항 삭제 시 공지사항을 삭제한다") + void shouldDeleteNotice() { + // given + Notice notice = Notice.builder() + .title("삭제 대상") + .content("삭제 내용") + .startTime(LocalDateTime.of(2026, 3, 10, 0, 0)) + .endTime(LocalDateTime.of(2026, 3, 11, 0, 0)) + .build(); + ReflectionTestUtils.setField(notice, "id", 5L); + given(noticeRepository.findById(5L)).willReturn(Optional.of(notice)); + + // when + adminNoticeService.deleteNotice(5L); + + // then + then(noticeRepository).should().delete(notice); + } + + @Test + @DisplayName("존재하지 않는 공지사항 삭제 시 예외가 발생한다") + void shouldThrowWhenDeletingMissingNotice() { + // given + given(noticeRepository.findById(888L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminNoticeService.deleteNotice(888L)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.NOT_FOUND); + } +} diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java index beeb398..5410fc8 100644 --- a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Optional; +import com.comatching.user.domain.admin.user.service.AdminMemberServiceImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,10 +30,10 @@ import com.comatching.common.domain.enums.MemberStatus; import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; -import com.comatching.user.domain.admin.dto.AdminInventoryAction; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.dto.AdminInventoryAction; +import com.comatching.user.domain.admin.user.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.entity.Profile; import com.comatching.user.domain.member.repository.MemberRepository; diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java index a476a75..58f117a 100644 --- a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -31,11 +31,11 @@ import com.comatching.common.exception.BusinessException; import com.comatching.common.exception.handler.GlobalExceptionHandler; import com.comatching.common.resolver.MemberInfoArgumentResolver; -import com.comatching.user.domain.admin.dto.AdminInventoryAction; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; -import com.comatching.user.domain.admin.service.AdminMemberService; +import com.comatching.user.domain.admin.user.dto.AdminInventoryAction; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.service.AdminMemberService; import com.comatching.user.global.exception.UserErrorCode; import com.fasterxml.jackson.databind.ObjectMapper; From 0971e123f986260d04ff87c698e25de04fa3d6e0 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Tue, 25 Aug 2026 20:29:15 +0900 Subject: [PATCH 06/66] =?UTF-8?q?feat(kafka):=20notification=20=EC=97=90?= =?UTF-8?q?=EB=8F=84=20DLT=20=EC=9E=AC=EC=A0=81=EC=9E=AC=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20=EC=97=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notification 은 common 을 컴포넌트 스캔하지 않고 @Import 로 골라 쓰는 서비스라, PR #64 의 KafkaDltRedriveController 가 다른 소비 서비스에는 자동 등록됐지만 여기서만 빠졌다. 재적재 로직(KafkaDltRedriveService)은 이미 KafkaConsumerConfig 를 통해 빈으로 떠 있었으므로, 이 서비스가 소비하는 member-signup·member-withdraw·chat-notification 의 DLT 만 되돌릴 수단이 없는 상태였다. 컨트롤러와 InternalApiAuthenticationFilter 를 반드시 함께 등록한다 — SecurityConfig 가 /api/internal/** 를 permitAll 로 두고 검사를 필터에 위임하므로, 컨트롤러만 넣으면 무인증 엔드포인트가 된다. 한쪽만 지우는 회귀는 테스트로 잡는다. yml 에는 다른 서비스와 같은 관례로 internal.service-token 매핑을 추가한다. 값은 compose 의 env_file 로 이미 전달되는 INTERNAL_SERVICE_TOKEN 을 쓴다. Co-Authored-By: Claude Fable 5 --- .../notification/NotificationApplication.java | 10 ++++++- .../src/main/resources/application-aws.yml | 5 ++++ .../src/main/resources/application.yml | 5 ++++ .../NotificationApplicationImportTest.java | 29 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 notification/src/test/java/com/comatching/notification/NotificationApplicationImportTest.java diff --git a/notification/src/main/java/com/comatching/notification/NotificationApplication.java b/notification/src/main/java/com/comatching/notification/NotificationApplication.java index 9bcdc01..f4f35f8 100644 --- a/notification/src/main/java/com/comatching/notification/NotificationApplication.java +++ b/notification/src/main/java/com/comatching/notification/NotificationApplication.java @@ -11,8 +11,10 @@ import com.comatching.common.config.S3Config; import com.comatching.common.config.kafka.KafkaConsumerConfig; +import com.comatching.common.config.kafka.KafkaDltRedriveController; import com.comatching.common.config.kafka.KafkaDltTopicConfig; import com.comatching.common.config.kafka.KafkaProducerConfig; +import com.comatching.common.filter.InternalApiAuthenticationFilter; @SpringBootApplication( exclude = { @@ -26,7 +28,13 @@ // 이 서비스는 발행을 하지 않지만 DLT 발행에는 프로듀서가 필요하다. // 없으면 재시도를 다 쓴 메시지를 옮길 수단이 없어 컨텍스트가 뜨지 않는다. KafkaProducerConfig.class, - KafkaDltTopicConfig.class + KafkaDltTopicConfig.class, + // DLT 재적재 트리거와 그 인증 필터. 이 서비스는 common 을 컴포넌트 + // 스캔하지 않아 여기 안 적으면 빈이 안 생긴다. 컨트롤러만 넣고 필터를 + // 빠뜨리면 /api/internal/** 이 무인증으로 열리므로(SecurityConfig 는 + // 해당 경로를 permitAll 로 두고 필터에 위임) 반드시 둘을 같이 등록한다. + KafkaDltRedriveController.class, + InternalApiAuthenticationFilter.class }) public class NotificationApplication { diff --git a/notification/src/main/resources/application-aws.yml b/notification/src/main/resources/application-aws.yml index 85fd996..f32dc2e 100644 --- a/notification/src/main/resources/application-aws.yml +++ b/notification/src/main/resources/application-aws.yml @@ -57,3 +57,8 @@ comatching: dlt-topics: member-signup,member-withdraw,chat-notification # DLT 적재를 알릴 웹훅(Discord/Slack 겸용). 비우면 로그만 남는다. dlt-alert-webhook-url: ${DLT_ALERT_WEBHOOK_URL:} + +# /api/internal/** 를 지키는 InternalApiAuthenticationFilter 의 검사 토큰. +# 비우면 내부 API 전체가 401 로 닫힌다(fail-closed). +internal: + service-token: ${INTERNAL_SERVICE_TOKEN:} diff --git a/notification/src/main/resources/application.yml b/notification/src/main/resources/application.yml index 9d5bc2d..c986404 100644 --- a/notification/src/main/resources/application.yml +++ b/notification/src/main/resources/application.yml @@ -55,3 +55,8 @@ comatching: dlt-topics: member-signup,member-withdraw,chat-notification # DLT 적재를 알릴 웹훅(Discord/Slack 겸용). 비우면 로그만 남는다. dlt-alert-webhook-url: ${DLT_ALERT_WEBHOOK_URL:} + +# /api/internal/** 를 지키는 InternalApiAuthenticationFilter 의 검사 토큰. +# 비우면 내부 API 전체가 401 로 닫힌다(fail-closed). +internal: + service-token: ${INTERNAL_SERVICE_TOKEN:} diff --git a/notification/src/test/java/com/comatching/notification/NotificationApplicationImportTest.java b/notification/src/test/java/com/comatching/notification/NotificationApplicationImportTest.java new file mode 100644 index 0000000..de2f432 --- /dev/null +++ b/notification/src/test/java/com/comatching/notification/NotificationApplicationImportTest.java @@ -0,0 +1,29 @@ +package com.comatching.notification; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import com.comatching.common.config.kafka.KafkaDltRedriveController; +import com.comatching.common.filter.InternalApiAuthenticationFilter; + +/** + * DLT 재적재 컨트롤러와 내부 인증 필터가 반드시 함께 등록되는지 검사한다. + * + * 이 서비스는 common 을 컴포넌트 스캔하지 않고 @Import 로 골라 쓰기 때문에, + * 컨트롤러만 넣고 필터를 빠뜨리면 /api/internal/** 이 무인증으로 열린다 + * (SecurityConfig 는 해당 경로를 permitAll 로 두고 검사를 필터에 위임한다). + * 실수로 한쪽만 지우는 회귀를 컴파일 수준이 아니라 테스트로 잡는다. + */ +class NotificationApplicationImportTest { + + @Test + void dltRedriveControllerMustBeImportedWithInternalAuthFilter() { + Import imports = NotificationApplication.class.getAnnotation(Import.class); + + assertThat(imports).isNotNull(); + assertThat(imports.value()) + .contains(KafkaDltRedriveController.class, InternalApiAuthenticationFilter.class); + } +} From ebb62336e1cbfa5b5a76078343e13a9b481b64fa Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:02:02 +0900 Subject: [PATCH 07/66] =?UTF-8?q?fix(matching):=20=ED=91=9C=EB=B3=B8=20?= =?UTF-8?q?=EC=B0=BD=EC=9D=B4=20=EB=B9=84=EB=A9=B4=20=EC=B2=98=EC=9D=8C?= =?UTF-8?q?=EB=B6=80=ED=84=B0=20=EB=8B=A4=EC=8B=9C=20=ED=9B=91=EC=96=B4=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=EB=A5=BC=20=EC=B0=BE=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 후보 표본은 (gender, is_matchable, random_key) 인덱스에서 random_key >= randomStart 지점으로 점프해 뽑는다. random_key 는 삽입할 때 0~10 억, randomStart 는 요청마다 0~9 억에서 새로 뽑힌다. 조건을 통과한 후보 전원의 키가 randomStart 보다 작으면 창이 텅 비는데, 그걸 그대로 '후보 없음'으로 돌려주고 있었다. 창이 빌 확률은 조건 통과 후보가 N 명일 때 0.9^N/(N+1) 이다. N=1 이면 45%, N=3 이면 18%, N=10 이면 3%, N=50 이면 0.01%. 5 만 명 규모를 가정한 원래 설계에서는 사실상 0 이라 드러나지 않았지만, 이성 50 명 규모에서 MBTI 를 필수 조건으로 걸면 N 이 3 명까지 떨어져 요청 4 번 중 1 번이 실패한다. 그 실패의 82% 는 조건에 맞는 상대가 실제로 있는데도 못 찾은 경우다. 후보 풀이 작은 초기 서비스일수록 심하다. 첫 조회가 빈손이면 randomStart 를 0 으로 낮춰 한 번 더 조회한다. 필수 조건은 그대로 유지되므로 조건에 맞는 사람이 정말 없으면 여전히 빈 결과가 나간다. 두 번째 조회는 첫 조회가 실패할 때만 나가고 그 상황은 애초에 조건 통과자가 적어 쿼리가 싸므로, 정상 경로의 비용은 달라지지 않는다. Co-Authored-By: Claude Opus 5 --- .../MatchingCandidateRepositoryImpl.java | 21 ++++++++++ .../MatchingCandidateSearchCondition.java | 8 ++++ .../MatchingCandidateRepositoryIT.java | 42 ++++++++++++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java index 8e8a2be..83cac21 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java @@ -55,6 +55,15 @@ * 연속한 N 건을 읽는다. '무작위 공간에서의 연속 구간'이므로 실제로는 무작위 표본이다. * 인덱스 시크 한 번이라 전체 인원과 무관하게 O(표본 크기) 다. *

+ * == 창이 비면 처음부터 다시 훑는다 == + * random_key 는 0~10 억, randomStart 는 0~9 억에서 뽑는다. 조건을 통과한 후보 + * 전원의 키가 randomStart 보다 작으면 창이 텅 빈다. 후보가 N 명일 때 그 확률이 + * 0.9^N/(N+1) 이라 N 이 작을수록 급격히 커진다 - 1 명이면 45%, 3 명이면 18%, + * 10 명이면 3% 다. 예전에는 이걸 그대로 '후보 없음'으로 돌려줘서, 후보 풀이 작은 + * 초기 서비스에서는 멀쩡한 상대를 두고도 매칭이 실패했다. + * 그래서 첫 조회가 빈손이면 randomStart 를 0 으로 낮춰 한 번 더 조회한다. + * 후보가 많을 때는 첫 조회가 거의 항상 성공하므로 추가 비용이 없다. + *

* == 필수 조건을 표본 '안'이 아니라 '밖'에서 거는 이유 == * 거꾸로다 — 필수 조건은 표본을 뽑을 때 함께 적용한다. * 뽑고 나서 거르면 5,000 명이 조건 통과분만 남아 수백 명으로 쪼그라든다. @@ -77,6 +86,18 @@ public class MatchingCandidateRepositoryImpl implements MatchingCandidateReposit @Override public Optional findBestCandidate(MatchingCandidateSearchCondition condition) { + Optional found = queryBestCandidate(condition); + + // 표본 창(random_key >= randomStart)이 비었다. 조건에 맞는 사람이 정말 없는 건지, + // 있는데 전원이 창 앞쪽에 몰린 건지 구분이 안 되므로 0 부터 한 번 더 훑는다. + // 이 두 번째 조회는 첫 조회가 빈손일 때만 나가므로 정상 경로의 비용은 그대로다. + if (found.isEmpty() && condition.randomStart() != 0) { + return queryBestCandidate(condition.withRandomStart(0)); + } + return found; + } + + private Optional queryBestCandidate(MatchingCandidateSearchCondition condition) { Map params = new LinkedHashMap<>(); diff --git a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateSearchCondition.java b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateSearchCondition.java index d32c642..8d0dd44 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateSearchCondition.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateSearchCondition.java @@ -29,4 +29,12 @@ public record MatchingCandidateSearchCondition( AgeOption scoreAgeOption, ContactFrequency scoreContactFrequency ) { + + /** randomStart 만 바꾼 사본. 표본 창이 비었을 때 처음부터 다시 훑기 위해 쓴다. */ + public MatchingCandidateSearchCondition withRandomStart(int newRandomStart) { + return new MatchingCandidateSearchCondition( + newRandomStart, sampleSize, targetGender, excludeMajor, excludeMemberIds, + minAge, maxAge, requiredMbtiTraits, requiredContactFrequency, requiredHobbyCategory, + myAge, scoreMbtiTraits, scoreHobbyCategory, scoreAgeOption, scoreContactFrequency); + } } diff --git a/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java b/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java index 10234c1..cdb1cb4 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java @@ -289,7 +289,7 @@ void findsBestWhenSampleCoversAll() { } @Test - @DisplayName("randomStart 보다 앞선 키를 가진 후보는 표본에서 빠진다") + @DisplayName("창에 후보가 있으면 randomStart 보다 앞선 키는 표본에서 빠진다") void randomStartSkipsEarlierKeys() { save(1L, "XXXX", 99, ContactFrequency.RARE, List.of()); save(2L, "XXXX", 99, ContactFrequency.RARE, List.of()); @@ -302,6 +302,46 @@ void randomStartSkipsEarlierKeys() { .isEqualTo(3L); } + @Test + @DisplayName("창이 비면 처음부터 다시 훑어 후보를 찾아낸다") + void wrapsAroundWhenWindowIsEmpty() { + // random_key 는 삽입할 때 0~10억 중 하나로 정해지고, randomStart 는 요청마다 + // 0~9억 중 하나로 새로 뽑힌다. 후보 전원의 키가 randomStart 보다 작으면 + // 창이 비는데, 예전에는 그대로 '후보 없음'이 되어 요청이 실패했다. + // 후보 수가 적을수록 자주 걸린다 - 조건 통과자가 1명이면 45%, 3명이면 18%다. + save(1L, "XXXX", 99, ContactFrequency.RARE, List.of()); + save(2L, "XXXX", 99, ContactFrequency.RARE, List.of()); + fixRandomKeysByMemberId(); // random_key = 1, 2 + + assertThat(findBest(base().randomStart(10).build())).isPresent(); + } + + @Test + @DisplayName("다시 훑을 때도 필수 조건은 그대로 지킨다") + void wrapAroundStillRespectsFilters() { + save(1L, "XXXX", 99, ContactFrequency.RARE, List.of()); + save(2L, "XXXX", 99, ContactFrequency.FREQUENT, List.of()); + fixRandomKeysByMemberId(); + + assertThat(findBest(base() + .randomStart(10) + .requiredContactFrequency(ContactFrequency.FREQUENT) + .build())) + .get() + .extracting(MatchingCandidate::getMemberId) + .isEqualTo(2L); + } + + @Test + @DisplayName("다시 훑어도 조건에 맞는 후보가 없으면 빈 결과를 준다") + void wrapAroundStillEmptyWhenNothingMatches() { + // 재조회가 조건을 무시하고 아무나 집어오지 않는지 본다. + save(1L, "XXXX", 99, ContactFrequency.RARE, List.of()); + fixRandomKeysByMemberId(); + + assertThat(findBest(base().randomStart(10).minAge(200).build())).isEmpty(); + } + @Test @DisplayName("동점자가 여럿이면 매번 같은 사람만 나오지 않는다") void tiedCandidatesAreShuffled() { From 7b4cf64d704ef7c96548eaee0460b0f1a06bab11 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:15:22 +0900 Subject: [PATCH 08/66] =?UTF-8?q?docs(monitoring):=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 배포는 끝났는데 관측 수단이 없다. 지금 상태를 아는 방법은 docker compose ps 와 docker logs 뿐이고 둘 다 사람이 직접 들어가야 해서, 문제가 생겨도 사용자가 먼저 안다. Prometheus/Grafana/Alertmanager/node-exporter 를 EC2 에 올리고 "EC2 가 통째로 죽는" 사각지대만 외부 업타임 감시로 덮는 안이다. 설계에서 갈린 지점 셋을 남긴다. 게이트웨이 관리 포트 분리. 라우트에 /actuator/** 가 없어서 그 경로는 게이트웨이 자신이 처리하는데, 8080 은 nginx 가 TLS 를 종단해 넘겨주는 대상이다. 그대로 prometheus 를 열면 인증 없이 공개된다. 관리 엔드포인트만 8081 로 옮기고 호스트에 매핑하지 않는다. 뒤쪽 5개는 라우트도 포트 매핑도 없어 외부 도달 경로가 아예 없으므로 건드리지 않는다. memswap_limit 명시. docker 는 mem_limit 만 주면 memory+swap 합계를 그 2배로 잡는다. 호스트에 스왑을 켜는 순간 컨테이너 9개의 실효 상한이 조용히 두 배가 된다는 뜻이다. JVM 은 full GC 가 힙 전체를 훑어서 스왑에 밀린 페이지를 EBS 에서 되읽으면 GC 한 번이 수십 초가 되고, redis 는 AOF rewrite 의 fork/COW 가 걸린다. 둘 다 mem_limit 과 같은 값으로 못박아 지금처럼 자기 한도에서 시끄럽게 죽게 둔다. 알림 채널 분리. DLT 알림은 잦고 안 급하며, 인프라 알림은 드물고 급하다. 한 채널에 섞으면 잦은 쪽이 드문 쪽을 묻어버려 결국 둘 다 안 읽는다. /docs/* 가 디렉터리째 막고 있어 하위 파일만 되살릴 수 없다. 상위 디렉터리를 열어주되 플러그인 상태 파일(.omc)은 그 안에서 다시 제외한다. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 + .../2026-08-26-prod-monitoring-design.md | 454 ++++++++++++++++++ 2 files changed, 459 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-prod-monitoring-design.md diff --git a/.gitignore b/.gitignore index 2dd2160..f36b7cd 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,11 @@ docker-compose.yml /database /docs/* !/docs/perf-log.md +# 설계 문서(spec)는 추적한다. /docs/* 가 디렉터리째 막고 있어서 하위 파일만 +# 되살릴 수 없다 - 상위 디렉터리를 먼저 열어줘야 한다. +!/docs/superpowers/ +# 플러그인이 남기는 상태 파일은 그 안에서도 제외한다. +/docs/superpowers/**/.omc/ application-docker.yml application-secret.yml serviceAccountKey.json diff --git a/docs/superpowers/specs/2026-08-26-prod-monitoring-design.md b/docs/superpowers/specs/2026-08-26-prod-monitoring-design.md new file mode 100644 index 0000000..e2fad41 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-prod-monitoring-design.md @@ -0,0 +1,454 @@ +# 운영 모니터링 도입 설계 + +- 작성일: 2026-08-26 +- 대상: EC2 단일 인스턴스(t3.large, ap-northeast-2) 운영 환경 +- 상태: 설계 확정, 구현 계획 대기 + +## 1. 배경과 목적 + +운영 배포는 [docker-compose.prod.yml](../../../docker-compose.prod.yml)로 끝났지만 관측 수단이 없다. +지금 서버 상태를 아는 방법은 `docker compose ps` 와 `docker logs` 뿐이고, 둘 다 사람이 +직접 들어가서 봐야 알 수 있다. 즉 **문제가 생겨도 사용자가 먼저 안다.** + +이번 작업으로 얻으려는 것은 두 가지다. + +1. **장애 감지와 알림** — 서비스가 죽거나 5xx가 튀면 사람이 접속하지 않아도 디스코드로 알려준다. +2. **성능 추적 대시보드** — 지연·처리량·JVM 힙을 시계열로 본다. + +로그 수집(Loki)은 이번 범위에서 뺀다. 메모리 예산이 그만큼 없다(4장 참고). + +로컬에는 이미 [docker-compose.monitoring.yml](../../../docker-compose.monitoring.yml)로 같은 스택이 +있지만 **부하 테스트 전용**이다. `host.docker.internal` 로 스크레이프하고 MySQL 데이터소스가 +`localhost:3307` 을 본다. 운영에 그대로 쓸 수 없어 별도 구성이 필요하다. + +## 2. 범위 + +**포함** + +- `docker-compose.prod.yml` 에 Prometheus / Grafana / Alertmanager / node-exporter 추가 +- 운영 전용 `monitoring/prometheus/prometheus.prod.yml` 및 알림 규칙 파일 +- 게이트웨이 `management.server.port` 분리 (보안, 3.2절) +- `application-aws.yml` 6종에 `/actuator/prometheus` 노출 추가 +- 기존 컨테이너 9종에 `memswap_limit` 명시 +- 외부 업타임 감시 설정 +- 부하 테스트 절차와 초기화 절차 (7장) + +**제외** + +- 로그 수집(Loki/Promtail) — 메모리 예산 부족 +- 분산 추적(Tempo/Jaeger) — 현 단계에서 과하다 +- cAdvisor — 컨테이너별 자원은 node-exporter + JVM 지표로 갈음 +- Grafana 외부 공개 — SSH 터널로만 접근하므로 nginx·인증서 작업 없음 + +## 3. 아키텍처 + +### 3.1 구성 요소 + +전부 기존 `comatching` 브리지 네트워크 안에 들어간다. **호스트에 여는 포트는 +Grafana의 `127.0.0.1:3001` 하나뿐이다.** + +| 서비스 | 이미지 | 호스트 포트 | mem_limit | +|---|---|---|---| +| prometheus | `prom/prometheus:v2.54.1` | 없음 | 512m | +| grafana | `grafana/grafana:11.2.0` | `127.0.0.1:3001:3000` | 256m | +| alertmanager | `prom/alertmanager:v0.27.0` | 없음 | 128m | +| node-exporter | `prom/node-exporter:v1.8.2` | 없음 | 64m | + +이미지 태그는 로컬 모니터링 스택과 맞췄다. 버전이 갈리면 대시보드 JSON 호환 문제를 +운영에서 처음 만나게 된다. + +접근은 SSH 터널이다. + +``` +ssh -L 3001:localhost:3001 @43.200.211.135 +# 브라우저에서 http://localhost:3001 +``` + +Grafana의 익명 접근은 **끈다**(`GF_AUTH_ANONYMOUS_ENABLED: "false"`). 로컬 스택은 켜 두었지만 +운영은 터널 너머라도 계정을 요구하는 편이 낫다. 관리자 비밀번호는 `.env.prod` 로 넘긴다. + +node-exporter는 호스트 메트릭을 읽어야 하므로 `/proc`, `/sys`, `/` 를 읽기 전용으로 마운트하고 +`pid: host` 를 쓴다. 이 컨테이너만 예외적으로 호스트 네임스페이스를 본다. + +### 3.2 게이트웨이 관리 포트 분리 + +**이 설계에서 가장 중요한 보안 결정이다.** + +게이트웨이 라우트 목록([gateway-service/src/main/resources/application-aws.yml](../../../gateway-service/src/main/resources/application-aws.yml))에 +`/actuator/**` 가 없다. Spring Cloud Gateway는 라우트에 걸리지 않은 경로를 자신이 처리하므로, +`/actuator/prometheus` 를 그냥 열면 게이트웨이 자신의 actuator가 응답한다. 그런데 게이트웨이 +8080은 nginx가 TLS를 종단해 넘겨주는 대상이다. 결과적으로 + +``` +https://srv.comatching.site/actuator/prometheus +``` + +가 **인증 없이 공개된다.** Prometheus 노출 지표에는 엔드포인트별 URI 패턴, 호출량, JVM 내부 +상태가 다 들어 있어 내부 구조가 그대로 드러난다. + +뒤쪽 5개 서비스(user 9000, matching 9001, chat 9003, notification 9005, item 9006)는 다르다. +게이트웨이 라우트에 actuator 경로가 없고 컴포즈에 `ports` 매핑도 없어서 외부에서 도달할 방법이 +아예 없다. **위험한 것은 게이트웨이 하나뿐이다.** + +해결은 게이트웨이만 관리 엔드포인트를 별도 포트로 옮기고 그 포트를 호스트에 매핑하지 않는 것이다. + +```yaml +# gateway-service/src/main/resources/application-aws.yml +management: + server: + port: 8081 # 컴포즈에서 ports 로 열지 않는다 + endpoints: + web: + exposure: + include: health,prometheus + endpoint: + health: + show-details: never +``` + +이러면 `gateway-service:8081` 은 컴포즈 네트워크 안에서만 닿고, 8080으로 들어온 외부 요청은 +`/actuator/*` 를 찾지 못해 라우트 매칭 실패로 떨어진다. + +> **함께 고쳐야 할 것**: `docker-compose.prod.yml` 의 게이트웨이 healthcheck가 +> `http://localhost:8080/actuator/health` 를 찌른다. 8081로 바꾸지 않으면 기동 직후 +> healthcheck가 영구 실패하고, `depends_on: service_healthy` 가 걸린 것이 없더라도 +> 컨테이너가 계속 unhealthy로 남는다. + +나머지 5개 서비스는 포트를 분리하지 않는다. 외부 도달 경로가 없으므로 얻는 게 없고, +바꿀수록 healthcheck URL을 5군데 더 건드려야 한다. `exposure.include` 에 `prometheus` 만 더한다. + +```yaml +# user/matching/chat/item/notification 의 application-aws.yml +management: + endpoints: + web: + exposure: + include: health,prometheus +``` + +### 3.3 스크레이프 대상 + +`monitoring/prometheus/prometheus.prod.yml` 을 새로 만든다. 로컬용 +[prometheus.yml](../../../monitoring/prometheus/prometheus.yml)과 두 가지가 다르다. + +- `host.docker.internal` 이 아니라 **컨테이너 이름**으로 간다(같은 네트워크 안이므로). +- `scrape_interval` 을 5초가 아니라 **15초**로 둔다. 5초는 짧은 부하 테스트에서 데이터 포인트를 + 확보하려던 값이라 상시 운영에는 과하다. 카디널리티와 TSDB 쓰기량이 3배가 된다. + +```yaml +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/rules/*.yml + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +scrape_configs: + - job_name: comatching-services + metrics_path: /actuator/prometheus + static_configs: + # 게이트웨이만 관리 포트가 8081 이다 (3.2절 참고) + - targets: ['gateway-service:8081'] + labels: { service: gateway-service } + - targets: ['user-service:9000'] + labels: { service: user-service } + - targets: ['matching-service:9001'] + labels: { service: matching-service } + - targets: ['chat-service:9003'] + labels: { service: chat-service } + - targets: ['notification:9005'] + labels: { service: notification } + - targets: ['item-service:9006'] + labels: { service: item-service } + + - job_name: node + static_configs: + - targets: ['node-exporter:9100'] + + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] +``` + +보존 기간은 15일, 크기 상한도 함께 건다. 디스크가 차서 죽는 것보다 오래된 데이터를 버리는 게 낫다. + +``` +--storage.tsdb.retention.time=15d +--storage.tsdb.retention.size=2GB +``` + +Grafana 데이터소스는 로컬의 [prometheus.yml](../../../monitoring/grafana/provisioning/datasources/prometheus.yml)을 +재사용하되 `timeInterval` 을 `15s` 로 맞춘다. InfluxDB·MySQL 데이터소스는 운영에 올리지 않는다 +(각각 JMeter 전용, 로컬 3307 전용). + +## 4. 메모리 설계 + +### 4.1 예산 + +t3.large는 8 GiB(커널 제외 실사용 약 7.7 GiB)다. 현재 `mem_limit` 합이 6,080m이고 +모니터링 960m을 더하면 **7,040m** 이 된다. 호스트 OS·docker 데몬·nginx 몫으로 약 860m이 남는다. + +동작은 하지만 여유가 크지 않다. `mem_limit` 은 상한이지 예약이 아니므로 실사용은 더 낮을 +가능성이 높지만, **추정으로 넘어가지 않고 7.2절에서 실측한다.** + +여유가 부족하면 순서대로 손댄다. + +1. Prometheus 보존 15일 → 7일 +2. Kafka `-Xmx1g` / `mem_limit 1280m` → `-Xmx512m` / `768m` + (단일 브로커·저트래픽 기준으로 현재 값이 후하다. 여기서만 512m이 확보된다) +3. 그래도 부족하면 t3.xlarge 승급 + +### 4.2 스왑 + +호스트에 **2 GB** 스왑 파일을 만들고 `vm.swappiness=1` 로 둔다. + +4 GB가 아니라 2 GB인 이유는, 4.3절의 핀을 걸고 나면 스왑을 쓰는 주체가 호스트 프로세스 +(sshd, systemd, docker 데몬)뿐이기 때문이다. 그쪽이 2 GB를 넘길 일이 없다. 크게 잡을수록 +루트 EBS 볼륨의 용량과 IOPS만 더 떼어준다. + +스왑을 아예 안 두지는 않는다. 메모리 압박이 걸렸을 때 **커널 OOM killer가 아무 프로세스나 +고르기 전에 차가운 페이지를 밀어내 sshd를 살려두는 것** — 즉 들어가서 고칠 수 있는 상태를 +유지하는 것이 목적이다. + +```bash +sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile \ + && sudo mkswap /swapfile && sudo swapon /swapfile +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +echo 'vm.swappiness=1' | sudo tee /etc/sysctl.d/99-swap.conf && sudo sysctl --system +``` + +**선결 확인**: `docker info` 에 `WARNING: No swap limit support` 가 뜨면 안 된다. +이 경고가 있으면 4.3절의 `memswap_limit` 이 조용히 무시되어 핀이 걸리지 않는다. +`stat -fc %T /sys/fs/cgroup` 이 `cgroup2fs` 면 대개 정상이다. `tmpfs`(cgroup v1)면 +커널 부팅 파라미터에 `swapaccount=1` 을 넣고 재부팅해야 한다. + +### 4.3 memswap_limit — 스왑보다 중요한 결정 + +Docker는 `--memory` 만 주고 `--memory-swap` 을 생략하면 **memory+swap 합계를 `--memory` 의 +2배**로 잡는다. 현재 `docker-compose.prod.yml` 은 전부 `mem_limit` 만 있다. 즉 호스트에 +스왑을 켜는 순간 9개 컨테이너의 실효 상한이 조용히 두 배가 된다. 한도를 늘린 게 아니라 +**한도를 무의미하게 만드는 것**에 가깝다. + +특히 JVM과 스왑은 상성이 나쁘다. full GC가 힙 전체를 훑는데 그 페이지가 EBS 스왑에 있으면 +GC 한 번이 수십 초로 늘어난다. healthcheck `timeout: 5s` 가 실패하고, 재시작이 다시 메모리를 +흔드는 악순환이 된다. EBS는 네트워크 스토리지라 대역폭을 9개 컨테이너가 공유하므로 +**한 컨테이너의 문제가 호스트 전체 브라운아웃**이 된다. + +Redis는 더 나쁘다. `appendonly yes` 라 AOF rewrite 때 fork하며 copy-on-write가 걸리는데, +그 순간 페이지가 스왑에 있으면 지연이 폭발한다. 그리고 이 코드베이스는 이미 +`maxmemory-policy noeviction` 을 "조용히 evict 되느니 시끄럽게 실패하는 게 낫다"는 이유로 +골랐다. 스왑은 정확히 그 반대 방향이다. + +그래서 **JVM 6종과 Redis는 스왑을 아예 쓰지 못하게 못박는다.** + +| 컨테이너 | mem_limit | memswap_limit | 근거 | +|---|---|---|---| +| gateway-service | 512m | **512m** | JVM — 스왑 금지 | +| user-service | 768m | **768m** | JVM — 스왑 금지 | +| matching-service | 640m | **640m** | JVM — 스왑 금지 | +| chat-service | 576m | **576m** | JVM — 스왑 금지 | +| item-service | 640m | **640m** | JVM — 스왑 금지 | +| notification | 576m | **576m** | JVM — 스왑 금지 | +| redis | 320m | **320m** | fork/COW — 스왑 금지 | +| kafka | 1280m | 1536m | 여유 256m | +| mongodb | 768m | 896m | 여유 128m | +| prometheus | 512m | 512m | 스왑 금지 | +| grafana | 256m | 256m | 스왑 금지 | +| alertmanager | 128m | 128m | 스왑 금지 | +| node-exporter | 64m | 64m | 스왑 금지 | + +`memswap_limit == mem_limit` 이면 해당 컨테이너의 스왑 사용량은 0이다. 지금과 똑같이 +자기 cgroup 한도에서 OOMKill로 시끄럽게 죽는다. [Dockerfile:52](../../../Dockerfile)의 +`-XX:+ExitOnOutOfMemoryError` 가 의도한 "좀비로 사느니 죽고 재시작한다"는 성질이 보존된다. + +## 5. 알림 + +### 5.1 채널 분리 + +Alertmanager는 **`DLT_ALERT_WEBHOOK_URL` 과 다른 디스코드 웹훅**을 쓴다. +`.env.prod` 에 `INFRA_ALERT_WEBHOOK_URL` 을 새로 둔다. + +두 알림은 성격이 반대다. DLT 알림은 "메시지 한 건이 처리에 실패했다"로 급하지 않고 잦다. +인프라 알림은 "서버가 아프다"로 지금 봐야 하고 드물다. 한 채널에 섞으면 잦은 쪽이 드문 쪽을 +묻어버리고, 결국 둘 다 안 읽게 된다. 나누는 비용은 채널 하나와 웹훅 URL 하나(2분)이고, +안 나누는 비용은 알림을 무시하는 습관이다. + +판단 기준은 **"이게 울리면 하던 일을 멈춰야 하나"** 다. DLT는 아니오, 인프라는 예다. + +Alertmanager는 v0.25.0부터 `discord_configs` 를 네이티브로 지원한다. 구현 시 실제 버전에서 +동작을 확인하고, 안 되면 `webhook_configs` + 얇은 변환 레이어로 대체한다. + +### 5.2 규칙 + +`monitoring/prometheus/rules/alerts.yml` 로 둔다. + +| 이름 | 조건 | for | 심각도 | +|---|---|---|---| +| ServiceDown | `up{job="comatching-services"} == 0` | 2m | critical | +| HighErrorRate | 5xx 비율 > 5% | 5m | critical | +| JvmHeapHigh | 힙 사용률 > 90% | 10m | warning | +| KafkaConsumerLag | `kafka_consumer_fetch_manager_records_lag_max > 1000` | 10m | warning | +| DiskSpaceLow | 루트 사용률 > 85% | 5m | warning | +| SwapInUse | 스왑 사용량 > 256MB | 10m | warning | + +`SwapInUse` 를 넣는 이유가 있다. 4.3절의 핀 때문에 컨테이너는 스왑을 못 쓰므로, +스왑이 실제로 쓰이고 있다는 것은 **호스트가 메모리 압박을 받고 있다**는 뜻이다. +7,040m / 7.7 GiB 예산의 조기 경보 역할을 한다. + +주요 PromQL: + +```promql +# HighErrorRate +sum by (service) (rate(http_server_requests_seconds_count{status=~"5.."}[5m])) + / sum by (service) (rate(http_server_requests_seconds_count[5m])) > 0.05 + +# JvmHeapHigh +sum by (service) (jvm_memory_used_bytes{area="heap"}) + / sum by (service) (jvm_memory_max_bytes{area="heap"}) > 0.9 + +# DiskSpaceLow +1 - node_filesystem_avail_bytes{mountpoint="/"} + / node_filesystem_size_bytes{mountpoint="/"} > 0.85 + +# SwapInUse +node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes > 256 * 1024 * 1024 +``` + +`kafka_consumer_fetch_manager_records_lag_max` 는 Spring Kafka의 Micrometer 바인딩이 있어야 +나온다. 구현 시 `/actuator/prometheus` 응답에서 실제 노출 여부를 확인하고, 없으면 해당 규칙을 +빼거나 바인딩을 켠다. + +## 6. 외부 업타임 감시 + +셀프호스팅 모니터링에는 근본 약점이 하나 있다. **EC2가 통째로 죽으면 알림도 같이 죽는다.** +"서버가 내려갔다"는 가장 중요한 알림을 그 서버가 보내야 하는 구조다. + +이것만 외부 무료 서비스(UptimeRobot 등)로 메운다. + +- 대상: `https://srv.comatching.site/actuator/health` +- 주기: 5분 +- 알림: 인프라 디스코드 채널 (5.1절과 동일) + +EC2 쪽 작업은 없고 설정은 웹에서 5분이면 끝난다. 이 한 겹으로 셀프호스팅의 유일한 사각지대가 덮인다. + +## 7. 부하 테스트 절차 + +### 7.1 선결 과제 + +현재 도구를 운영에 그대로 쓸 수 없다. 세 가지를 고쳐야 한다. + +**(1) JMeter 러너가 HTTPS를 못 쏜다.** +[run.ps1](../../../tools/perf/jmeter/run.ps1)이 `http://${TargetHost}:$Port` 로 URL을 만든다. +EC2의 게이트웨이 8080은 `127.0.0.1` 바인딩이라 외부에서 닿지 않으므로 nginx 443으로 가야 한다. +스킴 파라미터를 추가한다. JMX의 HTTP Sampler도 프로토콜이 변수로 빠져 있는지 확인이 필요하다. + +**(2) 시드 로더가 RDS에서 동작하지 않는다.** +[load_seed.sh](../../../tools/perf/seed/load_seed.sh)는 두 가지를 전제한다. + +- `docker exec comatching-mysql` — RDS는 컨테이너가 아니다 +- 서버사이드 `LOAD DATA INFILE` — RDS는 DB 서버 파일시스템에 파일을 놓을 수 없고 + `secure_file_priv` 가 막혀 있다 + +RDS용으로는 **EC2 호스트에서 `mysql` 클라이언트로 `LOAD DATA LOCAL INFILE`** 을 쓰도록 고친다. +이때 RDS 파라미터 그룹에 `local_infile=1` 이 필요하고 클라이언트도 `--local-infile=1` 로 붙어야 한다. +`docker cp` / `chown` / `secure_file_priv` 탐지 단계는 통째로 빠진다. + +**(3) 토큰을 운영 시크릿으로 다시 발급해야 한다.** +[generate_tokens.py](../../../tools/perf/tokens/generate_tokens.py)는 기본으로 로컬 `.env` 의 +`JWT_SECRET` 을 읽는데, `.env.prod.example` 이 운영 시크릿은 로컬과 달라야 한다고 못박고 있다. +기존 `tokens.csv` 는 서명이 안 맞아 전부 401이다. `--env` 로 `.env.prod` 를 가리켜 재발급한다. + +> **참고**: 시드는 `matching_candidate` 를 SQL로 직접 넣으므로 **Kafka를 거치지 않는다.** +> 따라서 `profile-updates` 토픽에 더미 이벤트가 쌓이지 않고, 늦게 도착한 이벤트가 후보를 +> 되살리는 문제도 이 경로에서는 발생하지 않는다. + +### 7.2 절차 + +런칭 전이라 실사용자가 없다. 운영 환경에 더미를 직접 넣고 매칭 경로까지 포함해 측정한다. +별도 스냅샷 환경은 필요 없다. + +1. **베이스라인 측정** — 모니터링 올리기 전 평상시 실사용량 + ```bash + docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" + ``` +2. **스왑 + memswap_limit 적용** (4장) +3. **모니터링 스택 기동** (3장) — 대시보드로 볼 수 있어야 부하가 의미를 갖는다 +4. **더미 시드** — `START_ID=1000001` 로 10만 건. `generate_seed.py` → RDS용으로 고친 로더 +5. **토큰 재발급** — `python3 generate_tokens.py --env ../../../.env.prod` +6. **부하** — 사용자가 없는 시간대에 작은 램프부터. + 게이트웨이에 `RequestRateLimiter` 가 없어 부하가 걸러지지 않는다는 점에 유의 +7. **측정** — 부하 중 `docker stats` 를 CSV로 남기고 Grafana로 함께 본다 + ```bash + docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/stats-$(date +%F-%H%M).csv + ``` +8. **판단** — 4.1절 예산과 대조. 부족하면 4.1의 완화 순서대로 + +### 7.3 초기화 + +측정이 끝나면 런칭 전에 전부 민다. **저장소 종류가 둘이라 방식도 둘이다.** + +`TRUNCATE` 는 쓰지 않는다. `profile.member_id` 가 `members` 를 FK로 참조하고 +`profile_hobby`/`profile_tag` 가 다시 `profile` 을 참조해서, MySQL InnoDB가 에러 1701로 거부한다. +`SET FOREIGN_KEY_CHECKS=0` 으로 우회할 수는 있지만 아래가 더 단순하다. + +```bash +# 1) 컨테이너 볼륨 — kafka/mongo/redis 상태 제거 +cd ~/comatching +docker compose -f docker-compose.prod.yml --env-file .env.prod down -v + +# 2) RDS — Hibernate가 세 스키마를 drop 후 재생성 +grep -q '^JPA_DDL_AUTO=' .env.prod \ + && sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=create/' .env.prod \ + || echo 'JPA_DDL_AUTO=create' >> .env.prod + +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d +``` + +`down -v` 가 `kafka-data` / `mongo-data` / `redis-data` 를 날린다. RDS는 외부라 볼륨 삭제로 +지워지지 않으므로 `ddl-auto: create` 로 재생성한다. 스키마가 +`comatching_user` / `comatching_matching` / `comatching_item` 으로 분리돼 있어 서비스별로 +자기 테이블만 다시 만든다. FK 순서를 사람이 신경 쓸 일이 없다. + +> **대안**: `load_seed.sh` 는 적재 전에 `member_id >= START_ID` 인 행을 지우는 로직을 이미 +> 갖고 있다. 즉 시드 행만 ID 범위로 골라 지우는 것도 가능하다. 런칭 후에 다시 부하를 걸 +> 일이 생기면 이쪽을 써야 한다 — 그때는 `ddl-auto: create` 를 절대 쓸 수 없다. + +**그리고 반드시 되돌린다.** + +```bash +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=validate/' .env.prod && grep JPA_DDL_AUTO .env.prod +``` + +`ddl-auto` 는 부팅 시점에만 적용되므로 지금 떠 있는 컨테이너에는 영향이 없고 다음 재기동부터 +`validate` 로 돈다. **이걸 빠뜨리면 실서비스 데이터가 언젠가 조용히 전부 사라진다.** +CI가 main 푸시마다 `up -d` 를 하고, `restart: unless-stopped` 로 컨테이너가 스스로 재시작하기도 +한다. `.env.prod.example` 이 경고하는 바로 그 함정이며, 런칭 직전이라 특히 위험하다. + +## 8. 리스크와 미결 사항 + +| 항목 | 내용 | 대응 | +|---|---|---| +| 메모리 예산 | 7,040m / 7.7 GiB로 여유가 860m뿐 | 7.2절 실측 후 4.1절 완화 순서대로 | +| cgroup 스왑 계정 | v1이면 `memswap_limit` 이 무시됨 | 4.2절 선결 확인. 미지원이면 스왑 도입 보류 | +| Alertmanager 디스코드 | `discord_configs` 동작을 실제 버전에서 미검증 | 구현 시 확인, 실패하면 `webhook_configs` 대체 | +| Kafka lag 지표 | Micrometer 바인딩 노출 여부 미확인 | `/actuator/prometheus` 응답으로 확인 후 규칙 확정 | +| nginx 설정 | 저장소 밖이라 `/actuator` 처리 방식 미확인 | 3.2절이 관리 포트 분리로 nginx와 무관하게 해결 | +| 부하 커버리지 | 공개 엔드포인트만으로는 matching/chat/item 유휴 | 더미 시드로 매칭까지 포함(7.2절) | + +## 9. 구현 순서 + +1. 게이트웨이 관리 포트 분리 + `application-aws.yml` 6종 `prometheus` 노출 (3.2절) +2. `docker-compose.prod.yml` 에 `memswap_limit` 명시 (4.3절) +3. `monitoring/prometheus/prometheus.prod.yml` + `rules/alerts.yml` 작성 (3.3, 5.2절) +4. Alertmanager 설정 + `.env.prod.example` 에 `INFRA_ALERT_WEBHOOK_URL` 추가 (5.1절) +5. `docker-compose.prod.yml` 에 모니터링 4종 추가 (3.1절) +6. JMeter 러너 HTTPS 지원 + 시드 로더 RDS 대응 (7.1절) +7. 외부 업타임 감시 등록 (6장) + +1~5는 코드 변경이고 6은 도구 변경이라 서로 독립적이다. +스왑(4.2절)은 EC2에서 직접 수행하며 저장소 변경이 아니다. From d687c98b60b892a71a740b1f6bc81261b2486cd4 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:23:33 +0900 Subject: [PATCH 09/66] =?UTF-8?q?docs(monitoring):=20=ED=83=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=208=EA=B0=9C=EC=A7=9C=EB=A6=AC=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-26-prod-monitoring.md | 1047 +++++++++++++++++ 1 file changed, 1047 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-prod-monitoring.md diff --git a/docs/superpowers/plans/2026-08-26-prod-monitoring.md b/docs/superpowers/plans/2026-08-26-prod-monitoring.md new file mode 100644 index 0000000..b74cc55 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-prod-monitoring.md @@ -0,0 +1,1047 @@ +# 운영 모니터링 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** EC2 운영 환경에 Prometheus/Grafana/Alertmanager/node-exporter 를 올리고 디스코드 알림까지 잇는다. + +**Architecture:** 모니터링 4종을 기존 `comatching` 브리지 네트워크에 넣고 호스트에는 Grafana `127.0.0.1:3001` 만 연다. 게이트웨이만 관리 포트를 8081로 분리해 `/actuator/prometheus` 의 외부 노출을 막는다. 호스트 스왑 도입에 대비해 전 컨테이너에 `memswap_limit` 을 명시한다. + +**Tech Stack:** Docker Compose, Prometheus v2.54.1, Grafana 11.2.0, Alertmanager v0.27.0, node-exporter v1.8.2, Spring Boot Actuator + Micrometer + +**Spec:** `docs/superpowers/specs/2026-08-26-prod-monitoring-design.md` + +## Global Constraints + +- 운영 컴포즈는 `docker-compose.prod.yml` 하나다. 모니터링용 별도 컴포즈 파일을 만들지 않는다 (로컬용 `docker-compose.monitoring.yml` 은 건드리지 않는다). +- 호스트에 새로 여는 포트는 Grafana `127.0.0.1:3001:3000` 하나뿐이다. Prometheus/Alertmanager/node-exporter 는 `ports` 를 아예 쓰지 않는다. +- 이미지 태그는 로컬 스택과 동일하게 고정: `prom/prometheus:v2.54.1`, `grafana/grafana:11.2.0`, `prom/alertmanager:v0.27.0`, `prom/node-exporter:v1.8.2`. +- mem_limit: prometheus 512m / grafana 256m / alertmanager 128m / node-exporter 64m. +- JVM 6종·redis·모니터링 4종은 `memswap_limit == mem_limit`. kafka 1536m, mongodb 896m. +- 운영 scrape/evaluation interval 은 15s (로컬의 5s 를 복사하지 않는다). +- 커밋은 태스크마다 하되 **푸시는 하지 않는다** (사용자 지시). +- 이 저장소의 주석 문화를 따른다: "무엇"이 아니라 "왜"를 한국어로 적는다. +- 로컬 검증에서 `.env.prod` 가 필요하면 `cp .env.prod.example .env.prod` 로 만든다(.gitignore 에 있어 커밋되지 않는다). 검증 후 지워도 되고 남겨도 된다. + +--- + +### Task 1: 게이트웨이 관리 포트 분리 + +라우트에 `/actuator/**` 가 없어서 그 경로는 게이트웨이 자신이 응답한다. 8080은 nginx가 TLS를 종단해 넘기는 대상이라, 이대로 prometheus 를 열면 `https://srv.comatching.site/actuator/prometheus` 가 인증 없이 공개된다. 관리 엔드포인트를 8081로 옮기고 호스트에 매핑하지 않는다. + +**Files:** +- Modify: `gateway-service/src/main/resources/application-aws.yml` (파일 끝 management 블록, 139행 부근) +- Modify: `docker-compose.prod.yml` (gateway-service 의 healthcheck) + +**Interfaces:** +- Produces: 컴포즈 네트워크 안에서 `gateway-service:8081/actuator/prometheus`, `gateway-service:8081/actuator/health`. Task 4 의 스크레이프 대상과 Task 6 의 기동 검증이 이 주소를 쓴다. + +- [ ] **Step 1: application-aws.yml 의 management 블록 교체** + +현재 블록: + +```yaml +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + # 게이트웨이의 8080 은 외부에 열려 있다. always 로 두면 인증 없이 + # DB/Redis 구성요소 상태와 접속 정보 일부가 그대로 드러난다. + show-details: never +``` + +다음으로 교체한다: + +```yaml +management: + server: + # 8080 은 nginx 가 TLS 를 종단해 넘겨주는 포트라, 라우트에 걸리지 않는 + # /actuator/** 를 게이트웨이 자신이 응답하면 그대로 외부에 공개된다. + # 관리 엔드포인트를 8081 로 옮기고 호스트에 매핑하지 않는다 - 컴포즈 + # 네트워크 안(Prometheus 스크레이프, 헬스체크)에서만 닿는다. + port: 8081 + endpoints: + web: + exposure: + include: health,prometheus + endpoint: + health: + # 8081 이라도 상세는 숨긴다. DB/Redis 구성요소 상태와 접속 정보 + # 일부가 드러나는 것은 내부라도 이득이 없다. + show-details: never +``` + +- [ ] **Step 2: 컴포즈 healthcheck 를 8081 로** + +`docker-compose.prod.yml` 의 gateway-service healthcheck 를 수정한다. 이걸 빼먹으면 기동 직후부터 healthcheck 가 영구 실패해 컨테이너가 unhealthy 로 남는다: + +```yaml + healthcheck: + # 관리 포트가 8081 로 분리됐다 (application-aws.yml 참고). + test: ["CMD", "curl", "-fsS", "http://localhost:8081/actuator/health"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 90s +``` + +- [ ] **Step 3: 컴포즈 문법 검증** + +```powershell +if (-not (Test-Path .env.prod)) { cp .env.prod.example .env.prod } +docker compose -f docker-compose.prod.yml --env-file .env.prod config -q; echo "exit=$LASTEXITCODE" +``` + +Expected: `exit=0` (경고는 무시, 에러만 실패) + +- [ ] **Step 4: 게이트웨이 테스트가 여전히 통과하는지 확인** + +`application-aws.yml` 은 aws 프로파일 전용이라 기본 테스트에 영향이 없어야 정상이다: + +```powershell +.\gradlew.bat :gateway-service:test --no-daemon +``` + +Expected: BUILD SUCCESSFUL + +- [ ] **Step 5: Commit** + +```bash +git add gateway-service/src/main/resources/application-aws.yml docker-compose.prod.yml +git commit -m "feat(monitoring): 게이트웨이 관리 엔드포인트를 8081 로 분리한다" +``` + +--- + +### Task 2: 백엔드 5종에 prometheus 노출 추가 + +user/matching/chat/item/notification 은 게이트웨이 라우트에도 없고 호스트 포트 매핑도 없어 외부 도달 경로가 아예 없다. 포트 분리 없이 `exposure.include` 에 `prometheus` 만 더한다. + +**Files:** +- Modify: `user-service/src/main/resources/application-aws.yml` +- Modify: `matching-service/src/main/resources/application-aws.yml` +- Modify: `chat-service/src/main/resources/application-aws.yml` +- Modify: `item-service/src/main/resources/application-aws.yml` +- Modify: `notification/src/main/resources/application-aws.yml` + +**Interfaces:** +- Produces: `user-service:9000` / `matching-service:9001` / `chat-service:9003` / `notification:9005` / `item-service:9006` 의 `/actuator/prometheus`. Task 4 의 스크레이프 대상이다. + +- [ ] **Step 1: 5개 파일에서 include 한 줄씩 수정** + +각 파일의 management 블록에서 아래 한 줄만 바꾼다 (블록 위치는 파일마다 다르다: chat 65행, item 95행, matching 84행, notification 42행, user 53행 부근): + +```yaml + include: health +``` + +→ + +```yaml + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus +``` + +- [ ] **Step 2: 5개 파일이 전부 바뀌었는지 확인** + +```bash +grep -l "health,prometheus" */src/main/resources/application-aws.yml | wc -l +``` + +Expected: `6` (Task 1 의 게이트웨이 포함) + +- [ ] **Step 3: Commit** + +```bash +git add */src/main/resources/application-aws.yml +git commit -m "feat(monitoring): aws 프로파일에서 prometheus 엔드포인트를 연다" +``` + +--- + +### Task 3: 전 컨테이너에 memswap_limit 명시 + +docker 는 `mem_limit` 만 주면 memory+swap 합계를 그 2배로 잡는다. 호스트에 스왑을 켜는 순간 컨테이너 9개의 실효 상한이 조용히 두 배가 된다. JVM(full GC 가 스왑 페이지를 EBS 에서 되읽으면 GC 한 번이 수십 초)과 redis(AOF rewrite 의 fork/COW)는 스왑을 아예 못 쓰게 못박는다. + +**Files:** +- Modify: `docker-compose.prod.yml` (기존 9개 서비스) + +**Interfaces:** +- Produces: 없음 (자기완결). EC2 에서 스왑을 켜는 런북(Task 8)이 이 핀을 전제한다. + +- [ ] **Step 1: 9개 서비스에 memswap_limit 추가** + +각 서비스의 `mem_limit` 바로 아래에 추가한다. 값 대응표: + +| 서비스 | mem_limit (기존) | memswap_limit (추가) | +|---|---|---| +| kafka | 1280m | 1536m | +| mongodb | 768m | 896m | +| redis | 320m | 320m | +| user-service | 768m | 768m | +| matching-service | 640m | 640m | +| chat-service | 576m | 576m | +| item-service | 640m | 640m | +| notification | 576m | 576m | +| gateway-service | 512m | 512m | + +JVM 6종과 redis 에는 이 주석을 한 번만(첫 등장인 user-service 에) 단다: + +```yaml + mem_limit: 768m + # mem_limit 과 같은 값 = 스왑 사용 0. memswap_limit 을 생략하면 docker 가 + # memory+swap 을 mem_limit 의 2배로 잡아서, 호스트에 스왑이 생기는 순간 + # 실효 상한이 조용히 두 배가 된다. JVM 은 full GC 가 힙 전체를 훑으므로 + # 스왑에 밀린 페이지를 EBS 에서 되읽으면 GC 한 번이 수십 초가 된다. + # 지금처럼 자기 한도에서 OOMKill 로 시끄럽게 죽는 편이 낫다 + # (Dockerfile 의 -XX:+ExitOnOutOfMemoryError 와 같은 철학). + memswap_limit: 768m +``` + +kafka 에는: + +```yaml + mem_limit: 1280m + # 힙(-Xmx1g) 밖 페이지캐시가 잠깐 넘칠 때를 위한 여유 256m. JVM 앱들과 + # 달리 약간의 스왑은 허용하되 무제한(기본값: mem_limit 의 2배)은 막는다. + memswap_limit: 1536m +``` + +mongodb 에는: + +```yaml + mem_limit: 768m + # WiredTiger 캐시(0.25GB) 밖 워킹셋이 잠깐 넘칠 때를 위한 여유 128m. + memswap_limit: 896m +``` + +redis 에는: + +```yaml + mem_limit: 320m + # AOF rewrite 가 fork 하며 copy-on-write 로 페이지를 복제한다. 그 순간 + # 페이지가 스왑에 있으면 지연이 폭발하므로 스왑 사용을 0 으로 못박는다. + memswap_limit: 320m +``` + +- [ ] **Step 2: 검증 — 9개 전부 들어갔는지, 값이 맞는지** + +```powershell +docker compose -f docker-compose.prod.yml --env-file .env.prod config | Select-String "memswap" +``` + +Expected: 9줄. `config` 출력은 바이트 단위로 풀리므로 (예: 768m → 805306368) 값 검증은 원본 yml 에서 한다: + +```bash +grep -c "memswap_limit" docker-compose.prod.yml +``` + +Expected: `9` + +- [ ] **Step 3: Commit** + +```bash +git add docker-compose.prod.yml +git commit -m "feat(monitoring): 호스트 스왑 도입에 대비해 memswap_limit 을 못박는다" +``` + +--- + +### Task 4: 운영 Prometheus 설정 + 알림 규칙 + +로컬 [prometheus.yml](../../../monitoring/prometheus/prometheus.yml)은 `host.docker.internal` 을 보고 5s 간격이라 운영에 못 쓴다. 운영 전용 설정과 알림 규칙을 새로 만든다. + +**Files:** +- Create: `monitoring/prometheus/prometheus.prod.yml` +- Create: `monitoring/prometheus/rules/alerts.yml` + +**Interfaces:** +- Consumes: Task 1 의 `gateway-service:8081`, Task 2 의 서비스별 `/actuator/prometheus` +- Produces: Task 6 이 이 두 파일(및 rules 디렉터리)을 컨테이너에 마운트한다. Alertmanager 대상 이름은 `alertmanager:9093` (Task 6 의 서비스명과 일치해야 한다). + +- [ ] **Step 1: prometheus.prod.yml 작성** + +```yaml +# 운영(EC2) 전용. 로컬 prometheus.yml 과 두 가지가 다르다. +# - 같은 컴포즈 네트워크 안이라 host.docker.internal 이 아니라 컨테이너 +# 이름으로 간다. +# - 15s 간격. 5s 는 짧은 부하 테스트에서 데이터 포인트를 확보하려던 +# 값이라 상시 운영에는 과하다(TSDB 쓰기량과 카디널리티가 3배). +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/rules/*.yml + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +scrape_configs: + - job_name: comatching-services + metrics_path: /actuator/prometheus + static_configs: + # 게이트웨이만 관리 포트가 8081 이다. 8080 은 nginx 뒤로 외부에 + # 노출되는 포트라 actuator 를 그쪽에 열 수 없다(application-aws.yml). + - targets: ['gateway-service:8081'] + labels: { service: gateway-service } + - targets: ['user-service:9000'] + labels: { service: user-service } + - targets: ['matching-service:9001'] + labels: { service: matching-service } + - targets: ['chat-service:9003'] + labels: { service: chat-service } + - targets: ['notification:9005'] + labels: { service: notification } + - targets: ['item-service:9006'] + labels: { service: item-service } + + - job_name: node + static_configs: + - targets: ['node-exporter:9100'] + + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] +``` + +- [ ] **Step 2: rules/alerts.yml 작성** + +```yaml +# 알림의 기준: "이게 울리면 하던 일을 멈춰야 하나". +# critical = 예(지금 본다), warning = 아니오(오늘 안에 본다). +groups: + - name: comatching + rules: + - alert: ServiceDown + expr: up{job="comatching-services"} == 0 + for: 2m + labels: { severity: critical } + annotations: + summary: "{{ $labels.service }} 스크레이프 실패 2분" + description: "컨테이너가 죽었거나 actuator 가 응답하지 않는다. docker compose ps 부터 본다." + + - alert: HighErrorRate + expr: > + sum by (service) (rate(http_server_requests_seconds_count{status=~"5.."}[5m])) + / sum by (service) (rate(http_server_requests_seconds_count[5m])) > 0.05 + for: 5m + labels: { severity: critical } + annotations: + summary: "{{ $labels.service }} 5xx 비율 5% 초과 5분" + description: "값: {{ $value | humanizePercentage }}. 해당 서비스 로그부터 본다." + + - alert: JvmHeapHigh + expr: > + sum by (service) (jvm_memory_used_bytes{area="heap"}) + / sum by (service) (jvm_memory_max_bytes{area="heap"}) > 0.9 + for: 10m + labels: { severity: warning } + annotations: + summary: "{{ $labels.service }} 힙 90% 초과 10분" + description: "OOMKill(-XX:+ExitOnOutOfMemoryError 로 즉사·재시작) 전조다." + + - alert: KafkaConsumerLag + expr: kafka_consumer_fetch_manager_records_lag_max > 1000 + for: 10m + labels: { severity: warning } + annotations: + summary: "컨슈머 lag 1000 초과 10분" + description: "{{ $labels.service }} 의 컨슈머가 밀리고 있다. DLT 적재 여부도 확인." + + - alert: DiskSpaceLow + expr: > + 1 - node_filesystem_avail_bytes{mountpoint="/"} + / node_filesystem_size_bytes{mountpoint="/"} > 0.85 + for: 5m + labels: { severity: warning } + annotations: + summary: "루트 디스크 85% 초과" + description: "docker image prune, Prometheus retention, 로그부터 의심한다." + + # memswap_limit 핀 때문에 컨테이너는 스왑을 못 쓴다. 스왑이 실제로 + # 쓰인다는 것은 호스트(sshd, docker 데몬 등)가 메모리 압박을 받고 + # 있다는 뜻이다 - mem_limit 합 7,040m / 7.7GiB 예산의 조기 경보. + - alert: SwapInUse + expr: node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes > 256 * 1024 * 1024 + for: 10m + labels: { severity: warning } + annotations: + summary: "호스트 스왑 사용 256MB 초과 10분" + description: "컨테이너 mem_limit 합이 호스트를 압박하고 있다. spec 4.1 의 완화 순서대로." +``` + +- [ ] **Step 3: promtool 로 검증** + +promtool 이 rule_files 글롭을 설정 파일 기준으로 해석하므로, 디렉터리를 `/etc/prometheus` 에 그대로 마운트해야 규칙 파일까지 함께 검증된다: + +```powershell +docker run --rm --entrypoint promtool -v "${PWD}\monitoring\prometheus:/etc/prometheus" prom/prometheus:v2.54.1 check config /etc/prometheus/prometheus.prod.yml +``` + +Expected: `SUCCESS: 6 rules found` 와 `prometheus.prod.yml is valid` + +- [ ] **Step 4: Commit** + +```bash +git add monitoring/prometheus/prometheus.prod.yml monitoring/prometheus/rules/alerts.yml +git commit -m "feat(monitoring): 운영 스크레이프 설정과 알림 규칙 6종" +``` + +--- + +### Task 5: Alertmanager 디스코드 설정 + +Alertmanager 는 설정 파일에서 환경변수를 확장하지 않는다. 그래서 spec 5.1 의 `INFRA_ALERT_WEBHOOK_URL` 환경변수 방식 대신, `.env.prod` 와 같은 패턴을 쓴다: **예제 파일은 추적하고 실제 파일(웹훅 URL 포함)은 EC2 에만 둔다.** deploy.yml 의 `git reset --hard` 는 추적되지 않는 파일을 건드리지 않으므로 배포에도 살아남는다. + +**Files:** +- Create: `monitoring/alertmanager/alertmanager.example.yml` +- Modify: `.gitignore` (실제 파일 제외 규칙) +- Modify: `.env.prod.example` (Grafana 비밀번호 추가 + 웹훅 안내 주석) + +**Interfaces:** +- Produces: Task 6 이 `./monitoring/alertmanager/alertmanager.yml` 을 마운트한다 (EC2 에서 example 을 복사해 만드는 파일). `GRAFANA_ADMIN_PASSWORD` 환경변수를 Task 6 의 grafana 서비스가 쓴다. + +- [ ] **Step 1: alertmanager.example.yml 작성** + +```yaml +# ============================================================ +# 이 파일을 alertmanager.yml 로 복사해서 웹훅 URL 을 채운다. +# cp alertmanager.example.yml alertmanager.yml +# alertmanager.yml 은 .gitignore 에 있다 - 웹훅 URL 자체가 비밀값이다 +# (URL 만 알면 누구나 그 채널에 글을 쓸 수 있다). +# +# 웹훅은 DLT_ALERT_WEBHOOK_URL 과 다른 채널로 판다. DLT 알림은 잦고 안 +# 급하며, 인프라 알림은 드물고 급하다. 한 채널에 섞으면 잦은 쪽이 드문 +# 쪽을 묻어버려 결국 둘 다 안 읽는다. +# ============================================================ +route: + receiver: discord + group_by: [alertname, service] + group_wait: 30s + group_interval: 5m + # 해결 안 된 알림의 재전송 간격. 너무 짧으면 같은 알림이 도배되어 + # "잦은 쪽이 드문 쪽을 묻는" 문제를 스스로 만든다. + repeat_interval: 4h + +receivers: + - name: discord + discord_configs: + - webhook_url: 'https://discord.com/api/webhooks/CHANGE/ME' + send_resolved: true +``` + +- [ ] **Step 2: .gitignore 에 실제 파일 제외** + +`.gitignore` 의 `/docs/superpowers/**/.omc/` 규칙 아래에 추가한다: + +``` +# Alertmanager 실제 설정은 디스코드 웹훅 URL 을 담고 있어 EC2 에만 둔다. +# 예제(alertmanager.example.yml)만 추적한다 - .env.prod 와 같은 패턴. +/monitoring/alertmanager/alertmanager.yml +``` + +- [ ] **Step 3: .env.prod.example 에 추가** + +`# ---------- DLT 적재 알림 ----------` 절 앞에 추가한다: + +``` +# ---------- 모니터링 ---------- +# Grafana 관리자 비밀번호. SSH 터널(127.0.0.1:3001) 너머로만 닿지만 +# 익명 접근을 꺼둔 이상 계정은 있어야 한다. +GRAFANA_ADMIN_PASSWORD= + +# 인프라 알림(서비스 다운·5xx·힙·디스크)의 디스코드 웹훅은 환경변수가 +# 아니라 monitoring/alertmanager/alertmanager.yml 에 넣는다 - Alertmanager 가 +# 설정 파일에서 환경변수를 확장하지 못한다. 그 파일은 .gitignore 에 있다. +# DLT_ALERT_WEBHOOK_URL 과 반드시 다른 채널로 팔 것(아래 DLT 절 참고). +``` + +- [ ] **Step 4: amtool 로 검증** + +```powershell +docker run --rm --entrypoint amtool -v "${PWD}\monitoring\alertmanager:/work" prom/alertmanager:v0.27.0 check-config /work/alertmanager.example.yml +``` + +Expected: `Found: 1 receivers` 및 에러 없음. `discord_configs` 를 파서가 거부하면(버전 문제) spec 8장의 대체 경로(webhook_configs)로 전환하고 사용자에게 보고한다. + +- [ ] **Step 5: Commit** + +```bash +git add monitoring/alertmanager/alertmanager.example.yml .gitignore .env.prod.example +git commit -m "feat(monitoring): 인프라 알림용 Alertmanager 디스코드 설정" +``` + +--- + +### Task 6: 컴포즈에 모니터링 4종 + Grafana 운영 프로비저닝 + +**Files:** +- Create: `monitoring/grafana/provisioning-prod/datasources/prometheus.yml` +- Create: `monitoring/grafana/provisioning-prod/dashboards/dashboards.yml` +- Modify: `docker-compose.prod.yml` (서비스 4개 + 볼륨 2개 추가) + +**Interfaces:** +- Consumes: Task 4 의 `prometheus.prod.yml`·`rules/`, Task 5 의 `alertmanager.yml`(EC2 에서 생성)·`GRAFANA_ADMIN_PASSWORD` +- Produces: `prometheus:9090`, `alertmanager:9093`, `node-exporter:9100`, 호스트의 `127.0.0.1:3001`(Grafana) + +- [ ] **Step 1: Grafana 운영 데이터소스 프로비저닝** + +로컬 프로비저닝 디렉터리를 그대로 마운트하면 안 된다 — influxdb.yml(운영에 InfluxDB 없음)과 mysql.yml(`host.docker.internal:3307` 를 봄)이 기동마다 에러를 만든다. 운영 전용 디렉터리를 만든다. + +`monitoring/grafana/provisioning-prod/datasources/prometheus.yml`: + +```yaml +# 운영 전용 프로비저닝. 로컬(provisioning/)과 분리한 이유: +# - influxdb 데이터소스는 JMeter 전용이라 운영에 없다. +# - mysql 데이터소스는 host.docker.internal:3307 을 봐서 운영에서 깨진다. +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus # 대시보드 JSON 이 이 uid 로 참조한다 + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: 15s # prometheus.prod.yml 의 scrape_interval 과 일치 +``` + +- [ ] **Step 2: Grafana 대시보드 프로비저닝** + +`monitoring/grafana/provisioning-prod/dashboards/dashboards.yml`: + +```yaml +apiVersion: 1 + +providers: + - name: comatching + orgId: 1 + folder: Comatching + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false +``` + +- [ ] **Step 3: 컴포즈에 서비스 4개 추가** + +`docker-compose.prod.yml` 의 gateway-service 아래에 추가한다. 대시보드는 `comatching-baseline.json` 만 마운트한다 — loadtest 는 influxdb 를, mysql-queries 는 mysql 데이터소스를 참조해 운영에서 패널이 깨진다. + +```yaml + # ---------- 모니터링 ---------- + # 넷 다 comatching 네트워크 안이다. 호스트에 여는 건 grafana 의 + # 127.0.0.1:3001 하나뿐이고, 보는 방법은 SSH 터널이다: + # ssh -L 3001:localhost:3001 @ + + prometheus: + image: prom/prometheus:v2.54.1 + container_name: comatching-prometheus + restart: unless-stopped + networks: [comatching] + volumes: + - ./monitoring/prometheus/prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/rules:/etc/prometheus/rules:ro + - prometheus-data:/prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + # 시간과 크기 중 먼저 걸리는 쪽이 이긴다. 디스크가 차서 죽는 것보다 + # 오래된 데이터를 버리는 게 낫다. + - --storage.tsdb.retention.time=15d + - --storage.tsdb.retention.size=2GB + mem_limit: 512m + memswap_limit: 512m + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"] + interval: 15s + timeout: 5s + retries: 10 + logging: *logging + + alertmanager: + image: prom/alertmanager:v0.27.0 + container_name: comatching-alertmanager + restart: unless-stopped + networks: [comatching] + volumes: + # 실제 파일은 EC2 에서 example 을 복사해 만든다(웹훅 URL 이 비밀값). + # 없으면 이 마운트가 실패하므로, 만들기 전에는 이 서비스가 뜨지 않는다. + - ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + mem_limit: 128m + memswap_limit: 128m + logging: *logging + + grafana: + image: grafana/grafana:11.2.0 + container_name: comatching-grafana + restart: unless-stopped + networks: [comatching] + # 루프백에만 바인딩한다. 게이트웨이 8080 과 같은 이유 - 보는 방법은 + # SSH 터널뿐이고, 외부에 열면 보안그룹 한 겹이 유일한 방어선이 된다. + ports: + - "127.0.0.1:3001:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + # 로컬 스택은 익명 Viewer 를 켜뒀지만 운영은 터널 너머라도 계정을 + # 요구한다. 터널 포트를 실수로 0.0.0.0 에 열었을 때의 마지막 겹이다. + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_USERS_DEFAULT_THEME: dark + volumes: + - ./monitoring/grafana/provisioning-prod:/etc/grafana/provisioning:ro + # baseline 만 올린다. loadtest 는 influxdb, mysql-queries 는 mysql + # 데이터소스를 참조해 운영에서 패널이 깨진다. + - ./monitoring/grafana/dashboards/comatching-baseline.json:/var/lib/grafana/dashboards/comatching-baseline.json:ro + - grafana-data:/var/lib/grafana + depends_on: + - prometheus + mem_limit: 256m + memswap_limit: 256m + logging: *logging + + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: comatching-node-exporter + restart: unless-stopped + networks: [comatching] + # 호스트 지표(CPU·메모리·디스크·스왑)를 읽어야 해서 이 컨테이너만 + # 예외적으로 호스트 네임스페이스를 본다. 전부 읽기 전용이다. + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/rootfs + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|var/lib/docker/.+)($$|/) + mem_limit: 64m + memswap_limit: 64m + logging: *logging +``` + +볼륨 절에 추가: + +```yaml +volumes: + kafka-data: + mongo-data: + redis-data: + prometheus-data: + grafana-data: +``` + +- [ ] **Step 4: 컴포즈 문법 검증** + +```powershell +docker compose -f docker-compose.prod.yml --env-file .env.prod config -q; echo "exit=$LASTEXITCODE" +``` + +Expected: `exit=0` + +- [ ] **Step 5: 로컬에서 모니터링 3종만 실기동 스모크** + +alertmanager 는 실제 설정 파일이 없으면 마운트가 실패하므로, 로컬에서만 example 을 복사해 흉내낸다: + +```powershell +cp monitoring/alertmanager/alertmanager.example.yml monitoring/alertmanager/alertmanager.yml +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d prometheus alertmanager node-exporter +Start-Sleep -Seconds 10 +docker compose -f docker-compose.prod.yml --env-file .env.prod ps +``` + +Expected: 셋 다 Up (prometheus 는 healthy). 앱 서비스는 띄우지 않으므로 스크레이프 대상은 down 으로 보이는 게 정상이다. + +```powershell +docker exec comatching-prometheus wget -qO- http://localhost:9090/api/v1/rules | Select-String "ServiceDown" +``` + +Expected: ServiceDown 규칙이 로드되어 있다 + +- [ ] **Step 6: 정리** + +```powershell +docker compose -f docker-compose.prod.yml --env-file .env.prod down +rm monitoring/alertmanager/alertmanager.yml +rm .env.prod +``` + +- [ ] **Step 7: Commit** + +```bash +git add monitoring/grafana/provisioning-prod docker-compose.prod.yml +git commit -m "feat(monitoring): 운영 컴포즈에 모니터링 4종을 올린다" +``` + +--- + +### Task 7: 부하 도구의 운영 대응 (HTTPS + RDS) + +부하 테스트(spec 7장)는 모니터링이 뜬 뒤의 별도 작업이지만, 도구 수정은 코드라 지금 같이 커밋한다. 두 가지: run.sh 가 http 를 하드코딩해서 nginx 443 으로 못 가고, load_seed.sh 는 `docker exec` + 서버사이드 `LOAD DATA INFILE` 이라 RDS 에서 안 돈다. + +**Files:** +- Modify: `tools/perf/jmeter/run.sh:31-33,50` (SCHEME 도입) +- Modify: `tools/perf/jmeter/S1-participants.jmx:67` (protocol 을 프로퍼티로) +- Create: `tools/perf/seed/load_seed_rds.sh` (로컬용 load_seed.sh 는 건드리지 않는다) + +**Interfaces:** +- Consumes: 없음 +- Produces: `SCHEME=https HOST=srv.comatching.site PORT=443 ./run.sh` 실행 계약. `load_seed_rds.sh` 는 `RDS_ENDPOINT`/`RDS_USERNAME`/`RDS_PASSWORD` 환경변수(.env.prod 와 동일한 이름)를 읽는다. + +- [ ] **Step 1: run.sh 에 SCHEME 도입** + +31~33행의 변수 선언에 한 줄 추가: + +```bash +HOST="${HOST:-localhost}" +PORT="${PORT:-8080}" +# 운영은 nginx 443 뒤라 https 로 가야 한다. 게이트웨이 8080 은 루프백 +# 바인딩이라 외부에서 직접 닿지 않는다. +SCHEME="${SCHEME:-http}" +INFLUX_HOST="${INFLUX_HOST:-localhost}" +``` + +50행의 사전 점검 curl 을 수정: + +```bash +code=$(curl -s -o /dev/null -w '%{http_code}' "$SCHEME://$HOST:$PORT/api/auth/participants" || echo 000) +``` + +114행의 JMeter 인자에 `-Jscheme` 추가: + +```bash + -Jhost="$HOST" -Jport="$PORT" -Jscheme="$SCHEME" -JinfluxHost="$INFLUX_HOST" \ +``` + +- [ ] **Step 2: S1 JMX 의 protocol 을 프로퍼티로** + +`S1-participants.jmx` 67행: + +```xml +http +``` + +→ + +```xml +${__P(scheme,http)} +``` + +- [ ] **Step 3: load_seed_rds.sh 작성** + +```bash +#!/usr/bin/env bash +# ============================================================================ +# 생성된 TSV 를 RDS 에 적재한다. EC2 호스트에서 실행한다. +# +# 로컬용 load_seed.sh 와 갈라놓은 이유 - 그쪽의 두 전제가 RDS 에서 깨진다: +# - docker exec comatching-mysql RDS 는 컨테이너가 아니다 +# - 서버사이드 LOAD DATA INFILE RDS 는 DB 서버 파일시스템에 파일을 +# 놓을 수 없다 (secure_file_priv) +# 대신 클라이언트가 파일을 읽어 보내는 LOAD DATA LOCAL INFILE 을 쓴다. +# +# 선결 조건: +# - RDS 파라미터 그룹에 local_infile=1 (기본 0. 콘솔에서 바꾸고 적용 대기) +# - EC2 에 mysql 클라이언트 (dnf install mariadb105 또는 mysql) +# - 시드 TSV: 로컬에서 generate_seed.py 로 만들어 scp 로 올린다 +# +# 사용법: +# RDS_ENDPOINT=... RDS_USERNAME=... RDS_PASSWORD=... ./load_seed_rds.sh +# 환경변수 이름은 .env.prod 와 같으므로 이렇게 부를 수도 있다: +# set -a; . ~/comatching/.env.prod; set +a; ./load_seed_rds.sh +# ============================================================================ +set -euo pipefail +cd "$(dirname "$0")" + +: "${RDS_ENDPOINT:?RDS_ENDPOINT 가 필요하다 (.env.prod 참고)}" +: "${RDS_USERNAME:?RDS_USERNAME 이 필요하다}" +: "${RDS_PASSWORD:?RDS_PASSWORD 가 필요하다}" +START_ID="${START_ID:-1000001}" +OUT_DIR="${OUT_DIR:-./out}" + +mysql_exec() { + mysql -h "$RDS_ENDPOINT" -u "$RDS_USERNAME" -p"$RDS_PASSWORD" \ + --local-infile=1 --default-character-set=utf8mb4 "$@" +} + +# ---------- 0. 사전 점검 ---------- +if [ ! -f "$OUT_DIR/members.tsv" ]; then + echo "❌ $OUT_DIR/members.tsv 가 없습니다. 로컬에서 generate_seed.py 를 돌려 scp 로 올리세요." + exit 1 +fi + +# 서버가 local_infile 을 거부하면 여기서 일찍 죽는 편이 낫다. +LI=$(mysql_exec -N -B -e "SELECT @@GLOBAL.local_infile;") +if [ "$LI" != "1" ]; then + echo "❌ RDS 의 local_infile 이 꺼져 있습니다 ($LI). 파라미터 그룹에서 local_infile=1 로 바꾸세요." + exit 1 +fi +echo "✅ RDS $RDS_ENDPOINT — local_infile=1" + +# ---------- 1. 기존 시드 삭제 ---------- +# 재실행 가능해야 한다. generate_seed.py 가 profile_id 를 member_id 와 1:1 로 +# 맞춰두므로 조인 없이 ID 범위로 지운다. +echo "🧹 기존 시드 삭제 (member_id >= $START_ID)..." +mysql_exec <= $START_ID; +DELETE FROM comatching_user.profile_tag WHERE profile_id >= $START_ID; +DELETE FROM comatching_user.profile WHERE member_id >= $START_ID; +DELETE FROM comatching_user.members WHERE member_id >= $START_ID; +DELETE FROM comatching_matching.candidate_hobby_categories WHERE member_id >= $START_ID; +DELETE FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID; +DELETE FROM comatching_item.item WHERE member_id >= $START_ID; +SET foreign_key_checks = 1; +SQL + +# ---------- 2. 적재 ---------- +# unique_checks / foreign_key_checks 를 끄면 InnoDB 가 행마다 검증하지 않아 +# 벌크 적재가 크게 빨라진다. 시드는 이미 유일성이 보장돼 있다. +echo "⬇️ 적재 중..." +time mysql_exec <= $START_ID +UNION ALL SELECT 'profile', COUNT(*) FROM comatching_user.profile WHERE member_id >= $START_ID +UNION ALL SELECT 'matching_candidate', COUNT(*) FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID +UNION ALL SELECT 'item', COUNT(*) FROM comatching_item.item WHERE member_id >= $START_ID; +SQL + +echo "🔎 age NULL 검사 (0 이어야 정상 — NULL 이면 매칭이 전부 실패한다)" +mysql_exec -t -e "SELECT COUNT(*) AS age_is_null FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID AND age IS NULL;" + +echo "🏁 완료. 다음: 운영 시크릿으로 토큰 재발급 (generate_tokens.py --env ~/comatching/.env.prod)" +``` + +- [ ] **Step 4: 문법 검증** + +```bash +bash -n tools/perf/jmeter/run.sh && bash -n tools/perf/seed/load_seed_rds.sh && echo OK +``` + +Expected: `OK` + +- [ ] **Step 5: 로컬 스모크 — SCHEME 기본값이 기존 동작을 보존하는지** + +```bash +grep -n "SCHEME" tools/perf/jmeter/run.sh && grep -n "__P(scheme" tools/perf/jmeter/S1-participants.jmx +``` + +Expected: run.sh 3곳(선언·curl·-Jscheme), JMX 1곳. 기본값이 둘 다 `http` 라서 로컬 사용법은 그대로다. + +- [ ] **Step 6: Commit** + +주의: `.gitignore` 에 `tools` 줄이 작업 트리에 있다면 `load_seed_rds.sh` 가 무시될 수 있다. `git check-ignore` 로 확인하고, 무시되면 사용자에게 보고한다(강제 추가하지 않는다). + +```bash +git check-ignore tools/perf/seed/load_seed_rds.sh || echo "추적 가능" +git add tools/perf/jmeter/run.sh tools/perf/jmeter/S1-participants.jmx tools/perf/seed/load_seed_rds.sh +git commit -m "feat(perf): 부하 도구의 운영 대응 - HTTPS 스킴과 RDS 시드 적재" +``` + +--- + +### Task 8: EC2 배포 런북 + +코드가 아니라 EC2 에서 사람이 순서대로 치는 명령의 기록이다. spec 의 절차(4.2 스왑, 7.2 부하, 7.3 초기화)를 한 문서로 모아 순서를 고정한다. + +**Files:** +- Create: `docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md` + +**Interfaces:** +- Consumes: Task 1~7 의 산출물 전부 +- Produces: 없음 (사람이 읽는 문서) + +- [ ] **Step 1: 런북 작성** + +````markdown +# 운영 모니터링 롤아웃 런북 + +설계: `docs/superpowers/specs/2026-08-26-prod-monitoring-design.md` +전부 EC2(43.200.211.135)에 SSH 로 들어가서 순서대로 실행한다. + +## 1. 사전 확인 + +```bash +free -h; swapon --show; df -h / +stat -fc %T /sys/fs/cgroup # cgroup2fs 여야 한다 +docker info 2>/dev/null | grep -i "no swap limit" || echo "swap limit 지원 OK" +``` + +마지막 줄이 "swap limit 지원 OK" 가 아니면 **여기서 멈춘다** — +memswap_limit 이 조용히 무시되어 스왑을 켜면 안 되는 상태다. + +## 2. 스왑 2GB + +```bash +sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile \ + && sudo mkswap /swapfile && sudo swapon /swapfile +grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +echo 'vm.swappiness=1' | sudo tee /etc/sysctl.d/99-swap.conf && sudo sysctl --system +free -h # Swap 2.0Gi 확인 +``` + +## 3. 베이스라인 실측 (모니터링 올리기 전) + +```bash +docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" | tee ~/baseline-$(date +%F).txt +``` + +## 4. 설정 준비 + +```bash +cd ~/comatching && git fetch origin main && git reset --hard origin/main +cp monitoring/alertmanager/alertmanager.example.yml monitoring/alertmanager/alertmanager.yml +vi monitoring/alertmanager/alertmanager.yml # 웹훅 URL 교체 (DLT 와 다른 채널!) +vi .env.prod # GRAFANA_ADMIN_PASSWORD= 채움 +``` + +## 5. 기동 + +```bash +docker compose -f docker-compose.prod.yml --env-file .env.prod pull +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build +docker compose -f docker-compose.prod.yml --env-file .env.prod ps +``` + +게이트웨이 관리 포트가 8081 로 바뀌었으므로 앱 이미지도 이 커밋으로 +빌드된 것이어야 한다(CI 배포를 먼저 태우거나 --build). + +## 6. 검증 + +```bash +# 스크레이프 6대상 전부 up=1 이어야 한다 +docker exec comatching-prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=up' | grep -o '"service":"[^"]*","value":\[[^]]*\]' + +# 외부에서 actuator 가 안 열리는지 (401/404 여야 하고 200 이면 사고다) +curl -s -o /dev/null -w '%{http_code}\n' https://srv.comatching.site/actuator/prometheus + +# 알림 경로: Alertmanager 가 디스코드로 쏘는지 임시 서비스 하나를 죽여 확인 +docker stop comatching-notification && sleep 180 # ServiceDown(2m) 발화 대기 +docker start comatching-notification # 디스코드에 알림+해제 확인 +``` + +로컬 PC 에서: + +```bash +ssh -L 3001:localhost:3001 @43.200.211.135 +# 브라우저 http://localhost:3001 → admin / GRAFANA_ADMIN_PASSWORD +``` + +## 7. 외부 업타임 감시 + +UptimeRobot 등에서 웹으로 등록 (EC2 작업 없음): +- URL: `https://srv.comatching.site/actuator/health`, 주기 5분 +- 알림: 인프라 디스코드 채널 + +## 8. 부하 테스트 (spec 7.2) + +```bash +# RDS 파라미터 그룹에서 local_infile=1 적용 후: +set -a; . ~/comatching/.env.prod; set +a +cd ~/comatching/tools/perf/seed && ./load_seed_rds.sh # 더미 10만 +cd ../tokens && python3 generate_tokens.py --env ~/comatching/.env.prod + +# 부하 중 실측 기록 +docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/stats-$(date +%F-%H%M).csv +``` + +부하는 로컬 PC 에서: `SCHEME=https HOST=srv.comatching.site PORT=443 ./run.sh` + +## 9. 초기화 (런칭 전 1회) + +```bash +cd ~/comatching +docker compose -f docker-compose.prod.yml --env-file .env.prod down -v # kafka/mongo/redis 볼륨 삭제 +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=create/' .env.prod +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build + +# 전부 healthy 확인 후 ★반드시★ 즉시 되돌린다. create 인 채로 재기동하면 +# 실서비스 데이터가 전부 날아간다 (CI 배포와 restart 정책이 재기동을 만든다). +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=validate/' .env.prod && grep JPA_DDL_AUTO .env.prod +``` +```` + +- [ ] **Step 2: Commit** + +```bash +git add docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md +git commit -m "docs(monitoring): EC2 롤아웃 런북" +``` + +--- + +## Self-Review 기록 + +- **Spec coverage:** 3.1→Task 6, 3.2→Task 1, 3.3→Task 4, 4.2→Task 8(§1-2), 4.3→Task 3, 5장→Task 4·5, 6장→Task 8(§7), 7.1→Task 7, 7.2~7.3→Task 8(§8-9), 9장 구현 순서 = Task 1~7 순서와 일치. +- **Spec 과의 의도적 편차 1건:** spec 5.1 은 `INFRA_ALERT_WEBHOOK_URL` 환경변수를 제안했으나 Alertmanager 가 설정 파일에서 환경변수를 확장하지 못한다. `.env.prod` 패턴(example 추적 + 실제 파일 gitignore)으로 대체했다 — Task 5 서두에 근거 명시. +- **미검증 리스크(스펙 8장)와의 대응:** `discord_configs` → Task 5 Step 4 의 amtool 검증에서 걸러짐. `kafka_consumer_fetch_manager_records_lag_max` → EC2 기동 후 런북 6절 검증에서 확인, 없으면 규칙만 제거. +- **Type consistency:** 서비스명 `alertmanager`(Task 4 의 타깃 = Task 6 의 서비스명), 볼륨 `prometheus-data`/`grafana-data`, 환경변수 `GRAFANA_ADMIN_PASSWORD`(Task 5 정의 = Task 6 사용), 실행 계약 `SCHEME`(Task 7 내 3곳) 일치 확인. From a9d0ca0ea719a6ce29bd521216839a90545f9037 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:32:27 +0900 Subject: [PATCH 10/66] =?UTF-8?q?feat(monitoring):=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EC=9B=A8=EC=9D=B4=20=EA=B4=80=EB=A6=AC=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=208081=20?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- docker-compose.prod.yml | 3 ++- .../src/main/resources/application-aws.yml | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2a5b2ef..db686f8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -338,7 +338,8 @@ services: - "127.0.0.1:8080:8080" mem_limit: 512m healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health"] + # 관리 포트가 8081 로 분리됐다 (application-aws.yml 참고). + test: ["CMD", "curl", "-fsS", "http://localhost:8081/actuator/health"] interval: 15s timeout: 5s retries: 10 diff --git a/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml index 808f97d..e6139e2 100644 --- a/gateway-service/src/main/resources/application-aws.yml +++ b/gateway-service/src/main/resources/application-aws.yml @@ -137,12 +137,18 @@ logging: org.springframework.cloud.gateway: INFO management: + server: + # 8080 은 nginx 가 TLS 를 종단해 넘겨주는 포트라, 라우트에 걸리지 않는 + # /actuator/** 를 게이트웨이 자신이 응답하면 그대로 외부에 공개된다. + # 관리 엔드포인트를 8081 로 옮기고 호스트에 매핑하지 않는다 - 컴포즈 + # 네트워크 안(Prometheus 스크레이프, 헬스체크)에서만 닿는다. + port: 8081 endpoints: web: exposure: - include: health + include: health,prometheus endpoint: health: - # 게이트웨이의 8080 은 외부에 열려 있다. always 로 두면 인증 없이 - # DB/Redis 구성요소 상태와 접속 정보 일부가 그대로 드러난다. + # 8081 이라도 상세는 숨긴다. DB/Redis 구성요소 상태와 접속 정보 + # 일부가 드러나는 것은 내부라도 이득이 없다. show-details: never \ No newline at end of file From 84e53eca1ddb633f80c1d15218e561c0b18126ce Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:32:55 +0900 Subject: [PATCH 11/66] =?UTF-8?q?feat(monitoring):=20aws=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8C=8C=EC=9D=BC=EC=97=90=EC=84=9C=20prometheus=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=97=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- chat-service/src/main/resources/application-aws.yml | 4 +++- item-service/src/main/resources/application-aws.yml | 4 +++- matching-service/src/main/resources/application-aws.yml | 4 +++- notification/src/main/resources/application-aws.yml | 4 +++- user-service/src/main/resources/application-aws.yml | 4 +++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/chat-service/src/main/resources/application-aws.yml b/chat-service/src/main/resources/application-aws.yml index 5e0ed63..4df228a 100644 --- a/chat-service/src/main/resources/application-aws.yml +++ b/chat-service/src/main/resources/application-aws.yml @@ -66,7 +66,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 diff --git a/item-service/src/main/resources/application-aws.yml b/item-service/src/main/resources/application-aws.yml index 2233ea0..c23f796 100644 --- a/item-service/src/main/resources/application-aws.yml +++ b/item-service/src/main/resources/application-aws.yml @@ -96,7 +96,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 diff --git a/matching-service/src/main/resources/application-aws.yml b/matching-service/src/main/resources/application-aws.yml index a884974..85ae71c 100644 --- a/matching-service/src/main/resources/application-aws.yml +++ b/matching-service/src/main/resources/application-aws.yml @@ -85,7 +85,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 diff --git a/notification/src/main/resources/application-aws.yml b/notification/src/main/resources/application-aws.yml index f32dc2e..84aac07 100644 --- a/notification/src/main/resources/application-aws.yml +++ b/notification/src/main/resources/application-aws.yml @@ -43,7 +43,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 diff --git a/user-service/src/main/resources/application-aws.yml b/user-service/src/main/resources/application-aws.yml index 88c0b3f..24dbc37 100644 --- a/user-service/src/main/resources/application-aws.yml +++ b/user-service/src/main/resources/application-aws.yml @@ -145,7 +145,9 @@ management: endpoints: web: exposure: - include: health + # 컨테이너 밖(호스트 포트 매핑·게이트웨이 라우트)으로 나가는 길이 + # 없는 포트라 그대로 연다. Prometheus 가 컴포즈 네트워크 안에서 긁는다. + include: health,prometheus endpoint: health: # base 프로파일의 always 를 여기서 덮는다. 상속되면 구성요소 상태가 From 32c3f78027e0602b20543736b8bb0fb70bdb2433 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:34:00 +0900 Subject: [PATCH 12/66] =?UTF-8?q?feat(monitoring):=20=ED=98=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=A4=EC=99=91=20=EB=8F=84=EC=9E=85=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=EB=B9=84=ED=95=B4=20memswap=5Flimit=20=EC=9D=84=20?= =?UTF-8?q?=EB=AA=BB=EB=B0=95=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- docker-compose.prod.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index db686f8..2a937d2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -95,6 +95,9 @@ services: volumes: - kafka-data:/var/lib/kafka/data mem_limit: 1280m + # 힙(-Xmx1g) 밖 페이지캐시가 잠깐 넘칠 때를 위한 여유 256m. JVM 앱들과 + # 달리 약간의 스왑은 허용하되 무제한(기본값: mem_limit 의 2배)은 막는다. + memswap_limit: 1536m healthcheck: test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server kafka:29092 >/dev/null 2>&1"] interval: 10s @@ -154,6 +157,8 @@ services: volumes: - mongo-data:/data/db mem_limit: 768m + # WiredTiger 캐시(0.25GB) 밖 워킹셋이 잠깐 넘칠 때를 위한 여유 128m. + memswap_limit: 896m healthcheck: test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] interval: 10s @@ -185,6 +190,9 @@ services: volumes: - redis-data:/data mem_limit: 320m + # AOF rewrite 가 fork 하며 copy-on-write 로 페이지를 복제한다. 그 순간 + # 페이지가 스왑에 있으면 지연이 폭발하므로 스왑 사용을 0 으로 못박는다. + memswap_limit: 320m healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -209,6 +217,13 @@ services: redis: condition: service_healthy mem_limit: 768m + # mem_limit 과 같은 값 = 스왑 사용 0. memswap_limit 을 생략하면 docker 가 + # memory+swap 을 mem_limit 의 2배로 잡아서, 호스트에 스왑이 생기는 순간 + # 실효 상한이 조용히 두 배가 된다. JVM 은 full GC 가 힙 전체를 훑으므로 + # 스왑에 밀린 페이지를 EBS 에서 되읽으면 GC 한 번이 수십 초가 된다. + # 지금처럼 자기 한도에서 OOMKill 로 시끄럽게 죽는 편이 낫다 + # (Dockerfile 의 -XX:+ExitOnOutOfMemoryError 와 같은 철학). + memswap_limit: 768m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9000/actuator/health"] interval: 15s @@ -231,6 +246,7 @@ services: redis: condition: service_healthy mem_limit: 640m + memswap_limit: 640m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9001/actuator/health"] interval: 15s @@ -255,6 +271,7 @@ services: mongodb: condition: service_healthy mem_limit: 576m + memswap_limit: 576m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9003/actuator/health"] interval: 15s @@ -277,6 +294,7 @@ services: redis: condition: service_healthy mem_limit: 640m + memswap_limit: 640m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9006/actuator/health"] interval: 15s @@ -299,6 +317,7 @@ services: redis: condition: service_healthy mem_limit: 576m + memswap_limit: 576m healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:9005/actuator/health"] interval: 15s @@ -337,6 +356,7 @@ services: ports: - "127.0.0.1:8080:8080" mem_limit: 512m + memswap_limit: 512m healthcheck: # 관리 포트가 8081 로 분리됐다 (application-aws.yml 참고). test: ["CMD", "curl", "-fsS", "http://localhost:8081/actuator/health"] From 0ca0134362df580549a514c11adc8b215ccc60d9 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:38:38 +0900 Subject: [PATCH 13/66] =?UTF-8?q?feat(monitoring):=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A0=88=EC=9D=B4=ED=94=84=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EA=B3=BC=20=EC=95=8C=EB=A6=BC=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?6=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- monitoring/prometheus/prometheus.prod.yml | 43 ++++++++++++++++ monitoring/prometheus/rules/alerts.yml | 61 +++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 monitoring/prometheus/prometheus.prod.yml create mode 100644 monitoring/prometheus/rules/alerts.yml diff --git a/monitoring/prometheus/prometheus.prod.yml b/monitoring/prometheus/prometheus.prod.yml new file mode 100644 index 0000000..a7ca870 --- /dev/null +++ b/monitoring/prometheus/prometheus.prod.yml @@ -0,0 +1,43 @@ +# 운영(EC2) 전용. 로컬 prometheus.yml 과 두 가지가 다르다. +# - 같은 컴포즈 네트워크 안이라 host.docker.internal 이 아니라 컨테이너 +# 이름으로 간다. +# - 15s 간격. 5s 는 짧은 부하 테스트에서 데이터 포인트를 확보하려던 +# 값이라 상시 운영에는 과하다(TSDB 쓰기량과 카디널리티가 3배). +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - /etc/prometheus/rules/*.yml + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +scrape_configs: + - job_name: comatching-services + metrics_path: /actuator/prometheus + static_configs: + # 게이트웨이만 관리 포트가 8081 이다. 8080 은 nginx 뒤로 외부에 + # 노출되는 포트라 actuator 를 그쪽에 열 수 없다(application-aws.yml). + - targets: ['gateway-service:8081'] + labels: { service: gateway-service } + - targets: ['user-service:9000'] + labels: { service: user-service } + - targets: ['matching-service:9001'] + labels: { service: matching-service } + - targets: ['chat-service:9003'] + labels: { service: chat-service } + - targets: ['notification:9005'] + labels: { service: notification } + - targets: ['item-service:9006'] + labels: { service: item-service } + + - job_name: node + static_configs: + - targets: ['node-exporter:9100'] + + - job_name: prometheus + static_configs: + - targets: ['localhost:9090'] diff --git a/monitoring/prometheus/rules/alerts.yml b/monitoring/prometheus/rules/alerts.yml new file mode 100644 index 0000000..5698dbe --- /dev/null +++ b/monitoring/prometheus/rules/alerts.yml @@ -0,0 +1,61 @@ +# 알림의 기준: "이게 울리면 하던 일을 멈춰야 하나". +# critical = 예(지금 본다), warning = 아니오(오늘 안에 본다). +groups: + - name: comatching + rules: + - alert: ServiceDown + expr: up{job="comatching-services"} == 0 + for: 2m + labels: { severity: critical } + annotations: + summary: "{{ $labels.service }} 스크레이프 실패 2분" + description: "컨테이너가 죽었거나 actuator 가 응답하지 않는다. docker compose ps 부터 본다." + + - alert: HighErrorRate + expr: > + sum by (service) (rate(http_server_requests_seconds_count{status=~"5.."}[5m])) + / sum by (service) (rate(http_server_requests_seconds_count[5m])) > 0.05 + for: 5m + labels: { severity: critical } + annotations: + summary: "{{ $labels.service }} 5xx 비율 5% 초과 5분" + description: "값: {{ $value | humanizePercentage }}. 해당 서비스 로그부터 본다." + + - alert: JvmHeapHigh + expr: > + sum by (service) (jvm_memory_used_bytes{area="heap"}) + / sum by (service) (jvm_memory_max_bytes{area="heap"}) > 0.9 + for: 10m + labels: { severity: warning } + annotations: + summary: "{{ $labels.service }} 힙 90% 초과 10분" + description: "OOMKill(-XX:+ExitOnOutOfMemoryError 로 즉사·재시작) 전조다." + + - alert: KafkaConsumerLag + expr: kafka_consumer_fetch_manager_records_lag_max > 1000 + for: 10m + labels: { severity: warning } + annotations: + summary: "컨슈머 lag 1000 초과 10분" + description: "{{ $labels.service }} 의 컨슈머가 밀리고 있다. DLT 적재 여부도 확인." + + - alert: DiskSpaceLow + expr: > + 1 - node_filesystem_avail_bytes{mountpoint="/"} + / node_filesystem_size_bytes{mountpoint="/"} > 0.85 + for: 5m + labels: { severity: warning } + annotations: + summary: "루트 디스크 85% 초과" + description: "docker image prune, Prometheus retention, 로그부터 의심한다." + + # memswap_limit 핀 때문에 컨테이너는 스왑을 못 쓴다. 스왑이 실제로 + # 쓰인다는 것은 호스트(sshd, docker 데몬 등)가 메모리 압박을 받고 + # 있다는 뜻이다 - mem_limit 합 7,040m / 7.7GiB 예산의 조기 경보. + - alert: SwapInUse + expr: node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes > 256 * 1024 * 1024 + for: 10m + labels: { severity: warning } + annotations: + summary: "호스트 스왑 사용 256MB 초과 10분" + description: "컨테이너 mem_limit 합이 호스트를 압박하고 있다. spec 4.1 의 완화 순서대로." From 7a8940e020673fd0a27baafbf517681ccb5c0470 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:40:25 +0900 Subject: [PATCH 14/66] =?UTF-8?q?feat(monitoring):=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EC=95=8C=EB=A6=BC=EC=9A=A9=20Alertmanager=20?= =?UTF-8?q?=EB=94=94=EC=8A=A4=EC=BD=94=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .env.prod.example | 10 ++++++++ .gitignore | 4 ++++ .../alertmanager/alertmanager.example.yml | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 monitoring/alertmanager/alertmanager.example.yml diff --git a/.env.prod.example b/.env.prod.example index 9f88623..f318277 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -62,6 +62,16 @@ DEFAULT_PROFILE_IMAGE_BASE_URL=https://srv.comatching.site/api/public/profile-im # 켜도 AuthorizationHeaderFilter 가 붙어 있어 로그인 쿠키가 있어야 열린다. # SWAGGER_PATH_PREFIX= +# ---------- 모니터링 ---------- +# Grafana 관리자 비밀번호. SSH 터널(127.0.0.1:3001) 너머로만 닿지만 +# 익명 접근을 꺼둔 이상 계정은 있어야 한다. +GRAFANA_ADMIN_PASSWORD= + +# 인프라 알림(서비스 다운·5xx·힙·디스크)의 디스코드 웹훅은 환경변수가 +# 아니라 monitoring/alertmanager/alertmanager.yml 에 넣는다 - Alertmanager 가 +# 설정 파일에서 환경변수를 확장하지 못한다. 그 파일은 .gitignore 에 있다. +# DLT_ALERT_WEBHOOK_URL 과 반드시 다른 채널로 팔 것(아래 DLT 절 참고). + # ---------- DLT 적재 알림 ---------- # Discord 웹훅 URL(https://discord.com/api/webhooks//) 또는 Slack. # 비우면 로그만 남는다. 이 URL 자체가 비밀값이다. diff --git a/.gitignore b/.gitignore index f36b7cd..34f69bc 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,10 @@ docker-compose.yml !/docs/superpowers/ # 플러그인이 남기는 상태 파일은 그 안에서도 제외한다. /docs/superpowers/**/.omc/ + +# Alertmanager 실제 설정은 디스코드 웹훅 URL 을 담고 있어 EC2 에만 둔다. +# 예제(alertmanager.example.yml)만 추적한다 - .env.prod 와 같은 패턴. +/monitoring/alertmanager/alertmanager.yml application-docker.yml application-secret.yml serviceAccountKey.json diff --git a/monitoring/alertmanager/alertmanager.example.yml b/monitoring/alertmanager/alertmanager.example.yml new file mode 100644 index 0000000..e3334ce --- /dev/null +++ b/monitoring/alertmanager/alertmanager.example.yml @@ -0,0 +1,24 @@ +# ============================================================ +# 이 파일을 alertmanager.yml 로 복사해서 웹훅 URL 을 채운다. +# cp alertmanager.example.yml alertmanager.yml +# alertmanager.yml 은 .gitignore 에 있다 - 웹훅 URL 자체가 비밀값이다 +# (URL 만 알면 누구나 그 채널에 글을 쓸 수 있다). +# +# 웹훅은 DLT_ALERT_WEBHOOK_URL 과 다른 채널로 판다. DLT 알림은 잦고 안 +# 급하며, 인프라 알림은 드물고 급하다. 한 채널에 섞으면 잦은 쪽이 드문 +# 쪽을 묻어버려 결국 둘 다 안 읽는다. +# ============================================================ +route: + receiver: discord + group_by: [alertname, service] + group_wait: 30s + group_interval: 5m + # 해결 안 된 알림의 재전송 간격. 너무 짧으면 같은 알림이 도배되어 + # "잦은 쪽이 드문 쪽을 묻는" 문제를 스스로 만든다. + repeat_interval: 4h + +receivers: + - name: discord + discord_configs: + - webhook_url: 'https://discord.com/api/webhooks/CHANGE/ME' + send_resolved: true From c0f6fdd86c3624a524fd5574937bf2c41b0347b0 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:46:07 +0900 Subject: [PATCH 15/66] =?UTF-8?q?feat(monitoring):=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EC=A6=88=EC=97=90=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EB=A7=81=204=EC=A2=85=EC=9D=84=20=EC=98=AC=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- docker-compose.prod.yml | 94 +++++++++++++++++++ .../dashboards/dashboards.yml | 13 +++ .../datasources/prometheus.yml | 15 +++ 3 files changed, 122 insertions(+) create mode 100644 monitoring/grafana/provisioning-prod/dashboards/dashboards.yml create mode 100644 monitoring/grafana/provisioning-prod/datasources/prometheus.yml diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2a937d2..0e60f54 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -42,6 +42,8 @@ volumes: kafka-data: mongo-data: redis-data: + prometheus-data: + grafana-data: services: @@ -364,3 +366,95 @@ services: timeout: 5s retries: 10 start_period: 90s + + # ---------- 모니터링 ---------- + # 넷 다 comatching 네트워크 안이다. 호스트에 여는 건 grafana 의 + # 127.0.0.1:3001 하나뿐이고, 보는 방법은 SSH 터널이다: + # ssh -L 3001:localhost:3001 @ + + prometheus: + image: prom/prometheus:v2.54.1 + container_name: comatching-prometheus + restart: unless-stopped + networks: [comatching] + volumes: + - ./monitoring/prometheus/prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/rules:/etc/prometheus/rules:ro + - prometheus-data:/prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + # 시간과 크기 중 먼저 걸리는 쪽이 이긴다. 디스크가 차서 죽는 것보다 + # 오래된 데이터를 버리는 게 낫다. + - --storage.tsdb.retention.time=15d + - --storage.tsdb.retention.size=2GB + mem_limit: 512m + memswap_limit: 512m + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"] + interval: 15s + timeout: 5s + retries: 10 + logging: *logging + + alertmanager: + image: prom/alertmanager:v0.27.0 + container_name: comatching-alertmanager + restart: unless-stopped + networks: [comatching] + volumes: + # 실제 파일은 EC2 에서 example 을 복사해 만든다(웹훅 URL 이 비밀값). + # 없으면 이 마운트가 실패하므로, 만들기 전에는 이 서비스가 뜨지 않는다. + - ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + mem_limit: 128m + memswap_limit: 128m + logging: *logging + + grafana: + image: grafana/grafana:11.2.0 + container_name: comatching-grafana + restart: unless-stopped + networks: [comatching] + # 루프백에만 바인딩한다. 게이트웨이 8080 과 같은 이유 - 보는 방법은 + # SSH 터널뿐이고, 외부에 열면 보안그룹 한 겹이 유일한 방어선이 된다. + ports: + - "127.0.0.1:3001:3000" + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD} + # 로컬 스택은 익명 Viewer 를 켜뒀지만 운영은 터널 너머라도 계정을 + # 요구한다. 터널 포트를 실수로 0.0.0.0 에 열었을 때의 마지막 겹이다. + GF_AUTH_ANONYMOUS_ENABLED: "false" + GF_USERS_DEFAULT_THEME: dark + volumes: + - ./monitoring/grafana/provisioning-prod:/etc/grafana/provisioning:ro + # baseline 만 올린다. loadtest 는 influxdb, mysql-queries 는 mysql + # 데이터소스를 참조해 운영에서 패널이 깨진다. + - ./monitoring/grafana/dashboards/comatching-baseline.json:/var/lib/grafana/dashboards/comatching-baseline.json:ro + - grafana-data:/var/lib/grafana + depends_on: + - prometheus + mem_limit: 256m + memswap_limit: 256m + logging: *logging + + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: comatching-node-exporter + restart: unless-stopped + networks: [comatching] + # 호스트 지표(CPU·메모리·디스크·스왑)를 읽어야 해서 이 컨테이너만 + # 예외적으로 호스트 네임스페이스를 본다. 전부 읽기 전용이다. + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/rootfs:ro + command: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/rootfs + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|var/lib/docker/.+)($$|/) + mem_limit: 64m + memswap_limit: 64m + logging: *logging diff --git a/monitoring/grafana/provisioning-prod/dashboards/dashboards.yml b/monitoring/grafana/provisioning-prod/dashboards/dashboards.yml new file mode 100644 index 0000000..62329c1 --- /dev/null +++ b/monitoring/grafana/provisioning-prod/dashboards/dashboards.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: comatching + orgId: 1 + folder: Comatching + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/monitoring/grafana/provisioning-prod/datasources/prometheus.yml b/monitoring/grafana/provisioning-prod/datasources/prometheus.yml new file mode 100644 index 0000000..602b52a --- /dev/null +++ b/monitoring/grafana/provisioning-prod/datasources/prometheus.yml @@ -0,0 +1,15 @@ +# 운영 전용 프로비저닝. 로컬(provisioning/)과 분리한 이유: +# - influxdb 데이터소스는 JMeter 전용이라 운영에 없다. +# - mysql 데이터소스는 host.docker.internal:3307 을 봐서 운영에서 깨진다. +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus # 대시보드 JSON 이 이 uid 로 참조한다 + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: 15s # prometheus.prod.yml 의 scrape_interval 과 일치 From 468bdfe7a2442abd010e827de4446d798119a84c Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 00:57:52 +0900 Subject: [PATCH 16/66] =?UTF-8?q?feat(perf):=20=EB=B6=80=ED=95=98=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=EC=9D=98=20=EC=9A=B4=EC=98=81=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91=20-=20HTTPS=20=EC=8A=A4=ED=82=B4=EA=B3=BC=20RDS=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=20=EC=A0=81=EC=9E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- tools/perf/jmeter/S1-participants.jmx | 2 +- tools/perf/jmeter/run.sh | 7 +- tools/perf/seed/load_seed_rds.sh | 137 ++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100755 tools/perf/seed/load_seed_rds.sh diff --git a/tools/perf/jmeter/S1-participants.jmx b/tools/perf/jmeter/S1-participants.jmx index f97b567..deecd17 100644 --- a/tools/perf/jmeter/S1-participants.jmx +++ b/tools/perf/jmeter/S1-participants.jmx @@ -64,7 +64,7 @@ ${__P(host,localhost)} ${__P(port,8080)} - http + ${__P(scheme,http)} 3000 diff --git a/tools/perf/jmeter/run.sh b/tools/perf/jmeter/run.sh index f408321..4a803cf 100755 --- a/tools/perf/jmeter/run.sh +++ b/tools/perf/jmeter/run.sh @@ -30,6 +30,9 @@ mkdir -p "$RUN_DIR" HOST="${HOST:-localhost}" PORT="${PORT:-8080}" +# 운영은 nginx 443 뒤라 https 로 가야 한다. 게이트웨이 8080 은 루프백 +# 바인딩이라 외부에서 직접 닿지 않는다. +SCHEME="${SCHEME:-http}" INFLUX_HOST="${INFLUX_HOST:-localhost}" echo "════════════════════════════════════════════════════════════" @@ -47,7 +50,7 @@ else echo "✅ jmeter $(jmeter --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)" fi -code=$(curl -s -o /dev/null -w '%{http_code}' "http://$HOST:$PORT/api/auth/participants" || echo 000) +code=$(curl -s -o /dev/null -w '%{http_code}' "$SCHEME://$HOST:$PORT/api/auth/participants" || echo 000) if [ "$code" = "200" ]; then echo "✅ 게이트웨이 $HOST:$PORT — 대상 엔드포인트 200" else @@ -111,7 +114,7 @@ set +e jmeter -n -t "$PLAN" \ -l "$RUN_DIR/result.jtl" \ -j "$RUN_DIR/jmeter.log" \ - -Jhost="$HOST" -Jport="$PORT" -JinfluxHost="$INFLUX_HOST" \ + -Jhost="$HOST" -Jport="$PORT" -Jscheme="$SCHEME" -JinfluxHost="$INFLUX_HOST" \ -JtestTitle="$NAME-$STAMP" \ -Jjmeter.save.saveservice.output_format=csv JM_RC=$? diff --git a/tools/perf/seed/load_seed_rds.sh b/tools/perf/seed/load_seed_rds.sh new file mode 100755 index 0000000..4b1b590 --- /dev/null +++ b/tools/perf/seed/load_seed_rds.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# ============================================================================ +# 생성된 TSV 를 RDS 에 적재한다. EC2 호스트에서 실행한다. +# +# 로컬용 load_seed.sh 와 갈라놓은 이유 - 그쪽의 두 전제가 RDS 에서 깨진다: +# - docker exec comatching-mysql RDS 는 컨테이너가 아니다 +# - 서버사이드 LOAD DATA INFILE RDS 는 DB 서버 파일시스템에 파일을 +# 놓을 수 없다 (secure_file_priv) +# 대신 클라이언트가 파일을 읽어 보내는 LOAD DATA LOCAL INFILE 을 쓴다. +# +# 선결 조건: +# - RDS 파라미터 그룹에 local_infile=1 (기본 0. 콘솔에서 바꾸고 적용 대기) +# - EC2 에 mysql 클라이언트 (dnf install mariadb105 또는 mysql) +# - 시드 TSV: 로컬에서 generate_seed.py 로 만들어 scp 로 올린다 +# +# 사용법: +# RDS_ENDPOINT=... RDS_USERNAME=... RDS_PASSWORD=... ./load_seed_rds.sh +# 환경변수 이름은 .env.prod 와 같으므로 이렇게 부를 수도 있다: +# set -a; . ~/comatching/.env.prod; set +a; ./load_seed_rds.sh +# ============================================================================ +set -euo pipefail +cd "$(dirname "$0")" + +: "${RDS_ENDPOINT:?RDS_ENDPOINT 가 필요하다 (.env.prod 참고)}" +: "${RDS_USERNAME:?RDS_USERNAME 이 필요하다}" +: "${RDS_PASSWORD:?RDS_PASSWORD 가 필요하다}" +START_ID="${START_ID:-1000001}" +OUT_DIR="${OUT_DIR:-./out}" + +mysql_exec() { + mysql -h "$RDS_ENDPOINT" -u "$RDS_USERNAME" -p"$RDS_PASSWORD" \ + --local-infile=1 --default-character-set=utf8mb4 "$@" +} + +# ---------- 0. 사전 점검 ---------- +if [ ! -f "$OUT_DIR/members.tsv" ]; then + echo "❌ $OUT_DIR/members.tsv 가 없습니다. 로컬에서 generate_seed.py 를 돌려 scp 로 올리세요." + exit 1 +fi + +# 서버가 local_infile 을 거부하면 여기서 일찍 죽는 편이 낫다. +LI=$(mysql_exec -N -B -e "SELECT @@GLOBAL.local_infile;") +if [ "$LI" != "1" ]; then + echo "❌ RDS 의 local_infile 이 꺼져 있습니다 ($LI). 파라미터 그룹에서 local_infile=1 로 바꾸세요." + exit 1 +fi +echo "✅ RDS $RDS_ENDPOINT — local_infile=1" + +# ---------- 1. 기존 시드 삭제 ---------- +# 재실행 가능해야 한다. generate_seed.py 가 profile_id 를 member_id 와 1:1 로 +# 맞춰두므로 조인 없이 ID 범위로 지운다. +echo "🧹 기존 시드 삭제 (member_id >= $START_ID)..." +mysql_exec <= $START_ID; +DELETE FROM comatching_user.profile_tag WHERE profile_id >= $START_ID; +DELETE FROM comatching_user.profile WHERE member_id >= $START_ID; +DELETE FROM comatching_user.members WHERE member_id >= $START_ID; +DELETE FROM comatching_matching.candidate_hobby_categories WHERE member_id >= $START_ID; +DELETE FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID; +DELETE FROM comatching_item.item WHERE member_id >= $START_ID; +SET foreign_key_checks = 1; +SQL + +# ---------- 2. 적재 ---------- +# unique_checks / foreign_key_checks 를 끄면 InnoDB 가 행마다 검증하지 않아 +# 벌크 적재가 크게 빨라진다. 시드는 이미 유일성이 보장돼 있다. +echo "⬇️ 적재 중..." +time mysql_exec <= $START_ID +UNION ALL SELECT 'profile', COUNT(*) FROM comatching_user.profile WHERE member_id >= $START_ID +UNION ALL SELECT 'matching_candidate', COUNT(*) FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID +UNION ALL SELECT 'item', COUNT(*) FROM comatching_item.item WHERE member_id >= $START_ID; +SQL + +echo "🔎 age NULL 검사 (0 이어야 정상 — NULL 이면 매칭이 전부 실패한다)" +mysql_exec -t -e "SELECT COUNT(*) AS age_is_null FROM comatching_matching.matching_candidate WHERE member_id >= $START_ID AND age IS NULL;" + +echo "🏁 완료. 다음: 운영 시크릿으로 토큰 재발급 (generate_tokens.py --env ~/comatching/.env.prod)" From 314d75468a9cd1271c94d6cf4a2971bb20938f15 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 01:01:24 +0900 Subject: [PATCH 17/66] =?UTF-8?q?docs(monitoring):=20EC2=20=EB=A1=A4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EB=9F=B0=EB=B6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../2026-08-26-prod-monitoring-rollout.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md diff --git a/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md b/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md new file mode 100644 index 0000000..709dded --- /dev/null +++ b/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md @@ -0,0 +1,105 @@ +# 운영 모니터링 롤아웃 런북 + +설계: `docs/superpowers/specs/2026-08-26-prod-monitoring-design.md` +전부 EC2(43.200.211.135)에 SSH 로 들어가서 순서대로 실행한다. + +## 1. 사전 확인 + +```bash +free -h; swapon --show; df -h / +stat -fc %T /sys/fs/cgroup # cgroup2fs 여야 한다 +docker info 2>/dev/null | grep -i "no swap limit" || echo "swap limit 지원 OK" +``` + +마지막 줄이 "swap limit 지원 OK" 가 아니면 **여기서 멈춘다** — +memswap_limit 이 조용히 무시되어 스왑을 켜면 안 되는 상태다. + +## 2. 스왑 2GB + +```bash +sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile \ + && sudo mkswap /swapfile && sudo swapon /swapfile +grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +echo 'vm.swappiness=1' | sudo tee /etc/sysctl.d/99-swap.conf && sudo sysctl --system +free -h # Swap 2.0Gi 확인 +``` + +## 3. 베이스라인 실측 (모니터링 올리기 전) + +```bash +docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" | tee ~/baseline-$(date +%F).txt +``` + +## 4. 설정 준비 + +```bash +cd ~/comatching && git fetch origin main && git reset --hard origin/main +cp monitoring/alertmanager/alertmanager.example.yml monitoring/alertmanager/alertmanager.yml +vi monitoring/alertmanager/alertmanager.yml # 웹훅 URL 교체 (DLT 와 다른 채널!) +vi .env.prod # GRAFANA_ADMIN_PASSWORD= 채움 +``` + +## 5. 기동 + +```bash +docker compose -f docker-compose.prod.yml --env-file .env.prod pull +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build +docker compose -f docker-compose.prod.yml --env-file .env.prod ps +``` + +게이트웨이 관리 포트가 8081 로 바뀌었으므로 앱 이미지도 이 커밋으로 +빌드된 것이어야 한다(CI 배포를 먼저 태우거나 --build). + +## 6. 검증 + +```bash +# 스크레이프 6대상 전부 up=1 이어야 한다 +docker exec comatching-prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=up' | grep -o '"service":"[^"]*","value":\[[^]]*\]' + +# 외부에서 actuator 가 안 열리는지 (401/404 여야 하고 200 이면 사고다) +curl -s -o /dev/null -w '%{http_code}\n' https://srv.comatching.site/actuator/prometheus + +# 알림 경로: Alertmanager 가 디스코드로 쏘는지 임시 서비스 하나를 죽여 확인 +docker stop comatching-notification && sleep 180 # ServiceDown(2m) 발화 대기 +docker start comatching-notification # 디스코드에 알림+해제 확인 +``` + +로컬 PC 에서: + +```bash +ssh -L 3001:localhost:3001 @43.200.211.135 +# 브라우저 http://localhost:3001 → admin / GRAFANA_ADMIN_PASSWORD +``` + +## 7. 외부 업타임 감시 + +UptimeRobot 등에서 웹으로 등록 (EC2 작업 없음): +- URL: `https://srv.comatching.site/actuator/health`, 주기 5분 +- 알림: 인프라 디스코드 채널 + +## 8. 부하 테스트 (spec 7.2) + +```bash +# RDS 파라미터 그룹에서 local_infile=1 적용 후: +set -a; . ~/comatching/.env.prod; set +a +cd ~/comatching/tools/perf/seed && ./load_seed_rds.sh # 더미 10만 +cd ../tokens && python3 generate_tokens.py --env ~/comatching/.env.prod + +# 부하 중 실측 기록 +docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/stats-$(date +%F-%H%M).csv +``` + +부하는 로컬 PC 에서: `SCHEME=https HOST=srv.comatching.site PORT=443 ./run.sh` + +## 9. 초기화 (런칭 전 1회) + +```bash +cd ~/comatching +docker compose -f docker-compose.prod.yml --env-file .env.prod down -v # kafka/mongo/redis 볼륨 삭제 +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=create/' .env.prod +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build + +# 전부 healthy 확인 후 ★반드시★ 즉시 되돌린다. create 인 채로 재기동하면 +# 실서비스 데이터가 전부 날아간다 (CI 배포와 restart 정책이 재기동을 만든다). +sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=validate/' .env.prod && grep JPA_DDL_AUTO .env.prod +``` From 7bbedd20ea311b2a53c234af246a9c028229ce74 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 01:06:38 +0900 Subject: [PATCH 18/66] =?UTF-8?q?docs(monitoring):=20=EB=9F=B0=EB=B6=81?= =?UTF-8?q?=EC=9D=98=20=EC=8A=A4=EC=99=91=20=EA=B2=80=EC=82=AC=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=BC=EA=B3=BC=20down=20-v=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=9D=84=20=EB=B0=94=EB=A1=9C=EC=9E=A1?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../runbooks/2026-08-26-prod-monitoring-rollout.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md b/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md index 709dded..f995285 100644 --- a/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md +++ b/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md @@ -8,7 +8,8 @@ ```bash free -h; swapon --show; df -h / stat -fc %T /sys/fs/cgroup # cgroup2fs 여야 한다 -docker info 2>/dev/null | grep -i "no swap limit" || echo "swap limit 지원 OK" +# 스트림을 합쳐서(2>&1) STDERR 로 나오는 경고가 버려지지 않게 한다 +docker info 2>&1 | grep -i "no swap limit" && echo "!! memswap_limit 미지원 - 여기서 멈춘다" || echo "swap limit 지원 OK" ``` 마지막 줄이 "swap limit 지원 OK" 가 아니면 **여기서 멈춘다** — @@ -95,7 +96,7 @@ docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/s ```bash cd ~/comatching -docker compose -f docker-compose.prod.yml --env-file .env.prod down -v # kafka/mongo/redis 볼륨 삭제 +docker compose -f docker-compose.prod.yml --env-file .env.prod down -v # 명명 볼륨 5개 전부 삭제 - kafka/mongo/redis 는 물론 Prometheus TSDB(베이스라인·부하 기록)와 Grafana 상태까지 초기화된다. 남기고 싶은 지표가 있으면 §8 의 stats CSV 를 먼저 백업할 것. sed -i 's/^JPA_DDL_AUTO=.*/JPA_DDL_AUTO=create/' .env.prod docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build From b15abdb7140ffec3de4c875aa710def367c24364 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 01:14:00 +0900 Subject: [PATCH 19/66] =?UTF-8?q?fix(monitoring):=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A7=80=EC=A0=81=204=EA=B1=B4=20-=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=85=B8=EC=B6=9C=C2=B7?= =?UTF-8?q?=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC=C2=B7=EC=9D=98=EC=A1=B4=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=C2=B7=EA=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- docker-compose.prod.yml | 8 +++++++- gateway-service/src/main/resources/application-aws.yml | 2 +- tools/perf/seed/load_seed_rds.sh | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0e60f54..9368255 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -408,6 +408,11 @@ services: - ./monitoring/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro mem_limit: 128m memswap_limit: 128m + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9093/-/healthy"] + interval: 15s + timeout: 5s + retries: 10 logging: *logging grafana: @@ -433,7 +438,8 @@ services: - ./monitoring/grafana/dashboards/comatching-baseline.json:/var/lib/grafana/dashboards/comatching-baseline.json:ro - grafana-data:/var/lib/grafana depends_on: - - prometheus + prometheus: + condition: service_healthy mem_limit: 256m memswap_limit: 256m logging: *logging diff --git a/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml index e6139e2..edfa430 100644 --- a/gateway-service/src/main/resources/application-aws.yml +++ b/gateway-service/src/main/resources/application-aws.yml @@ -151,4 +151,4 @@ management: health: # 8081 이라도 상세는 숨긴다. DB/Redis 구성요소 상태와 접속 정보 # 일부가 드러나는 것은 내부라도 이득이 없다. - show-details: never \ No newline at end of file + show-details: never diff --git a/tools/perf/seed/load_seed_rds.sh b/tools/perf/seed/load_seed_rds.sh index 4b1b590..08e5380 100755 --- a/tools/perf/seed/load_seed_rds.sh +++ b/tools/perf/seed/load_seed_rds.sh @@ -28,7 +28,9 @@ START_ID="${START_ID:-1000001}" OUT_DIR="${OUT_DIR:-./out}" mysql_exec() { - mysql -h "$RDS_ENDPOINT" -u "$RDS_USERNAME" -p"$RDS_PASSWORD" \ + # 비밀번호를 -p 인자로 주면 ps 에서 다른 사용자에게 보인다. 로컬 스크립트는 + # 일회용 개발 비밀번호라 넘어갔지만 이쪽은 운영 RDS 자격증명이다. + MYSQL_PWD="$RDS_PASSWORD" mysql -h "$RDS_ENDPOINT" -u "$RDS_USERNAME" \ --local-infile=1 --default-character-set=utf8mb4 "$@" } From 05af43adb421323549850ab675e6560ee7198c48 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 06:38:43 +0900 Subject: [PATCH 20/66] =?UTF-8?q?fix(chat):=20=EC=A3=BD=EC=9D=80=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=9F=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=82=B4=EB=A6=AC=EA=B3=A0=20=EB=8B=89=EB=84=A4=EC=9E=84=20NPE?= =?UTF-8?q?=20=EB=A5=BC=20=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @MessageExceptionHandler(ChatException.class) 는 발화할 수 없었다 - ChatException 을 던지는 곳이 코드 어디에도 없고, 실제로 던져지는 BusinessException 과는 상속 관계도 없다. 그래서 방이 없거나 권한이 없어 실패한 전송이 클라이언트에 아무 신호도 남기지 못하고 증발했다. BusinessException 을 잡아 REST 와 같은 ApiResponse 형태로 /user/queue/errors 에 내려준다. 닉네임 세션 속성은 프로필 완성 전에 발급된 토큰(클레임 없음)이면 존재하지 않는데, null 체크 없이 toString() 을 불러 전송 자체가 NPE 로 죽었다. 닉네임은 알림 미리보기용이므로 없으면 빈 값으로 두고 전송은 살린다. Co-Authored-By: Claude Fable 5 --- .../global/exception/ChatErrorHandler.java | 12 ++++++ .../chat/infra/controller/ChatController.java | 12 +++++- .../exception/ChatErrorHandlerTest.java | 37 +++++++++++++++++++ .../infra/controller/ChatControllerTest.java | 31 ++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 chat-service/src/test/java/com/comatching/chat/global/exception/ChatErrorHandlerTest.java diff --git a/chat-service/src/main/java/com/comatching/chat/global/exception/ChatErrorHandler.java b/chat-service/src/main/java/com/comatching/chat/global/exception/ChatErrorHandler.java index 2780bd7..800a828 100644 --- a/chat-service/src/main/java/com/comatching/chat/global/exception/ChatErrorHandler.java +++ b/chat-service/src/main/java/com/comatching/chat/global/exception/ChatErrorHandler.java @@ -4,6 +4,9 @@ import org.springframework.messaging.simp.annotation.SendToUser; import org.springframework.web.bind.annotation.ControllerAdvice; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.common.exception.BusinessException; + @ControllerAdvice public class ChatErrorHandler { @@ -12,4 +15,13 @@ public class ChatErrorHandler { public String handleException(ChatException e) { return "ERROR: " + e.getMessage(); } + + // 채팅 코드가 실제로 던지는 예외는 BusinessException 이다. 위의 ChatException + // 핸들러는 던지는 곳이 없어서 한 번도 발화하지 않았고, 그동안 방이 없거나 + // 권한이 없어 실패한 전송이 클라이언트에 아무 신호도 남기지 못했다. + @MessageExceptionHandler(BusinessException.class) + @SendToUser("/queue/errors") + public ApiResponse handleBusinessException(BusinessException e) { + return ApiResponse.errorResponse(e.getErrorCode()); + } } diff --git a/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java b/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java index b6c7764..b21ccb3 100644 --- a/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java +++ b/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java @@ -1,6 +1,7 @@ package com.comatching.chat.infra.controller; import java.util.List; +import java.util.Map; import org.springframework.data.domain.Pageable; import org.springframework.data.redis.listener.ChannelTopic; @@ -59,14 +60,20 @@ public void sendMessage( @Payload ChatMessageRequest request, SimpMessageHeaderAccessor headerAccessor) { - Long memberId = (Long)headerAccessor.getSessionAttributes().get("memberId"); - String nickname = headerAccessor.getSessionAttributes().get("nickname").toString(); + Map sessionAttributes = headerAccessor.getSessionAttributes(); + Long memberId = (sessionAttributes == null) ? null : (Long)sessionAttributes.get("memberId"); if (memberId == null) { log.error("인증되지 않은 사용자의 접근입니다."); return; } + // 닉네임은 알림 미리보기에만 쓴다. 프로필 완성 전에 발급된 토큰에는 + // nickname 클레임이 없어서 게이트웨이가 헤더를 안 보내는데, 예전에는 + // 여기서 바로 NPE 가 나 전송 자체가 죽었다. + Object rawNickname = sessionAttributes.get("nickname"); + String nickname = (rawNickname == null) ? "" : rawNickname.toString(); + ChatMessageRequest securedRequest = new ChatMessageRequest( request.roomId(), memberId, @@ -95,4 +102,5 @@ public ResponseEntity>> getChatMessages( List messages = chatService.getChatHistory(roomId, memberInfo.memberId(), pageable); return ResponseEntity.ok(ApiResponse.ok(messages)); } + } diff --git a/chat-service/src/test/java/com/comatching/chat/global/exception/ChatErrorHandlerTest.java b/chat-service/src/test/java/com/comatching/chat/global/exception/ChatErrorHandlerTest.java new file mode 100644 index 0000000..5dcf5eb --- /dev/null +++ b/chat-service/src/test/java/com/comatching/chat/global/exception/ChatErrorHandlerTest.java @@ -0,0 +1,37 @@ +package com.comatching.chat.global.exception; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; + +class ChatErrorHandlerTest { + + private final ChatErrorHandler handler = new ChatErrorHandler(); + + @Test + @DisplayName("존재하지 않는 채팅방 예외를 에러 코드가 담긴 응답으로 바꿔 내려준다") + void handleBusinessException_carriesChatErrorCode() { + ApiResponse response = + handler.handleBusinessException(new BusinessException(ChatErrorCode.NOT_EXIST_CHATROOM)); + + assertThat(response.getCode()).isEqualTo(ChatErrorCode.NOT_EXIST_CHATROOM.getCode()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(response.getMessage()).isEqualTo(ChatErrorCode.NOT_EXIST_CHATROOM.getMessage()); + } + + @Test + @DisplayName("남의 방에 보낸 경우도 같은 형태로 내려준다") + void handleBusinessException_carriesGeneralErrorCode() { + ApiResponse response = + handler.handleBusinessException(new BusinessException(GeneralErrorCode.FORBIDDEN)); + + assertThat(response.getCode()).isEqualTo(GeneralErrorCode.FORBIDDEN.getCode()); + assertThat(response.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value()); + } +} diff --git a/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java b/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java index 98eae72..04307f8 100644 --- a/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java +++ b/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java @@ -78,4 +78,35 @@ void sendMessage_publishesAfterProcessingMessage() { inOrder.verify(chatService).processMessage(securedRequest); inOrder.verify(redisPublisher).publish(any(ChannelTopic.class), eq(response)); } + + @Test + @DisplayName("세션에 nickname이 없어도 메시지를 정상 처리하고 발행한다") + void sendMessage_worksWhenSessionHasNoNickname() { + // given - 프로필 완성 전에 발급된 토큰이면 게이트웨이가 X-Member-Nickname 을 안 보낸다 + ChatMessageRequest request = new ChatMessageRequest(ROOM_ID, null, null, "hello", MessageType.TALK); + ChatMessageRequest securedRequest = + new ChatMessageRequest(ROOM_ID, MEMBER_ID, "", "hello", MessageType.TALK); + ChatMessageResponse response = new ChatMessageResponse( + "message-1", + ROOM_ID, + MEMBER_ID, + "hello", + MessageType.TALK, + LocalDateTime.of(2026, 5, 18, 0, 0), + 1 + ); + SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(); + headerAccessor.setSessionAttributes(new HashMap<>(Map.of("memberId", MEMBER_ID))); + + given(chatService.processMessage(securedRequest)).willReturn(response); + given(redisPublisher.publish(any(ChannelTopic.class), eq(response))).willReturn(1L); + + // when + chatController.sendMessage(request, headerAccessor); + + // then - 닉네임은 알림 미리보기용이라 없으면 빈 값으로 두고 전송은 살린다 + then(chatService).should().processMessage(securedRequest); + then(redisPublisher).should().publish(any(ChannelTopic.class), eq(response)); + } + } From 34ebc188ae410e610872ee6a23a95a226faedb61 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 06:39:15 +0900 Subject: [PATCH 21/66] =?UTF-8?q?feat(chat):=20=EB=B0=A9=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=20=EC=9E=88=EB=8A=94=20=EC=83=81=EB=8C=80?= =?UTF-8?q?=EC=9D=98=20=EC=9D=BD=EC=9D=8C=EC=9D=84=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EB=8C=80=EC=8B=A0=20=EC=B2=98=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 읽음 갱신이 클라이언트의 READ 전송 한 번(방 입장 시)에만 의존해서, 방을 켜둔 채 받은 메시지의 "1" 은 상대가 방을 나갔다 다시 들어올 때까지 절대 사라지지 않았다. ChatRoomPresenceTracker 가 이 인스턴스에 붙은 세션의 방 구독을 추적하고, Redis 로 메시지를 뿌리는 시점에 수신자가 그 방을 보고 있으면 서버가 읽음 처리 후 READ 를 발행한다. READ 는 수신자 세션을 쥔 인스턴스가 Redis 로 발행하므로 발신자가 다른 인스턴스에 붙어 있어도 전파되고, READ 메시지 자체에는 반응하지 않아 발행이 꼬리를 물지 않는다. WebSocket 이 없는 상태를 위한 REST 폴백 (POST /api/chat/rooms/{roomId}/read)도 연다. Co-Authored-By: Claude Fable 5 --- .../presence/ChatRoomPresenceTracker.java | 85 +++++++++ .../domain/service/redis/RedisSubscriber.java | 36 ++++ .../chat/infra/controller/ChatController.java | 12 ++ .../presence/ChatRoomPresenceTrackerTest.java | 122 ++++++++++++ .../service/redis/RedisSubscriberTest.java | 177 ++++++++++++++++++ .../infra/controller/ChatControllerTest.java | 26 +++ 6 files changed, 458 insertions(+) create mode 100644 chat-service/src/main/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTracker.java create mode 100644 chat-service/src/test/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTrackerTest.java create mode 100644 chat-service/src/test/java/com/comatching/chat/domain/service/redis/RedisSubscriberTest.java diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTracker.java b/chat-service/src/main/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTracker.java new file mode 100644 index 0000000..6b758a9 --- /dev/null +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTracker.java @@ -0,0 +1,85 @@ +package com.comatching.chat.domain.service.presence; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +/** + * 이 인스턴스에 붙어 있는 WebSocket 세션이 어느 채팅방 토픽을 구독 중인지 기억한다. + * + * 읽음 처리는 원래 클라이언트가 방 입장 때 보내는 READ 한 번에만 의존했다. + * 방을 켜둔 채로 받은 메시지는 아무도 읽음 처리하지 않아 "1" 이 영원히 남았다. + * 이 추적기가 있으면 메시지를 뿌리는 시점에 수신자가 그 방을 보고 있는지 판단해 + * 서버가 대신 읽음 처리할 수 있다. + * + * 인스턴스별 메모리라는 점이 핵심이다. 메시지는 Redis 를 거쳐 모든 인스턴스에 + * 도착하므로, 각 인스턴스는 자기에게 붙은 세션만 책임지면 수평 확장에도 맞다. + */ +@Component +public class ChatRoomPresenceTracker { + + private static final String CHAT_ROOM_TOPIC_PREFIX = "/topic/chat.room."; + + private record RoomSubscription(String roomId, Long memberId) { + } + + // sessionId -> (subscriptionId -> 구독 정보). 구독 해지 이벤트에는 destination 이 + // 없고 subscriptionId 만 오므로 이 형태로 들고 있어야 지울 수 있다. + private final Map> subscriptionsBySession = new ConcurrentHashMap<>(); + + @EventListener + public void onSubscribe(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + + String destination = accessor.getDestination(); + if (destination == null || !destination.startsWith(CHAT_ROOM_TOPIC_PREFIX)) { + return; + } + + Map sessionAttributes = accessor.getSessionAttributes(); + Object memberId = (sessionAttributes == null) ? null : sessionAttributes.get("memberId"); + String sessionId = accessor.getSessionId(); + String subscriptionId = accessor.getSubscriptionId(); + if (!(memberId instanceof Long) || sessionId == null || subscriptionId == null) { + return; + } + + String roomId = destination.substring(CHAT_ROOM_TOPIC_PREFIX.length()); + subscriptionsBySession + .computeIfAbsent(sessionId, key -> new ConcurrentHashMap<>()) + .put(subscriptionId, new RoomSubscription(roomId, (Long)memberId)); + } + + @EventListener + public void onUnsubscribe(SessionUnsubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = accessor.getSessionId(); + String subscriptionId = accessor.getSubscriptionId(); + if (sessionId == null || subscriptionId == null) { + return; + } + + Map sessionSubscriptions = subscriptionsBySession.get(sessionId); + if (sessionSubscriptions != null) { + sessionSubscriptions.remove(subscriptionId); + } + } + + @EventListener + public void onDisconnect(SessionDisconnectEvent event) { + subscriptionsBySession.remove(event.getSessionId()); + } + + public boolean isViewing(String roomId, Long memberId) { + return subscriptionsBySession.values().stream() + .flatMap(sessionSubscriptions -> sessionSubscriptions.values().stream()) + .anyMatch(subscription -> + subscription.roomId().equals(roomId) && subscription.memberId().equals(memberId)); + } +} diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/redis/RedisSubscriber.java b/chat-service/src/main/java/com/comatching/chat/domain/service/redis/RedisSubscriber.java index 1d866ca..7ab2c6e 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/redis/RedisSubscriber.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/redis/RedisSubscriber.java @@ -3,13 +3,17 @@ import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.connection.MessageListener; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; import org.springframework.messaging.simp.SimpMessageSendingOperations; import org.springframework.stereotype.Service; import com.comatching.chat.domain.dto.ChatMessageResponse; import com.comatching.chat.domain.entity.ChatRoom; import com.comatching.chat.domain.repository.ChatRoomRepository; +import com.comatching.chat.domain.enums.MessageType; import com.comatching.chat.domain.service.block.BlockService; +import com.comatching.chat.domain.service.chat.ChatService; +import com.comatching.chat.domain.service.presence.ChatRoomPresenceTracker; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -25,6 +29,9 @@ public class RedisSubscriber implements MessageListener { private final SimpMessageSendingOperations messagingTemplate; private final ChatRoomRepository chatRoomRepository; private final BlockService blockService; + private final ChatRoomPresenceTracker presenceTracker; + private final ChatService chatService; + private final RedisPublisher redisPublisher; @Override public void onMessage(Message message, byte[] pattern) { @@ -49,11 +56,40 @@ public void onMessage(Message message, byte[] pattern) { messagingTemplate.convertAndSend("/topic/chat.room." + roomMessage.roomId(), roomMessage); + autoReadIfReceiverIsViewing(roomMessage, receiverId); + } catch (Exception e) { log.error("Redis 메시지 전달 오류: {}", e.getMessage()); } } + /** + * 수신자가 지금 이 인스턴스에서 그 방을 보고 있으면 서버가 대신 읽음 처리한다. + * + * 읽음 갱신이 클라이언트의 READ 전송에만 의존하면, 방을 켜둔 채 받은 메시지는 + * 수신자가 방을 나갔다 다시 들어올 때까지 "안 읽음" 으로 남는다. + * + * READ 는 sender 의 인스턴스가 아니라 여기(수신자 세션을 쥔 인스턴스)서 Redis 로 + * 발행한다. 그래야 발신자가 다른 인스턴스에 붙어 있어도 읽음 전파를 받는다. + * READ 메시지 자체에는 반응하지 않으므로 발행이 꼬리를 물지 않는다. + */ + private void autoReadIfReceiverIsViewing(ChatMessageResponse roomMessage, Long receiverId) { + if (roomMessage.type() == MessageType.READ) { + return; + } + if (!presenceTracker.isViewing(roomMessage.roomId(), receiverId)) { + return; + } + + try { + ChatMessageResponse readResponse = chatService.markAsRead(roomMessage.roomId(), receiverId); + redisPublisher.publish(new ChannelTopic("chatroom"), readResponse); + } catch (Exception e) { + // 자동 읽음은 편의 기능이다. 실패해도 메시지 전달을 되돌릴 이유는 없다. + log.warn("자동 읽음 처리 실패 - roomId: {}, receiverId: {}", roomMessage.roomId(), receiverId, e); + } + } + private Long getReceiverId(ChatRoom chatRoom, Long senderId) { return senderId.equals(chatRoom.getTargetUserId()) ? chatRoom.getInitiatorUserId() diff --git a/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java b/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java index b21ccb3..1cc6d8d 100644 --- a/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java +++ b/chat-service/src/main/java/com/comatching/chat/infra/controller/ChatController.java @@ -103,4 +103,16 @@ public ResponseEntity>> getChatMessages( return ResponseEntity.ok(ApiResponse.ok(messages)); } + // 읽음 처리의 REST 경로. WebSocket 이 아직 안 붙었거나 끊긴 채로 방을 열면 + // 히스토리는 HTTP 로 읽히는데 읽음은 기록될 길이 없었다. READ 는 WS 때와 + // 같이 Redis 로 발행해 상대 화면의 "1" 도 같이 지운다. + @PostMapping("/api/chat/rooms/{roomId}/read") + public ResponseEntity> markAsRead( + @PathVariable String roomId, + @CurrentMember MemberInfo memberInfo) { + + ChatMessageResponse readResponse = chatService.markAsRead(roomId, memberInfo.memberId()); + redisPublisher.publish(topic, readResponse); + return ResponseEntity.ok(ApiResponse.ok()); + } } diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTrackerTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTrackerTest.java new file mode 100644 index 0000000..2fc9469 --- /dev/null +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/presence/ChatRoomPresenceTrackerTest.java @@ -0,0 +1,122 @@ +package com.comatching.chat.domain.service.presence; + +import static org.assertj.core.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +class ChatRoomPresenceTrackerTest { + + private static final String ROOM_ID = "room-1"; + private static final Long MEMBER_ID = 1L; + + private ChatRoomPresenceTracker tracker; + + @BeforeEach + void setUp() { + tracker = new ChatRoomPresenceTracker(); + } + + @Test + @DisplayName("채팅방 토픽을 구독하면 그 방을 보고 있는 것으로 기록한다") + void subscribe_marksMemberAsViewing() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isTrue(); + } + + @Test + @DisplayName("구독한 적 없으면 보고 있지 않다") + void isViewing_falseByDefault() { + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + } + + @Test + @DisplayName("채팅방 토픽이 아닌 구독은 무시한다") + void subscribe_ignoresNonChatRoomDestinations() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/user/queue/errors", MEMBER_ID)); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + } + + @Test + @DisplayName("구독을 해지하면 더 이상 보고 있지 않다") + void unsubscribe_removesViewing() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + + tracker.onUnsubscribe(unsubscribeEvent("session-1", "sub-1")); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + } + + @Test + @DisplayName("연결이 끊기면 그 세션의 모든 구독이 사라진다") + void disconnect_removesAllSubscriptionsOfSession() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + tracker.onSubscribe(subscribeEvent("session-1", "sub-2", "/topic/chat.room.room-2", MEMBER_ID)); + + tracker.onDisconnect(disconnectEvent("session-1")); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + assertThat(tracker.isViewing("room-2", MEMBER_ID)).isFalse(); + } + + @Test + @DisplayName("같은 방을 두 세션으로 보다가 한 세션만 끊기면 여전히 보고 있다") + void disconnect_keepsViewingWhileAnotherSessionRemains() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + tracker.onSubscribe(subscribeEvent("session-2", "sub-1", "/topic/chat.room." + ROOM_ID, MEMBER_ID)); + + tracker.onDisconnect(disconnectEvent("session-1")); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isTrue(); + } + + @Test + @DisplayName("memberId 없는 구독은 기록하지 않는다") + void subscribe_ignoresSessionWithoutMemberId() { + tracker.onSubscribe(subscribeEvent("session-1", "sub-1", "/topic/chat.room." + ROOM_ID, null)); + + assertThat(tracker.isViewing(ROOM_ID, MEMBER_ID)).isFalse(); + } + + private SessionSubscribeEvent subscribeEvent(String sessionId, String subId, String destination, Long memberId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setSessionId(sessionId); + accessor.setSubscriptionId(subId); + accessor.setDestination(destination); + Map attributes = new HashMap<>(); + if (memberId != null) { + attributes.put("memberId", memberId); + } + accessor.setSessionAttributes(attributes); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionSubscribeEvent(this, message); + } + + private SessionUnsubscribeEvent unsubscribeEvent(String sessionId, String subId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.UNSUBSCRIBE); + accessor.setSessionId(sessionId); + accessor.setSubscriptionId(subId); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionUnsubscribeEvent(this, message); + } + + private SessionDisconnectEvent disconnectEvent(String sessionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.DISCONNECT); + accessor.setSessionId(sessionId); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionDisconnectEvent(this, message, sessionId, null); + } +} diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/redis/RedisSubscriberTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/redis/RedisSubscriberTest.java new file mode 100644 index 0000000..375338c --- /dev/null +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/redis/RedisSubscriberTest.java @@ -0,0 +1,177 @@ +package com.comatching.chat.domain.service.redis; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.DefaultMessage; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.chat.domain.dto.ChatMessageResponse; +import com.comatching.chat.domain.entity.ChatRoom; +import com.comatching.chat.domain.enums.MessageType; +import com.comatching.chat.domain.repository.ChatRoomRepository; +import com.comatching.chat.domain.service.block.BlockService; +import com.comatching.chat.domain.service.chat.ChatService; +import com.comatching.chat.domain.service.presence.ChatRoomPresenceTracker; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@ExtendWith(MockitoExtension.class) +class RedisSubscriberTest { + + private static final String ROOM_ID = "room-1"; + private static final Long INITIATOR_ID = 1L; + private static final Long TARGET_ID = 2L; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private SimpMessageSendingOperations messagingTemplate; + + @Mock + private ChatRoomRepository chatRoomRepository; + + @Mock + private BlockService blockService; + + @Mock + private ChatRoomPresenceTracker presenceTracker; + + @Mock + private ChatService chatService; + + @Mock + private RedisPublisher redisPublisher; + + private RedisSubscriber redisSubscriber; + + private final ObjectMapper objectMapper = createObjectMapper(); + + @BeforeEach + void setUp() { + redisSubscriber = new RedisSubscriber( + objectMapper, + redisTemplate, + messagingTemplate, + chatRoomRepository, + blockService, + presenceTracker, + chatService, + redisPublisher + ); + given(redisTemplate.getStringSerializer()).willReturn(new StringRedisSerializer()); + } + + @Test + @DisplayName("수신자가 방을 보고 있으면 브로드캐스트 후 서버가 대신 읽음 처리하고 READ를 발행한다") + void onMessage_autoReadsWhenReceiverIsViewingRoom() throws Exception { + ChatRoom room = chatRoom(); + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(false); + given(presenceTracker.isViewing(ROOM_ID, TARGET_ID)).willReturn(true); + + ChatMessageResponse readResponse = readResponse(TARGET_ID); + given(chatService.markAsRead(ROOM_ID, TARGET_ID)).willReturn(readResponse); + + redisSubscriber.onMessage(redisMessage(talkResponse(INITIATOR_ID)), null); + + then(messagingTemplate).should().convertAndSend(eq("/topic/chat.room." + ROOM_ID), any(ChatMessageResponse.class)); + then(chatService).should().markAsRead(ROOM_ID, TARGET_ID); + then(redisPublisher).should().publish(any(ChannelTopic.class), eq(readResponse)); + } + + @Test + @DisplayName("수신자가 방을 보고 있지 않으면 읽음 처리하지 않는다") + void onMessage_skipsAutoReadWhenReceiverIsNotViewing() throws Exception { + ChatRoom room = chatRoom(); + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(false); + given(presenceTracker.isViewing(ROOM_ID, TARGET_ID)).willReturn(false); + + redisSubscriber.onMessage(redisMessage(talkResponse(INITIATOR_ID)), null); + + then(messagingTemplate).should().convertAndSend(eq("/topic/chat.room." + ROOM_ID), any(ChatMessageResponse.class)); + then(chatService).shouldHaveNoInteractions(); + then(redisPublisher).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("READ 메시지에는 다시 읽음 처리하지 않는다 - 무한 발행을 막는다") + void onMessage_neverAutoReadsReadMessages() throws Exception { + ChatRoom room = chatRoom(); + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(false); + + redisSubscriber.onMessage(redisMessage(readResponse(INITIATOR_ID)), null); + + then(messagingTemplate).should().convertAndSend(eq("/topic/chat.room." + ROOM_ID), any(ChatMessageResponse.class)); + then(chatService).shouldHaveNoInteractions(); + then(redisPublisher).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("수신자가 발신자를 차단했으면 전달도 읽음 처리도 하지 않는다") + void onMessage_skipsEverythingWhenBlocked() throws Exception { + ChatRoom room = chatRoom(); + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(true); + + redisSubscriber.onMessage(redisMessage(talkResponse(INITIATOR_ID)), null); + + then(messagingTemplate).shouldHaveNoInteractions(); + then(chatService).shouldHaveNoInteractions(); + then(redisPublisher).shouldHaveNoInteractions(); + } + + private ChatRoom chatRoom() { + ChatRoom room = ChatRoom.builder() + .matchingId(100L) + .initiatorUserId(INITIATOR_ID) + .targetUserId(TARGET_ID) + .build(); + ReflectionTestUtils.setField(room, "id", ROOM_ID); + return room; + } + + private ChatMessageResponse talkResponse(Long senderId) { + return new ChatMessageResponse( + "message-1", ROOM_ID, senderId, "hello", MessageType.TALK, + LocalDateTime.of(2026, 8, 26, 10, 0), 1 + ); + } + + private ChatMessageResponse readResponse(Long senderId) { + return new ChatMessageResponse( + null, ROOM_ID, senderId, null, MessageType.READ, + LocalDateTime.of(2026, 8, 26, 10, 1), 0 + ); + } + + private DefaultMessage redisMessage(ChatMessageResponse response) throws Exception { + byte[] body = objectMapper.writeValueAsBytes(response); + return new DefaultMessage("chatroom".getBytes(StandardCharsets.UTF_8), body); + } + + private static ObjectMapper createObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} diff --git a/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java b/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java index 04307f8..a02167f 100644 --- a/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java +++ b/chat-service/src/test/java/com/comatching/chat/infra/controller/ChatControllerTest.java @@ -23,6 +23,7 @@ import com.comatching.chat.domain.service.chat.ChatService; import com.comatching.chat.domain.service.chatroom.ChatRoomService; import com.comatching.chat.domain.service.redis.RedisPublisher; +import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.service.S3Service; @ExtendWith(MockitoExtension.class) @@ -109,4 +110,29 @@ void sendMessage_worksWhenSessionHasNoNickname() { then(redisPublisher).should().publish(any(ChannelTopic.class), eq(response)); } + @Test + @DisplayName("REST로도 읽음 처리할 수 있고 READ 이벤트가 상대에게 전파된다") + void markAsRead_viaRestPublishesReadEvent() { + // given - WebSocket 이 아직 안 붙었거나 끊긴 상태에서 방을 연 경우 + ChatMessageResponse readResponse = new ChatMessageResponse( + null, + ROOM_ID, + MEMBER_ID, + null, + MessageType.READ, + LocalDateTime.of(2026, 8, 26, 10, 0), + 0 + ); + MemberInfo memberInfo = new MemberInfo(MEMBER_ID, "user@test.com", "ROLE_USER"); + + given(chatService.markAsRead(ROOM_ID, MEMBER_ID)).willReturn(readResponse); + given(redisPublisher.publish(any(ChannelTopic.class), eq(readResponse))).willReturn(1L); + + // when + chatController.markAsRead(ROOM_ID, memberInfo); + + // then + then(chatService).should().markAsRead(ROOM_ID, MEMBER_ID); + then(redisPublisher).should().publish(any(ChannelTopic.class), eq(readResponse)); + } } From fb1522711e61b8b14d3df1c44b6e92dfb380dbc2 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 06:39:26 +0900 Subject: [PATCH 22/66] =?UTF-8?q?fix(chat):=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=9D=98=20=EC=B2=AB=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EC=97=90=EB=8F=84=20=EB=B0=A9=EC=9D=84=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 방 활성화(WAITING -> ACTIVE)는 요약 갱신이 겸하고 있었는데, 차단된 상대에게 보낸 메시지는 요약 갱신을 통째로 건너뛰어 방이 영구 WAITING 으로 남았다. WAITING 방은 target 목록에 보이지 않으므로 차단을 풀어도 방이 영영 나타나지 않는다. 활성화를 요약과 독립적인 원자 업데이트(activateRoom)로 떼어내 차단 경로에서도 수행한다. 요약과 알림은 차단 수신자에게 새 메시지 힌트를 주지 않도록 여전히 건너뛴다. Co-Authored-By: Claude Fable 5 --- .../repository/ChatRoomRepositoryCustom.java | 2 ++ .../repository/ChatRoomRepositoryImpl.java | 14 +++++++++++ .../domain/service/chat/ChatServiceImpl.java | 5 ++++ .../ChatRoomRepositoryImplTest.java | 23 +++++++++++++++++++ .../service/chat/ChatServiceImplTest.java | 22 ++++++++++++++++++ 5 files changed, 66 insertions(+) diff --git a/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryCustom.java b/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryCustom.java index d61b95e..dae26c4 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryCustom.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryCustom.java @@ -7,4 +7,6 @@ public interface ChatRoomRepositoryCustom { boolean touchLastReadAt(String roomId, Long memberId, LocalDateTime readAt); boolean updateLastMessageIfLatest(String roomId, String previewContent, LocalDateTime sentAt); + + boolean activateRoom(String roomId); } diff --git a/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImpl.java b/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImpl.java index 867d6ff..322de46 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImpl.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImpl.java @@ -62,4 +62,18 @@ public boolean updateLastMessageIfLatest(String roomId, String previewContent, L UpdateResult result = mongoTemplate.updateFirst(query, update, ChatRoom.class); return result.getMatchedCount() > 0; } + + // 활성화만 따로 떼어낸 이유: 요약 갱신(updateLastMessageIfLatest)이 활성화를 + // 겸하는데, 차단된 상대에게 보낸 메시지는 요약을 갱신하지 않아 방이 영구 + // WAITING 으로 남았다. 차단이 풀린 뒤에도 target 에게 방이 보이려면 + // 활성화는 요약과 독립적으로 일어나야 한다. + @Override + public boolean activateRoom(String roomId) { + UpdateResult result = mongoTemplate.updateFirst( + Query.query(Criteria.where("_id").is(roomId).and("status").is(ChatRoomStatus.WAITING)), + new Update().set("status", ChatRoomStatus.ACTIVE), + ChatRoom.class + ); + return result.getModifiedCount() > 0; + } } diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/chat/ChatServiceImpl.java b/chat-service/src/main/java/com/comatching/chat/domain/service/chat/ChatServiceImpl.java index 913c71a..8fa7b48 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/chat/ChatServiceImpl.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/chat/ChatServiceImpl.java @@ -92,6 +92,11 @@ public ChatMessageResponse processMessage(ChatMessageRequest request) { if (!isBlockedByReceiver) { updateChatRoomInfo(chatRoom, request, savedMessage.getId(), sentAt); publishNotificationEvent(chatRoom, request, sentAt); + } else { + // 요약·알림은 차단 수신자에게 새 메시지 힌트를 주지 않으려 스킵하지만, + // 활성화까지 스킵하면 방이 영구 WAITING 으로 남아 차단을 풀어도 + // target 목록에 방이 나타나지 않는다. + chatRoomRepository.activateRoom(chatRoom.getId()); } return ChatMessageResponse.from(savedMessage, 1); diff --git a/chat-service/src/test/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImplTest.java b/chat-service/src/test/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImplTest.java index c68cb22..e4fe5bd 100644 --- a/chat-service/src/test/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImplTest.java +++ b/chat-service/src/test/java/com/comatching/chat/domain/repository/ChatRoomRepositoryImplTest.java @@ -115,4 +115,27 @@ void updateLastMessageIfLatest_usesConditionalAtomicUpdate() { assertThat(update.get("$set", Document.class)).containsEntry("status", ChatRoomStatus.ACTIVE); assertThat(update.get("$max", Document.class)).containsEntry("updatedAt", sentAt); } + + @Test + @DisplayName("activateRoom은 WAITING인 방만 ACTIVE로 바꾼다") + void activateRoom_flipsOnlyWaitingRoom() { + // given + given(mongoTemplate.updateFirst(any(Query.class), any(Update.class), eq(ChatRoom.class))) + .willReturn(UpdateResult.acknowledged(1, 1L, null)); + + // when + boolean result = repository.activateRoom(ROOM_ID); + + // then + assertThat(result).isTrue(); + ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(Query.class); + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Update.class); + then(mongoTemplate).should().updateFirst(queryCaptor.capture(), updateCaptor.capture(), eq(ChatRoom.class)); + + Document query = queryCaptor.getValue().getQueryObject(); + Document update = updateCaptor.getValue().getUpdateObject(); + assertThat(query).containsEntry("_id", ROOM_ID); + assertThat(query).containsEntry("status", ChatRoomStatus.WAITING); + assertThat(update.get("$set", Document.class)).containsEntry("status", ChatRoomStatus.ACTIVE); + } } diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/chat/ChatServiceImplTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/chat/ChatServiceImplTest.java index d484aa8..317c85f 100644 --- a/chat-service/src/test/java/com/comatching/chat/domain/service/chat/ChatServiceImplTest.java +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/chat/ChatServiceImplTest.java @@ -152,6 +152,28 @@ void processMessage_skipsRoomSummaryWhenSenderIsBlockedByReceiver() { then(chatEventProducer).shouldHaveNoInteractions(); } + @Test + @DisplayName("차단된 상대에게 보낸 첫 메시지도 방은 활성화한다 - 차단 해제 후 방이 보여야 한다") + void processMessage_activatesRoomEvenWhenSenderIsBlockedByReceiver() { + // given - 요약 갱신(updateLastMessageIfLatest)이 활성화를 겸하는데 차단 시 + // 그 경로가 통째로 스킵되어 방이 영구 WAITING 으로 남는 구멍이 있었다 + LocalDateTime createdAt = LocalDateTime.of(2026, 5, 16, 10, 6); + ChatRoom room = chatRoom(ROOM_ID, INITIATOR_ID, TARGET_ID); + ChatMessage savedMessage = savedMessage("message-3", INITIATOR_ID, "blocked", MessageType.TALK, createdAt); + + given(chatRoomRepository.findById(ROOM_ID)).willReturn(Optional.of(room)); + given(blockService.isBlocked(TARGET_ID, INITIATOR_ID)).willReturn(true); + given(chatMessageRepository.save(any(ChatMessage.class))).willReturn(savedMessage); + + // when + chatService.processMessage(talkRequest(INITIATOR_ID, "sender", "blocked")); + + // then - 활성화만 하고 요약과 알림은 여전히 스킵한다 + then(chatRoomRepository).should().activateRoom(ROOM_ID); + then(chatRoomRepository).should(never()).updateLastMessageIfLatest(anyString(), anyString(), any()); + then(chatEventProducer).shouldHaveNoInteractions(); + } + @Test @DisplayName("READ 메시지는 메시지를 저장하지 않고 발신자의 readAt만 atomic update 한다") void processMessage_readOnlyTouchesLastReadAt() { From 2bb260c8a1d7e27940b9ad44e35d86456c007308 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 06:39:39 +0900 Subject: [PATCH 23/66] =?UTF-8?q?feat(chat):=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=97=86=EB=8A=94=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=EC=9D=84=20=EA=B7=B8=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A7=8C=EB=93=A0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 방 생성은 매칭 성공 Kafka 이벤트 하나에만 의존했다. 매칭 API 는 방이 생기기 전에 응답을 돌려주므로 직후 조회에는 방 ID 가 없고, 발행이 유실되면 그 매칭은 영구히 방이 없었다. 매칭 이력 조회가 chat-service 의 ensure 경로를 타게 한다. 방이 없는 매칭은 이력이 아는 참여자 정보로 그 자리에서 만들어지므로, 시간차든 유실이든 사용자가 이력을 여는 순간 복구된다. Kafka 컨슈머와의 동시 생성은 matchingId unique 인덱스가 막고, 충돌하면 먼저 만들어진 방을 다시 읽어 돌려준다. Co-Authored-By: Claude Fable 5 --- .../service/chatroom/ChatRoomService.java | 3 + .../service/chatroom/ChatRoomServiceImpl.java | 53 ++++++++++++++ .../InternalChatRoomController.java | 6 ++ .../chatroom/ChatRoomServiceImplTest.java | 71 +++++++++++++++++++ .../dto/chat/ChatRoomEnsureRequest.java | 16 +++++ .../service/MatchingHistoryServiceImpl.java | 19 +++-- .../matching/infra/client/ChatRoomClient.java | 6 ++ .../MatchingHistoryServiceImplTest.java | 5 +- 8 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 common-module/src/main/java/com/comatching/common/dto/chat/ChatRoomEnsureRequest.java diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomService.java b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomService.java index f9987ac..014450f 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomService.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomService.java @@ -3,6 +3,7 @@ import java.util.List; import com.comatching.chat.domain.dto.ChatRoomResponse; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.event.matching.MatchingSuccessEvent; @@ -17,4 +18,6 @@ public interface ChatRoomService { void validateRoomMember(String roomId, Long memberId); List getChatRoomReferencesByMatchingIds(List matchingIds); + + List ensureChatRooms(List requests); } diff --git a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java index 181b5dd..19a61a6 100644 --- a/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java +++ b/chat-service/src/main/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImpl.java @@ -7,6 +7,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +import org.springframework.dao.DuplicateKeyException; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,6 +22,7 @@ import com.comatching.chat.domain.service.block.BlockService; import com.comatching.chat.global.exception.ChatErrorCode; import com.comatching.chat.infra.client.MemberClient; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.event.matching.MatchingSuccessEvent; import com.comatching.common.dto.member.ProfileResponse; @@ -136,6 +138,57 @@ public List getChatRoomReferencesByMatchingIds(List ensureChatRooms(List requests) { + if (requests == null || requests.isEmpty()) { + return List.of(); + } + + List matchingIds = requests.stream() + .map(ChatRoomEnsureRequest::matchingId) + .toList(); + + Map roomsByMatchingId = chatRoomRepository.findByMatchingIdIn(matchingIds).stream() + .collect(Collectors.toMap(ChatRoom::getMatchingId, Function.identity(), (left, right) -> left)); + + return requests.stream() + .map(request -> { + ChatRoom room = roomsByMatchingId.get(request.matchingId()); + if (room == null) { + room = createMissingRoom(request); + } + return new ChatRoomReferenceResponse(request.matchingId(), room.getId()); + }) + .toList(); + } + + private ChatRoom createMissingRoom(ChatRoomEnsureRequest request) { + ChatRoom newRoom = ChatRoom.builder() + .matchingId(request.matchingId()) + .initiatorUserId(request.initiatorUserId()) + .targetUserId(request.targetUserId()) + .build(); + + try { + ChatRoom saved = chatRoomRepository.save(newRoom); + log.info("Lazily created chat room. matchingId={}, roomId={}", request.matchingId(), saved.getId()); + return saved; + } catch (DuplicateKeyException e) { + return chatRoomRepository.findByMatchingId(request.matchingId()) + .orElseThrow(() -> e); + } + } + private Map getUnreadCountsByRoom(List rooms, Long memberId) { if (rooms.isEmpty()) { return Map.of(); diff --git a/chat-service/src/main/java/com/comatching/chat/infra/controller/InternalChatRoomController.java b/chat-service/src/main/java/com/comatching/chat/infra/controller/InternalChatRoomController.java index fdafbdf..4c6da03 100644 --- a/chat-service/src/main/java/com/comatching/chat/infra/controller/InternalChatRoomController.java +++ b/chat-service/src/main/java/com/comatching/chat/infra/controller/InternalChatRoomController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RestController; import com.comatching.chat.domain.service.chatroom.ChatRoomService; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import lombok.RequiredArgsConstructor; @@ -23,4 +24,9 @@ public class InternalChatRoomController { public List getChatRoomReferences(@RequestBody List matchingIds) { return chatRoomService.getChatRoomReferencesByMatchingIds(matchingIds); } + + @PostMapping("/ensure") + public List ensureChatRooms(@RequestBody List requests) { + return chatRoomService.ensureChatRooms(requests); + } } diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java index 9dbdbd3..c37c7bf 100644 --- a/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java +++ b/chat-service/src/test/java/com/comatching/chat/domain/service/chatroom/ChatRoomServiceImplTest.java @@ -17,6 +17,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; @@ -29,6 +30,8 @@ import com.comatching.chat.domain.service.block.BlockService; import com.comatching.chat.global.exception.ChatErrorCode; import com.comatching.chat.infra.client.MemberClient; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; +import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.member.ProfileResponse; import com.comatching.common.exception.BusinessException; import com.comatching.common.exception.code.GeneralErrorCode; @@ -303,6 +306,74 @@ void getChatRoomReferencesByMatchingIds_returnsRoomIds() { ); } + @Test + @DisplayName("ensure는 방이 없는 매칭만 그 자리에서 만들고 전체 참조를 돌려준다") + void ensureChatRooms_createsMissingRoomsAndReturnsAllReferences() { + // given - 100번 매칭은 방이 있고 101번은 Kafka 유실 등으로 방이 없다 + ChatRoom existingRoom = chatRoom("room-100", 100L, MEMBER_ID, OTHER_MEMBER_ID, LocalDateTime.now()); + given(chatRoomRepository.findByMatchingIdIn(List.of(100L, 101L))) + .willReturn(List.of(existingRoom)); + given(chatRoomRepository.save(any(ChatRoom.class))).willAnswer(invocation -> { + ChatRoom saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", "room-101"); + return saved; + }); + + List requests = List.of( + new ChatRoomEnsureRequest(100L, MEMBER_ID, OTHER_MEMBER_ID), + new ChatRoomEnsureRequest(101L, MEMBER_ID, SECOND_OTHER_MEMBER_ID) + ); + + // when + List result = chatRoomService.ensureChatRooms(requests); + + // then + assertThat(result).extracting("matchingId", "chatRoomId") + .containsExactlyInAnyOrder( + tuple(100L, "room-100"), + tuple(101L, "room-101") + ); + then(chatRoomRepository).should().save(argThat(room -> + room.getMatchingId().equals(101L) + && room.getInitiatorUserId().equals(MEMBER_ID) + && room.getTargetUserId().equals(SECOND_OTHER_MEMBER_ID))); + } + + @Test + @DisplayName("ensure는 방이 전부 있으면 아무것도 만들지 않는다") + void ensureChatRooms_createsNothingWhenAllRoomsExist() { + // given + ChatRoom existingRoom = chatRoom("room-100", 100L, MEMBER_ID, OTHER_MEMBER_ID, LocalDateTime.now()); + given(chatRoomRepository.findByMatchingIdIn(List.of(100L))) + .willReturn(List.of(existingRoom)); + + // when + List result = + chatRoomService.ensureChatRooms(List.of(new ChatRoomEnsureRequest(100L, MEMBER_ID, OTHER_MEMBER_ID))); + + // then + assertThat(result).extracting("chatRoomId").containsExactly("room-100"); + then(chatRoomRepository).should(never()).save(any(ChatRoom.class)); + } + + @Test + @DisplayName("ensure는 동시 생성 충돌이 나면 이미 만들어진 방을 다시 읽어 돌려준다") + void ensureChatRooms_recoversFromDuplicateKeyRace() { + // given - findByMatchingIdIn 과 save 사이에 Kafka 컨슈머가 먼저 방을 만든 경우 + given(chatRoomRepository.findByMatchingIdIn(List.of(100L))).willReturn(List.of()); + given(chatRoomRepository.save(any(ChatRoom.class))) + .willThrow(new DuplicateKeyException("matchingId dup")); + ChatRoom concurrentlyCreated = chatRoom("room-100", 100L, MEMBER_ID, OTHER_MEMBER_ID, LocalDateTime.now()); + given(chatRoomRepository.findByMatchingId(100L)).willReturn(Optional.of(concurrentlyCreated)); + + // when + List result = + chatRoomService.ensureChatRooms(List.of(new ChatRoomEnsureRequest(100L, MEMBER_ID, OTHER_MEMBER_ID))); + + // then + assertThat(result).extracting("chatRoomId").containsExactly("room-100"); + } + private ChatRoom chatRoom(String id, Long matchingId, Long initiatorId, Long targetId, LocalDateTime readAt) { ChatRoom room = ChatRoom.builder() .matchingId(matchingId) diff --git a/common-module/src/main/java/com/comatching/common/dto/chat/ChatRoomEnsureRequest.java b/common-module/src/main/java/com/comatching/common/dto/chat/ChatRoomEnsureRequest.java new file mode 100644 index 0000000..4991195 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/dto/chat/ChatRoomEnsureRequest.java @@ -0,0 +1,16 @@ +package com.comatching.common.dto.chat; + +/** + * 매칭에 대응하는 채팅방이 있는지 확인하고 없으면 만들어 달라는 요청. + * + * 방 생성은 원래 매칭 성공 Kafka 이벤트로만 일어났는데, 발행이 유실되면 + * 그 매칭은 영구히 방이 없었다. 조회 시점에 생성까지 맡기면 유실분이 + * 자동으로 복구된다. 생성에 필요한 참여자 정보는 매칭 이력이 알고 있으므로 + * matching-service 가 채워 보낸다. + */ +public record ChatRoomEnsureRequest( + Long matchingId, + Long initiatorUserId, + Long targetUserId +) { +} diff --git a/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingHistoryServiceImpl.java b/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingHistoryServiceImpl.java index 0553b0b..9e673c3 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingHistoryServiceImpl.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/service/MatchingHistoryServiceImpl.java @@ -3,7 +3,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -11,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.matching.MatchingHistoryReferenceResponse; import com.comatching.common.dto.member.ProfileResponse; @@ -76,18 +76,25 @@ public PagingResponse getMyMatchingHistory( return PagingResponse.from(resultPage); } + // 조회가 곧 보장이다. 방 생성은 매칭 성공 Kafka 이벤트에만 의존했는데, + // 발행이 유실되면 그 매칭은 영구히 방이 없었다. ensure 는 방이 없으면 + // chat-service 가 그 자리에서 만들므로 유실분이 이력 조회 시점에 복구된다. private Map getChatRoomIdMap(Page histories) { - List matchingIds = histories.stream() - .map(MatchingHistory::getId) - .filter(Objects::nonNull) + List ensureRequests = histories.stream() + .filter(history -> history.getId() != null) + .map(history -> new ChatRoomEnsureRequest( + history.getId(), + history.getMemberId(), + history.getPartnerId() + )) .distinct() .toList(); - if (matchingIds.isEmpty()) { + if (ensureRequests.isEmpty()) { return Map.of(); } - List chatRooms = chatRoomClient.getChatRoomReferences(matchingIds); + List chatRooms = chatRoomClient.ensureChatRooms(ensureRequests); if (chatRooms == null || chatRooms.isEmpty()) { return Map.of(); } diff --git a/matching-service/src/main/java/com/comatching/matching/infra/client/ChatRoomClient.java b/matching-service/src/main/java/com/comatching/matching/infra/client/ChatRoomClient.java index f0e4a39..dd0f4dd 100644 --- a/matching-service/src/main/java/com/comatching/matching/infra/client/ChatRoomClient.java +++ b/matching-service/src/main/java/com/comatching/matching/infra/client/ChatRoomClient.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; @FeignClient(name = "chat-service", path = "/api/internal/chat/rooms", url = "${chat-service.url}") @@ -13,4 +14,9 @@ public interface ChatRoomClient { @PostMapping("/references") List getChatRoomReferences(@RequestBody List matchingIds); + + // 방이 없는 매칭은 chat-service 가 그 자리에서 만든다. Kafka 발행이 유실된 + // 매칭도 이력 조회 시점에 복구된다. + @PostMapping("/ensure") + List ensureChatRooms(@RequestBody List requests); } diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingHistoryServiceImplTest.java b/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingHistoryServiceImplTest.java index 795c129..5ce5f81 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingHistoryServiceImplTest.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingHistoryServiceImplTest.java @@ -18,6 +18,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; +import com.comatching.common.dto.chat.ChatRoomEnsureRequest; import com.comatching.common.dto.chat.ChatRoomReferenceResponse; import com.comatching.common.dto.matching.MatchingHistoryReferenceResponse; import com.comatching.common.dto.member.ProfileResponse; @@ -104,7 +105,7 @@ void shouldReturnChatRoomIdInHistory() { given(historyRepository.searchHistory(memberId, null, null, pageable)) .willReturn(new PageImpl<>(List.of(history), pageable, 1)); given(memberClient.getProfiles(List.of(partnerId))).willReturn(List.of(partnerProfile)); - given(chatRoomClient.getChatRoomReferences(List.of(historyId))) + given(chatRoomClient.ensureChatRooms(List.of(new ChatRoomEnsureRequest(historyId, memberId, partnerId)))) .willReturn(List.of(new ChatRoomReferenceResponse(historyId, "room-100"))); // when @@ -112,7 +113,7 @@ void shouldReturnChatRoomIdInHistory() { memberId, null, null, pageable, false ); - // then + // then - 조회가 곧 보장이다: 방이 없던 매칭도 이 호출로 만들어진 방 ID 를 받는다 assertThat(response.content()).hasSize(1); assertThat(response.content().get(0).historyId()).isEqualTo(historyId); assertThat(response.content().get(0).chatRoomId()).isEqualTo("room-100"); From 54a865016eb75b239b7830f578b90fd788d5ce58 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 10:51:27 +0900 Subject: [PATCH 24/66] =?UTF-8?q?ci:=20gemini=20=EB=8C=80=EC=8B=A0=20openc?= =?UTF-8?q?ode=20=EB=A1=9C=20PR=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini Code Assist 가 서비스를 종료해 .gemini/styleguide.md 가 아무 데도 읽히지 않는 파일이 됐다. 리뷰 기준 자체는 그대로 쓸 수 있으므로 벤더 색깔을 뺀 docs/code-review-guidelines.md 로 옮기고, opencode GitHub Action 이 그 파일을 읽어 리뷰하도록 한다. 모델은 그대로 Gemini 를 쓴다 (google/gemini-3.7-flash) - 죽은 것은 Gemini 가 아니라 그 GitHub App 이므로, API 로는 계속 쓸 수 있다. - opencode-review.yml: PR open/synchronize 시 자동 리뷰 - opencode.yml: 코멘트에 /oc 로 수동 호출 GEMINI_API_KEY 시크릿이 있어야 동작한다. --- .github/workflows/opencode-review.yml | 55 +++++++++++++++++++ .github/workflows/opencode.yml | 36 ++++++++++++ .../code-review-guidelines.md | 0 3 files changed, 91 insertions(+) create mode 100644 .github/workflows/opencode-review.yml create mode 100644 .github/workflows/opencode.yml rename .gemini/styleguide.md => docs/code-review-guidelines.md (100%) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml new file mode 100644 index 0000000..4a940a6 --- /dev/null +++ b/.github/workflows/opencode-review.yml @@ -0,0 +1,55 @@ +# PR 이 열리거나 갱신될 때 opencode 가 자동으로 코드 리뷰를 남긴다. +# 리뷰 기준은 docs/code-review-guidelines.md 한 곳에서 관리한다 - 프롬프트에 +# 기준을 복붙해 두면 워크플로와 문서가 따로 놀기 시작한다. +# +# 수동 트리거(PR 코멘트에 /oc)는 opencode.yml 이 따로 담당한다. +name: opencode-review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +# 같은 PR 에 연속으로 푸시하면 이전 리뷰는 취소한다. synchronize 가 푸시마다 +# 발화하므로, 없으면 커밋 3개를 연달아 밀 때 API 요금을 3번 낸다. +concurrency: + group: opencode-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + # 초안 PR 은 아직 볼 준비가 안 된 코드다. + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + # 리뷰 코멘트를 남기려면 read 로는 부족하다. + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + # 리뷰는 base..head 차이를 봐야 하므로 얕은 체크아웃으로는 부족하다. + fetch-depth: 0 + persist-credentials: false + + - uses: anomalyco/opencode/github@latest + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + model: google/gemini-3.7-flash + use_github_token: true + prompt: | + 이 PR 의 변경분(base 브랜치 대비)을 코드 리뷰해라. + + 리뷰 기준은 저장소의 `docs/code-review-guidelines.md` 에 있다. + 먼저 그 파일을 읽고, 거기 적힌 페르소나·언어·관점을 그대로 따라라. + + 추가 지침: + - 변경된 코드만 본다. 기존 코드의 문제는 이번 변경이 건드린 + 경우에만 지적한다. + - 확신이 없으면 단정하지 말고 질문 형태로 남긴다. + - 지적할 게 없으면 억지로 만들지 말고 "특이사항 없음"이라고 답한다. + - 이 저장소는 Spring 기반 MSA 다. 서비스 경계를 넘는 변경 + (common-module, 서비스 간 내부 API, Kafka 이벤트 스키마)은 + 하위 호환성을 특히 주의해서 본다. diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 0000000..7fcf1a8 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,36 @@ +# PR·이슈 코멘트에서 opencode 를 직접 부른다. 자동 리뷰(opencode-review.yml)와 +# 달리 사람이 명시적으로 부를 때만 돈다. +# +# /oc 이 부분 왜 이렇게 짰는지 설명해줘 +# /opencode 이 테스트 깨지는 원인 찾아줘 +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, '/oc') || + contains(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: anomalyco/opencode/github@latest + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + model: google/gemini-3.7-flash + use_github_token: true diff --git a/.gemini/styleguide.md b/docs/code-review-guidelines.md similarity index 100% rename from .gemini/styleguide.md rename to docs/code-review-guidelines.md From 1063a0b7727a09688676b40b70e48e3d3aa3c260 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:02:14 +0900 Subject: [PATCH 25/66] =?UTF-8?q?fix(ci):=20google=20provider=20=EA=B0=80?= =?UTF-8?q?=20=EB=A1=9C=EB=93=9C=EB=90=98=EB=8F=84=EB=A1=9D=20=ED=82=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=84=B8=20=EA=B0=80=EC=A7=80=EB=A5=BC=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=20=EC=B1=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode 는 자격증명이 감지된 provider 만 로드한다. GEMINI_API_KEY 만 넣었더니 google provider 가 켜지지 않아 google/gemini-3.7-flash 를 못 찾았다(에러가 provider 접두사 없는 이름을 제안한 것이 단서였다). --- .github/workflows/opencode-review.yml | 5 +++++ .github/workflows/opencode.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 4a940a6..2025e6c 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -34,7 +34,12 @@ jobs: - uses: anomalyco/opencode/github@latest env: + # opencode 는 자격증명이 감지된 provider 만 로드한다. models.dev 가 + # Google provider 에 등록해 둔 키 이름 세 가지를 모두 채워, 런타임이 + # 어느 이름을 보든 google provider 가 켜지게 한다. GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: google/gemini-3.7-flash diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 7fcf1a8..ca56186 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -29,7 +29,12 @@ jobs: - uses: anomalyco/opencode/github@latest env: + # opencode 는 자격증명이 감지된 provider 만 로드한다. models.dev 가 + # Google provider 에 등록해 둔 키 이름 세 가지를 모두 채워, 런타임이 + # 어느 이름을 보든 google provider 가 켜지게 한다. GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: google/gemini-3.7-flash From e8f099221ede1c31637b0d59bd3b1a94323ad32e Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:03:41 +0900 Subject: [PATCH 26/66] =?UTF-8?q?fix(ci):=20opencode.json=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20google=20provider=20=EB=A5=BC=20=EB=AA=85=EC=8B=9C?= =?UTF-8?q?=20=EC=84=A0=EC=96=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 환경변수만으로는 google provider 가 로드되지 않아 google/gemini-3.7-flash 를 못 찾았다. opencode 는 models.dev 카탈로그 -> opencode.json -> 환경변수 순으로 provider 를 로드하므로, 설정 파일에 박아 두면 확실하다. --- opencode.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 opencode.json diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..032afb7 --- /dev/null +++ b/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "google": { + "options": { + "apiKey": "{env:GEMINI_API_KEY}" + } + } + } +} From 1c79f85e9b9e97867cd6d7c98bef2cbf43b90b69 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:04:47 +0900 Subject: [PATCH 27/66] =?UTF-8?q?fix(ci):=20apiKey=20=EB=AA=85=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=B9=BC=EA=B3=A0=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=9E=90=EB=8F=99=EA=B0=90=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EB=A7=A1=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {env:GEMINI_API_KEY} 치환이 빈 값이 되면 빈 apiKey 가 환경변수 자동감지를 덮어써 'unregistered callers' 가 된다. provider 선언만 남기고 키는 env 로 넘긴다. 키가 러너까지 오는지 길이로 확인하는 단계도 임시로 넣는다. --- .github/workflows/opencode-review.yml | 8 ++++++++ opencode.json | 4 +--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 2025e6c..c8dfa59 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -32,6 +32,14 @@ jobs: fetch-depth: 0 persist-credentials: false + # 키가 실제로 러너까지 도달했는지 확인한다. 값은 찍지 않고 길이만 본다. + - name: GEMINI_API_KEY 확인 + env: + K: ${{ secrets.GEMINI_API_KEY }} + run: | + if [ -z "$K" ]; then echo "!! GEMINI_API_KEY 가 비어 있다"; exit 1; fi + echo "GEMINI_API_KEY 길이: ${#K}" + - uses: anomalyco/opencode/github@latest env: # opencode 는 자격증명이 감지된 provider 만 로드한다. models.dev 가 diff --git a/opencode.json b/opencode.json index 032afb7..9239311 100644 --- a/opencode.json +++ b/opencode.json @@ -2,9 +2,7 @@ "$schema": "https://opencode.ai/config.json", "provider": { "google": { - "options": { - "apiKey": "{env:GEMINI_API_KEY}" - } + "options": {} } } } From 07544dd194c5d54dd0b8a4f023e175979031b6d9 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:08:20 +0900 Subject: [PATCH 28/66] =?UTF-8?q?chore:=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=AC=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 692b8390994ee6f554757a21249c5ce2c4d1ddb3 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:10:03 +0900 Subject: [PATCH 29/66] =?UTF-8?q?fix(ci):=203.7-flash=20=EB=8C=80=EC=8B=A0?= =?UTF-8?q?=203.5-flash=20=EB=A5=BC=20=EC=93=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google 이 Gemini 3.6 Flash 이후 모델에서 'model 턴으로 끝나는 요청'을 막았다. opencode 는 툴 호출을 유도할 때 model 턴을 prefill 하므로 3.7 에서 400 이 난다. 제한 이전 모델인 3.5-flash 로 내린다(3.5-flash-lite 는 제한 대상이니 주의). opencode 가 prefill 을 걷어내면 다시 올릴 수 있다. --- .github/workflows/opencode-review.yml | 2 +- .github/workflows/opencode.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index c8dfa59..3a5cfc9 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -50,7 +50,7 @@ jobs: GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: google/gemini-3.7-flash + model: google/gemini-3.5-flash use_github_token: true prompt: | 이 PR 의 변경분(base 브랜치 대비)을 코드 리뷰해라. diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index ca56186..8c28df7 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -37,5 +37,5 @@ jobs: GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: google/gemini-3.7-flash + model: google/gemini-3.5-flash use_github_token: true From 38a624917ca1d0aad97a521447d26c3f3d133d92 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:14:52 +0900 Subject: [PATCH 30/66] =?UTF-8?q?chore(ci):=20=EC=A7=84=EB=8B=A8=EC=9A=A9?= =?UTF-8?q?=20=ED=82=A4=20=ED=99=95=EC=9D=B8=20=EB=8B=A8=EA=B3=84=EB=A5=BC?= =?UTF-8?q?=20=EB=BA=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 역할을 다했다. 원인은 빈 시크릿이었고 값을 채워 해결됐다. 환경변수 주석도 실제 원인(opencode.json 의 provider 선언)에 맞게 고친다. --- .github/workflows/opencode-review.yml | 14 +++----------- .github/workflows/opencode.yml | 6 +++--- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 3a5cfc9..08f7bd1 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -32,19 +32,11 @@ jobs: fetch-depth: 0 persist-credentials: false - # 키가 실제로 러너까지 도달했는지 확인한다. 값은 찍지 않고 길이만 본다. - - name: GEMINI_API_KEY 확인 - env: - K: ${{ secrets.GEMINI_API_KEY }} - run: | - if [ -z "$K" ]; then echo "!! GEMINI_API_KEY 가 비어 있다"; exit 1; fi - echo "GEMINI_API_KEY 길이: ${#K}" - - uses: anomalyco/opencode/github@latest env: - # opencode 는 자격증명이 감지된 provider 만 로드한다. models.dev 가 - # Google provider 에 등록해 둔 키 이름 세 가지를 모두 채워, 런타임이 - # 어느 이름을 보든 google provider 가 켜지게 한다. + # provider 등록 자체는 opencode.json 이 한다. 여기서는 키만 넘기는데, + # models.dev 가 Google provider 에 등록해 둔 이름이 셋이라 어느 + # 쪽을 읽든 걸리도록 모두 채운다. GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 8c28df7..bf4617a 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -29,9 +29,9 @@ jobs: - uses: anomalyco/opencode/github@latest env: - # opencode 는 자격증명이 감지된 provider 만 로드한다. models.dev 가 - # Google provider 에 등록해 둔 키 이름 세 가지를 모두 채워, 런타임이 - # 어느 이름을 보든 google provider 가 켜지게 한다. + # provider 등록 자체는 opencode.json 이 한다. 여기서는 키만 넘기는데, + # models.dev 가 Google provider 에 등록해 둔 이름이 셋이라 어느 + # 쪽을 읽든 걸리도록 모두 채운다. GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} From e9d0e6b63e99b430f24946ee2dd0335c3cf90999 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:31:21 +0900 Subject: [PATCH 31/66] =?UTF-8?q?fix(ci):=20=EB=A6=AC=EB=B7=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=EC=9D=84=20claude-haiku-4-5=20=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EA=BE=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini 무료 티어가 PR 몇 개 만에 한도에 걸린다. Anthropic 라인업에서 가장 싼 claude-haiku-4-5($1/$5 per MTok)로 옮긴다. provider 선언과 키 이름도 같이 바꾼다. ANTHROPIC_API_KEY 시크릿이 필요하다. GEMINI_API_KEY 는 이제 안 쓴다. --- .github/workflows/opencode-review.yml | 10 +++------- .github/workflows/opencode.yml | 10 +++------- opencode.json | 2 +- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 08f7bd1..0428806 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -34,15 +34,11 @@ jobs: - uses: anomalyco/opencode/github@latest env: - # provider 등록 자체는 opencode.json 이 한다. 여기서는 키만 넘기는데, - # models.dev 가 Google provider 에 등록해 둔 이름이 셋이라 어느 - # 쪽을 읽든 걸리도록 모두 채운다. - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} + # provider 등록 자체는 opencode.json 이 한다. 여기서는 키만 넘긴다. + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: google/gemini-3.5-flash + model: anthropic/claude-haiku-4-5 use_github_token: true prompt: | 이 PR 의 변경분(base 브랜치 대비)을 코드 리뷰해라. diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index bf4617a..879a15d 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -29,13 +29,9 @@ jobs: - uses: anomalyco/opencode/github@latest env: - # provider 등록 자체는 opencode.json 이 한다. 여기서는 키만 넘기는데, - # models.dev 가 Google provider 에 등록해 둔 이름이 셋이라 어느 - # 쪽을 읽든 걸리도록 모두 채운다. - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} + # provider 등록 자체는 opencode.json 이 한다. 여기서는 키만 넘긴다. + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: google/gemini-3.5-flash + model: anthropic/claude-haiku-4-5 use_github_token: true diff --git a/opencode.json b/opencode.json index 9239311..5585d63 100644 --- a/opencode.json +++ b/opencode.json @@ -1,7 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "provider": { - "google": { + "anthropic": { "options": {} } } From 5748b62f12c4c39509e5a164c4a30dbfc87d3100 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:38:23 +0900 Subject: [PATCH 32/66] =?UTF-8?q?ci:=20opencode=20=EB=8C=80=EC=8B=A0=20cla?= =?UTF-8?q?ude-code-action=20=EC=9C=BC=EB=A1=9C=20PR=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode 로는 Claude 구독을 못 쓴다 - 2026-03 Anthropic 의 법적 요청으로 번들 OAuth 플러그인이 제거됐고, 문서도 공식 지원이 아니라고 명시한다. Anthropic 공식 액션은 claude_code_oauth_token 으로 구독 인증을 지원하므로 API 크레딧을 따로 쓰지 않아도 된다. - claude-code-review.yml: PR open/synchronize 시 code-review 스킬 자동 실행, --comment 로 인라인 코멘트 게시 - claude.yml: 코멘트에 @claude 로 수동 호출 - CLAUDE.md: 리뷰 기준 문서를 가리키게 한다. 액션이 매 실행마다 읽는다 - opencode 관련 파일 3개 제거 CLAUDE_CODE_OAUTH_TOKEN 시크릿과 Claude GitHub App 설치가 필요하다. --- .github/workflows/claude-code-review.yml | 44 +++++++++++++++++++ .github/workflows/claude.yml | 34 ++++++++++++++ .github/workflows/opencode-review.yml | 56 ------------------------ .github/workflows/opencode.yml | 37 ---------------- opencode.json | 8 ---- 5 files changed, 78 insertions(+), 101 deletions(-) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml delete mode 100644 .github/workflows/opencode-review.yml delete mode 100644 .github/workflows/opencode.yml delete mode 100644 opencode.json diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..e43957b --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +# PR 이 열리거나 갱신될 때 Claude 가 자동으로 코드 리뷰를 남긴다. +# +# 리뷰 기준은 CLAUDE.md 가 가리키는 docs/code-review-guidelines.md 한 곳에서 +# 관리한다 - 프롬프트에 기준을 복붙해 두면 워크플로와 문서가 따로 논다. +# +# 수동 호출(코멘트에 @claude)은 claude.yml 이 담당한다. +name: Code Review + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +# 같은 PR 에 연속으로 푸시하면 이전 리뷰는 취소한다. synchronize 가 푸시마다 +# 발화하므로, 없으면 커밋 3개를 연달아 밀 때 리뷰를 3번 돌린다. +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + # 액션의 기본 GitHub App 인증에 필요하다. + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: anthropics/claude-code-action@v1 + with: + # API 크레딧이 아니라 Claude 구독으로 돌린다. + # 토큰은 `claude setup-token` 으로 만든다. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: "https://github.com/anthropics/claude-code.git" + plugins: "code-review@claude-code-plugins" + prompt: "/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" + # 스킬 frontmatter 에 같은 도구가 적혀 있어도 이 줄은 남겨야 한다. + # 액션은 claude_args 의 --allowedTools 가 이름을 대야만 인라인 + # 코멘트용 MCP 서버를 띄운다. + claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..ccf219e --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,34 @@ +# PR·이슈 코멘트에서 Claude 를 직접 부른다. 자동 리뷰(claude-code-review.yml)와 +# 달리 사람이 명시적으로 부를 때만 돈다. +# +# @claude ChatRoomPresenceTracker 가 동시성 안전한지 봐줘 +# @claude 이 테스트 깨지는 원인 찾아줘 +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + claude: + # 러너가 관계없는 코멘트마다 뜨는 것을 막는다. 액션 자체도 트리거 + # 문구를 다시 확인한다. + if: contains(github.event.comment.body, '@claude') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + # PR 의 CI 결과를 읽게 한다. + actions: read + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml deleted file mode 100644 index 0428806..0000000 --- a/.github/workflows/opencode-review.yml +++ /dev/null @@ -1,56 +0,0 @@ -# PR 이 열리거나 갱신될 때 opencode 가 자동으로 코드 리뷰를 남긴다. -# 리뷰 기준은 docs/code-review-guidelines.md 한 곳에서 관리한다 - 프롬프트에 -# 기준을 복붙해 두면 워크플로와 문서가 따로 놀기 시작한다. -# -# 수동 트리거(PR 코멘트에 /oc)는 opencode.yml 이 따로 담당한다. -name: opencode-review - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -# 같은 PR 에 연속으로 푸시하면 이전 리뷰는 취소한다. synchronize 가 푸시마다 -# 발화하므로, 없으면 커밋 3개를 연달아 밀 때 API 요금을 3번 낸다. -concurrency: - group: opencode-review-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - review: - # 초안 PR 은 아직 볼 준비가 안 된 코드다. - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - # 리뷰 코멘트를 남기려면 read 로는 부족하다. - pull-requests: write - steps: - - uses: actions/checkout@v6 - with: - # 리뷰는 base..head 차이를 봐야 하므로 얕은 체크아웃으로는 부족하다. - fetch-depth: 0 - persist-credentials: false - - - uses: anomalyco/opencode/github@latest - env: - # provider 등록 자체는 opencode.json 이 한다. 여기서는 키만 넘긴다. - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - model: anthropic/claude-haiku-4-5 - use_github_token: true - prompt: | - 이 PR 의 변경분(base 브랜치 대비)을 코드 리뷰해라. - - 리뷰 기준은 저장소의 `docs/code-review-guidelines.md` 에 있다. - 먼저 그 파일을 읽고, 거기 적힌 페르소나·언어·관점을 그대로 따라라. - - 추가 지침: - - 변경된 코드만 본다. 기존 코드의 문제는 이번 변경이 건드린 - 경우에만 지적한다. - - 확신이 없으면 단정하지 말고 질문 형태로 남긴다. - - 지적할 게 없으면 억지로 만들지 말고 "특이사항 없음"이라고 답한다. - - 이 저장소는 Spring 기반 MSA 다. 서비스 경계를 넘는 변경 - (common-module, 서비스 간 내부 API, Kafka 이벤트 스키마)은 - 하위 호환성을 특히 주의해서 본다. diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml deleted file mode 100644 index 879a15d..0000000 --- a/.github/workflows/opencode.yml +++ /dev/null @@ -1,37 +0,0 @@ -# PR·이슈 코멘트에서 opencode 를 직접 부른다. 자동 리뷰(opencode-review.yml)와 -# 달리 사람이 명시적으로 부를 때만 돈다. -# -# /oc 이 부분 왜 이렇게 짰는지 설명해줘 -# /opencode 이 테스트 깨지는 원인 찾아줘 -name: opencode - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - -jobs: - opencode: - if: | - contains(github.event.comment.body, '/oc') || - contains(github.event.comment.body, '/opencode') - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - pull-requests: write - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - uses: anomalyco/opencode/github@latest - env: - # provider 등록 자체는 opencode.json 이 한다. 여기서는 키만 넘긴다. - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - model: anthropic/claude-haiku-4-5 - use_github_token: true diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 5585d63..0000000 --- a/opencode.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "anthropic": { - "options": {} - } - } -} From f77ca67ea757d78bd0671fa52477cb7ed8d72d0c Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:41:09 +0900 Subject: [PATCH 33/66] =?UTF-8?q?chore:=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=AC=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 70632b159edc7ca26143bf415e024c5c42655574 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 11:44:59 +0900 Subject: [PATCH 34/66] =?UTF-8?q?chore:=20=EB=A6=AC=EB=B7=B0=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 28165190ac75a3973854f7b695db8915fa084057 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 12:10:22 +0900 Subject: [PATCH 35/66] =?UTF-8?q?chore:=20CLAUDE.md=20=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EC=A0=81=ED=95=9C=EB=8B=A4=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: CLAUDE.md 를 추적한다 리뷰 기준(docs/code-review-guidelines.md)으로 가는 유일한 연결고리인데 .gitignore 에 걸려 있어 액션이 읽지 못했다. 개인 메모가 아니라 팀이 공유하는 프로젝트 규칙이므로 추적 대상으로 돌린다. * chore(ci): 임시로 전체 출력을 켠다 (원인 확인용) * Revert "chore(ci): 임시로 전체 출력을 켠다 (원인 확인용)" This reverts commit b9a6a8190f513af12902c00c9e43b18570aa619f. * chore: 리뷰 재실행 * chore: 리뷰 재실행 * chore: 리뷰 재실행 * chore(ci): 원인 확인용으로 전체 출력을 임시로 켠다 --- .github/workflows/claude-code-review.yml | 3 +++ .gitignore | 3 ++- CLAUDE.md | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index e43957b..a7084d6 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -42,3 +42,6 @@ jobs: # 액션은 claude_args 의 --allowedTools 가 이름을 대야만 인라인 # 코멘트용 MCP 서버를 띄운다. claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"' + # TODO(임시): 액션이 SDK 출력을 가려서 실패 원인이 로그에 안 남는다. + # 원인 확인용으로만 켠다. 공개 저장소라 확인 즉시 되돌릴 것. + show_full_output: true diff --git a/.gitignore b/.gitignore index 34f69bc..b16d467 100644 --- a/.gitignore +++ b/.gitignore @@ -47,7 +47,8 @@ bin/ .claudeignore .claude .mcp.json -CLAUDE.md +# CLAUDE.md 는 추적한다. 개인 메모가 아니라 팀이 공유하는 프로젝트 규칙이고, +# PR 리뷰 액션이 매 실행마다 읽어 docs/code-review-guidelines.md 로 간다. scripts .idea docker-compose.yml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2006c01 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +# COMAtching Backend + +Spring 기반 MSA. 서비스는 gateway / user / matching / chat / item / notification 6개이며 +`common-module` 을 공유한다. 서비스 간 통신은 내부 REST(`/api/internal/**`)와 Kafka 이벤트. + +## 코드 리뷰 + +리뷰 기준은 [docs/code-review-guidelines.md](docs/code-review-guidelines.md) 에 있다. +코드 리뷰를 할 때는 그 문서를 먼저 읽고 거기 적힌 페르소나·언어·관점을 따른다. +요약하면: + +- **리뷰 코멘트는 한국어로 작성한다.** +- Spring MSA 실무 경험이 있는 Senior Back-end Engineer 관점으로 본다. +- "동작한다"는 이유만으로 승인하지 않고, 미래의 변경에 얼마나 버티는지를 본다. +- 문제를 지적할 때는 "왜 문제인지"와 "어떻게 개선할지"를 함께 쓴다. + +이 저장소 특유의 주의점: + +- **서비스 경계를 넘는 변경은 하위 호환성을 특히 주의해서 본다** — + `common-module` 의 DTO, 서비스 간 내부 API, Kafka 이벤트 스키마는 + 배포 순서가 어긋나는 순간 깨진다. +- 운영 설정(`docker-compose.prod.yml`, `application-aws.yml`)은 EC2 단일 호스트에 + 6개 JVM 이 함께 뜨는 전제로 메모리 한도가 잡혀 있다. 한도를 건드리는 변경은 + 전체 예산과 함께 본다. From 5ba8df6b07bc659221c35ca7d7fba2e7270741e6 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 13:03:22 +0900 Subject: [PATCH 36/66] =?UTF-8?q?ci:=20=EB=94=94=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=EC=9D=84=20=EB=81=84=EA=B3=A0=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EC=97=90=20=ED=84=B4=20=EC=83=81=ED=95=9C=EC=9D=84=20?= =?UTF-8?q?=EC=A4=80=EB=8B=A4=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show_full_output 은 401 원인을 찾는 동안만 필요했다. 공개 저장소라 Claude 출력 전체가 로그에 남으므로 되돌린다. 인증이 풀린 뒤 드러난 다음 문제는 스킬이 서브에이전트를 백그라운드로 띄우고 그 알림을 기다리며 턴을 끝내는 것이다. CI 는 1회 실행이라 그 알림이 올 자리가 없어 리뷰가 시작도 못 하고 끝난다. --max-turns 30 으로 루프 안에서 마치도록 여유를 준다. --- .github/workflows/claude-code-review.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index a7084d6..5d4dcce 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -41,7 +41,7 @@ jobs: # 스킬 frontmatter 에 같은 도구가 적혀 있어도 이 줄은 남겨야 한다. # 액션은 claude_args 의 --allowedTools 가 이름을 대야만 인라인 # 코멘트용 MCP 서버를 띄운다. - claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"' - # TODO(임시): 액션이 SDK 출력을 가려서 실패 원인이 로그에 안 남는다. - # 원인 확인용으로만 켠다. 공개 저장소라 확인 즉시 되돌릴 것. - show_full_output: true + # --max-turns: 리뷰 한 건이 파일 읽기·검색·코멘트로 10~20턴을 쓴다. + # 상한이 낮으면 리뷰를 끝내기 전에 잘리고, 없으면 폭주할 때 멈출 + # 지점이 없다. 실행 1회당 상한이지 하루 총량이 아니다. + claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment" --max-turns 30' From 6d6e42d0f0844155422af948a4e9094fac0c2554 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 14:07:21 +0900 Subject: [PATCH 37/66] =?UTF-8?q?ci:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EA=B0=80=20=EC=A1=B0=EC=9A=A9=ED=9E=88=20=EC=A6=9D?= =?UTF-8?q?=EB=B0=9C=ED=95=98=EB=8D=98=20=EC=9B=90=EC=9D=B8=EC=9D=84=20?= =?UTF-8?q?=EB=A7=89=EB=8A=94=EB=8B=A4=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 워크플로가 매번 초록불인데 코멘트는 한 번도 안 달렸다. 실행 로그를 보면 15초 5턴에 끝나고 permission_denials 에 Skill 이 찍혀 있다. allowedTools 는 화이트리스트라 목록에 Skill 이 없으면 /code-review 스킬 본체가 로드되지 못한다. 거부당한 모델은 프롬프트에 실린 요약만 보고 즉흥적으로 움직였고, 리뷰를 백그라운드 서브에이전트에 넘긴 뒤 "완료 알림을 기다리겠다"며 턴을 끝냈다. Actions 헤드리스 실행에는 그 알림을 받을 루프가 없어 프로세스가 그대로 죽고, 버퍼에 코멘트 0개인 채로 스텝은 exit 0 을 냈다. - allowedTools 에 Skill·Bash·Read·Glob·Grep·Agent 를 추가한다. - 프롬프트에 "이 턴 안에서 끝내라"를 박아 백그라운드 전환을 막는다. - show_full_output 을 잠시 켠다. 또 조용히 죽으면 로그가 없으면 못 잡는다. Co-authored-by: Claude Opus 5 --- .github/workflows/claude-code-review.yml | 29 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 5d4dcce..8e9de68 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -37,11 +37,30 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: "https://github.com/anthropics/claude-code.git" plugins: "code-review@claude-code-plugins" - prompt: "/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" - # 스킬 frontmatter 에 같은 도구가 적혀 있어도 이 줄은 남겨야 한다. - # 액션은 claude_args 의 --allowedTools 가 이름을 대야만 인라인 - # 코멘트용 MCP 서버를 띄운다. + # 두 번째 문단이 없으면 리뷰가 조용히 증발한다. 실제로 겪은 실패: + # 모델이 리뷰를 백그라운드 서브에이전트에 넘기고 "완료 알림을 + # 기다리겠다"며 턴을 끝냈는데, Actions 헤드리스 실행에는 그 알림을 + # 받을 루프가 없어 프로세스가 그대로 죽었다. 코멘트 0개로 초록불. + prompt: | + /code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }} + + 리뷰는 이 턴 안에서 끝낸다. 서브에이전트를 백그라운드로 띄우고 + 완료 알림을 기다리지 마라 - Actions 에는 알림을 받을 루프가 없어 + 프로세스가 그대로 죽는다. + # allowedTools 는 화이트리스트다. 목록에 없는 도구는 permissionMode + # default + 비대화형 조합에서 그대로 거부된다. + # - Skill: 없으면 /code-review 스킬 본체가 로드되지 못한다. 모델은 + # 거부당한 뒤 프롬프트에 실린 요약만 보고 즉흥적으로 움직인다. + # - Bash/Read/Glob/Grep: 스킬이 diff 를 읽고 코드를 뒤지는 데 쓴다. + # - Agent: 스킬이 리뷰어를 병렬로 나눌 때 쓴다. 백그라운드 전환은 + # 위 프롬프트가 막는다. + # - mcp__github_inline_comment__*: 액션은 --allowedTools 가 이름을 + # 대야만 인라인 코멘트용 MCP 서버를 띄운다. 스킬 frontmatter 에 + # 같은 도구가 적혀 있어도 이 줄은 남겨야 한다. # --max-turns: 리뷰 한 건이 파일 읽기·검색·코멘트로 10~20턴을 쓴다. # 상한이 낮으면 리뷰를 끝내기 전에 잘리고, 없으면 폭주할 때 멈출 # 지점이 없다. 실행 1회당 상한이지 하루 총량이 아니다. - claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment" --max-turns 30' + claude_args: '--allowedTools "Skill,Bash,Read,Glob,Grep,Agent,mcp__github_inline_comment__create_inline_comment" --max-turns 30' + # 리뷰가 또 조용히 죽으면 로그에서 원인을 봐야 한다. 이 설정으로 + # 몇 번 정상 동작하는 걸 확인하면 다시 지운다(기본값 false). + show_full_output: true From e8d99f5fd86fe127a3e2607041f4f4b79ebcff1e Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 15:13:19 +0900 Subject: [PATCH 38/66] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=A8=EC=9C=84/=ED=86=B5=ED=95=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=82=98=EB=88=84=EA=B3=A0=20=EA=B0=80=EC=A7=9C=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 테스트를 단위/통합으로 나누고 가짜 성능 테스트를 걷어낸다 PR 테스트가 224초 걸렸다. 그중 167초(74%)가 BlockServiceCachePerformanceTest 한 클래스였다. 이 테스트는 목에 Thread.sleep 을 심어놓고 1000번 돌린 뒤 "캐싱이 빠르다"를 출력한다 - 측정하는 것은 시스템이 아니라 방금 자기가 넣은 sleep 상수이고, assertThat(cacheTime).isLessThan(noCacheTime) 은 산술적으로 항상 참이다. 캐시 히트·미스 동작은 BlockServiceImplTest 가 이미 검증하고 있어 커버리지 손실도 없다. @Disabled 로 멈춰 있던 contextLoads 5개와 MatchingConcurrencyTest 도 지운다. 돌지 않는 테스트는 있으나 마나가 아니라 "스모크 테스트가 있다"는 착각을 만든다. 남은 IO 테스트는 지우지 않는다. 목으로는 볼 수 없는 것 - 재시도 소진 후 DLT 이동, 네이티브 MySQL 쿼리의 실제 결과 - 을 보기 때문이다. 대신 @Tag("integration") 으로 표시하고 Gradle 태스크를 test / integrationTest 로 나눠 CI 에서 두 잡이 동시에 돌게 한다. 전체 대기 시간이 느린 쪽 하나로 수렴한다. 가르는 기준을 클래스 이름(*IT)이 아니라 태그로 잡은 이유는, 이름은 리팩터링에 딸려 바뀌지만 태그는 "이 테스트가 무엇을 필요로 하는가"를 직접 적어둔 것이라 따라다니기 때문이다. 곁들여: - 하위 모듈의 useJUnitPlatform() 중복 호출을 지운다. 다시 부르면 루트에서 건 excludeTags 가 리셋돼 통합 테스트가 조용히 단위 쪽에 섞인다. - gradle.properties 를 추가해 모듈 7개를 병렬로 돌리고 빌드 캐시를 켠다. maxParallelForks 는 넣지 않았다 - 모듈 병렬만으로 러너 코어가 이미 찬다. - chat-service 의 showStandardStreams 를 끈다. 실패했을 때만 본다. - --no-daemon 을 뺀다. 데몬을 끄면 setup-gradle 이 이어주는 캐시를 못 쓴다. Co-Authored-By: Claude Opus 5 * build: gradle.properties 가 운영 호스트 빌드로 새는 것을 막는다 gradle.properties 를 추가하면서 그 값이 CI 러너와 개발 PC 에만 닿는다고 봤는데, 그렇지 않았다. Dockerfile 의 빌드 스테이지는 COPY . . 로 컨텍스트를 통째로 받아 루트에서 ./gradlew --no-daemon bootJar 를 돌린다. .dockerignore 에는 .gradle(래퍼 캐시 디렉터리)만 있고 gradle.properties 는 없어서, 병렬 실행과 힙 2g 가 그대로 따라 들어갔다. 이 스테이지가 도는 곳이 문제다. CI 배포는 Dockerfile.ci 로 미리 만든 jar 만 복사하니 Gradle 을 돌리지 않는다. 실제로 이 스테이지를 타는 경로는 docker-compose.prod.yml 이 문서화한 두 번째 기동 하나뿐이고 docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build # "CI 없이 급하게 올릴 때" 그건 운영 EC2(t3.large, 8GB) 위에서 돈다. 같은 파일의 mem_limit 합이 7040m(6.88GiB)라 여유가 1GB 남짓인데, 하필 장애 대응으로 그 경로를 타는 시점에 7개 모듈을 병렬로 힙 2g 까지 잡고 빌드하면 이미 떠 있는 서비스 JVM 들과 메모리를 다투게 된다. CLAUDE.md 가 "운영 설정은 EC2 단일 호스트에 6개 JVM 이 함께 뜨는 전제로 메모리 한도가 잡혀 있다. 한도를 건드리는 변경은 전체 예산과 함께 본다"고 못박은 예산이 그것이다. CI 러너 시간을 아끼려던 설정이 예산을 건드리고 있었다. .dockerignore 로 이 파일을 컨텍스트에서 뺀다. 문제 경로만 정확히 겨냥하는 방법이다 - GRADLE_OPTS 로 워크플로에 옮기면 로컬 개발자도 병렬·캐시를 잃는데, 거기서는 이 설정이 멀쩡히 값어치를 한다. 그 경로에서 아껴야 할 것은 빌드 시간이 아니라 메모리다. 확인: - 컨텍스트에서 gradle.properties 만 빠지고 gradle/wrapper/*.properties, settings.gradle, 모듈 7개 build.gradle 은 그대로 들어간다(패턴이 루트에만 걸린다) - 그 상태로 빌드 스테이지가 1분 45초에 BUILD SUCCESSFUL, 서비스 jar 6개 생성 Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .dockerignore | 20 + .github/workflows/deploy.yml | 2 +- .github/workflows/test.yml | 61 ++- build.gradle | 53 ++- chat-service/build.gradle | 7 - .../chat/ChatServiceApplicationTests.java | 15 - .../BlockServiceCachePerformanceTest.java | 411 ------------------ .../common/CommonModuleApplicationTests.java | 15 - .../config/kafka/KafkaDltRedriveIT.java | 2 + .../config/kafka/KafkaRetryAndDltIT.java | 2 + gateway-service/build.gradle | 4 - .../GatewayServiceApplicationTests.java | 15 - gradle.properties | 33 ++ item-service/build.gradle | 4 - .../item/ItemServiceApplicationTests.java | 15 - matching-service/build.gradle | 3 - .../MatchingServiceApplicationTests.java | 15 - .../MatchingCandidateRepositoryIT.java | 2 + .../service/CandidateServiceImplIT.java | 2 + .../service/MatchingConcurrencyTest.java | 106 ----- .../WithdrawnMemberCleanupSchedulerIT.java | 2 + notification/build.gradle | 4 - .../NotificationApplicationTests.java | 15 - .../domain/event/UserEventPublisherIT.java | 2 + 24 files changed, 169 insertions(+), 641 deletions(-) delete mode 100644 chat-service/src/test/java/com/comatching/chat/ChatServiceApplicationTests.java delete mode 100644 chat-service/src/test/java/com/comatching/chat/domain/service/block/BlockServiceCachePerformanceTest.java delete mode 100644 common-module/src/test/java/com/comatching/common/CommonModuleApplicationTests.java delete mode 100644 gateway-service/src/test/java/com/comatching/gateway/GatewayServiceApplicationTests.java create mode 100644 gradle.properties delete mode 100644 item-service/src/test/java/com/comatching/item/ItemServiceApplicationTests.java delete mode 100644 matching-service/src/test/java/com/comatching/matching/MatchingServiceApplicationTests.java delete mode 100644 matching-service/src/test/java/com/comatching/matching/domain/service/MatchingConcurrencyTest.java delete mode 100644 notification/src/test/java/com/comatching/notification/NotificationApplicationTests.java diff --git a/.dockerignore b/.dockerignore index 67a7e02..bcaab02 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,26 @@ .gradle build **/build + +# Gradle 빌드 설정을 이미지 빌드 컨텍스트에서 뺀다. +# +# 이 파일의 값(병렬 실행, 힙 2g)은 CI 러너와 개발 PC 를 상대로 잡은 것이다. +# 그런데 Dockerfile 의 빌드 스테이지는 컨텍스트 루트에서 ./gradlew bootJar 를 +# 돌리므로, 그냥 두면 그 값이 여기까지 따라 들어온다. +# +# 문제는 이 빌드 스테이지를 쓰는 곳이 어디냐다. CI 배포는 Dockerfile.ci 로 +# 미리 만든 jar 만 복사하니 Gradle 을 돌리지 않는다. 이 스테이지가 실제로 +# 도는 경로는 docker-compose.prod.yml 이 문서화한 두 번째 기동 +# ("CI 없이 급하게 올릴 때" up -d --build) 하나뿐이고, 그건 운영 EC2 +# (t3.large, 8GB) 위에서 돈다. compose 의 mem_limit 합이 7040m(6.88GiB)라 +# 여유가 1GB 남짓인데, 하필 장애 대응으로 그 경로를 탈 때 이미 떠 있는 +# 서비스 JVM 들과 메모리를 다투게 된다. +# +# CLAUDE.md 가 "운영 설정은 EC2 단일 호스트에 6개 JVM 이 함께 뜨는 전제로 +# 메모리 한도가 잡혀 있다"고 못박은 예산이 그것이다. 여기서는 Gradle 기본값 +# (보수적인 힙, 순차 실행)으로 빌드하게 둔다 - 이 경로에서 아껴야 할 것은 +# 빌드 시간이 아니라 메모리다. +gradle.properties .idea .vscode .claude diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aded364..b250cca 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -60,7 +60,7 @@ jobs: # bootJar(실행 가능)와 별개로 jar 태스크가 -plain.jar 를 만들 수 있어, # 아래 이미지 잡에서 실행 가능한 쪽을 골라낸다. - name: jar 빌드 - run: ./gradlew bootJar --no-daemon + run: ./gradlew bootJar - name: jar 보관 uses: actions/upload-artifact@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1f32a9..49f2668 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,9 +5,23 @@ # 사라진 게 아니라 시점이 앞당겨진 것이다 - main 에 머지된 뒤 배포를 # 붙잡고 있는 대신, PR 을 보고 있는 동안 백그라운드로 돌아간다. # +# 잡이 둘로 나뉜 이유: +# 단위 테스트 IO 가 붙지 않는다. 도커도 브로커도 필요 없어서 빠르다. +# 고쳐야 할 게 있으면 여기서 가장 먼저 알게 된다. +# 통합 테스트 EmbeddedKafka 로 브로커를, Testcontainers 로 MySQL 을 +# 실제로 띄운다. 느린 대신 목으로는 볼 수 없는 것을 본다 +# (재시도 소진 후 DLT 이동, 네이티브 MySQL 쿼리의 실제 결과). +# +# 둘 사이에 needs 를 걸지 않았다. 동시에 돌아야 전체 대기 시간이 느린 +# 쪽 하나로 수렴한다. 순서를 세우면 두 시간이 더해진다. +# +# 어느 쪽을 나누는지는 @Tag("integration") 이 정한다. 태스크 정의는 +# 루트 build.gradle 에 있다. +# # 이 검증이 실제로 게이트가 되려면 브랜치 보호가 필요하다: # Settings > Branches > main > Require status checks to pass -# 에서 "테스트" 체크를 필수로 지정한다. 없으면 빨간 PR 도 머지된다. +# 에서 "단위 테스트"와 "통합 테스트"를 둘 다 필수로 지정한다. +# 없으면 빨간 PR 도 머지된다. # ============================================================ name: test @@ -22,8 +36,8 @@ concurrency: cancel-in-progress: true jobs: - test: - name: 테스트 + unit: + name: 단위 테스트 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -37,10 +51,10 @@ jobs: - run: chmod +x gradlew - # matching-service 는 Testcontainers(MySQL)를, 카프카 IT 는 - # EmbeddedKafka 를 쓴다. 둘 다 ubuntu-latest 러너에서 돈다. - - name: 테스트 - run: ./gradlew test --no-daemon + # --no-daemon 을 쓰지 않는다. 데몬을 끄면 setup-gradle 이 이어주는 + # 빌드 캐시와 설정 캐시를 제대로 못 쓴다(Gradle 공식 권고사항이기도 하다). + - name: 단위 테스트 + run: ./gradlew test # 실패했을 때만이 아니라 항상 올린다. 통과했지만 오래 걸린 테스트를 # 찾아볼 때도 리포트가 필요하다. @@ -48,6 +62,35 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-reports - path: '*/build/reports/tests/**' + name: test-reports-unit + path: '*/build/reports/tests/test/**' + retention-days: 7 + + integration: + name: 통합 테스트 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v4 + + - run: chmod +x gradlew + + # matching-service 는 Testcontainers(MySQL)를, 카프카 IT 는 + # EmbeddedKafka 를 쓴다. 둘 다 ubuntu-latest 러너에 이미 있는 + # 도커/JVM 으로 돈다 - 별도 서비스 컨테이너 선언이 필요 없다. + - name: 통합 테스트 + run: ./gradlew integrationTest + + - name: 테스트 리포트 보관 + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports-integration + path: '*/build/reports/tests/integrationTest/**' retention-days: 7 diff --git a/build.gradle b/build.gradle index c7b54bd..2813b65 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,56 @@ subprojects { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } - tasks.named('test') { - useJUnitPlatform() + // 테스트를 둘로 나눈다. + // test IO 가 붙지 않는 단위 테스트. PR 게이트가 보는 것은 이쪽이다. + // integrationTest EmbeddedKafka·Testcontainers 처럼 브로커나 DB 를 실제로 띄우는 것. + // + // 가르는 기준은 @Tag("integration") 하나뿐이다. 클래스 이름(*IT)으로 나누지 않는 + // 이유는, 이름은 리팩터링에 딸려 바뀌지만 태그는 "이 테스트가 무엇을 필요로 하는가"를 + // 직접 적어둔 것이라 따라다니기 때문이다. + // + // 주의 - useJUnitPlatform() 을 하위 모듈에서 다시 부르면 여기서 건 excludeTags 가 + // 통째로 리셋된다. 통합 테스트가 조용히 단위 테스트에 섞여 들어오므로 하위 + // build.gradle 에는 이 호출을 두지 않는다. + tasks.named('test', Test) { + useJUnitPlatform { + excludeTags 'integration' + } + + // maxParallelForks 는 일부러 두지 않았다. org.gradle.parallel 이 이미 모듈 + // 7개를 동시에 돌리고 있어서 2~4코어 러너는 그것만으로 찬다. 여기에 포크를 + // 더 쪼개면 (병렬 모듈 × 포크) 만큼 JVM 이 뜨는데, 채울 유휴 코어는 없고 + // 포크마다 기동·클래스로딩 비용만 새로 낸다. 전체가 30초대인 스위트에서는 + // 그 비용이 이득보다 크다. + // + // 나중에 한 모듈에만 테스트가 몰려 다른 모듈이 놀게 되면, 그때 그 모듈의 + // build.gradle 에서만 붙이면 된다. + } + + tasks.register('integrationTest', Test) { + group = 'verification' + description = '브로커·DB 컨테이너가 필요한 통합 테스트를 실행한다.' + + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + + useJUnitPlatform { + includeTags 'integration' + } + + // 통합 테스트가 없는 모듈(gateway, chat, item, notification)에서도 이 태스크는 + // 돈다. 대상이 0개라고 실패하면 CI 가 빨개지므로 끈다. + filter { + failOnNoMatchingTests = false + } + } + + tasks.withType(Test).configureEach { + // 통과한 테스트까지 표준 출력을 흘리면 CI 로그가 수천 줄이 되고, 정작 + // 실패했을 때 스택트레이스를 찾기 어려워진다. 볼 것만 본다. + testLogging { + events 'skipped', 'failed' + exceptionFormat = 'full' + } } } \ No newline at end of file diff --git a/chat-service/build.gradle b/chat-service/build.gradle index 9f7c4ee..001a77b 100644 --- a/chat-service/build.gradle +++ b/chat-service/build.gradle @@ -29,10 +29,3 @@ dependencyManagement { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } - -test { - testLogging { - showStandardStreams = true - events "passed", "skipped", "failed" - } -} \ No newline at end of file diff --git a/chat-service/src/test/java/com/comatching/chat/ChatServiceApplicationTests.java b/chat-service/src/test/java/com/comatching/chat/ChatServiceApplicationTests.java deleted file mode 100644 index e302a53..0000000 --- a/chat-service/src/test/java/com/comatching/chat/ChatServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.chat; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class ChatServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/chat-service/src/test/java/com/comatching/chat/domain/service/block/BlockServiceCachePerformanceTest.java b/chat-service/src/test/java/com/comatching/chat/domain/service/block/BlockServiceCachePerformanceTest.java deleted file mode 100644 index cdd4bcd..0000000 --- a/chat-service/src/test/java/com/comatching/chat/domain/service/block/BlockServiceCachePerformanceTest.java +++ /dev/null @@ -1,411 +0,0 @@ -package com.comatching.chat.domain.service.block; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.LongStream; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; - -import com.comatching.chat.domain.entity.UserBlock; -import com.comatching.chat.domain.repository.UserBlockRepository; - -@ExtendWith(MockitoExtension.class) -@DisplayName("BlockService 캐싱 성능 비교 테스트") -class BlockServiceCachePerformanceTest { - - @Mock - private UserBlockRepository userBlockRepository; - - @Mock - private RedisTemplate redisTemplate; - - @Mock - private ValueOperations valueOperations; - - private BlockServiceImpl blockService; - - private static final Long BLOCKER_USER_ID = 1L; - private static final int BLOCKED_USER_COUNT = 10; - private static final int ITERATION_COUNT = 1000; - - // 시뮬레이션용 지연 시간 (실제 환경 근사치) - private static final long SIMULATED_DB_LATENCY_MS = 10; // MongoDB: ~10ms - private static final long SIMULATED_REDIS_LATENCY_MS = 1; // Redis: ~1ms - - private List mockBlockedUsers; - - @BeforeEach - void setUp() { - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - blockService = new BlockServiceImpl(userBlockRepository, redisTemplate); - - // 차단된 사용자 목록 생성 - mockBlockedUsers = LongStream.rangeClosed(2, BLOCKED_USER_COUNT + 1) - .mapToObj(id -> UserBlock.builder() - .blockerUserId(BLOCKER_USER_ID) - .blockedUserId(id) - .build()) - .toList(); - } - - @Test - @DisplayName("캐싱 미적용 시나리오 - 매번 DB 조회") - void performanceTest_withoutCache() { - // given: 캐시 미스 시뮬레이션 (항상 null 반환) - given(valueOperations.get(anyString())).willReturn(null); - - // DB 조회 시 지연 시뮬레이션 - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - // when: 1000번 isBlocked 호출 - long startTime = System.currentTimeMillis(); - - for (int i = 0; i < ITERATION_COUNT; i++) { - Long targetUserId = (long) (i % BLOCKED_USER_COUNT) + 2; - blockService.isBlocked(BLOCKER_USER_ID, targetUserId); - } - - long endTime = System.currentTimeMillis(); - long totalTime = endTime - startTime; - - // then - System.out.println("==========================================="); - System.out.println("📊 캐싱 미적용 시나리오 결과"); - System.out.println("==========================================="); - System.out.println("호출 횟수: " + ITERATION_COUNT + "번"); - System.out.println("총 소요 시간: " + totalTime + "ms"); - System.out.println("평균 응답 시간: " + (totalTime / (double) ITERATION_COUNT) + "ms"); - System.out.println("DB 쿼리 횟수: " + ITERATION_COUNT + "번"); - System.out.println("===========================================\n"); - - // DB 조회가 매번 발생했는지 검증 - then(userBlockRepository).should(times(ITERATION_COUNT)).findByBlockerUserId(BLOCKER_USER_ID); - } - - @Test - @DisplayName("캐싱 적용 시나리오 - 첫 호출만 DB 조회, 이후 캐시 히트") - void performanceTest_withCache() { - // given: 첫 호출은 캐시 미스, 이후는 캐시 히트 - Set cachedBlockedIds = mockBlockedUsers.stream() - .map(UserBlock::getBlockedUserId) - .collect(Collectors.toSet()); - - // 첫 번째 호출: 캐시 미스 → DB 조회 - // 두 번째 이후: 캐시 히트 - given(valueOperations.get("user:block:" + BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - return null; // 첫 호출 - }) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - return cachedBlockedIds; // 이후 호출 - }); - - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - // when: 1000번 isBlocked 호출 - long startTime = System.currentTimeMillis(); - - for (int i = 0; i < ITERATION_COUNT; i++) { - Long targetUserId = (long) (i % BLOCKED_USER_COUNT) + 2; - blockService.isBlocked(BLOCKER_USER_ID, targetUserId); - } - - long endTime = System.currentTimeMillis(); - long totalTime = endTime - startTime; - - // then - System.out.println("==========================================="); - System.out.println("📊 캐싱 적용 시나리오 결과"); - System.out.println("==========================================="); - System.out.println("호출 횟수: " + ITERATION_COUNT + "번"); - System.out.println("총 소요 시간: " + totalTime + "ms"); - System.out.println("평균 응답 시간: " + (totalTime / (double) ITERATION_COUNT) + "ms"); - System.out.println("DB 쿼리 횟수: 1번 (첫 호출만)"); - System.out.println("캐시 히트 횟수: " + (ITERATION_COUNT - 1) + "번"); - System.out.println("===========================================\n"); - - // DB 조회는 1번만 발생했는지 검증 - then(userBlockRepository).should(times(1)).findByBlockerUserId(BLOCKER_USER_ID); - } - - @Test - @DisplayName("캐싱 적용 vs 미적용 성능 비교") - void performanceComparison() { - System.out.println("\n"); - System.out.println("╔═══════════════════════════════════════════════════════════════╗"); - System.out.println("║ BlockService 캐싱 성능 비교 테스트 ║"); - System.out.println("╠═══════════════════════════════════════════════════════════════╣"); - System.out.println("║ 설정값: ║"); - System.out.println("║ - 호출 횟수: " + ITERATION_COUNT + "번 ║"); - System.out.println("║ - 차단된 사용자 수: " + BLOCKED_USER_COUNT + "명 ║"); - System.out.println("║ - 시뮬레이션 DB 지연: " + SIMULATED_DB_LATENCY_MS + "ms ║"); - System.out.println("║ - 시뮬레이션 Redis 지연: " + SIMULATED_REDIS_LATENCY_MS + "ms ║"); - System.out.println("╚═══════════════════════════════════════════════════════════════╝"); - - // ========== 캐싱 미적용 테스트 ========== - given(valueOperations.get(anyString())).willReturn(null); - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long noCacheStart = System.currentTimeMillis(); - for (int i = 0; i < ITERATION_COUNT; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long noCacheTime = System.currentTimeMillis() - noCacheStart; - - // Mock 리셋 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // ========== 캐싱 적용 테스트 ========== - Set cachedBlockedIds = mockBlockedUsers.stream() - .map(UserBlock::getBlockedUserId) - .collect(Collectors.toSet()); - - // 첫 호출만 캐시 미스 - List> responses = new ArrayList<>(); - responses.add(null); // 첫 호출: 캐시 미스 - for (int i = 1; i < ITERATION_COUNT; i++) { - responses.add(cachedBlockedIds); // 이후: 캐시 히트 - } - - final int[] callCount = {0}; - given(valueOperations.get("user:block:" + BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - if (callCount[0] < responses.size()) { - return responses.get(callCount[0]++); - } - return cachedBlockedIds; - }); - - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long cacheStart = System.currentTimeMillis(); - for (int i = 0; i < ITERATION_COUNT; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long cacheTime = System.currentTimeMillis() - cacheStart; - - // ========== 결과 출력 ========== - double improvement = ((double)(noCacheTime - cacheTime) / noCacheTime) * 100; - double speedup = (double) noCacheTime / cacheTime; - - System.out.println("\n"); - System.out.println("┌───────────────────────────────────────────────────────────────┐"); - System.out.println("│ 📊 성능 비교 결과 │"); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.printf("│ ❌ 캐싱 미적용: %,10d ms (DB 쿼리 %d번) │%n", noCacheTime, ITERATION_COUNT); - System.out.printf("│ ✅ 캐싱 적용: %,10d ms (DB 쿼리 1번) │%n", cacheTime); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.printf("│ 🚀 성능 향상: %.1f%% 감소 │%n", improvement); - System.out.printf("│ ⚡ 속도 향상: %.1fx 빠름 │%n", speedup); - System.out.println("└───────────────────────────────────────────────────────────────┘"); - - // 검증: 캐싱 적용 시 더 빨라야 함 - assertThat(cacheTime).isLessThan(noCacheTime); - System.out.println("\n✅ 테스트 통과: 캐싱 적용 시 " + String.format("%.1fx", speedup) + " 성능 향상 확인\n"); - } - - @Test - @DisplayName("다양한 호출 횟수에서의 성능 비교") - void performanceComparisonAtDifferentScales() { - int[] iterationCounts = {100, 500, 1000, 5000}; - - System.out.println("\n"); - System.out.println("╔═══════════════════════════════════════════════════════════════════════════╗"); - System.out.println("║ 다양한 호출 횟수에서의 캐싱 성능 비교 ║"); - System.out.println("╠═══════════════════════════════════════════════════════════════════════════╣"); - System.out.println("║ 호출 횟수 │ 캐싱 미적용 │ 캐싱 적용 │ 성능 향상 │ 속도 향상 ║"); - System.out.println("╠═══════════════════════════════════════════════════════════════════════════╣"); - - for (int iterations : iterationCounts) { - // Mock 리셋 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // 캐싱 미적용 테스트 - given(valueOperations.get(anyString())).willReturn(null); - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long noCacheStart = System.currentTimeMillis(); - for (int i = 0; i < iterations; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long noCacheTime = System.currentTimeMillis() - noCacheStart; - - // Mock 리셋 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - // 캐싱 적용 테스트 - Set cachedBlockedIds = mockBlockedUsers.stream() - .map(UserBlock::getBlockedUserId) - .collect(Collectors.toSet()); - - final int[] callCount = {0}; - given(valueOperations.get("user:block:" + BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - if (callCount[0]++ == 0) { - return null; // 첫 호출: 캐시 미스 - } - return cachedBlockedIds; // 이후: 캐시 히트 - }); - - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long cacheStart = System.currentTimeMillis(); - for (int i = 0; i < iterations; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long cacheTime = System.currentTimeMillis() - cacheStart; - - double improvement = ((double)(noCacheTime - cacheTime) / noCacheTime) * 100; - double speedup = (double) noCacheTime / cacheTime; - - System.out.printf("║ %,6d회 │ %,8d ms │ %,7d ms │ %5.1f%% │ %5.1fx ║%n", - iterations, noCacheTime, cacheTime, improvement, speedup); - - assertThat(cacheTime).isLessThan(noCacheTime); - } - - System.out.println("╚═══════════════════════════════════════════════════════════════════════════╝"); - System.out.println("\n💡 결론: 호출 횟수가 많아질수록 캐싱의 효과가 더욱 커집니다.\n"); - } - - @Test - @DisplayName("실제 채팅 시나리오 시뮬레이션 - 1분간 활성 채팅") - void realWorldScenarioSimulation() { - // 시나리오: 1분간 초당 10개의 메시지가 오가는 활성 채팅방 - int messagesPerSecond = 10; - int durationSeconds = 60; - int totalMessages = messagesPerSecond * durationSeconds; - - System.out.println("\n"); - System.out.println("╔═══════════════════════════════════════════════════════════════╗"); - System.out.println("║ 🗨️ 실제 채팅 시나리오 시뮬레이션 ║"); - System.out.println("╠═══════════════════════════════════════════════════════════════╣"); - System.out.println("║ 시나리오: 1분간 활성 채팅 (초당 10개 메시지) ║"); - System.out.println("║ 총 메시지 수: " + totalMessages + "개 ║"); - System.out.println("║ 각 메시지마다 isBlocked() 호출 발생 ║"); - System.out.println("╚═══════════════════════════════════════════════════════════════╝"); - - // 캐싱 미적용 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - given(valueOperations.get(anyString())).willReturn(null); - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long noCacheStart = System.currentTimeMillis(); - for (int i = 0; i < totalMessages; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long noCacheTime = System.currentTimeMillis() - noCacheStart; - - // 캐싱 적용 - reset(valueOperations, userBlockRepository); - lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); - - Set cachedBlockedIds = mockBlockedUsers.stream() - .map(UserBlock::getBlockedUserId) - .collect(Collectors.toSet()); - - final int[] callCount = {0}; - given(valueOperations.get("user:block:" + BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_REDIS_LATENCY_MS); - if (callCount[0]++ == 0) { - return null; - } - return cachedBlockedIds; - }); - - given(userBlockRepository.findByBlockerUserId(BLOCKER_USER_ID)) - .willAnswer(invocation -> { - simulateLatency(SIMULATED_DB_LATENCY_MS); - return mockBlockedUsers; - }); - - long cacheStart = System.currentTimeMillis(); - for (int i = 0; i < totalMessages; i++) { - blockService.isBlocked(BLOCKER_USER_ID, 2L); - } - long cacheTime = System.currentTimeMillis() - cacheStart; - - double savedTime = noCacheTime - cacheTime; - double speedup = (double) noCacheTime / cacheTime; - - System.out.println("\n"); - System.out.println("┌───────────────────────────────────────────────────────────────┐"); - System.out.println("│ 📊 시뮬레이션 결과 │"); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.printf("│ ❌ 캐싱 미적용 총 지연: %,d ms (%.1f초) │%n", - noCacheTime, noCacheTime / 1000.0); - System.out.printf("│ ✅ 캐싱 적용 총 지연: %,d ms (%.1f초) │%n", - cacheTime, cacheTime / 1000.0); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.printf("│ ⏱️ 절약된 시간: %,.0f ms (%.1f초) │%n", - savedTime, savedTime / 1000.0); - System.out.printf("│ 🚀 속도 향상: %.1fx │%n", speedup); - System.out.println("├───────────────────────────────────────────────────────────────┤"); - System.out.println("│ 💡 의미: │"); - System.out.printf("│ - 캐싱 미적용 시 DB에 %d번 쿼리 발생 │%n", totalMessages); - System.out.println("│ - 캐싱 적용 시 DB에 1번만 쿼리 발생 │"); - System.out.printf("│ - 1분 채팅 동안 약 %.1f초의 지연 시간 절약 │%n", savedTime / 1000.0); - System.out.println("└───────────────────────────────────────────────────────────────┘"); - - assertThat(cacheTime).isLessThan(noCacheTime); - } - - private void simulateLatency(long milliseconds) { - try { - Thread.sleep(milliseconds); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } -} diff --git a/common-module/src/test/java/com/comatching/common/CommonModuleApplicationTests.java b/common-module/src/test/java/com/comatching/common/CommonModuleApplicationTests.java deleted file mode 100644 index b703c38..0000000 --- a/common-module/src/test/java/com/comatching/common/CommonModuleApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.common; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class CommonModuleApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/common-module/src/test/java/com/comatching/common/config/kafka/KafkaDltRedriveIT.java b/common-module/src/test/java/com/comatching/common/config/kafka/KafkaDltRedriveIT.java index 4767848..e350b36 100644 --- a/common-module/src/test/java/com/comatching/common/config/kafka/KafkaDltRedriveIT.java +++ b/common-module/src/test/java/com/comatching/common/config/kafka/KafkaDltRedriveIT.java @@ -18,6 +18,7 @@ import org.apache.kafka.common.serialization.StringDeserializer; import org.awaitility.Awaitility; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -63,6 +64,7 @@ "comatching.kafka.dlt-topics=" + KafkaDltRedriveIT.REDRIVE_TOPIC }) @DisplayName("Kafka DLT 재적재") +@Tag("integration") class KafkaDltRedriveIT { static final String REDRIVE_TOPIC = "redrive-it-topic"; diff --git a/common-module/src/test/java/com/comatching/common/config/kafka/KafkaRetryAndDltIT.java b/common-module/src/test/java/com/comatching/common/config/kafka/KafkaRetryAndDltIT.java index a1b31d6..ef41705 100644 --- a/common-module/src/test/java/com/comatching/common/config/kafka/KafkaRetryAndDltIT.java +++ b/common-module/src/test/java/com/comatching/common/config/kafka/KafkaRetryAndDltIT.java @@ -17,6 +17,7 @@ import org.apache.kafka.common.serialization.StringDeserializer; import org.awaitility.Awaitility; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -64,6 +65,7 @@ "spring.kafka.consumer.group-id=kafka-retry-dlt-it" }) @DisplayName("Kafka 재시도·DLT 정책") +@Tag("integration") class KafkaRetryAndDltIT { static final String ALWAYS_FAILS_TOPIC = "retry-it-always-fails"; diff --git a/gateway-service/build.gradle b/gateway-service/build.gradle index 9081ea4..d7aba92 100644 --- a/gateway-service/build.gradle +++ b/gateway-service/build.gradle @@ -28,7 +28,3 @@ dependencyManagement { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/gateway-service/src/test/java/com/comatching/gateway/GatewayServiceApplicationTests.java b/gateway-service/src/test/java/com/comatching/gateway/GatewayServiceApplicationTests.java deleted file mode 100644 index bf1e3cb..0000000 --- a/gateway-service/src/test/java/com/comatching/gateway/GatewayServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.gateway; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class GatewayServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3f80a40 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,33 @@ +# ============================================================ +# Gradle 빌드 설정. +# +# 여기 없던 시절에는 전부 기본값이었다. 기본값은 "모듈 7개를 한 줄로 세워 +# 차례로 돌린다"는 뜻이고, 서비스끼리 의존이 없는 이 저장소에서는 그만큼 +# 러너 시간을 그냥 버리는 것이었다. +# ============================================================ + +# common-module 을 뺀 서비스 6개는 서로 의존하지 않는다. 동시에 돌린다. +org.gradle.parallel=true + +# 입력이 그대로면 결과를 다시 만들지 않는다. PR 에 커밋을 여러 번 올릴 때 +# 손대지 않은 모듈의 테스트까지 매번 다시 도는 것을 막는다. +# gradle/actions/setup-gradle 이 이 캐시를 실행 간에 이어준다. +org.gradle.caching=true + +# 모듈을 병렬로 돌리면 JVM 도 그만큼 뜬다. 기본 힙으로는 부족해서 +# 컴파일 도중 OOM 이 나기 시작한다. +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 + +# ============================================================ +# 이 값들이 닿는 범위는 CI 러너와 개발 PC 까지다. +# +# Dockerfile 의 빌드 스테이지도 컨텍스트 루트에서 gradlew 를 돌리기 때문에 +# 그냥 두면 여기까지 따라 들어가는데, 그 스테이지는 운영 EC2 위에서 +# (compose 의 up -d --build 경로) 서비스들이 이미 떠 있는 채로 도는 경우가 +# 있다. 남는 메모리가 1GB 남짓인 곳에서 힙 2g 를 잡고 7개 모듈을 동시에 +# 빌드하면 안 된다. 그래서 .dockerignore 로 이 파일을 컨텍스트에서 뺐다 +# (사유는 그쪽 주석에 적어 뒀다). +# +# 여기에 값을 더할 때는 그게 운영 호스트에서 돌아도 괜찮은지 한 번 보고, +# 아니라면 .dockerignore 의 그 항목이 여전히 살아 있는지 확인한다. +# ============================================================ diff --git a/item-service/build.gradle b/item-service/build.gradle index 8f6c725..3c7413a 100644 --- a/item-service/build.gradle +++ b/item-service/build.gradle @@ -27,7 +27,3 @@ dependencyManagement { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/item-service/src/test/java/com/comatching/item/ItemServiceApplicationTests.java b/item-service/src/test/java/com/comatching/item/ItemServiceApplicationTests.java deleted file mode 100644 index 624d190..0000000 --- a/item-service/src/test/java/com/comatching/item/ItemServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.item; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class ItemServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/matching-service/build.gradle b/matching-service/build.gradle index dac2df6..12e8863 100644 --- a/matching-service/build.gradle +++ b/matching-service/build.gradle @@ -44,6 +44,3 @@ dependencyManagement { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } -tasks.named('test') { - useJUnitPlatform() -} diff --git a/matching-service/src/test/java/com/comatching/matching/MatchingServiceApplicationTests.java b/matching-service/src/test/java/com/comatching/matching/MatchingServiceApplicationTests.java deleted file mode 100644 index 618e2c3..0000000 --- a/matching-service/src/test/java/com/comatching/matching/MatchingServiceApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.matching; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경 설정 필요 - Feign 클라이언트 설정 완료 후 활성화") -@SpringBootTest -class MatchingServiceApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java b/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java index cdb1cb4..48bb1c7 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryIT.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -44,6 +45,7 @@ @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ContextConfiguration(classes = MatchingCandidateRepositoryIT.Config.class) @DisplayName("MatchingCandidateRepository 통합 테스트") +@Tag("integration") class MatchingCandidateRepositoryIT extends MySqlContainerSupport { private static final int MY_AGE = 24; diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/CandidateServiceImplIT.java b/matching-service/src/test/java/com/comatching/matching/domain/service/CandidateServiceImplIT.java index b53e0e2..9b9f34a 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/CandidateServiceImplIT.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/service/CandidateServiceImplIT.java @@ -12,6 +12,7 @@ import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -50,6 +51,7 @@ @ContextConfiguration(classes = CandidateServiceImplIT.Config.class) @Import(CandidateServiceImpl.class) @DisplayName("CandidateService 탈퇴-갱신 순서 역전 가드") +@Tag("integration") class CandidateServiceImplIT extends MySqlContainerSupport { private static final Long MEMBER_ID = 777L; diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingConcurrencyTest.java b/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingConcurrencyTest.java deleted file mode 100644 index a7cc74a..0000000 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/MatchingConcurrencyTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.comatching.matching.domain.service; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashSet; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import com.comatching.common.domain.enums.ContactFrequency; -import com.comatching.common.domain.enums.Gender; -import com.comatching.common.domain.enums.HobbyCategory; -import com.comatching.common.dto.member.ProfileResponse; -import com.comatching.matching.domain.dto.MatchingRequest; -import com.comatching.matching.domain.entity.MatchingCandidate; -import com.comatching.matching.domain.repository.candidate.MatchingCandidateRepository; -import com.comatching.matching.infra.client.MemberClient; - -@Disabled("통합 테스트 환경 설정 필요 - Redis/Kafka 실행 후 활성화") -@SpringBootTest -class MatchingConcurrencyTest { - - @Autowired - private MatchingService matchingService; - - @Autowired - private MatchingCandidateRepository candidateRepository; - - @MockitoBean - private MemberClient memberClient; - - @Test - void matchingConcurrencyTest() throws InterruptedException { - // given - int threadCount = 2; - // 멀티스레드 환경을 만들기 위한 실행기 (32개 스레드 풀) - ExecutorService executorService = Executors.newFixedThreadPool(threadCount); - // 모든 스레드가 준비될 때까지 기다렸다가 동시에 '땅!' 출발시키기 위한 장치 - CountDownLatch latch = new CountDownLatch(threadCount); - - AtomicInteger successCount = new AtomicInteger(); - AtomicInteger failCount = new AtomicInteger(); - - Long memberId = 1L; // 테스트할 유저 ID - - MatchingCandidate partner = MatchingCandidate.create( - 2L, 20L, Gender.FEMALE, "ISTJ", "디자인학과", ContactFrequency.FREQUENT, - new ArrayList<>(Collections.singletonList(HobbyCategory.SPORTS)), LocalDate.of(2000, 11, 11), true - ); - candidateRepository.save(partner); - - MatchingRequest request = new MatchingRequest( - null, "IS", HobbyCategory.SPORTS, ContactFrequency.FREQUENT, false, null - ); - - given(memberClient.getProfile(anyLong())) - .willReturn(ProfileResponse.builder() - .memberId(memberId) - .gender(Gender.MALE) - .mbti("ISTJ") - .major("컴퓨터공학과") - .birthDate(LocalDate.of(2000, 1, 1)) - .build()); - - // when - for (int i = 0; i < threadCount; i++) { - executorService.submit(() -> { - try { - // 매칭 요청 실행 - matchingService.match(memberId, request); - // 예외가 안 나면 성공 카운트 증가 - successCount.getAndIncrement(); - } catch (Exception e) { - // 락 획득 실패 등으로 예외가 나면 실패 카운트 증가 - System.out.println("매칭 실패: " + e.getMessage()); - failCount.getAndIncrement(); - } finally { - latch.countDown(); // 작업 끝났음을 알림 - } - }); - } - - latch.await(); // 모든 스레드가 끝날 때까지 대기 - - // then - System.out.println("=== 테스트 결과 ==="); - System.out.println("성공 횟수: " + successCount.get()); - System.out.println("실패 횟수: " + failCount.get()); - - // 검증: 성공은 무조건 1번이어야 함 (락이 제대로 동작했다면) - assertThat(successCount.get()).isEqualTo(1); - } - -} \ No newline at end of file diff --git a/matching-service/src/test/java/com/comatching/matching/domain/service/WithdrawnMemberCleanupSchedulerIT.java b/matching-service/src/test/java/com/comatching/matching/domain/service/WithdrawnMemberCleanupSchedulerIT.java index 7e6781d..7240305 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/service/WithdrawnMemberCleanupSchedulerIT.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/service/WithdrawnMemberCleanupSchedulerIT.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; @@ -33,6 +34,7 @@ @ContextConfiguration(classes = WithdrawnMemberCleanupSchedulerIT.Config.class) @Import(WithdrawnMemberCleanupScheduler.class) @DisplayName("탈퇴 tombstone TTL 정리 배치") +@Tag("integration") class WithdrawnMemberCleanupSchedulerIT extends MySqlContainerSupport { private static final long RETENTION_DAYS = 2; diff --git a/notification/build.gradle b/notification/build.gradle index 3c5340d..31dec79 100644 --- a/notification/build.gradle +++ b/notification/build.gradle @@ -18,7 +18,3 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } - -tasks.named('test') { - useJUnitPlatform() -} diff --git a/notification/src/test/java/com/comatching/notification/NotificationApplicationTests.java b/notification/src/test/java/com/comatching/notification/NotificationApplicationTests.java deleted file mode 100644 index a9b0df3..0000000 --- a/notification/src/test/java/com/comatching/notification/NotificationApplicationTests.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.comatching.notification; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@Disabled("통합 테스트 환경에서만 실행") -@SpringBootTest -class NotificationApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/user-service/src/test/java/com/comatching/user/domain/event/UserEventPublisherIT.java b/user-service/src/test/java/com/comatching/user/domain/event/UserEventPublisherIT.java index f236ff7..6df8ae2 100644 --- a/user-service/src/test/java/com/comatching/user/domain/event/UserEventPublisherIT.java +++ b/user-service/src/test/java/com/comatching/user/domain/event/UserEventPublisherIT.java @@ -17,6 +17,7 @@ import org.apache.kafka.common.serialization.StringDeserializer; import org.apache.kafka.common.serialization.StringSerializer; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.kafka.core.DefaultKafkaConsumerFactory; import org.springframework.kafka.core.DefaultKafkaProducerFactory; @@ -43,6 +44,7 @@ */ @EmbeddedKafka(partitions = 3, topics = {"member-withdraw", "profile-updates"}) @DisplayName("UserEventPublisher 메시지 키 검증") +@Tag("integration") class UserEventPublisherIT { private static final Long MEMBER_A = 101L; From 0bc2d27192663ce910fe2cce1c3a6e1954998ba0 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 15:13:36 +0900 Subject: [PATCH 39/66] =?UTF-8?q?ci:=20=EB=A6=AC=EB=B7=B0=20=EB=94=94?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=B6=9C=EB=A0=A5=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=81=88=EB=8B=A4=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #79 에서 진단용으로 켠 show_full_output 을 되돌린다. 리뷰가 실제로 동작하는 걸 두 PR 에서 확인했다 — PR #80 은 인라인 코멘트 게시(24턴), PR #78 은 이슈 없음 요약(26턴). 기본값 false 로 돌아간다. Co-authored-by: Claude Opus 5 --- .github/workflows/claude-code-review.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8e9de68..b6f4af5 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -61,6 +61,3 @@ jobs: # 상한이 낮으면 리뷰를 끝내기 전에 잘리고, 없으면 폭주할 때 멈출 # 지점이 없다. 실행 1회당 상한이지 하루 총량이 아니다. claude_args: '--allowedTools "Skill,Bash,Read,Glob,Grep,Agent,mcp__github_inline_comment__create_inline_comment" --max-turns 30' - # 리뷰가 또 조용히 죽으면 로그에서 원인을 봐야 한다. 이 설정으로 - # 몇 번 정상 동작하는 걸 확인하면 다시 지운다(기본값 false). - show_full_output: true From 6836a7d8c2063740d9be4418ac5445ae5c82a86f Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 15:13:55 +0900 Subject: [PATCH 40/66] =?UTF-8?q?feat:=20Feign=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=97=90=20=ED=83=80=EC=9E=84=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=9D=84=20=EA=B1=B4=EB=8B=A4=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Feign 내부 호출에 타임아웃을 건다 기본값(connect 10s / read 60s)은 피호출 서비스 장애 시 톰캣 스레드를 60초씩 붙잡아 장애를 호출자까지 전파한다. 같은 호스트 내부 REST 전제로 connect 1s / read 3s(배치성 호출은 5s)로 잡는다. 값은 common-module 의 feign-resilience.yml 한 곳에만 두고 각 서비스가 spring.config.import 로 불러와 서비스 간 설정 드리프트를 막는다. OpenFeign 이 한 블록에 두 타임아웃이 모두 있어야 적용하는 함정은 바인딩 테스트로 회귀를 막는다. Co-Authored-By: Claude Fable 5 * docs: Feign 타임아웃 상속 동작에 대한 설명을 사실로 고친다 yml 주석과 테스트 주석이 "OpenFeign 은 한 config 블록에 connect·read 가 둘 다 있어야 적용하고, 하나만 적힌 블록은 통째로 무시되어 기본 60s 로 돌아간다"고 설명했는데 사실이 아니다. spring-cloud-openfeign-core 4.3.1 FeignClientFactoryBean#configureUsingProperties: connectTimeoutMillis = config.getConnectTimeout() != null ? config.getConnectTimeout() : connectTimeoutMillis; readTimeoutMillis = config.getReadTimeout() != null ? config.getReadTimeout() : readTimeoutMillis; builder.options(new Request.Options(connectTimeoutMillis, ...)); 두 값은 항목별로 독립 상속되고 options() 는 조건 없이 호출된다. configureFeign 이 default 블록을 먼저 적용해 필드를 1000/3000 으로 채운 뒤 클라이언트 블록을 덧씌우므로, 클라이언트 항목에 read-timeout 만 적어도 connect 는 default 의 1s 가 남는다. 60s 로 돌아가는 경로는 없다. 설정 값과 테스트 단언은 그대로 두고 설명만 고친다. everyEntryHasBothTimeouts 는 남기되, 동작상 필수라서가 아니라 유효 타임아웃을 상속 계산 없이 그 줄에서 바로 읽기 위한 관례라고 근거를 다시 적었다. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 --- .../src/main/resources/application.yml | 4 ++ .../src/main/resources/feign-resilience.yml | 33 ++++++++++ .../config/FeignResilienceConfigTest.java | 62 +++++++++++++++++++ .../src/main/resources/application.yml | 4 ++ .../src/main/resources/application.yml | 4 ++ 5 files changed, 107 insertions(+) create mode 100644 common-module/src/main/resources/feign-resilience.yml create mode 100644 common-module/src/test/java/com/comatching/common/config/FeignResilienceConfigTest.java diff --git a/chat-service/src/main/resources/application.yml b/chat-service/src/main/resources/application.yml index 43d1369..69fed53 100644 --- a/chat-service/src/main/resources/application.yml +++ b/chat-service/src/main/resources/application.yml @@ -8,6 +8,10 @@ matching-service: url: http://localhost:9001 spring: + # Feign 내부 호출 타임아웃 공통값 (common-module 소유) + config: + import: classpath:feign-resilience.yml + application: name: chat-service diff --git a/common-module/src/main/resources/feign-resilience.yml b/common-module/src/main/resources/feign-resilience.yml new file mode 100644 index 0000000..ad3c99c --- /dev/null +++ b/common-module/src/main/resources/feign-resilience.yml @@ -0,0 +1,33 @@ +# Feign 내부 호출 공통 타임아웃 설정. +# Feign 클라이언트를 쓰는 서비스의 application.yml 에서 +# spring.config.import: classpath:feign-resilience.yml 로 불러온다. +# 값이 이 파일 한 곳에만 존재해야 서비스 간 설정 드리프트가 안 생긴다. +# +# 같은 EC2 호스트 안의 내부 REST 호출 전제라 공격적으로 잡는다. +# OpenFeign 기본값(connect 10s / read 60s)은 피호출 서비스가 멈추면 +# 호출 서비스의 톰캣 스레드를 60초씩 붙잡아 장애를 그대로 전파한다. +# +# 두 값은 항목별로 독립 상속된다. FeignClientFactoryBean 은 default 블록을 +# 먼저 적용해 필드를 채운 뒤 클라이언트별 블록을 덧씌우는데, 클라이언트 +# 블록에 없는 항목은 앞 단계 값을 그대로 유지한다 — +# read-timeout 만 적어도 connect 는 default 의 1s 가 남는다. +# 아래에서 항목마다 두 값을 다 적는 건 유효 타임아웃을 한눈에 읽기 위한 +# 관례일 뿐 동작상 필수는 아니다. (키는 @FeignClient 의 name/contextId, 단위 ms) +spring: + cloud: + openfeign: + client: + config: + default: + connect-timeout: 1000 + read-timeout: 3000 + # 배치성 호출은 읽기 시간을 더 준다. + user-service: # POST /profiles/bulk (matching·chat 공용) + connect-timeout: 1000 + read-timeout: 5000 + chat-service: # POST /references, /ensure — 매칭 이력 일괄 조회·생성 + connect-timeout: 1000 + read-timeout: 5000 + user-service-admin: # 관리자 페이징 조회 + connect-timeout: 1000 + read-timeout: 5000 diff --git a/common-module/src/test/java/com/comatching/common/config/FeignResilienceConfigTest.java b/common-module/src/test/java/com/comatching/common/config/FeignResilienceConfigTest.java new file mode 100644 index 0000000..026a2a2 --- /dev/null +++ b/common-module/src/test/java/com/comatching/common/config/FeignResilienceConfigTest.java @@ -0,0 +1,62 @@ +package com.comatching.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.cloud.openfeign.FeignClientProperties; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; + +/** + * feign-resilience.yml 이 실제로 FeignClientProperties 에 바인딩되는지 검증. + * + * 이 파일은 각 서비스가 spring.config.import 로 불러 쓰는 공통 타임아웃의 + * 단일 원천인데, 키 경로나 필드명이 틀려도 Spring 은 조용히 무시하고 + * 기본값(connect 10s / read 60s)으로 돌아간다 — 즉 "타임아웃이 안 걸린 + * 상태"가 아무 에러 없이 운영까지 나간다. 여기서 파싱과 바인딩을 잡는다. + */ +@DisplayName("Feign 공통 타임아웃 설정 바인딩") +class FeignResilienceConfigTest { + + private FeignClientProperties load() throws IOException { + List> sources = new YamlPropertySourceLoader() + .load("feign-resilience", new ClassPathResource("feign-resilience.yml")); + return new Binder(ConfigurationPropertySources.from(sources.get(0))) + .bind("spring.cloud.openfeign.client", Bindable.of(FeignClientProperties.class)) + .orElseThrow(() -> new IllegalStateException("spring.cloud.openfeign.client 바인딩 실패")); + } + + @Test + @DisplayName("default 타임아웃이 짧게 잡혀 있다") + void defaultTimeouts() throws IOException { + FeignClientProperties.FeignClientConfiguration config = load().getConfig().get("default"); + + assertThat(config).isNotNull(); + assertThat(config.getConnectTimeout()).isEqualTo(1000); + assertThat(config.getReadTimeout()).isEqualTo(3000); + } + + @Test + @DisplayName("모든 클라이언트 항목은 connect·read 타임아웃을 둘 다 가진다") + void everyEntryHasBothTimeouts() throws IOException { + // 동작상 필수는 아니다 — FeignClientFactoryBean 은 default 블록을 먼저 + // 적용한 뒤 클라이언트 블록을 덧씌우고, 없는 항목은 앞 단계 값을 그대로 + // 유지하므로 read-timeout 만 적어도 connect 는 default 의 1s 가 남는다. + // 그래도 항목마다 두 값을 다 적게 강제하는 건, 유효 타임아웃을 상속을 + // 머릿속으로 계산하지 않고 그 줄에서 바로 읽기 위해서다. + load().getConfig().forEach((name, config) -> { + assertThat(config.getConnectTimeout()) + .as("%s 의 connect-timeout", name).isNotNull(); + assertThat(config.getReadTimeout()) + .as("%s 의 read-timeout", name).isNotNull(); + }); + } +} diff --git a/item-service/src/main/resources/application.yml b/item-service/src/main/resources/application.yml index c47410b..9a18dc2 100644 --- a/item-service/src/main/resources/application.yml +++ b/item-service/src/main/resources/application.yml @@ -8,6 +8,10 @@ user-service: url: http://localhost:9000 spring: + # Feign 내부 호출 타임아웃 공통값 (common-module 소유) + config: + import: classpath:feign-resilience.yml + application: name: item-service diff --git a/matching-service/src/main/resources/application.yml b/matching-service/src/main/resources/application.yml index c4d007e..46cc0ea 100644 --- a/matching-service/src/main/resources/application.yml +++ b/matching-service/src/main/resources/application.yml @@ -11,6 +11,10 @@ chat-service: url: http://localhost:9003 spring: + # Feign 내부 호출 타임아웃 공통값 (common-module 소유) + config: + import: classpath:feign-resilience.yml + application: name: matching-service From 1d15a20afc8d25d2cee4ee11e1370993a9acda5c Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 15:33:02 +0900 Subject: [PATCH 41/66] =?UTF-8?q?feat:=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=20=EC=A1=B0=ED=9A=8C=EC=97=90=2010=EC=B4=88=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EC=BA=90=EC=8B=9C=EB=A5=BC=20=EA=B1=B4?= =?UTF-8?q?=EB=8B=A4=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 부하 측정(perf-log 5회차)에서 /api/auth/participants 의 COUNT 쿼리가 요청마다 ACTIVE 회원 수만큼 인덱스를 훑어 (10만 시드 기준 rows=49,616) 처리량 상한이 60 RPS 로 눌렸다. 참여자 수는 표시용이라 몇 초 늦어도 되므로 Caffeine expireAfterWrite 10초 로컬 캐시로 DB 부하를 끊는다. user-service 는 단일 인스턴스 전제. Co-authored-by: Claude Fable 5 --- user-service/build.gradle | 4 + .../member/service/MemberServiceImpl.java | 3 + .../user/global/config/CacheConfig.java | 32 ++++++++ .../service/MemberServiceCacheTest.java | 74 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 user-service/src/main/java/com/comatching/user/global/config/CacheConfig.java create mode 100644 user-service/src/test/java/com/comatching/user/domain/member/service/MemberServiceCacheTest.java diff --git a/user-service/build.gradle b/user-service/build.gradle index 0729480..eddaab3 100644 --- a/user-service/build.gradle +++ b/user-service/build.gradle @@ -26,6 +26,10 @@ dependencies { // Kafka implementation 'org.springframework.kafka:spring-kafka' + // Cache (참여자 수 등 읽기 빈도가 높고 실시간성이 낮은 조회의 로컬 캐시) + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' + // Mail implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/MemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/MemberServiceImpl.java index 23353a8..0837d36 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/MemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/MemberServiceImpl.java @@ -1,5 +1,6 @@ package com.comatching.user.domain.member.service; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -13,6 +14,7 @@ import com.comatching.user.domain.event.UserEventPublisher; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.global.config.CacheConfig; import com.comatching.user.global.config.ProfileImageProperties; import com.comatching.user.global.exception.UserErrorCode; @@ -86,6 +88,7 @@ public void withdrawMember(Long memberId) { } @Override + @Cacheable(CacheConfig.PARTICIPANT_COUNT) public long getActiveUserCount() { return memberRepository.countByRoleAndStatus(MemberRole.ROLE_USER, MemberStatus.ACTIVE); } diff --git a/user-service/src/main/java/com/comatching/user/global/config/CacheConfig.java b/user-service/src/main/java/com/comatching/user/global/config/CacheConfig.java new file mode 100644 index 0000000..c7719f9 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/global/config/CacheConfig.java @@ -0,0 +1,32 @@ +package com.comatching.user.global.config; + +import java.time.Duration; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.github.benmanes.caffeine.cache.Caffeine; + +@Configuration +@EnableCaching +public class CacheConfig { + + public static final String PARTICIPANT_COUNT = "participantCount"; + + /** + * 참여자 수 COUNT 쿼리는 요청마다 ACTIVE 회원 수만큼 인덱스를 훑는다(회원 수에 비례). + * 값 자체는 표시용이라 몇 초 늦어도 되므로 짧은 TTL 로컬 캐시로 DB 부하를 끊는다. + * user-service 는 단일 인스턴스 전제라 인스턴스 간 정합성은 고려하지 않는다. + */ + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(PARTICIPANT_COUNT); + cacheManager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(10)) + .maximumSize(100)); + return cacheManager; + } +} diff --git a/user-service/src/test/java/com/comatching/user/domain/member/service/MemberServiceCacheTest.java b/user-service/src/test/java/com/comatching/user/domain/member/service/MemberServiceCacheTest.java new file mode 100644 index 0000000..86af22d --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/domain/member/service/MemberServiceCacheTest.java @@ -0,0 +1,74 @@ +package com.comatching.user.domain.member.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.user.domain.event.UserEventPublisher; +import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.global.config.CacheConfig; +import com.comatching.user.global.config.ProfileImageProperties; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = MemberServiceCacheTest.TestConfig.class) +@DisplayName("참여자 수 캐시 테스트") +class MemberServiceCacheTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Test + @DisplayName("TTL 안에서는 반복 조회해도 COUNT 쿼리는 한 번만 나간다") + void shouldHitDatabaseOnlyOnceWithinTtl() { + // given + given(memberRepository.countByRoleAndStatus(MemberRole.ROLE_USER, MemberStatus.ACTIVE)).willReturn(50000L); + + // when + long first = memberService.getActiveUserCount(); + long second = memberService.getActiveUserCount(); + long third = memberService.getActiveUserCount(); + + // then + assertThat(first).isEqualTo(50000L); + assertThat(second).isEqualTo(50000L); + assertThat(third).isEqualTo(50000L); + then(memberRepository).should(times(1)).countByRoleAndStatus(MemberRole.ROLE_USER, MemberStatus.ACTIVE); + } + + @Configuration + @Import({CacheConfig.class, MemberServiceImpl.class}) + static class TestConfig { + + @Bean + MemberRepository memberRepository() { + return mock(MemberRepository.class); + } + + @Bean + UserEventPublisher userEventPublisher() { + return mock(UserEventPublisher.class); + } + + @Bean + ProfileImageProperties profileImageProperties() { + return mock(ProfileImageProperties.class); + } + } +} From 691cc3dc86e36f50b3156e69884aab5c12f5d3a8 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 15:49:56 +0900 Subject: [PATCH 42/66] =?UTF-8?q?ci:=20=EB=B0=B0=ED=8F=AC=20=ED=9B=84=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EB=B6=80=20=EC=A7=80=EC=9A=B4=EB=8B=A4=20?= =?UTF-8?q?(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포마다 커밋 SHA 태그 이미지 6개(~3GB)가 쌓이는데 prune 의 until=72h 필터 때문에 머지가 잦은 날에는 하나도 안 지워졌다. 13세대 24GB 가 쌓여 디스크 99% 로 배포가 pull 도중 죽는 사고가 났다(#82 배포 실패). 롤백은 GHCR 재-pull 로 가능하므로 로컬에 옛 세대를 남겨둘 이유가 없다. 필터를 빼서 실행 중인 세트만 남긴다. Co-authored-by: Claude Fable 5 --- .github/workflows/deploy.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b250cca..f6d5791 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -172,11 +172,13 @@ jobs: # --no-build: EC2 에서 소스를 빌드하지 않는다. 이미지는 이미 만들어져 있다. docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --no-build - # 배포마다 서비스 6개의 이미지가 새로 쌓인다. prune -f 는 태그 - # 없는 것만 지우는데, 옛 커밋 SHA 이미지는 태그가 붙어 있어 그대로 - # 남는다. -a 로 "컨테이너가 쓰지 않는 것"까지 대상에 넣되, 72시간은 - # 남겨 최근 버전으로 되돌릴 여지를 둔다. - docker image prune -af --filter "until=72h" + # 배포마다 서비스 6개의 이미지(~3GB 세트)가 새로 쌓인다. prune -f 는 + # 태그 없는 것만 지우는데, 옛 커밋 SHA 이미지는 태그가 붙어 있어 + # 그대로 남는다. -a 로 "컨테이너가 쓰지 않는 것"을 전부 지운다. + # 예전엔 until=72h 로 롤백 여지를 뒀지만, 머지가 잦은 날 세대가 + # 쌓여 29GB 디스크가 가득 차 배포 자체가 죽었다(2026-08-26). + # 롤백은 GHCR 에 남아 있는 이미지를 다시 pull 하면 된다. + docker image prune -af - name: 상태 확인 uses: appleboy/ssh-action@v1 From 00d897e3b5cfb63b6bed5d4b70b57d629ff4050a Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 16:53:40 +0900 Subject: [PATCH 43/66] =?UTF-8?q?test:=20=EC=9A=B4=EC=98=81=20=EB=B6=80?= =?UTF-8?q?=ED=95=98=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8F=84=EA=B5=AC=C2=B7?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20(=ED=9A=8C=EC=B0=A8=204~6)=20+=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=A8=B8=EC=A7=80=20=EC=8B=9C=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EC=83=9D=EB=9E=B5=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 운영 부하 1차(S1) 준비 — 스모크 스크립트·Windows 부하기 호환·체크리스트 - tools/perf/smoke.sh: 운영 읽기 전용 스모크 (공개 6종 + 인증 5종) - run.sh: Windows Git Bash 호환 (python3 폴백, mac 전용 sysctl/pgrep/cpu_sampler 가드) - runbooks/2026-08-26-prod-S1-loadtest-checklist.md: D-day 절차 (1만 리허설 → 10만 본판) - 런북 §7: UptimeRobot URL 을 공개 엔드포인트로 정정 (actuator 는 외부 404 가 정상) Co-Authored-By: Claude Fable 5 * perf: 운영 첫 부하 리허설(시드 1만) 기록 — knee 300 RPS - 회차 4 기록: 분리된 부하기, 에러 0%, knee=400 계단(p95 1187ms) - run.sh: Windows python 스텁 감지 수정, 요약 UTF-8 강제 - 체크리스트: 부하 전 deploy 워크플로 확인 절차 추가 (실전에서 당함) Co-Authored-By: Claude Fable 5 * perf: 운영 본판(시드 10만) 기록 — 상한 60 RPS, COUNT 풀스캔 가설 재현 처리량이 부하 4배에도 59-60 RPS 로 평평, 데이터 10배에 상한 1/5. EC2 2vCPU 포화(load avg 2.85), user-service 는 0.8코어로 여유. 다음 회차 결정 후보: 인덱스 / participants 캐시 / kafka·mongo 버스트 조사 Co-Authored-By: Claude Fable 5 * perf: EXPLAIN 실측 반영 — 인덱스는 이미 있고 COUNT 자체가 행수 비례 idx_role_status 커버링 스캔이지만 rows 49,616. 결정 후보에서 인덱스 제거, 캐시를 추천으로 승격, RDS 버퍼 풀 128MB 기본값 발견 추가 Co-Authored-By: Claude Fable 5 * docs: perf-log 6회차 — 캐시 적용 후 상한 60→330 RPS Co-Authored-By: Claude Fable 5 * ci: 문서·도구만 바뀐 머지에는 배포를 건너뛴다 docs/tools/마크다운만 바뀐 push 에도 deploy 가 돌아 6개 컨테이너가 전부 재기동되고 있었다. 런타임이 읽지 않는 경로는 paths-ignore 로 배포를 생략한다. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/deploy.yml | 9 ++ docs/perf-log.md | 117 ++++++++++++++++++ .../2026-08-26-prod-S1-loadtest-checklist.md | 102 +++++++++++++++ .../2026-08-26-prod-monitoring-rollout.md | 3 +- tools/perf/jmeter/run.sh | 41 ++++-- tools/perf/smoke.sh | 67 ++++++++++ 6 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/runbooks/2026-08-26-prod-S1-loadtest-checklist.md create mode 100644 tools/perf/smoke.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f6d5791..f516328 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,6 +24,15 @@ name: deploy on: push: branches: [main] + # 문서·부하테스트 도구만 바뀐 머지에 6개 컨테이너를 전부 재기동할 이유가 + # 없다. 런타임이 읽지 않는 경로는 배포를 건너뛴다. + # 주의: 여기 걸린 경로는 EC2 의 git clone 에도 다음 "진짜 배포" 때까지 + # 반영되지 않는다 - 서버가 읽는 파일(compose, 설정)은 절대 넣지 말 것. + paths-ignore: + - 'docs/**' + - 'tools/**' + - '**.md' + - '.gitignore' # 코드 변경 없이 다시 배포하고 싶을 때 수동 실행 workflow_dispatch: diff --git a/docs/perf-log.md b/docs/perf-log.md index da60ba8..a76c66f 100644 --- a/docs/perf-log.md +++ b/docs/perf-log.md @@ -445,3 +445,120 @@ GROUP BY 임시 테이블 ~8ms 못 뽑아도 한 티어 아래가 나올 뿐인데, 애초에 그 그룹 안에서 아무나 주는 구조다. 품질 손실을 정량화해서 받아들일지 정하는 게 이 회차의 핵심이다. - 회차 4 이후: S2-read-mix — `default_batch_fetch_size` 오타, `open-in-view`, 풀 크기 + +--- + +## 회차 4 — 운영 첫 부하: S1 리허설, 시드 1만 (2026-08-26) + +**환경이 이전 회차와 완전히 다르다 — 절대 수치를 회차 1~3과 비교하지 말 것.** + +| 항목 | 값 | +|---|---| +| 부하 생성기 | 별도 Windows PC (JMeter 5.6.3) — **대상과 분리된 첫 측정** | +| 대상 | 운영 EC2 (srv.comatching.site, nginx→gateway) + RDS | +| 데이터 | 시드 1만 (리허설 — 본판은 10만) | +| 계단 | 50→100→200→400 RPS × 120초, 워밍업 60초 제외 | + +결과: + +| 계단 | 달성 | 에러% | p50 | p95 | p99 | max | +|---|---|---|---|---|---|---| +| 50 RPS | 100% | 0 | 20 | 62 | 141 | 349 | +| 100 RPS | 100% | 0 | 18 | 64 | 243 | 1040 | +| 200 RPS | 100% | 0 | 20 | 147 | 398 | 1622 | +| 400 RPS | **77% (307)** | 0 | **522** | **1187** | 1647 | 3489 | + +**knee = 400 RPS 계단** (p95 1187ms > 500ms). 실질 상한 ~300 RPS. + +관측: +- 에러 0% 인 채로 p50 이 20ms→522ms 로 통째로 밀렸다 — 간헐 경합이 아니라 + **전면 포화**의 신호. 큐잉이 시작됐지만 무너지지는 않았다. +- EC2 실측(docker stats): user-service CPU 최대 118% (1.18코어) — 부하 경로의 + CPU 병목으로 추정. kafka 190%/mongodb 118% 도 찍혔으나 부하 직전 CI 배포로 + 전 컨테이너가 재시작된 직후라 워밍업 노이즈가 섞여 있다. +- 메모리는 전 구간 여유 (가용 4~6GB, 스왑 0). + +사고 기록: 1차 시도는 부하 도중 main 머지 → CI deploy 가 컨테이너를 전부 +재생성해 게이트웨이 다운, 에러 100% 로 폐기. **부하 전 deploy 워크플로 확인이 +체크리스트에 들어간 이유다.** + +다음: 시드 10만 본판. 1만에서 이미 300 RPS 에서 꺾였으므로 10만(행 10배)에서는 +더 이른 knee 예상 — 회차 1의 가설(COUNT 풀스캔)이 운영에서도 재현되는지 본다. + +--- + +## 회차 5 — 운영 본판: S1, 시드 10만 (2026-08-26) + +측정 조건: 회차 4와 동일, 시드만 1만 → 10만. + +결과: + +| 계단 | 달성 | 에러% | p50 | p95 | p99 | max | +|---|---|---|---|---|---|---| +| 50 RPS | 99% | 0 | 120 | 472 | 731 | 1580 | +| 100 RPS | **59%** | 0 | 839 | 1078 | 1513 | 2100 | +| 200 RPS | 30% | 0 | 1678 | 2057 | 3201 | 4722 | +| 400 RPS | 15% | 0.01 | 3351 | 3800 | 6780 | 10011 | + +**knee = 100 RPS 계단. 실질 상한 ~60 RPS** (목표 1,667 대비 28배 부족). + +관측 — 세 가지가 하나의 그림을 그린다: + +1. **처리량이 59→59→60 으로 완전히 평평하다.** 부하를 4배로 올려도 처리량이 + 안 움직이고 p50 만 정비례로 늘었다(839→1678→3351ms). 단단한 단일 병목 뒤에 + 줄이 늘어서는 전형적 형태다. +2. **데이터 10배에 상한이 5분의 1 (300→60 RPS).** 요청당 비용이 행 수에 + 비례한다는 뜻 — 회차 1에서 잡았던 participants COUNT 풀스캔 가설이 운영에서 + 그대로 재현됐다. +3. **EC2 호스트(2 vCPU)가 포화였다** — load average 2.85. 단 user-service 는 + 최대 0.8코어로 여유가 있었고, kafka(1.8코어)·mongodb(1.1코어)가 부하와 무관한 + 버스트로 코어를 잠식했다. 유휴 시 kafka 2.4%인 걸 보면 상시는 아니고 간헐 버스트다. + +부하기 쪽 이상 없음: 부하기 PC 는 분리돼 있고 에러 0%, 소켓 타임아웃 1건뿐. + +추가 확인: RDS `innodb_buffer_pool_size` = **128MB (기본값)**. 10만 행 워킹셋과 +풀스캔이 겹치면 캐시 적중이 흔들린다 — 개선 회차에서 인덱스와 함께 볼 것. + +### 결정 후보 (다음 회차) + +**추가 실측 (운영 RDS EXPLAIN):** `idx_role_status` 인덱스는 **이미 존재**하고 +쿼리도 그걸 탄다 (`type: ref, Using index`). 그런데 **rows: 49,616** — 커버링 +인덱스여도 COUNT 는 조건에 맞는 엔트리를 전부 센다. 요청당 5만 행 × 60 RPS +≈ 초당 300만 행 스캔. 인덱스로 해결되는 단계는 지났다. + +| 선택지 | 기대 효과 | +|---|---| +| participants 응답 캐시 (TTL 수 초) | 부하 경로에서 DB 를 아예 제거 — 참가자 수는 실시간성이 필요 없다. **추천** | +| kafka/mongo 버스트 원인 조사 | 2 vCPU 에서 유휴 컨테이너가 코어를 뺏는 구조 해소 | +| RDS `innodb_buffer_pool_size` 상향 | 현재 기본값 128MB. 지금 병목은 아니나 데이터 증가 시 디스크 I/O 혼입 예방 | + +## 회차 6 — 캐시 적용 후 재측정: S1, 시드 10만 (2026-08-26) + +적용한 개선: `getActiveUserCount()` 에 Caffeine 로컬 캐시 TTL 10초 (PR #82). +COUNT 쿼리가 10초에 1번만 나간다. 측정 조건은 회차 5와 동일 +(부하기 분리, 시드 10만 유지, 배포 태그 691cc3d). + +결과 (`tools/perf/jmeter/results/S1-participants-20260826-161013`): + +| 계단 | 달성 | 에러% | p50 | p95 | p99 | max | +|---|---|---|---|---|---|---| +| 50 RPS | 100% | 0 | 9 | 26 | 58 | 259 | +| 100 RPS | 100% | 0 | 8 | 21 | 52 | 274 | +| 200 RPS | 100% | 0 | 8 | 37 | 190 | 759 | +| 400 RPS | 83% (331) | 0 | 17 | 592 | 1103 | 4070 | + +**knee = 400 RPS 계단 (p95 592ms). 실질 상한 ~330 RPS — 회차 5의 5.5배.** + +관측: + +1. **60 RPS 평탄 상한이 사라졌다.** 회차 5에서 100 RPS 계단부터 p50 이 839ms 로 + 치솟던 것이 8ms 로 내려앉았다(100배). DB COUNT 병목 가설이 개선→재측정으로 + 확정됐다. +2. **병목이 DB 에서 호스트 CPU 로 이동했다.** 400 계단에서 컨테이너 CPU 피크: + kafka 179% / mongodb 114% / user-service 99% / gateway 79%. 2 vCPU 호스트라 + 이 넷이 겹치면 포화다. 회차 5에서 지적한 kafka·mongo 유휴 버스트가 이제 + 1순위 병목이 됐다. +3. 에러 0%, 메모리는 전 컨테이너 70% 이하로 여유. + +목표 1,667 RPS 대비 5.0배 부족. 다음 지렛대는 코드가 아니라 인프라 — +kafka/mongo 버스트 해소(조사 태스크 진행 중) 또는 스케일업/분리가 남은 카드다. diff --git a/docs/superpowers/runbooks/2026-08-26-prod-S1-loadtest-checklist.md b/docs/superpowers/runbooks/2026-08-26-prod-S1-loadtest-checklist.md new file mode 100644 index 0000000..c75d9ba --- /dev/null +++ b/docs/superpowers/runbooks/2026-08-26-prod-S1-loadtest-checklist.md @@ -0,0 +1,102 @@ +# 운영 부하 1차 (S1 읽기 전용) — D-day 체크리스트 + +계획: 스모크 → 1만 리허설 → 10만 본판. 전부 **읽기 전용(S1)** — 매칭·구매는 안 친다. +부하기: 로컬 PC (JMeter 5.6.3, `~/apps/apache-jmeter-5.6.3`). 대상: `srv.comatching.site`. + +**이번 회차는 부하기와 대상이 분리된 첫 측정이다.** 이전 회차(단일 맥)와 절대 수치를 +직접 비교하지 말 것. 호스트 CPU 70% 폐기 기준은 이제 "부하기 PC CPU"에만 적용된다. + +## 0. 사전 완료된 것 (2026-08-26) + +- [x] 공개 스모크 6/6 통과 (`tools/perf/smoke.sh`) +- [x] JMeter 5.6.3 설치, `run.sh` Windows 호환 패치 +- [x] 로컬 InfluxDB(8086)·Grafana(**3002** — 3001 은 다른 프로젝트가 점유) 기동 +- [x] 1만 시드 TSV 생성 (`tools/perf/seed/out/`) +- [ ] RDS 파라미터 그룹 `local_infile=1` (콘솔에서 확인/적용) + +## 1. EC2 준비 (SSH: ubuntu@43.200.211.135) + +```bash +# 모니터링 6타깃 up=1 확인 +docker exec comatching-prometheus wget -qO- 'http://localhost:9090/api/v1/query?query=up' + +# 디스코드에 "부하 테스트 공지" (또는 Alertmanager silence) +``` + +로컬에서 서버 Grafana 터널: `ssh -L 3001:localhost:3001 ubuntu@43.200.211.135` +→ 브라우저 `http://localhost:3001` (서버 지표) + `http://localhost:3002` (JMeter 클라 지표) + +## 2. 시드·토큰 (리허설은 1만으로) + +```bash +# [로컬] TSV 업로드 +scp -r tools/perf/seed/out ubuntu@43.200.211.135:~/comatching/tools/perf/seed/ + +# [EC2] RDS 적재 — 시드 ID 는 1000001 부터라 나중에 범위 삭제 가능 +set -a; . ~/comatching/.env.prod; set +a +cd ~/comatching/tools/perf/seed && ./load_seed_rds.sh +# 검사 3종(행 수 / age_is_null=0 / gender 5:5) 통과 확인 + +# [EC2] 토큰 발급 (운영 JWT_SECRET 필요 — 로컬 불가) +cd ../tokens && python3 generate_tokens.py --env ~/comatching/.env.prod + +# [로컬] 토큰 회수 +scp ubuntu@43.200.211.135:~/comatching/tools/perf/tokens/tokens.csv tools/perf/tokens/ +``` + +토큰 검증 + 인증 스모크 (로컬): + +```bash +TOKEN=$(head -1 tools/perf/tokens/tokens.csv | cut -d, -f2) +ACCESS_TOKEN=$TOKEN ./tools/perf/smoke.sh # 11개 전부 ✅ 여야 진행 +``` + +## 3. 실행 (사용자 없는 시간대 — 게이트웨이에 rate limiter 없음) + +**⚠️ 부하 직전에 배포 충돌 확인 (2026-08-26 실전에서 당한 것):** +main 에 머지가 일어나면 deploy 워크플로가 EC2 컨테이너를 통째로 재생성한다. +부하 도중 배포가 겹치면 게이트웨이가 내려가 에러 100% — 회차 전체 폐기다. + +```bash +gh run list --workflow deploy --limit 1 # in_progress 면 끝날 때까지 대기 +``` + +부하 도는 동안(±15분) main 머지는 보류할 것. + +```bash +# [EC2] 실측 기록 시작 (부하 끝나면 Ctrl-C) +docker stats --format "{{.Name}},{{.MemUsage}},{{.MemPerc}},{{.CPUPerc}}" >> ~/stats-$(date +%F-%H%M).csv + +# [로컬 Git Bash] 부하 실행 — 워밍업 60초 + 50→100→200→400→800 RPS +export PATH="$HOME/apps/apache-jmeter-5.6.3/bin:$PATH" +cd tools/perf/jmeter +SCHEME=https HOST=srv.comatching.site PORT=443 ./run.sh +``` + +**중단 기준 (하나라도 걸리면 Ctrl-C):** +- EC2 메모리: 컨테이너 합계가 한도(여유 860m) 육박, 스왑 급증, OOM 킬 +- 에러율이 계단 초반부터 10% 이상 (측정이 아니라 장애다) +- ServiceDown 알림 발화 + +## 4. 판정·기록 + +- knee 판정은 `summarize.py` 자동: p95 > 500ms / 에러율 > 1% / 2배 부하에 처리량 1.2배 미만 +- 부하기 PC CPU 를 작업관리자로 관찰 — 70% 초과 구간은 폐기 +- 클라 p95(Grafana 3002) vs 서버 p95(터널 3001) gap 확인 +- `docs/perf-log.md` 에 회차 기록 (환경: "부하기 분리, 대상 운영 EC2+RDS" 명시) + +## 5. 리허설 → 본판 + +리허설(1만)이 절차상 문제 없으면: + +```bash +# [로컬] 10만 재생성 후 2단계 반복 (load_seed_rds.sh 가 기존 시드를 지우고 새로 적재) +PYTHONIOENCODING=utf-8 python tools/perf/seed/generate_seed.py --count 100000 +``` + +## 6. 정리 + +- 시드 삭제는 급하지 않다 — 런칭 전 초기화(런북 §9 `down -v` + `create`)에서 일괄 소멸. + 그 전에 지우려면 ID ≥ 1000001 범위 삭제. +- `~/stats-*.csv` 백업, 디스코드에 부하 종료 공지, Alertmanager silence 해제 +- S1 은 읽기 전용이라 `matching_history`/`item.quantity` 오염 없음 — 리셋 불필요 diff --git a/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md b/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md index f995285..6271504 100644 --- a/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md +++ b/docs/superpowers/runbooks/2026-08-26-prod-monitoring-rollout.md @@ -75,7 +75,8 @@ ssh -L 3001:localhost:3001 @43.200.211.135 ## 7. 외부 업타임 감시 UptimeRobot 등에서 웹으로 등록 (EC2 작업 없음): -- URL: `https://srv.comatching.site/actuator/health`, 주기 5분 +- URL: `https://srv.comatching.site/api/auth/participants`, 주기 5분 + (actuator 는 관리 포트 8081 로 분리되어 외부에서 404 다 — 공개 엔드포인트로 감시한다) - 알림: 인프라 디스코드 채널 ## 8. 부하 테스트 (spec 7.2) diff --git a/tools/perf/jmeter/run.sh b/tools/perf/jmeter/run.sh index 4a803cf..9fcaf1a 100755 --- a/tools/perf/jmeter/run.sh +++ b/tools/perf/jmeter/run.sh @@ -77,9 +77,10 @@ fi # 메모리. 1회차를 통째로 날린 진짜 원인이 여기였다 — CPU 100% 는 결과였고, # 원인은 8GB 맥에서 압축 메모리 3.3GB / 스왑 5.9GB 로 스래싱이 난 것이었다. -JVMS=$(pgrep -f 'build/libs/.*\.jar' | wc -l | tr -d ' ') +# pgrep/sysctl 은 macOS/리눅스 전용 — 없는 호스트(Windows Git Bash)에서는 건너뛴다 +JVMS=$( (pgrep -f 'build/libs/.*\.jar' 2>/dev/null || true) | wc -l | tr -d ' ') CONTAINERS=$(docker ps --format '{{.Names}}' | wc -l | tr -d ' ') -SWAP_USED=$(sysctl -n vm.swapusage | sed -n 's/.*used = \([0-9.]*\)M.*/\1/p') +SWAP_USED=$( (sysctl -n vm.swapusage 2>/dev/null || true) | sed -n 's/.*used = \([0-9.]*\)M.*/\1/p') echo "ℹ️ JVM $JVMS개 / 컨테이너 $CONTAINERS개 / 스왑 사용 ${SWAP_USED:-?}M" if docker ps --format '{{.Names}}' | grep -qE 'comatching-(kafka|mongo)'; then @@ -95,11 +96,18 @@ fi # ---------- 2. CPU 샘플러 ---------- echo "" -../cpu_sampler.sh "$RUN_DIR/cpu.csv" 0 > "$RUN_DIR/cpu.log" 2>&1 & -SAMPLER_PID=$! -# 스크립트가 어떻게 끝나든(Ctrl-C 포함) 샘플러는 반드시 정리한다 -trap 'kill "$SAMPLER_PID" 2>/dev/null || true' EXIT -echo "🖥️ CPU 샘플러 시작 (pid $SAMPLER_PID)" +SAMPLER_PID="" +if [ "$(uname -s)" = "Darwin" ]; then + ../cpu_sampler.sh "$RUN_DIR/cpu.csv" 0 > "$RUN_DIR/cpu.log" 2>&1 & + SAMPLER_PID=$! + # 스크립트가 어떻게 끝나든(Ctrl-C 포함) 샘플러는 반드시 정리한다 + trap 'kill "$SAMPLER_PID" 2>/dev/null || true' EXIT + echo "🖥️ CPU 샘플러 시작 (pid $SAMPLER_PID)" +else + # sampler 는 macOS 전용(sysctl/top). 부하기와 대상이 다른 호스트면 + # 70% 폐기 기준의 대상은 부하기 CPU 뿐이므로 작업관리자로 관찰한다. + echo "ℹ️ cpu_sampler 는 macOS 전용 — 이 호스트에서는 건너뜀" +fi # ---------- 3. JMeter ---------- # 1회차엔 -Xmx2g 였는데, 8GB 맥에서는 부하기가 대상의 메모리를 뺏는 셈이었다. @@ -120,9 +128,11 @@ jmeter -n -t "$PLAN" \ JM_RC=$? set -e -kill "$SAMPLER_PID" 2>/dev/null || true -wait "$SAMPLER_PID" 2>/dev/null || true -trap - EXIT +if [ -n "$SAMPLER_PID" ]; then + kill "$SAMPLER_PID" 2>/dev/null || true + wait "$SAMPLER_PID" 2>/dev/null || true + trap - EXIT +fi if [ "$JM_RC" -ne 0 ]; then echo "⚠️ JMeter 종료 코드 $JM_RC — $RUN_DIR/jmeter.log 를 확인하세요." @@ -130,8 +140,15 @@ fi # ---------- 4. 요약 ---------- echo "" -cat "$RUN_DIR/cpu.log" | tail -5 -python3 summarize.py "$RUN_DIR/result.jtl" | tee "$RUN_DIR/summary.txt" +[ -f "$RUN_DIR/cpu.log" ] && tail -5 "$RUN_DIR/cpu.log" +# Windows Git Bash 는 python3 가 "스토어 스텁"이라 command -v 로는 못 거른다. +# 실제로 --version 이 도는 인터프리터를 고른다. cp949 콘솔 깨짐도 막는다. +PY="" +for c in python3 python; do + if "$c" --version >/dev/null 2>&1; then PY="$c"; break; fi +done +[ -n "$PY" ] || { echo "❌ python 이 없습니다"; exit 1; } +PYTHONIOENCODING=utf-8 "$PY" summarize.py "$RUN_DIR/result.jtl" | tee "$RUN_DIR/summary.txt" # ---------- 5. HTML 리포트 ---------- echo "" diff --git a/tools/perf/smoke.sh b/tools/perf/smoke.sh new file mode 100644 index 0000000..9444723 --- /dev/null +++ b/tools/perf/smoke.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# ============================================================================ +# 운영 스모크 테스트 — 부하 전에 "문이 다 열리는지" 확인한다. +# +# 읽기 전용 GET 만 친다. 상태를 바꾸는 요청(가입·매칭·구매)은 없다. +# +# 사용법: +# ./smoke.sh # 공개 엔드포인트만 +# ACCESS_TOKEN= ./smoke.sh # 인증 엔드포인트까지 +# BASE_URL=http://localhost:8080 ./smoke.sh # 로컬 대상 +# +# 인증은 게이트웨이가 accessToken "쿠키"만 읽는다 (Authorization 헤더 아님). +# 토큰은 EC2 에서 tools/perf/tokens/generate_tokens.py 로 발급하거나 +# 브라우저 로그인 후 개발자도구에서 복사한다. +# ============================================================================ +set -uo pipefail + +BASE_URL="${BASE_URL:-https://srv.comatching.site}" +ACCESS_TOKEN="${ACCESS_TOKEN:-}" +TIMEOUT="${TIMEOUT:-10}" + +pass=0; fail=0 +declare -a rows + +check() { # <이름> <기대코드> <경로> [auth] + local name="$1" expect="$2" path="$3" auth="${4:-}" + local cookie=() + [ "$auth" = "auth" ] && cookie=(-b "accessToken=$ACCESS_TOKEN") + local out + out=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time "$TIMEOUT" \ + "${cookie[@]}" "$BASE_URL$path" 2>/dev/null) || out="000 -" + local code="${out%% *}" time="${out##* }" + local mark="❌" + if [ "$code" = "$expect" ]; then mark="✅"; pass=$((pass+1)); else fail=$((fail+1)); fi + rows+=("$(printf '%s %-38s %s (기대 %s) %ss' "$mark" "$name" "$code" "$expect" "$time")") +} + +echo "════════════════════════════════════════════════════════" +echo " 스모크 대상: $BASE_URL" +echo "════════════════════════════════════════════════════════" + +# ---------- 공개 (인증 불필요) ---------- +check "참가자 수 조회" 200 "/api/auth/participants" +check "닉네임 중복 확인" 200 "/api/auth/signup/nickname/availability?nickname=smoke_test_probe" +check "취미 카테고리" 200 "/api/hobbies/categories" +check "프로필 태그" 200 "/api/profile/tags" + +# ---------- 인증 경계 (토큰 없이 401 이 나와야 정상) ---------- +check "매칭 히스토리(비인증)" 401 "/api/matching/history" +check "아이템 목록(비인증)" 401 "/api/items" + +# ---------- 인증 (읽기 전용) ---------- +if [ -n "$ACCESS_TOKEN" ]; then + check "매칭 히스토리" 200 "/api/matching/history" auth + check "아이템 목록" 200 "/api/items" auth + check "아이템 사용 이력" 200 "/api/items/history" auth + check "채팅방 목록" 200 "/api/chat/rooms" auth + check "채팅 안읽음 수" 200 "/api/chat/rooms/unread-count" auth +else + echo "ℹ️ ACCESS_TOKEN 미설정 — 인증 엔드포인트 5개는 건너뜀" +fi + +echo +for r in "${rows[@]}"; do echo "$r"; done +echo +echo "결과: ✅ $pass ❌ $fail" +[ "$fail" -eq 0 ] From b3fa9d18d6f529695c0497660e21ee1d5fef6d06 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 17:26:53 +0900 Subject: [PATCH 44/66] =?UTF-8?q?feat:=20Feign=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=97=90=20=EC=84=9C=ED=82=B7=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=BB=A4=EB=A5=BC=20=EA=B1=B4=EB=8B=A4=20(#8?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 타임아웃(#78)만으로는 피호출 서비스가 죽어 있는 동안에도 모든 요청이 매번 타임아웃까지 기다린다. Resilience4j 서킷브레이커를 붙여 실패가 쌓이면 호출을 즉시 차단하고 503 으로 응답한다. - 브레이커는 메서드가 아니라 Feign 클라이언트(name) 단위로 묶는다. 내부 호출은 메서드별 트래픽이 적어 기본(메서드 단위) 네이밍으로는 minimum-number-of-calls 에 도달하지 못해 브레이커가 열리지 않는다. - 4xx 는 실패로 세지 않는다. 비즈니스 에러가 브레이커를 열면 멀쩡한 서비스로 가는 호출까지 차단된다. - 스레드풀 실행을 꺼서 Spring Cloud 기본 TimeLimiter(1s)가 read-timeout 보다 먼저 호출을 끊는 것을 막는다. - 폴백 없는 호출의 예외가 NoFallbackAvailableException 으로 래핑되는 문제는 프록시 단계 언랩퍼로 원본 예외를 복원한다. 기존 catch (FeignException) 분기가 그대로 동작한다. - 브레이커 open / 타임아웃은 503(GEN-105) 으로 내린다. Co-authored-by: Claude Fable 5 --- common-module/build.gradle | 4 + ...FeignCircuitBreakerExceptionUnwrapper.java | 58 +++++ .../common/config/InternalFeignConfig.java | 15 ++ .../exception/code/GeneralErrorCode.java | 3 + .../handler/GlobalExceptionHandler.java | 58 +++++ .../src/main/resources/feign-resilience.yml | 36 +++ .../config/FeignCircuitBreakerTest.java | 225 ++++++++++++++++++ gateway-service/build.gradle | 3 + notification/build.gradle | 1 + 9 files changed, 403 insertions(+) create mode 100644 common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java create mode 100644 common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java diff --git a/common-module/build.gradle b/common-module/build.gradle index 04f40d9..5285683 100644 --- a/common-module/build.gradle +++ b/common-module/build.gradle @@ -62,6 +62,10 @@ dependencies { // feign api 'org.springframework.cloud:spring-cloud-starter-openfeign' + // 서킷브레이커 (Resilience4j) — Feign 내부 호출 보호. + // 설정은 feign-resilience.yml, 브레이커 이름 규칙은 InternalFeignConfig 참고. + api 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + // kafka api 'org.springframework.kafka:spring-kafka' diff --git a/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java b/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java new file mode 100644 index 0000000..ac24b03 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java @@ -0,0 +1,58 @@ +package com.comatching.common.config; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; + +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.stereotype.Component; + +/** + * 서킷브레이커가 씌운 래퍼를 벗겨 Feign 원본 예외를 복원한다. + * + * spring.cloud.openfeign.circuitbreaker.enabled 를 켜면 폴백 없는 Feign 호출의 + * 모든 예외가 NoFallbackAvailableException 으로 래핑되어 나온다. 그대로 두면 + * 기존 호출부의 catch (FeignException) 분기(예: 404 를 비즈니스 에러로 변환)가 + * 전부 조용히 무력화된다 — 컴파일 에러도 없이 의미가 바뀌는 종류의 회귀다. + * + * 그래서 Feign 클라이언트 빈을 한 겹 감싸 래퍼의 cause(FeignException, + * CallNotPermittedException 등)를 그대로 다시 던진다. 호출부 입장에서는 + * 서킷브레이커 도입 전과 같은 예외 계약이 유지되고, 브레이커 open 만 + * CallNotPermittedException 이라는 새 예외로 추가된다. + */ +@Component +public class FeignCircuitBreakerExceptionUnwrapper implements BeanPostProcessor { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (!isFeignClient(bean)) { + return bean; + } + + return Proxy.newProxyInstance( + bean.getClass().getClassLoader(), + bean.getClass().getInterfaces(), + (proxy, method, args) -> { + try { + return method.invoke(bean, args); + } catch (InvocationTargetException e) { + Throwable thrown = e.getTargetException(); + if (thrown instanceof NoFallbackAvailableException && thrown.getCause() != null) { + throw thrown.getCause(); + } + throw thrown; + } + } + ); + } + + private boolean isFeignClient(Object bean) { + for (Class itf : bean.getClass().getInterfaces()) { + if (itf.isAnnotationPresent(FeignClient.class)) { + return true; + } + } + return false; + } +} diff --git a/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java b/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java index 5a7dedc..d983d6a 100644 --- a/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java +++ b/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java @@ -1,6 +1,7 @@ package com.comatching.common.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.openfeign.CircuitBreakerNameResolver; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.StringUtils; @@ -22,4 +23,18 @@ public RequestInterceptor internalServiceTokenRequestInterceptor( } }; } + + /** + * 서킷브레이커를 Feign 클라이언트(name) 단위로 묶는다. + * + * 기본 리졸버는 메서드 시그니처까지 붙여 브레이커를 만드는데, 그러면 + * 트래픽이 메서드별로 쪼개져 minimum-number-of-calls(feign-resilience.yml)에 + * 도달하지 못한 브레이커는 영영 열리지 않는다. 장애는 엔드포인트가 아니라 + * 프로세스 단위로 오므로(피호출 서비스의 GC 멈춤·커넥션 고갈은 그 서비스의 + * 모든 엔드포인트를 함께 느리게 만든다) 서비스 단위 판단이 맞다. + */ + @Bean + public CircuitBreakerNameResolver feignClientCircuitBreakerNameResolver() { + return (feignClientName, target, method) -> feignClientName; + } } diff --git a/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java b/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java index d1d8881..cac4d4c 100644 --- a/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java +++ b/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java @@ -30,6 +30,9 @@ public enum GeneralErrorCode implements ErrorCode { UNAUTHORIZED("GEN-010", HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."), FORBIDDEN("GEN-011", HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + // 503 Service Unavailable + INTERNAL_SERVICE_UNAVAILABLE("GEN-105", HttpStatus.SERVICE_UNAVAILABLE, "내부 서비스가 일시적으로 응답할 수 없습니다. 잠시 후 다시 시도해주세요."), + // 500 Internal Server Error INTERNAL_SERVER_ERROR("GEN-099", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), DATABASE_ERROR("GEN-100", HttpStatus.INTERNAL_SERVER_ERROR, "데이터베이스 오류가 발생했습니다."), diff --git a/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java b/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java index d664fda..ee73e5b 100644 --- a/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java +++ b/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.Map; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindingResult; @@ -23,6 +24,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; +import feign.RetryableException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -138,6 +141,61 @@ public ResponseEntity> handleNotFoundException(Exception e) { .body(ApiResponse.errorResponse(GeneralErrorCode.NOT_FOUND)); } + /** + * 서킷브레이커 open (503) + * + * 피호출 서비스가 장애 판정을 받아 호출이 차단된 상태. 서버 오류(500)가 + * 아니라 일시적 불가(503)로 내려야 클라이언트·게이트웨이가 재시도 판단을 + * 할 수 있다. + */ + @ExceptionHandler(CallNotPermittedException.class) + public ResponseEntity> handleCallNotPermittedException(CallNotPermittedException e) { + log.error("[Circuit Breaker Open] Breaker: {}", e.getCausingCircuitBreakerName()); + + return ResponseEntity + .status(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE.getHttpStatus()) + .body(ApiResponse.errorResponse(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE)); + } + + /** + * Feign 연결 실패·타임아웃 (503) + * + * FeignException 의 하위 타입이지만 더 구체적이라 이 핸들러가 먼저 잡는다. + * HTTP 응답을 받은 게 아니므로 아래 핸들러처럼 응답 본문을 전달할 수 없고, + * 피호출 서비스의 일시적 불가(503)로 내린다. + */ + @ExceptionHandler(RetryableException.class) + public ResponseEntity> handleFeignRetryableException(RetryableException e) { + log.error("[Feign Timeout] {}", e.getMessage()); + + return ResponseEntity + .status(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE.getHttpStatus()) + .body(ApiResponse.errorResponse(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE)); + } + + /** + * 서킷브레이커 래퍼 언랩 (방어선) + * + * 정상 경로에서는 FeignCircuitBreakerExceptionUnwrapper 가 프록시 단계에서 + * 원본 예외로 복원하므로 여기까지 오지 않는다. 그 층을 우회한 호출이 + * 생기더라도 원인에 맞는 응답이 나가도록 한 번 더 푼다. + */ + @ExceptionHandler(NoFallbackAvailableException.class) + public ResponseEntity handleNoFallbackAvailableException(NoFallbackAvailableException e) { + Throwable cause = e.getCause(); + + if (cause instanceof CallNotPermittedException callNotPermitted) { + return handleCallNotPermittedException(callNotPermitted); + } + if (cause instanceof RetryableException retryable) { + return handleFeignRetryableException(retryable); + } + if (cause instanceof FeignException feignException) { + return handleFeignException(feignException); + } + return handleException(e); + } + /** * Feign Client 예외 처리 */ diff --git a/common-module/src/main/resources/feign-resilience.yml b/common-module/src/main/resources/feign-resilience.yml index ad3c99c..7e77868 100644 --- a/common-module/src/main/resources/feign-resilience.yml +++ b/common-module/src/main/resources/feign-resilience.yml @@ -16,6 +16,11 @@ spring: cloud: openfeign: + # 모든 Feign 호출을 서킷브레이커로 감싼다. 브레이커 이름은 메서드 단위가 + # 아니라 Feign 클라이언트(name) 단위로 묶는다 — InternalFeignConfig 의 + # CircuitBreakerNameResolver 참고. + circuitbreaker: + enabled: true client: config: default: @@ -31,3 +36,34 @@ spring: user-service-admin: # 관리자 페이징 조회 connect-timeout: 1000 read-timeout: 5000 + circuitbreaker: + resilience4j: + # 호출 스레드에서 그대로 실행한다(세마포어 방식). 스레드풀 실행을 켜두면 + # ① Spring Cloud 기본 TimeLimiter(1s)가 위의 read-timeout 보다 먼저 + # 호출을 끊고 ② EC2 한 대에 6개 JVM 이 뜨는 전제에서 서비스마다 + # 스레드풀이 하나씩 늘어난다. 끄면 Feign read-timeout 이 유일한 + # 시간 상한이 된다. + disable-thread-pool: true + +resilience4j: + circuitbreaker: + configs: + # 모든 브레이커(= Feign 클라이언트 단위)의 공통값. 클라이언트별로 바꾸려면 + # resilience4j.circuitbreaker.instances. 으로 오버라이드한다. + default: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + # 내부 호출은 클라이언트별 트래픽이 적다. 이 값에 못 미치면 브레이커가 + # 영영 안 열리므로 낮게 잡는다. + minimum-number-of-calls: 5 + failure-rate-threshold: 50 + # read-timeout(3s)까지 가기 전에 "느려짐"을 먼저 감지한다. + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 80 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + # 4xx 는 피호출 서비스가 정상 동작하며 내린 판정(없는 리소스, 잘못된 + # 요청)이라 장애 신호가 아니다. 실패율에 넣으면 비즈니스 에러가 + # 브레이커를 열어 멀쩡한 서비스로 가는 호출까지 차단한다. + ignore-exceptions: + - feign.FeignException$FeignClientException diff --git a/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java b/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java new file mode 100644 index 0000000..e089c6a --- /dev/null +++ b/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java @@ -0,0 +1,225 @@ +package com.comatching.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.bind.annotation.GetMapping; + +import com.sun.net.httpserver.HttpServer; + +import feign.FeignException; +import feign.RetryableException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; + +/** + * Feign + 서킷브레이커 체인이 feign-resilience.yml 대로 실제 동작하는지 검증. + * + * 설정 바인딩만 확인해서는(FeignResilienceConfigTest) 잡히지 않는 것들을 + * 실제 HTTP 서버(JDK 내장, 루프백)를 상대로 본다: + * 1) 브레이커가 메서드가 아닌 Feign 클라이언트(name) 단위로 만들어지고 + * yml 의 공통값이 그 브레이커에 실제로 들어간다 + * 2) 5xx 가 쌓이면 브레이커가 열려 이후 호출은 서버에 닿지 않는다 + * 3) 4xx 는 실패로 세지 않는다 — 비즈니스 에러가 브레이커를 열면 안 된다 + * 4) Spring Cloud 기본 TimeLimiter(1s)가 개입하지 않는다 — 켜져 있다면 + * read-timeout(3s) 안쪽의 1s 초과 호출이 잘려나간다 + * 5) read-timeout 초과는 RetryableException 으로 터지고 실패로 집계된다 + * 6) 호출부에는 래퍼(NoFallbackAvailableException)가 아니라 원본 예외가 + * 도달한다 — 아래의 모든 예외 타입 단언이 곧 언랩퍼 + * (FeignCircuitBreakerExceptionUnwrapper) 검증이다 + * + * 브로커·DB 없이 루프백 소켓만 쓰므로 단위 테스트 태스크에서 돈다. + */ +@SpringBootTest( + classes = FeignCircuitBreakerTest.App.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + // 운영에 나가는 파일 그대로를 물려 테스트한다 + "spring.config.import=classpath:feign-resilience.yml", + // 타임아웃 검증용 클라이언트만 read 를 짧게 줄인다 (기본 3s 를 기다리지 않도록) + "spring.cloud.openfeign.client.config.cb-timeout.connect-timeout=1000", + "spring.cloud.openfeign.client.config.cb-timeout.read-timeout=500" + } +) +@DisplayName("Feign 서킷브레이커 동작") +class FeignCircuitBreakerTest { + + private static final HttpServer SERVER; + private static final AtomicInteger FAIL_HITS = new AtomicInteger(); + + private static final int SLOW_DELAY_MILLIS = 1500; + private static final String SLOW_BODY = "slow-ok"; + + static { + try { + SERVER = HttpServer.create(new InetSocketAddress(0), 0); + } catch (IOException e) { + throw new IllegalStateException(e); + } + SERVER.createContext("/fail", exchange -> { + FAIL_HITS.incrementAndGet(); + respond(exchange, 500, "boom"); + }); + SERVER.createContext("/notfound", exchange -> respond(exchange, 404, "no such thing")); + SERVER.createContext("/slow", exchange -> { + try { + Thread.sleep(SLOW_DELAY_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + respond(exchange, 200, SLOW_BODY); + }); + SERVER.start(); + } + + private static void respond(com.sun.net.httpserver.HttpExchange exchange, int status, String body) + throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/plain;charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + @DynamicPropertySource + static void serverUrl(DynamicPropertyRegistry registry) { + registry.add("cb-it.base-url", () -> "http://localhost:" + SERVER.getAddress().getPort()); + } + + @AfterAll + static void stopServer() { + SERVER.stop(0); + } + + @FeignClient(name = "cb-server-error", url = "${cb-it.base-url}") + interface ServerErrorClient { + @GetMapping("/fail") + String call(); + } + + @FeignClient(name = "cb-client-error", url = "${cb-it.base-url}") + interface ClientErrorClient { + @GetMapping("/notfound") + String call(); + } + + @FeignClient(name = "cb-slow", url = "${cb-it.base-url}") + interface SlowClient { + @GetMapping("/slow") + String call(); + } + + @FeignClient(name = "cb-timeout", url = "${cb-it.base-url}") + interface TimeoutClient { + @GetMapping("/slow") + String call(); + } + + @SpringBootConfiguration + @ImportAutoConfiguration({ + org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration.class, + org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration.class, + org.springframework.cloud.openfeign.FeignAutoConfiguration.class, + io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration.class, + io.github.resilience4j.springboot3.timelimiter.autoconfigure.TimeLimiterAutoConfiguration.class, + org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JAutoConfiguration.class + }) + @EnableFeignClients(clients = { + ServerErrorClient.class, ClientErrorClient.class, SlowClient.class, TimeoutClient.class + }) + @Import({InternalFeignConfig.class, FeignCircuitBreakerExceptionUnwrapper.class}) + static class App { + } + + @Autowired + Resilience4JCircuitBreakerFactory circuitBreakerFactory; + + @Autowired + ServerErrorClient serverErrorClient; + + @Autowired + ClientErrorClient clientErrorClient; + + @Autowired + SlowClient slowClient; + + @Autowired + TimeoutClient timeoutClient; + + private CircuitBreaker breakerOf(String name) { + return circuitBreakerFactory.getCircuitBreakerRegistry() + .find(name) + .orElseThrow(() -> new AssertionError("브레이커가 없다: " + name)); + } + + @Test + @DisplayName("5xx 가 최소 호출 수만큼 쌓이면 브레이커가 열려 서버에 닿지 않는다") + void opensOnServerErrors() { + int minimumCalls = 5; // feign-resilience.yml 의 minimum-number-of-calls + + for (int i = 0; i < minimumCalls; i++) { + assertThatThrownBy(serverErrorClient::call).isInstanceOf(FeignException.class); + } + + int hitsBeforeOpen = FAIL_HITS.get(); + assertThatThrownBy(serverErrorClient::call).isInstanceOf(CallNotPermittedException.class); + assertThat(FAIL_HITS.get()) + .as("차단된 호출은 서버에 도달하면 안 된다") + .isEqualTo(hitsBeforeOpen); + + // 브레이커 이름이 메서드가 아니라 Feign 클라이언트 name 그대로인지, + // yml 공통값이 그 브레이커에 들어갔는지도 여기서 같이 확인한다. + CircuitBreaker breaker = breakerOf("cb-server-error"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + assertThat(breaker.getCircuitBreakerConfig().getMinimumNumberOfCalls()).isEqualTo(minimumCalls); + assertThat(breaker.getCircuitBreakerConfig().getSlidingWindowSize()).isEqualTo(10); + } + + @Test + @DisplayName("4xx 는 실패로 세지 않는다 — 비즈니스 에러로 브레이커가 열리면 안 된다") + void ignoresClientErrors() { + for (int i = 0; i < 8; i++) { + assertThatThrownBy(clientErrorClient::call) + .isInstanceOf(FeignException.NotFound.class); + } + + CircuitBreaker breaker = breakerOf("cb-client-error"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + assertThat(breaker.getMetrics().getNumberOfFailedCalls()).isZero(); + } + + @Test + @DisplayName("read-timeout 안쪽의 느린 호출은 성공한다 — 기본 TimeLimiter(1s) 미개입 증명") + void slowCallWithinReadTimeoutSucceeds() { + // disable-thread-pool 이 풀리면 Spring Cloud 기본 TimeLimiter(1s)가 + // 이 1.5s 호출을 read-timeout(3s) 전에 끊어 이 테스트가 깨진다. + assertThat(slowClient.call()).isEqualTo(SLOW_BODY); + } + + @Test + @DisplayName("read-timeout 초과는 RetryableException 으로 터지고 실패로 집계된다") + void timeoutCountsAsFailure() { + assertThatThrownBy(timeoutClient::call).isInstanceOf(RetryableException.class); + + assertThat(breakerOf("cb-timeout").getMetrics().getNumberOfFailedCalls()).isEqualTo(1); + } +} diff --git a/gateway-service/build.gradle b/gateway-service/build.gradle index d7aba92..e1b72e0 100644 --- a/gateway-service/build.gradle +++ b/gateway-service/build.gradle @@ -16,6 +16,9 @@ dependencies { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa' exclude group: 'org.springframework.boot', module: 'spring-boot-starter-security' exclude group: 'io.awspring.cloud', module: 'spring-cloud-aws-starter-s3' + // Feign 을 쓰지 않으므로 서킷브레이커 스타터도 제외한다. WebFlux 게이트웨이에 + // blocking 팩토리 자동설정이 뜨는 것을 막는 목적도 있다. + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-circuitbreaker-resilience4j' } implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' diff --git a/notification/build.gradle b/notification/build.gradle index 31dec79..64409ad 100644 --- a/notification/build.gradle +++ b/notification/build.gradle @@ -9,6 +9,7 @@ description = 'notification' dependencies { implementation(project(':common-module')) { exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-openfeign' + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-circuitbreaker-resilience4j' exclude group: 'io.awspring.cloud', module: 'spring-cloud-aws-starter-s3' } From 6783abf9985dd900f3bbfae7d0ef54dd96ed3934 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 17:31:06 +0900 Subject: [PATCH 45/66] =?UTF-8?q?ci:=20=EC=9E=90=EB=8F=99=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EB=8A=94=20diff=EB=A7=8C,=20cross-service=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=EC=9D=80=20@claude=20=EC=88=98=EB=8F=99=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=82=98=EB=88=88=EB=8B=A4=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit common-module 변경 PR(#85)의 자동 리뷰가 9분 넘게 걸렸다. CLAUDE.md 의 "서비스 경계를 넘는 변경은 하위 호환성을 특히 주의해서 본다" 지침을 따라 diff 밖의 다른 서비스 소비 코드까지 찾아 읽었기 때문으로 보인다 - diff 자체는 403줄로 크지 않았다. 자동 리뷰(claude-code-review.yml)는 diff 에 나온 코드만 보게 프롬프트로 범위를 좁히고, common-module/내부 API/Kafka 스키마 변경이 보이면 실제 분석 대신 "@claude 로 수동 리뷰를 요청하라"는 코멘트만 남기도록 한다. cross-service 하위 호환성 분석은 claude.yml(@claude 수동 호출)의 역할로 문서화한다. allowedTools 는 건드리지 않았다 - #79 에서 도구를 뺐다가 리뷰가 통째로 증발한 사고가 있어서, 속도 문제는 프롬프트로만 좁힌다. --- .github/workflows/claude-code-review.yml | 7 +++++++ .github/workflows/claude.yml | 6 ++++++ CLAUDE.md | 6 ++++++ 3 files changed, 19 insertions(+) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b6f4af5..e9593b7 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -47,6 +47,13 @@ jobs: 리뷰는 이 턴 안에서 끝낸다. 서브에이전트를 백그라운드로 띄우고 완료 알림을 기다리지 마라 - Actions 에는 알림을 받을 루프가 없어 프로세스가 그대로 죽는다. + + 이 자동 리뷰는 diff 에 나온 코드만 본다. common-module 등 + diff 밖의 다른 서비스로 건너가 소비 코드를 찾아 읽는 하위 호환성 + 분석은 하지 마라 - 시간이 몇 배로 늘어난다. diff 가 + common-module 의 DTO, 서비스 간 내부 API, Kafka 이벤트 스키마를 + 건드린다면, 그 분석 대신 코멘트에 "cross-service 영향 확인이 + 필요합니다 - `@claude` 로 수동 리뷰를 요청하세요" 한 줄만 남겨라. # allowedTools 는 화이트리스트다. 목록에 없는 도구는 permissionMode # default + 비대화형 조합에서 그대로 거부된다. # - Skill: 없으면 /code-review 스킬 본체가 로드되지 못한다. 모델은 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index ccf219e..56d9efb 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -3,6 +3,12 @@ # # @claude ChatRoomPresenceTracker 가 동시성 안전한지 봐줘 # @claude 이 테스트 깨지는 원인 찾아줘 +# +# common-module/내부 API/Kafka 스키마처럼 서비스 경계를 넘는 변경의 하위 +# 호환성 분석은 여기서 한다. claude-code-review.yml 은 diff 만 보는 얕은 +# 리뷰라 그 분석을 하지 않고, 대신 이 워크플로를 부르라는 코멘트만 남긴다. +# +# @claude 이 PR 의 common-module 변경이 다른 서비스에 미치는 영향 봐줘 name: Claude Code on: diff --git a/CLAUDE.md b/CLAUDE.md index 2006c01..81ed067 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,12 @@ Spring 기반 MSA. 서비스는 gateway / user / matching / chat / item / notifi - **서비스 경계를 넘는 변경은 하위 호환성을 특히 주의해서 본다** — `common-module` 의 DTO, 서비스 간 내부 API, Kafka 이벤트 스키마는 배포 순서가 어긋나는 순간 깨진다. + - **단, PR 자동 리뷰(`claude-code-review.yml`)는 diff 에 나온 코드만 보고 + 끝낸다.** 다른 서비스로 건너가 소비 코드를 찾아 읽는 하위 호환성 + 분석은 턴·시간을 크게 늘린다 — diff 가 `common-module`/내부 API/Kafka + 스키마를 건드리면 자동 리뷰는 코멘트에 "cross-service 영향 확인 필요" + 한 줄만 남기고, 실제 분석은 PR 코멘트에 `@claude` 로 명시적으로 + 요청했을 때(`claude.yml`)만 수행한다. - 운영 설정(`docker-compose.prod.yml`, `application-aws.yml`)은 EC2 단일 호스트에 6개 JVM 이 함께 뜨는 전제로 메모리 한도가 잡혀 있다. 한도를 건드리는 변경은 전체 예산과 함께 본다. From a1cff98b207744cb04c7245581a6c8f81d3da8e8 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Wed, 26 Aug 2026 17:54:17 +0900 Subject: [PATCH 46/66] =?UTF-8?q?fix:=20kafka=C2=B7mongo=20=ED=97=AC?= =?UTF-8?q?=EC=8A=A4=EC=B2=B4=ED=81=AC=EB=A5=BC=20TCP=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BF=94=20=EC=9C=A0=ED=9C=B4=20?= =?UTF-8?q?CPU=20=EB=B2=84=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=97=86=EC=95=A4?= =?UTF-8?q?=EB=8B=A4=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유휴 서버에서 kafka 평균 34%/피크 188%, mongo 평균 12%/피크 111% CPU 를 확인했고, 버스트 시각이 docker inspect 의 헬스체크 실행 기록과 초 단위로 일치했다. kafka-broker-api-versions.sh 는 호출마다 JVM 을(1회 3~6초), mongosh 는 Node.js 를(1회 ~1초) 새로 띄운다. interval 10s 와 만나 2 vCPU 중 ~0.46코어를 헬스체크가 상시 소모 - 회차 6 부하에서 본 kafka 179% 피크의 정체다. TCP 접속 확인(실측 0.1초)으로 교체하고 interval 30s, 기동 중에만 start_interval 2s 로 촘촘히 본다. 이 헬스체크의 소비자는 compose 기동 순서뿐이라 API 수준 검사를 유지할 이유가 없다. 조사 기록은 docs/perf-log.md 회차 7 에 남겼다. Co-authored-by: Claude Fable 5 --- docker-compose.prod.yml | 32 +++++++++++++++++++++++--------- docs/perf-log.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9368255..9ef9658 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -101,11 +101,20 @@ services: # 달리 약간의 스왑은 허용하되 무제한(기본값: mem_limit 의 2배)은 막는다. memswap_limit: 1536m healthcheck: - test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server kafka:29092 >/dev/null 2>&1"] - interval: 10s - timeout: 10s - retries: 30 - start_period: 30s + # 예전엔 kafka-broker-api-versions.sh 였는데, 이 스크립트는 호출마다 + # JVM 을 새로 띄워 한 번에 3~6초를 태운다. interval 10s 와 만나면 + # 브로커 컨테이너가 유휴 상태에서도 시간의 30~40%를 헬스체크에 쓴다 + # (2026-08-26 실측 — 2 vCPU 호스트 유휴 CPU 버스트의 주범). + # TCP 접속 확인으로 바꾼다. 브로커는 기동 마지막에 리스너를 열므로 + # 포트가 열렸다 = 요청을 받는다이고, 이 헬스체크의 소비자는 + # compose 기동 순서(kafka-topic-init·앱들의 depends_on)뿐이다. + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/kafka/29092"] + interval: 30s + timeout: 5s + retries: 10 + start_period: 60s + # 기동 중에만 2초 간격으로 촘촘히 본다(engine 25+). 안정 상태는 30s. + start_interval: 2s logging: *logging # auto-create 를 껐으므로 토픽을 누가 만들지 정해야 한다. @@ -162,11 +171,16 @@ services: # WiredTiger 캐시(0.25GB) 밖 워킹셋이 잠깐 넘칠 때를 위한 여유 128m. memswap_limit: 896m healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"] - interval: 10s + # mongosh 는 Node.js 앱이라 호출마다 콜드스타트로 ~1초를 태운다. + # interval 10s 면 유휴 CPU 의 ~10%를 헬스체크가 먹는다 (2026-08-26 실측). + # mongod 는 스토리지 초기화가 끝난 뒤에야 리스너를 열므로 + # TCP 접속 성공이 곧 준비됐다는 신호다. + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/27017"] + interval: 30s timeout: 5s - retries: 20 - start_period: 20s + retries: 10 + start_period: 30s + start_interval: 2s logging: *logging redis: diff --git a/docs/perf-log.md b/docs/perf-log.md index a76c66f..3972e20 100644 --- a/docs/perf-log.md +++ b/docs/perf-log.md @@ -562,3 +562,32 @@ COUNT 쿼리가 10초에 1번만 나간다. 측정 조건은 회차 5와 동일 목표 1,667 RPS 대비 5.0배 부족. 다음 지렛대는 코드가 아니라 인프라 — kafka/mongo 버스트 해소(조사 태스크 진행 중) 또는 스케일업/분리가 남은 카드다. + +## 회차 7 — kafka·mongo 유휴 CPU 버스트 원인 규명 (2026-08-26) + +회차 5·6에서 지적한 "유휴 상태의 kafka·mongo CPU 버스트"를 추적했다. +부하를 걸지 않은 서버에서 `docker stats` 를 2초 간격 100회 샘플링한 결과: + +| 컨테이너 | 평균 CPU | 피크 | 20% 초과 비율 | +|---|---|---|---| +| kafka | 34.4% | 188% | 29% | +| mongodb | 11.6% | 111% | 17% | +| 앱 6개 합계 | ~17% | — | — | + +**범인은 헬스체크였다.** 버스트 발생 시각이 `docker inspect` 의 헬스체크 +실행 기록과 초 단위로 일치한다: + +- **kafka**: `kafka-broker-api-versions.sh` 는 호출마다 JVM 을 새로 띄운다. + 1회 실측 2.9~5.7초, interval 10s → 브로커 컨테이너가 유휴 시간의 30~40%를 + 헬스체크 JVM 기동에 쓰고 있었다. +- **mongodb**: `mongosh --eval ping` 은 Node.js 콜드스타트로 1회 0.9~1.4초. + 로그에 10초마다 mongosh 접속 5~6개가 찍혀 있었다. + +둘이 합쳐 **2 vCPU 중 평균 ~0.46코어를 상시 소모** — 회차 6의 400 RPS 계단에서 +kafka 179% / mongo 114% 피크로 보이던 것의 정체다. 부하 중엔 CPU 경합으로 +헬스체크 JVM 기동이 더 느려져 버스트가 더 길고 겹치게 된다. + +개선: 두 헬스체크를 TCP 접속 확인(`bash /dev/tcp`, 실측 0.1초)으로 교체하고 +interval 30s + `start_interval: 2s`(기동 중에만 촘촘히)로 조정했다. +포트 개방 시점 = 요청 수신 시점이고, 이 헬스체크의 소비자는 compose 기동 +순서뿐이라 API 수준 검사를 유지할 이유가 없다. From 22d0eba6cac937fb6e71070dcd637c0e05cce203 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Wed, 26 Aug 2026 18:13:48 +0900 Subject: [PATCH 47/66] =?UTF-8?q?fix(gateway/user/item):=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20API=20=EC=9D=B4=EA=B4=80=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EC=9B=A8=EC=9D=B4=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gateway에 관리자 사용자·공지 및 활성 공지 user-service 라우트 추가 - user-service 관리자 사용자·공지 API 경로를 /api/v1으로 변경 - item-service 기존 관리자 사용자·공지 구현 비활성화 --- .gitignore | 1 + .../src/main/resources/application.yml | 20 ++++- .../admin/service/AdminUserItemService.java | 5 ++ .../service/AdminUserItemServiceImpl.java | 5 ++ .../domain/notice/service/NoticeService.java | 5 ++ .../notice/service/NoticeServiceImpl.java | 5 ++ .../item/infra/client/UserAdminClient.java | 6 ++ .../{ => admin}/AdminPaymentController.java | 2 +- .../{ => admin}/AdminProductController.java | 2 +- .../{ => admin}/AdminUserController.java | 8 +- .../{ => admin}/NoticeController.java | 8 +- .../InternalAdminItemController.java | 2 +- .../service/AdminUserItemServiceImplTest.java | 5 ++ .../notice/service/NoticeServiceImplTest.java | 5 ++ .../controller/AdminMemberController.java | 2 +- .../controller/AdminNoticeController.java | 8 +- .../InternalAdminUserController.java | 74 +++++++++---------- 17 files changed, 114 insertions(+), 49 deletions(-) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/AdminPaymentController.java (98%) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/AdminProductController.java (98%) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/AdminUserController.java (93%) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/NoticeController.java (94%) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => internal}/InternalAdminItemController.java (96%) diff --git a/.gitignore b/.gitignore index 8a83e8f..b0310c4 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ bin/ .claudeignore .claude .mcp.json +AGENTS.md CLAUDE.md scripts .idea diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index c5b2555..05c2473 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -49,7 +49,7 @@ spring: - id: user-service-protected uri: http://localhost:9000 predicates: - - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/** + - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/**, /api/notices/** filters: - AuthorizationHeaderFilter @@ -91,7 +91,7 @@ spring: - id: item-service uri: http://localhost:9006 predicates: - - Path=/api/items/**, /api/v1/** + - Path=/api/items/** filters: - AuthorizationHeaderFilter @@ -101,6 +101,22 @@ spring: - Path=/api/fcm/** filters: - AuthorizationHeaderFilter + + - id: admin-user-notice + uri: http://localhost:9000 + predicates: + - Path=/api/v1/admin/users/**, /api/v1/admin/notices/** + filters: + - AuthorizationHeaderFilter + + - id: admin-payment-product-items + uri: http://localhost:9006 + predicates: + - Path=/api/v1/admin/shop/**, /api/v1/admin/payment/** + filters: + - AuthorizationHeaderFilter + + default-filters: - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemService.java index 4c832bd..dc9d2ca 100644 --- a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemService.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemService.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.admin.service; +/* + * Temporarily disabled with the item-service admin user API. + * + import org.springframework.data.domain.Pageable; import com.comatching.common.dto.response.PagingResponse; @@ -15,3 +19,4 @@ public interface AdminUserItemService { void updateUserInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request); } +*/ diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImpl.java index 4a2a5a1..4114e5e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImpl.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.admin.service; +/* + * Temporarily disabled with the item-service admin user API. + * + import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -159,3 +163,4 @@ private List toSortParams(Sort sort) { .toList(); } } +*/ diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java index e0505dc..54ae003 100644 --- a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java +++ b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.notice.service; +/* + * Temporarily disabled with the item-service notice API. + * + import java.util.List; import com.comatching.item.domain.notice.dto.AdminNoticeResponse; @@ -19,3 +23,4 @@ public interface NoticeService { List getAdminNotices(); } +*/ diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java index 0797afc..91ceb6e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.notice.service; +/* + * Temporarily disabled with the item-service notice API. + * + import java.time.LocalDateTime; import java.util.List; @@ -87,3 +91,4 @@ private void validatePeriod(LocalDateTime startTime, LocalDateTime endTime) { } } } +*/ diff --git a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java index 0d524ef..73c1f47 100644 --- a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java +++ b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java @@ -1,5 +1,10 @@ package com.comatching.item.infra.client; +/* + * Temporarily disabled with the item-service admin user API. + * This prevents requests to user-service for the migrated endpoints. + * + import java.util.List; import org.springframework.cloud.openfeign.FeignClient; @@ -24,3 +29,4 @@ PagingResponse getUsers( @GetMapping("/{memberId}") AdminUserProfileDto getUserDetail(@PathVariable Long memberId); } +*/ diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminPaymentController.java similarity index 98% rename from item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminPaymentController.java index 24fbb09..7dd59f9 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminPaymentController.java @@ -1,4 +1,4 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminProductController.java similarity index 98% rename from item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminProductController.java index 207b7a5..f34cf2f 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminProductController.java @@ -1,4 +1,4 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; import java.util.List; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminUserController.java similarity index 93% rename from item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminUserController.java index 30325e5..ad30cfc 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminUserController.java @@ -1,4 +1,9 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; + +/* + * Temporarily disabled: admin user/item API has moved out of item-service. + * Keep the prior implementation here as a comment for the migration rollback. + * import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -69,3 +74,4 @@ public ResponseEntity> updateUserInventory( return ResponseEntity.ok(ApiResponse.ok()); } } +*/ diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/NoticeController.java similarity index 94% rename from item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/NoticeController.java index 4251032..f0424c0 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/NoticeController.java @@ -1,4 +1,9 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; + +/* + * Temporarily disabled: notice API has moved out of item-service. + * Keep the prior implementation here as a comment for the migration rollback. + * import java.util.List; @@ -88,3 +93,4 @@ public ResponseEntity>> getActiveNotices( return ResponseEntity.ok(ApiResponse.ok(noticeService.getActiveNotices())); } } +*/ diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java b/item-service/src/main/java/com/comatching/item/infra/controller/internal/InternalAdminItemController.java similarity index 96% rename from item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/internal/InternalAdminItemController.java index 0dd7303..582c7b1 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/internal/InternalAdminItemController.java @@ -1,4 +1,4 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.internal; import java.util.List; import java.util.Map; diff --git a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImplTest.java index 2e0af31..54d492a 100644 --- a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImplTest.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.admin.service; +/* + * Temporarily disabled with the item-service admin user API. + * + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -196,3 +200,4 @@ public Long getQuantity() { }; } } +*/ diff --git a/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java index 3666840..a7a2c4d 100644 --- a/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.notice.service; +/* + * Temporarily disabled with the item-service notice API. + * + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -239,3 +243,4 @@ void shouldThrowWhenDeletingMissingNotice() { .isEqualTo(GeneralErrorCode.NOT_FOUND); } } +*/ diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index 455a002..43b6e0f 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -30,7 +30,7 @@ @Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리") @RestController -@RequestMapping("/api/admin/users") +@RequestMapping("/api/v1/admin/users") @RequiredArgsConstructor public class AdminMemberController { diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java index 1ffed93..8eb3ad9 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java @@ -28,7 +28,7 @@ public class AdminNoticeController { @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "관리자 공지사항 목록 조회", description = "관리자가 등록된 전체 공지사항을 노출 시작시간 내림차순으로 조회합니다.") - @GetMapping("/admin/notices") + @GetMapping("v1/admin/notices") public ResponseEntity>> getAdminNotices( @CurrentMember MemberInfo memberInfo ) { @@ -37,7 +37,7 @@ public ResponseEntity>> getAdminNotices( @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "공지사항 등록", description = "관리자가 제목, 내용, 시작시간, 종료시간으로 공지사항을 등록합니다.") - @PostMapping("/admin/notices") + @PostMapping("v1/admin/notices") public ResponseEntity> createNotice( @CurrentMember MemberInfo memberInfo, @RequestBody @Valid NoticeCreateRequest request @@ -48,7 +48,7 @@ public ResponseEntity> createNotice( @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "공지사항 수정", description = "관리자가 기존 공지사항의 제목, 내용, 노출 기간을 수정합니다.") - @PatchMapping("/admin/notices/{noticeId}") + @PatchMapping("v1/admin/notices/{noticeId}") public ResponseEntity> updateNotice( @CurrentMember MemberInfo memberInfo, @PathVariable Long noticeId, @@ -60,7 +60,7 @@ public ResponseEntity> updateNotice( @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "공지사항 삭제", description = "관리자가 공지사항을 삭제합니다.") - @DeleteMapping("/admin/notices/{noticeId}") + @DeleteMapping("v1/admin/notices/{noticeId}") public ResponseEntity> deleteNotice( @CurrentMember MemberInfo memberInfo, @PathVariable Long noticeId diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java index 73cb058..1761cd5 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java @@ -1,37 +1,37 @@ -package com.comatching.user.infra.controller; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import com.comatching.common.dto.member.AdminUserProfileDto; -import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.member.service.AdminMemberQueryService; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/internal/admin/users") -@RequiredArgsConstructor -public class InternalAdminUserController { - - private final AdminMemberQueryService adminMemberQueryService; - - @GetMapping - public PagingResponse getUsers( - @RequestParam(required = false) String keyword, - @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable - ) { - return adminMemberQueryService.getUsers(keyword, pageable); - } - - @GetMapping("/{memberId}") - public AdminUserProfileDto getUserDetail(@PathVariable Long memberId) { - return adminMemberQueryService.getUserDetail(memberId); - } -} +//package com.comatching.user.infra.controller; +// +//import org.springframework.data.domain.Pageable; +//import org.springframework.data.domain.Sort; +//import org.springframework.data.web.PageableDefault; +//import org.springframework.web.bind.annotation.GetMapping; +//import org.springframework.web.bind.annotation.PathVariable; +//import org.springframework.web.bind.annotation.RequestMapping; +//import org.springframework.web.bind.annotation.RequestParam; +//import org.springframework.web.bind.annotation.RestController; +// +//import com.comatching.common.dto.member.AdminUserProfileDto; +//import com.comatching.common.dto.response.PagingResponse; +//import com.comatching.user.domain.member.service.AdminMemberQueryService; +// +//import lombok.RequiredArgsConstructor; +// +//@RestController +//@RequestMapping("/api/internal/admin/users") +//@RequiredArgsConstructor +//public class InternalAdminUserController { +// +// private final AdminMemberQueryService adminMemberQueryService; +// +// @GetMapping +// public PagingResponse getUsers( +// @RequestParam(required = false) String keyword, +// @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable +// ) { +// return adminMemberQueryService.getUsers(keyword, pageable); +// } +// +// @GetMapping("/{memberId}") +// public AdminUserProfileDto getUserDetail(@PathVariable Long memberId) { +// return adminMemberQueryService.getUserDetail(memberId); +// } +//} From 7f239ccf41caf1cbb6f5ee85eca3534e21fa4d2d Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 27 Aug 2026 14:07:02 +0900 Subject: [PATCH 48/66] =?UTF-8?q?feat(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouletteReward, RouletteHistory 엔티티 추가 - RouletteType, PrizeType enum 추가 --- .gitignore | 2 + .../roulette/entity/RouletteHistory.java | 54 +++++++++++++++ .../roulette/entity/RouletteReward.java | 66 +++++++++++++++++++ .../item/domain/roulette/enums/PrizeType.java | 7 ++ .../domain/roulette/enums/RouletteType.java | 6 ++ 5 files changed, 135 insertions(+) create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java diff --git a/.gitignore b/.gitignore index b16d467..d8a3325 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,5 @@ _workspace id_rsa id_ed25519 comatching-ec2 +/AGENTS.md + diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java new file mode 100644 index 0000000..281f059 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -0,0 +1,54 @@ +package com.comatching.item.domain.roulette.entity; + +import java.time.LocalDateTime; + +import com.comatching.item.domain.roulette.enums.RouletteType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RouletteHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long memberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reward_id", nullable = false) + private RouletteReward reward; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RouletteType rouletteType; + + private final LocalDateTime participatedAt = LocalDateTime.now(); + + @Builder + public RouletteHistory( + Long memberId, + RouletteReward reward, + RouletteType rouletteType + ) { + this.memberId = memberId; + this.reward = reward; + this.rouletteType = rouletteType; + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java new file mode 100644 index 0000000..6a6c419 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java @@ -0,0 +1,66 @@ +package com.comatching.item.domain.roulette.entity; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.item.domain.roulette.enums.RouletteType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RouletteReward { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RouletteType rouletteType; + + @Column(nullable = false) + private String rewardName; + + @Enumerated(EnumType.STRING) + private ItemType itemType; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private int rangeStart; + + @Column(nullable = false) + private int rangeEnd; + + private Integer remainingCount; + + @Builder + public RouletteReward( + RouletteType rouletteType, + String rewardName, + ItemType itemType, + int quantity, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + this.rouletteType = rouletteType; + this.rewardName = rewardName; + this.itemType = itemType; + this.quantity = quantity; + this.rangeStart = rangeStart; + this.rangeEnd = rangeEnd; + this.remainingCount = remainingCount; + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java new file mode 100644 index 0000000..890dc2a --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java @@ -0,0 +1,7 @@ +package com.comatching.item.domain.roulette.enums; + +public enum PrizeType { + ITEM, + GIFT_CARD, + NONE +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java new file mode 100644 index 0000000..d74ed40 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java @@ -0,0 +1,6 @@ +package com.comatching.item.domain.roulette.enums; + +public enum RouletteType { + FREE, + PAID +} \ No newline at end of file From e237117c0890e64de2fef640e39cf06997a0040e Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 27 Aug 2026 19:22:42 +0900 Subject: [PATCH 49/66] =?UTF-8?q?feat(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EC=B6=94=EC=B2=A8=20=EB=B0=8F=20=EB=B3=B4=EC=83=81=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FREE 룰렛 중복 참여 검증 및 PAID 룰렛 티켓 차감 처리 - 확률 범위 기반 보상 추첨과 제한 재고 차감 로직 추가 - 아이템 보상과 아이템 사용·획득 이력 저장 - 상품권·꽝을 포함한 룰렛 참여 이력 저장 - 당첨 보상명을 반환하는 응답 DTO 및 룰렛 API 추가 - 보상 처리 예외와 난수 경계 조건 테스트 작성 --- .../java/com/comatching/common/domain/enums/ItemType.java | 3 ++- .../item/domain/item/repository/ItemRepository.java | 8 ++++++++ .../item/domain/roulette/entity/RouletteReward.java | 8 +++++++- .../comatching/item/global/exception/ItemErrorCode.java | 1 + 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java b/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java index a1e7c0c..33ef652 100644 --- a/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java +++ b/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java @@ -7,7 +7,8 @@ @RequiredArgsConstructor public enum ItemType { MATCHING_TICKET("매칭권", "기본 옵션만으로 매칭을 할 수 있는 아이템"), - OPTION_TICKET("옵션권", "같은 학과 제외, 중요 옵션 선택이 가능해지는 아이템") + OPTION_TICKET("옵션권", "같은 학과 제외, 중요 옵션 선택이 가능해지는 아이템"), + ROULETTE_TICKET("룰렛티켓", "룰렛을 돌릴 수 있는 티켓") ; private final String name; diff --git a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java index 8055318..d6fcbfc 100644 --- a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java @@ -1,6 +1,8 @@ package com.comatching.item.domain.item.repository; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -28,6 +30,12 @@ List findAllUsableItems( @Param("memberId") Long memberId, @Param("itemType") ItemType itemType); + Optional findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + Long memberId, + ItemType itemType, + int quantity, + LocalDateTime now); + @Query("SELECT i FROM Item i " + "WHERE i.memberId = :memberId " + "AND i.quantity > 0 " + diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java index 6a6c419..d95ae3e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java @@ -63,4 +63,10 @@ public RouletteReward( this.rangeEnd = rangeEnd; this.remainingCount = remainingCount; } -} \ No newline at end of file + + public void decreaseRemainingCount() { + if (remainingCount != null && remainingCount > 0) { + remainingCount--; + } + } +} diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 1123123..3490cb8 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -15,6 +15,7 @@ public enum ItemErrorCode implements ErrorCode { TARGET_USER_NOT_FOUND("ITEM-004", HttpStatus.BAD_REQUEST, "대상 사용자를 찾을 수 없습니다."), USER_QUERY_FAILED("ITEM-005", HttpStatus.INTERNAL_SERVER_ERROR, "사용자 조회 중 오류가 발생했습니다."), DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), + ALREADY_PARTICIPATED_FREE_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "Free roulette already participated."), ; private final String code; From 8ab8b3c9ed3a98cd132218317fccdf4fe2b08a5c Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 27 Aug 2026 20:44:53 +0900 Subject: [PATCH 50/66] =?UTF-8?q?test(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PAID 룰렛 티켓 실제 차감 검증 - FREE 당일 참여 조회 조건 검증 - 풀세트 전체 재고 차감 검증 - PAID 일일 제한 미적용 검증 - 보상 처리 실패 시 트랜잭션 롤백 검증 - 아이템 이력 설명과 룰렛 API 계약 검증 --- .../roulette/dto/RouletteSpinResponse.java | 6 + .../repository/RouletteHistoryRepository.java | 20 + .../repository/RouletteRewardRepository.java | 26 ++ .../roulette/service/RouletteService.java | 9 + .../roulette/service/RouletteServiceImpl.java | 104 ++++++ .../infra/controller/RouletteController.java | 30 ++ .../service/RouletteRewardRepositoryTest.java | 265 ++++++++++++++ .../service/RouletteServiceImplTest.java | 346 ++++++++++++++++++ .../controller/RouletteControllerTest.java | 85 +++++ 9 files changed, 891 insertions(+) create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java create mode 100644 item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java create mode 100644 item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java new file mode 100644 index 0000000..644806f --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java @@ -0,0 +1,6 @@ +package com.comatching.item.domain.roulette.dto; + +public record RouletteSpinResponse( + String rewardName +) { +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java new file mode 100644 index 0000000..8571973 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -0,0 +1,20 @@ +package com.comatching.item.domain.roulette.repository; + +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RouletteType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RouletteHistoryRepository extends JpaRepository { + @Query(""" + SELECT CASE WHEN COUNT(rh) > 0 THEN true ELSE false END + FROM RouletteHistory rh + WHERE rh.memberId = :memberId + AND rh.rouletteType = :rouletteType + AND rh.participatedAt >= CURRENT_DATE + """) + boolean existsTodayByMemberIdAndRouletteType( + @Param("memberId") Long memberId, + @Param("rouletteType") RouletteType rouletteType); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java new file mode 100644 index 0000000..a63b718 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -0,0 +1,26 @@ +package com.comatching.item.domain.roulette.repository; + +import java.util.List; + +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RouletteRewardRepository extends JpaRepository { + List findAllByRouletteType(RouletteType rouletteType); + + @Query(""" + SELECT rr + FROM RouletteReward rr + WHERE rr.rouletteType = :rouletteType + AND rr.rangeStart <= :rouletteNumber + AND rr.rangeEnd >= :rouletteNumber + AND (rr.remainingCount IS NULL OR rr.remainingCount > 0) + ORDER BY rr.id ASC + """) + List findAvailableByRouletteTypeAndRouletteNumber( + @Param("rouletteType") RouletteType rouletteType, + @Param("rouletteNumber") int rouletteNumber); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java new file mode 100644 index 0000000..55d606d --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java @@ -0,0 +1,9 @@ +package com.comatching.item.domain.roulette.service; + +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; + +public interface RouletteService { + RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java new file mode 100644 index 0000000..9608d98 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -0,0 +1,104 @@ +package com.comatching.item.domain.roulette.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@AllArgsConstructor +public class RouletteServiceImpl implements RouletteService { + private static final LocalDateTime ROULETTE_REWARD_EXPIRED_AT = + LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + private final RouletteHistoryRepository rouletteHistoryRepository; + private final RouletteRewardRepository rouletteRewardRepository; + private final ItemRepository itemRepository; + private final ItemHistoryRepository itemHistoryRepository; + + @Override + @Transactional + public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { + // 타입이 무료인 경우 참여여부 검증 + 예외처리 + if (rouletteType == RouletteType.FREE && rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(memberInfo.memberId(), rouletteType)) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + } + + if (rouletteType == RouletteType.PAID) { + Item rouletteTicket = itemRepository + .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + memberInfo.memberId(), ItemType.ROULETTE_TICKET, 1, LocalDateTime.now()) + .orElseThrow(() -> new BusinessException(ItemErrorCode.NOT_ENOUGH_ITEM)); + + rouletteTicket.decrease(1); + + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberInfo.memberId()) + .itemType(ItemType.ROULETTE_TICKET) + .historyType(ItemHistoryType.USE) + .quantity(-1) + .description(ItemType.ROULETTE_TICKET.getName()) + .build()); + } + + // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) + List rouletteRewards; + do { + int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); + rouletteRewards = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); + } while (rouletteRewards.isEmpty()); + + RouletteReward rouletteReward = rouletteRewards.get(0); + + // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 + rouletteRewards.stream() + .forEach(RouletteReward::decreaseRemainingCount); // 남은 아이템 수 줄이기 + + rouletteRewards.stream() + .filter(reward -> reward.getItemType() != null) + .forEach(reward -> { + itemRepository.save(Item.builder() + .memberId(memberInfo.memberId()) + .itemType(reward.getItemType()) + .quantity(reward.getQuantity()) + .expiredAt(ROULETTE_REWARD_EXPIRED_AT) + .build()); + + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberInfo.memberId()) + .itemType(reward.getItemType()) + .historyType(ItemHistoryType.EVENT) + .quantity(reward.getQuantity()) + .description(reward.getItemType().getName()) + .build()); + }); + + rouletteHistoryRepository.save(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward) + .rouletteType(rouletteType) + .build()); + + return new RouletteSpinResponse(rouletteReward.getRewardName()); + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java new file mode 100644 index 0000000..b8ba595 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -0,0 +1,30 @@ +package com.comatching.item.infra.controller; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.RouletteService; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/roulette") +public class RouletteController { + private final RouletteService rouletteService; + + @PostMapping("/spins") + public ResponseEntity> spinRoulette( + @CurrentMember MemberInfo memberInfo, + @RequestParam RouletteType rouletteType + ) { + RouletteSpinResponse response = rouletteService.spinRoulette(memberInfo, rouletteType); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java new file mode 100644 index 0000000..70d7d41 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -0,0 +1,265 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; + +@DataJpaTest +@ContextConfiguration(classes = RouletteRewardRepositoryTest.JpaTestConfig.class) +@Import(RouletteServiceImpl.class) +@DisplayName("Roulette Repository 및 트랜잭션 테스트") +class RouletteRewardRepositoryTest { + + @Autowired + private RouletteRewardRepository rouletteRewardRepository; + + @Autowired + private TestEntityManager entityManager; + @Autowired + private RouletteHistoryRepository rouletteHistoryRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private RouletteServiceImpl rouletteService; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + + @MockitoBean + private ItemHistoryRepository itemHistoryRepository; + + @Test + @DisplayName("난수 범위의 양 끝 1과 10000에 해당하는 보상을 조회한다") + void shouldFindRewardsAtBothBoundaries() { + RouletteReward first = persist(reward(RouletteType.FREE, "첫 보상", 1, 5000, null)); + RouletteReward last = persist(reward(RouletteType.FREE, "마지막 보상", 5001, 10000, null)); + flushAndClear(); + + List numberOne = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 1); + List numberTenThousand = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 10000); + + assertThat(numberOne).extracting(RouletteReward::getId).containsExactly(first.getId()); + assertThat(numberTenThousand).extracting(RouletteReward::getId).containsExactly(last.getId()); + } + + @Test + @DisplayName("동일 범위의 풀세트 두 행을 ID 오름차순으로 조회한다") + void shouldFindFullSetRewardsOrderedById() { + RouletteReward matchingTicket = persist(reward( + RouletteType.PAID, "풀세트", ItemType.MATCHING_TICKET, 8201, 9200, null)); + RouletteReward optionTicket = persist(reward( + RouletteType.PAID, "풀세트", ItemType.OPTION_TICKET, 8201, 9200, null)); + flushAndClear(); + + List rewards = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 8500); + + assertThat(rewards).extracting(RouletteReward::getId) + .containsExactly(matchingTicket.getId(), optionTicket.getId()); + } + + @Test + @DisplayName("재고가 소진된 제한 보상은 조회하지 않는다") + void shouldExcludeOutOfStockReward() { + persist(reward(RouletteType.PAID, "상품권", 9701, 9900, 0)); + flushAndClear(); + + List rewards = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 9800); + + assertThat(rewards).isEmpty(); + } + + @Test + @DisplayName("같은 난수 범위라도 요청한 룰렛 타입의 보상만 조회한다") + void shouldSeparateRewardsByRouletteType() { + RouletteReward freeReward = persist(reward(RouletteType.FREE, "무료 보상", 1, 10000, null)); + persist(reward(RouletteType.PAID, "유료 보상", 1, 10000, null)); + flushAndClear(); + + List rewards = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 5000); + + assertThat(rewards).extracting(RouletteReward::getId).containsExactly(freeReward.getId()); + } + + @Test + @DisplayName("오늘 같은 회원의 FREE 참여 이력이 있으면 true를 반환한다") + void shouldFindTodayFreeHistoryForSameMember() { + persistHistory(1L, RouletteType.FREE); + flushAndClear(); + + boolean exists = rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("과거 FREE 참여 이력만 있으면 false를 반환한다") + void shouldNotFindPastFreeHistory() { + RouletteHistory history = persistHistory(1L, RouletteType.FREE); + entityManager.flush(); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().minusDays(1).atTime(12, 0)), + history.getId()); + entityManager.clear(); + + boolean exists = rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("다른 회원의 오늘 FREE 참여 이력은 조회하지 않는다") + void shouldNotFindAnotherMembersFreeHistory() { + persistHistory(2L, RouletteType.FREE); + flushAndClear(); + + boolean exists = rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("같은 회원의 오늘 PAID 참여 이력은 FREE 참여로 조회하지 않는다") + void shouldNotFindPaidHistoryAsFreeHistory() { + persistHistory(1L, RouletteType.PAID); + flushAndClear(); + + boolean exists = rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @DisplayName("아이템 이력 저장 실패 시 아이템과 룰렛 이력을 실제로 롤백한다") + void shouldRollbackSavedItemWhenItemHistorySaveFails() { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.executeWithoutResult(status -> rouletteRewardRepository.saveAndFlush( + reward(RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, 10000, 2))); + given(itemHistoryRepository.save(any(ItemHistory.class))) + .willThrow(new RuntimeException("item history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette( + new MemberInfo(1L, "member@example.com", "USER"), RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item history save failed"); + + assertThat(itemRepository.count()).isZero(); + assertThat(rouletteHistoryRepository.count()).isZero(); + assertThat(rouletteRewardRepository.findAll()) + .extracting(RouletteReward::getRemainingCount) + .containsExactly(2); + } + + private RouletteReward persist(RouletteReward reward) { + return entityManager.persist(reward); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } + + private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) { + RouletteReward historyReward = persist(reward( + RouletteType.FREE, "꽝", null, 1, 10000, 999999)); + return entityManager.persist(RouletteHistory.builder() + .memberId(memberId) + .reward(historyReward) + .rouletteType(rouletteType) + .build()); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return reward(rouletteType, rewardName, null, rangeStart, rangeEnd, remainingCount); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + ItemType itemType, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .itemType(itemType) + .quantity(itemType == null ? 0 : 1) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .remainingCount(remainingCount) + .build(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = { + RouletteReward.class, + RouletteHistory.class, + Item.class, + ItemHistory.class + }) + @EnableJpaRepositories(basePackageClasses = { + RouletteRewardRepository.class, + RouletteHistoryRepository.class, + ItemRepository.class, + ItemHistoryRepository.class + }) + static class JpaTestConfig { + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java new file mode 100644 index 0000000..0e6d2eb --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -0,0 +1,346 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RouletteServiceImpl 보상 처리 테스트") +class RouletteServiceImplTest { + + private static final MemberInfo MEMBER = new MemberInfo(1L, "member@example.com", "USER"); + private static final LocalDateTime REWARD_EXPIRED_AT = LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + @Mock + private RouletteHistoryRepository rouletteHistoryRepository; + @Mock + private RouletteRewardRepository rouletteRewardRepository; + @Mock + private ItemRepository itemRepository; + @Mock + private ItemHistoryRepository itemHistoryRepository; + + @InjectMocks + private RouletteServiceImpl rouletteService; + + @Test + @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") + void shouldGrantItemRewardAndReturnRewardName() { + RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", ItemType.OPTION_TICKET, 2, null); + givenRewards(RouletteType.FREE, List.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); + then(itemRepository).should().save(itemCaptor.capture()); + assertThat(itemCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(itemCaptor.getValue().getItemType()).isEqualTo(ItemType.OPTION_TICKET); + assertThat(itemCaptor.getValue().getQuantity()).isEqualTo(2); + assertThat(itemCaptor.getValue().getExpiredAt()).isEqualTo(REWARD_EXPIRED_AT); + + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + then(itemHistoryRepository).should().save(itemHistoryCaptor.capture()); + assertThat(itemHistoryCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(itemHistoryCaptor.getValue().getItemType()).isEqualTo(ItemType.OPTION_TICKET); + assertThat(itemHistoryCaptor.getValue().getHistoryType()).isEqualTo(ItemHistoryType.EVENT); + assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(2); + assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.OPTION_TICKET.getName()); + + assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(response.rewardName()).isEqualTo("옵션권 2장"); + } + + @Test + @DisplayName("풀세트는 조회된 두 아이템을 모두 지급하고 대표 보상명을 반환한다") + void shouldGrantEveryFullSetReward() { + RouletteReward matchingTicket = reward( + RouletteType.PAID, "풀세트", ItemType.MATCHING_TICKET, 1, 2); + RouletteReward optionTicket = reward( + RouletteType.PAID, "풀세트", ItemType.OPTION_TICKET, 3, 4); + Item rouletteTicket = givenRewards(RouletteType.PAID, List.of(matchingTicket, optionTicket)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); + + assertThat(rouletteTicket.getQuantity()).isZero(); + assertThat(matchingTicket.getRemainingCount()).isEqualTo(1); + assertThat(optionTicket.getRemainingCount()).isEqualTo(3); + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); + then(itemRepository).should(times(2)).save(itemCaptor.capture()); + assertThat(itemCaptor.getAllValues()) + .extracting(Item::getItemType, Item::getQuantity) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1), + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, 3) + ); + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + then(itemHistoryRepository).should(times(3)).save(itemHistoryCaptor.capture()); + assertThat(itemHistoryCaptor.getAllValues()) + .extracting(ItemHistory::getItemType, ItemHistory::getHistoryType, ItemHistory::getQuantity) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(ItemType.ROULETTE_TICKET, ItemHistoryType.USE, -1), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1), + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3) + ); + assertHistorySavedWith(matchingTicket, RouletteType.PAID); + then(rouletteHistoryRepository).should(never()) + .existsTodayByMemberIdAndRouletteType(MEMBER.memberId(), RouletteType.PAID); + assertThat(response.rewardName()).isEqualTo("풀세트"); + } + + @Test + @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") + void shouldRecordGiftCardWithoutGrantingItem() { + RouletteReward reward = reward(RouletteType.PAID, "1만원권 상품권", null, 1, 3); + givenRewards(RouletteType.PAID, List.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); + + assertThat(reward.getRemainingCount()).isEqualTo(2); + then(itemRepository).should(never()).save(any(Item.class)); + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + then(itemHistoryRepository).should().save(itemHistoryCaptor.capture()); + assertThat(itemHistoryCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(itemHistoryCaptor.getValue().getItemType()).isEqualTo(ItemType.ROULETTE_TICKET); + assertThat(itemHistoryCaptor.getValue().getHistoryType()).isEqualTo(ItemHistoryType.USE); + assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(-1); + assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.ROULETTE_TICKET.getName()); + assertHistorySavedWith(reward, RouletteType.PAID); + assertThat(response.rewardName()).isEqualTo("1만원권 상품권"); + } + + @Test + @DisplayName("꽝은 아이템을 지급하지 않고 룰렛 이력과 보상명을 남긴다") + void shouldRecordNoPrizeWithoutGrantingItem() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + givenRewards(RouletteType.FREE, List.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + then(itemRepository).should(never()).save(any(Item.class)); + then(itemHistoryRepository).shouldHaveNoInteractions(); + assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(response.rewardName()).isEqualTo("꽝"); + } + + @Test + @DisplayName("이미 무료 룰렛에 참여한 회원은 예외가 발생하고 보상을 처리하지 않는다") + void shouldRejectDuplicatedFreeRouletteParticipation() { + given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType( + MEMBER.memberId(), RouletteType.FREE)).willReturn(true); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE)); + + then(itemRepository).shouldHaveNoInteractions(); + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("룰렛 티켓이 없으면 예외가 발생하고 추첨하지 않는다") + void shouldRejectWhenRouletteTicketDoesNotExist() { + given(itemRepository + .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + eq(MEMBER.memberId()), eq(ItemType.ROULETTE_TICKET), eq(1), any(LocalDateTime.class))) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.PAID)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.NOT_ENOUGH_ITEM)); + + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("선택된 범위의 보상이 비어 있으면 다시 추첨한다") + void shouldRetryWhenSelectedRewardIsEmpty() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType( + MEMBER.memberId(), RouletteType.FREE)).willReturn(false); + given(rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) + .willReturn(List.of(), List.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + then(rouletteRewardRepository).should(times(2)) + .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt()); + assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(response.rewardName()).isEqualTo("꽝"); + } + + @Test + @DisplayName("아이템 저장이 실패하면 아이템 이력과 룰렛 이력을 저장하지 않는다") + void shouldStopWhenItemSaveFails() { + RouletteReward reward = reward( + RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); + givenRewards(RouletteType.FREE, List.of(reward)); + given(itemRepository.save(any(Item.class))).willThrow(new RuntimeException("item save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item save failed"); + + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("아이템 이력 저장이 실패하면 룰렛 이력을 저장하지 않는다") + void shouldStopWhenItemHistorySaveFails() { + RouletteReward reward = reward( + RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); + givenRewards(RouletteType.FREE, List.of(reward)); + given(itemHistoryRepository.save(any(ItemHistory.class))) + .willThrow(new RuntimeException("item history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item history save failed"); + + then(itemRepository).should().save(any(Item.class)); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("룰렛 이력 저장이 실패하면 예외를 그대로 전달한다") + void shouldPropagateRouletteHistorySaveFailure() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + givenRewards(RouletteType.FREE, List.of(reward)); + given(rouletteHistoryRepository.save(any(RouletteHistory.class))) + .willThrow(new RuntimeException("roulette history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("roulette history save failed"); + } + + @Test + @DisplayName("제한 재고는 한 개 차감한다") + void shouldDecreaseLimitedRemainingCount() { + RouletteReward reward = reward(RouletteType.PAID, "상품권", null, 1, 2); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isEqualTo(1); + } + + @Test + @DisplayName("무제한 보상은 재고를 변경하지 않는다") + void shouldNotChangeUnlimitedRemainingCount() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isNull(); + } + + @Test + @DisplayName("소진된 재고는 음수가 되지 않는다") + void shouldNotDecreaseRemainingCountBelowZero() { + RouletteReward reward = reward(RouletteType.PAID, "상품권", null, 1, 0); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isZero(); + } + + private Item givenRewards(RouletteType rouletteType, List rewards) { + Item rouletteTicket = null; + if (rouletteType == RouletteType.PAID) { + rouletteTicket = givenTicket(); + } + if (rouletteType == RouletteType.FREE) { + given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType(MEMBER.memberId(), rouletteType)) + .willReturn(false); + } + given(rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(eq(rouletteType), anyInt())) + .willReturn(rewards); + return rouletteTicket; + } + + private Item givenTicket() { + Item rouletteTicket = rouletteTicket(); + given(itemRepository + .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + eq(MEMBER.memberId()), eq(ItemType.ROULETTE_TICKET), eq(1), any(LocalDateTime.class))) + .willReturn(Optional.of(rouletteTicket)); + return rouletteTicket; + } + + private void assertHistorySavedWith(RouletteReward reward, RouletteType rouletteType) { + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(RouletteHistory.class); + then(rouletteHistoryRepository).should().save(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(historyCaptor.getValue().getReward()).isSameAs(reward); + assertThat(historyCaptor.getValue().getRouletteType()).isEqualTo(rouletteType); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + ItemType itemType, + int quantity, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .itemType(itemType) + .quantity(quantity) + .rangeStart(1) + .rangeEnd(1) + .remainingCount(remainingCount) + .build(); + } + + private Item rouletteTicket() { + return Item.builder() + .memberId(MEMBER.memberId()) + .itemType(ItemType.ROULETTE_TICKET) + .quantity(1) + .expiredAt(LocalDateTime.now().plusDays(1)) + .build(); + } +} diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java new file mode 100644 index 0000000..b284f89 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -0,0 +1,85 @@ +package com.comatching.item.infra.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.RouletteService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RouletteController API 계약 테스트") +class RouletteControllerTest { + + private MockMvc mockMvc; + + @Mock + private RouletteService rouletteService; + + @InjectMocks + private RouletteController rouletteController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(rouletteController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("POST /api/roulette/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") + void shouldSpinRouletteAndReturnRewardName() throws Exception { + given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.PAID))) + .willReturn(new RouletteSpinResponse("2만원권 상품권")); + + mockMvc.perform(post("/api/roulette/spins") + .param("rouletteType", "PAID") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.rewardName").value("2만원권 상품권")); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().spinRoulette(memberCaptor.capture(), eq(RouletteType.PAID)); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("rouletteType 요청 파라미터가 없으면 400을 반환한다") + void shouldRejectMissingRouletteType() throws Exception { + mockMvc.perform(post("/api/roulette/spins") + .header("X-Member-Id", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GeneralErrorCode.MISSING_REQUEST_PARAMETER.getCode())); + + then(rouletteService).shouldHaveNoInteractions(); + } +} From df1bc1bb2f8064113e028548ec9d5e87470149e8 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Fri, 28 Aug 2026 13:53:58 +0900 Subject: [PATCH 51/66] =?UTF-8?q?docs:=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20TCP=20=EA=B5=90=EC=B2=B4=EC=9D=98=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=ED=9B=84=20=EC=8B=A4=EC=B8=A1=EC=9D=84=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=207=EC=97=90=20=EA=B8=B0=EB=A1=9D=20(kafka=2034?= =?UTF-8?q?=E2=86=924%)=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Fable 5 --- docs/perf-log.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/perf-log.md b/docs/perf-log.md index 3972e20..b74d21c 100644 --- a/docs/perf-log.md +++ b/docs/perf-log.md @@ -591,3 +591,13 @@ kafka 179% / mongo 114% 피크로 보이던 것의 정체다. 부하 중엔 CPU interval 30s + `start_interval: 2s`(기동 중에만 촘촘히)로 조정했다. 포트 개방 시점 = 요청 수신 시점이고, 이 헬스체크의 소비자는 compose 기동 순서뿐이라 API 수준 검사를 유지할 이유가 없다. + +**배포 후 검증 (2026-08-28, PR #88 적용 2일 뒤 유휴 상태 30회 샘플링):** + +| 컨테이너 | 적용 전 평균 → 후 | 적용 전 피크 → 후 | +|---|---|---| +| kafka | 34.4% → **4.1%** | 188% → 7.7% | +| mongodb | 11.6% → **1.0%** | 111% → 2.3% | + +헬스체크 1회 실행 시간도 3~6초 → 0.07~0.17초. 2 vCPU 중 **~0.4코어가 +돌아왔다.** 다음 부하테스트(매칭 시나리오)는 이 상태를 기준선으로 돈다. From 625a882234e2f8b16ee3ef77f5ce8bbc92bc69b1 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Fri, 28 Aug 2026 14:40:44 +0900 Subject: [PATCH 52/66] =?UTF-8?q?test:=20S2=20=EB=A7=A4=EC=B9=AD=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20JMeter=20=ED=94=8C?= =?UTF-8?q?=EB=9E=9C=20(=EC=93=B0=EA=B8=B0=20=EA=B2=BD=EB=A1=9C=2010~100?= =?UTF-8?q?=20RPS)=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: S2 매칭 시나리오 JMeter 플랜 — 쓰기 경로 첫 계단(10~100 RPS) POST /api/matching 을 VU 500계정 토큰 로테이션으로 때린다. 옵션권을 쓰지 않는 본문(점수 조건 4개만)으로 소모를 매칭권 1장에 고정하고, 계단은 S1 상한(331 RPS)과 요청당 비용을 감안해 10/25/50/100 으로 낮게 잡았다. 설계 근거와 실행 전 체크리스트는 jmx 머리 주석에 있다. Co-Authored-By: Claude Fable 5 * fix: S2 인증을 accessToken 쿠키로, DNS clientHold 우회(정적 매핑·--resolve) 추가 스모크 실측: 게이트웨이 extractToken 은 Authorization 헤더가 아니라 accessToken 쿠키만 읽는다(Bearer 는 401 TOKEN_MISSING). 도메인이 2026-08-27 registrar clientHold 로 NXDOMAIN 이 돼 jmx 에 DNS 정적 매핑, run.sh 에 RESOLVE 환경변수를 추가했다. E2E 스모크(매칭→Kafka→Mongo 채팅방 생성)까지 확인. Co-Authored-By: Claude Fable 5 * docs: 회차 8 — S2 매칭 실측, 상한 30 RPS 의 정체는 OSIV 커넥션 점유 knee 25 RPS(p95 1086ms), 상한 ~30 RPS, 에러 0%. active 10/10 + pending 38 + 점유 113→337ms 로 풀 수학(10÷0.33s≈30)이 상한을 정했다. OSIV + 트랜잭션 없는 네이티브 쿼리가 커넥션을 Feign 까지 쥔다. Kafka 체인은 11,592/11,592 손실 0, 지연 p95 0.04s — 병목 아님. 회차 7 헬스체크 수정도 부하에서 검증(kafka 평균 8~12%). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- docs/perf-log.md | 68 ++++++ tools/perf/jmeter/S2-matching.jmx | 389 ++++++++++++++++++++++++++++++ tools/perf/jmeter/run.sh | 4 +- 3 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 tools/perf/jmeter/S2-matching.jmx diff --git a/docs/perf-log.md b/docs/perf-log.md index b74d21c..04b69f5 100644 --- a/docs/perf-log.md +++ b/docs/perf-log.md @@ -601,3 +601,71 @@ interval 30s + `start_interval: 2s`(기동 중에만 촘촘히)로 조정했다. 헬스체크 1회 실행 시간도 3~6초 → 0.07~0.17초. 2 vCPU 중 **~0.4코어가 돌아왔다.** 다음 부하테스트(매칭 시나리오)는 이 상태를 기준선으로 돈다. + +## 회차 8 — S2 매칭, 쓰기 경로 첫 측정 (2026-08-28) + +**대상**: `POST /api/matching` — 락 → Feign 3회 → 표본 5,000 SQL → INSERT +→ Kafka 발행 → (비동기) Mongo 채팅방 생성. S1과 정반대 극단. +**조건**: 운영(EC2 2 vCPU), 시드 10만, VU 500계정 쿠키 토큰 로테이션, +계단 10/25/50/100 RPS ×120초. 배포 태그 6783abf. + +측정 전에 두 가지가 터졌다: + +1. **도메인이 죽어 있었다.** 8/27 registrar clientHold 로 `comatching.site` + 전체가 NXDOMAIN(갱신 문제). 서버는 정상이라 jmx 에 DNS 정적 매핑, + run.sh 에 `RESOLVE` 를 넣어 IP 직결로 우회했다. **부하와 무관하게 + 실사용자도 접속 불가였던 상태** — 부하테스트 준비가 운영 장애를 발견했다. +2. **게이트웨이 인증은 Authorization 헤더가 아니다.** 스모크에서 Bearer 가 + 401 TOKEN_MISSING. `extractToken` 은 `accessToken` 쿠키만 읽는다. + +### 결과 (`results/S2-matching-20260828-141613`) + +| 계단 | 달성 | 에러% | p50 | p95 | p99 | max | +|---|---|---|---|---|---|---| +| 10 RPS | 100% | 0 | 131 | 282 | 433 | 878 | +| 25 RPS | 100% | 0 | 469 | 1086 | 1394 | 1921 | +| 50 RPS | 59% (29) | 0 | 1678 | 2070 | 2946 | 4246 | +| 100 RPS | 30% (30) | 0 | 3397 | 3867 | 6345 | 6950 | + +**knee = 25 RPS (p95 1086ms). 실질 상한 ~30 RPS. 에러는 전 구간 0%.** + +### 관측 — 상한 30 은 CPU 가 아니라 커넥션 풀 수학이다 + +50 RPS 계단에서 컨테이너 CPU 는 오히려 25 계단보다 **내려갔는데** +(matching 54%→35%) 처리량은 29 에 박혔다. CPU 에 줄 선 게 아니라는 뜻이다. +Prometheus 가 답을 줬다: + +``` +hikaricp_connections_active(matching-service) = 10 (풀 상한) +hikaricp_connections_pending = 38 +커넥션 1회 점유(usage) 평균: 113ms @10RPS 계단 → 337ms @100RPS 계단 +``` + +**순수 SQL 은 ~25ms 인데 커넥션은 113~337ms 잡혀 있다.** 원인은 +OSIV — `open-in-view` 를 어디서도 끄지 않아 부트 기본값 true 로 돌고, +`findBestCandidate` 는 트랜잭션 없는 네이티브 쿼리라 Hibernate 의 +release-after-transaction 이 발동할 트랜잭션 자체가 없다. 커넥션이 +상대 프로필 Feign 호출(부하 시 수백 ms)까지 쥔 채로 간다. +풀 10개 ÷ 점유 0.33초 ≈ **30 RPS — 관측된 상한과 정확히 일치.** + +보조 관측: + +1. **knee(25 RPS)는 호스트 CPU 도 거들었다.** 25 계단에서 컨테이너 CPU + 합계가 2 vCPU 의 ~81%. 풀을 고쳐도 다음 벽은 CPU 다. +2. **회차 7 수정이 부하에서 검증됐다.** kafka 컨테이너 평균 8~12% + (회차 6에는 피크 179% 였다). mongodb 5%. +3. **Kafka 비동기 체인은 병목이 아니다.** 매칭 11,592건 전부 채팅방 생성 + (손실 0), 발행→Mongo 생성 지연 p50 0.01s / p95 0.04s / max 0.19s. + 최고 부하에서도 컨슈머 랙이 쌓이지 않았다. + (측정은 사후 조인 — matching_history.matched_at ↔ chat_rooms.createdAt. + 부하 중 kafka-consumer-groups.sh 를 돌리면 회차 7의 헬스체크와 같은 + JVM 기동 오염이 생기므로 일부러 피했다.) +4. matched_at 이 KST 로 저장돼 UTC 인 Mongo 와 9시간 어긋난다. 조인 시 + 보정했지만, 서비스 간 시각 비교가 생기는 순간 밟을 지뢰다. + +### 다음 지렛대 + +1. **커넥션 점유를 SQL 시간으로 줄인다** — `open-in-view: false` 와/또는 + `findBestCandidate` 에 `@Transactional(readOnly = true)`. + 점유 330ms → ~30ms 면 풀이 정하는 상한이 30 → 수백 RPS 로 풀린다. +2. 그 다음은 호스트 CPU(6 JVM / 2 vCPU) — 코드가 아니라 인프라의 영역. diff --git a/tools/perf/jmeter/S2-matching.jmx b/tools/perf/jmeter/S2-matching.jmx new file mode 100644 index 0000000..783ce44 --- /dev/null +++ b/tools/perf/jmeter/S2-matching.jmx @@ -0,0 +1,389 @@ + + + + + + false + true + + true + + + + + + + + + + + + ${__P(host,localhost)} + ${__P(port,8080)} + ${__P(scheme,http)} + 3000 + + 10000 + + + + + + ${__P(tokensCsv,../tokens/tokens.csv)} + UTF-8 + memberId,token + false + , + false + true + false + shareMode.all + + + + + + + + + srv.comatching.site + ${__P(hostIp,43.200.211.135)} + + + false + true + + + + + + + + Cookie + accessToken=${token} + + + Content-Type + application/json + + + + + + + + + + influxdbMetricsSender + org.apache.jmeter.visualizers.backend.influxdb.HttpMetricsSender + = + + + influxdbUrl + http://${__P(influxHost,localhost)}:8086/write?db=jmeter + = + + + application + comatching + = + + + measurement + jmeter + = + + + summaryOnly + false + = + + + samplersRegex + .* + = + + + percentiles + 50;90;95;99 + = + + + testTitle + ${__P(testTitle,S2-matching)} + = + + + eventTags + + = + + + + org.apache.jmeter.visualizers.backend.influxdb.InfluxdbBackendListenerClient + + + + + + continue + + false + -1 + + 5 + 5 + true + 60 + 0 + true + + + + true + + + + false + {"ageOption":"EQUAL","mbtiOption":"ENFP","hobbyOption":"GAME","contactFrequency":"NORMAL","sameMajorOption":false} + = + + + + /api/matching + POST + true + true + + + + + 4 + throughput300.00.0 + + + + + + + continue + + false + -1 + + 10 + 5 + true + 120 + 0 + true + + + + true + + + + false + {"ageOption":"EQUAL","mbtiOption":"ENFP","hobbyOption":"GAME","contactFrequency":"NORMAL","sameMajorOption":false} + = + + + + /api/matching + POST + true + true + + + + 4 + throughput600.00.0 + + + + + + + continue + + false + -1 + + 25 + 5 + true + 120 + 0 + true + + + + true + + + + false + {"ageOption":"EQUAL","mbtiOption":"ENFP","hobbyOption":"GAME","contactFrequency":"NORMAL","sameMajorOption":false} + = + + + + /api/matching + POST + true + true + + + + 4 + throughput1500.00.0 + + + + + + + continue + + false + -1 + + 50 + 5 + true + 120 + 0 + true + + + + true + + + + false + {"ageOption":"EQUAL","mbtiOption":"ENFP","hobbyOption":"GAME","contactFrequency":"NORMAL","sameMajorOption":false} + = + + + + /api/matching + POST + true + true + + + + 4 + throughput3000.00.0 + + + + + + + continue + + false + -1 + + 100 + 10 + true + 120 + 0 + true + + + + true + + + + false + {"ageOption":"EQUAL","mbtiOption":"ENFP","hobbyOption":"GAME","contactFrequency":"NORMAL","sameMajorOption":false} + = + + + + /api/matching + POST + true + true + + + + 4 + throughput6000.00.0 + + + + + + + diff --git a/tools/perf/jmeter/run.sh b/tools/perf/jmeter/run.sh index 9fcaf1a..3993516 100755 --- a/tools/perf/jmeter/run.sh +++ b/tools/perf/jmeter/run.sh @@ -50,7 +50,9 @@ else echo "✅ jmeter $(jmeter --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1)" fi -code=$(curl -s -o /dev/null -w '%{http_code}' "$SCHEME://$HOST:$PORT/api/auth/participants" || echo 000) +# RESOLVE="host:port:ip" 를 주면 DNS 를 거치지 않고 그 IP 로 붙는다. +# 2026-08-27 도메인 clientHold 로 NXDOMAIN 이 된 상황에서 추가 (회차 8). +code=$(curl -s ${RESOLVE:+--resolve "$RESOLVE"} -o /dev/null -w '%{http_code}' "$SCHEME://$HOST:$PORT/api/auth/participants" || echo 000) if [ "$code" = "200" ]; then echo "✅ 게이트웨이 $HOST:$PORT — 대상 엔드포인트 200" else From 8658877eb1e05e97fd4e65dfa798d5aab5e105d5 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Fri, 28 Aug 2026 14:45:05 +0900 Subject: [PATCH 53/66] =?UTF-8?q?fix:=20findBestCandidate=20=EC=97=90=20re?= =?UTF-8?q?adOnly=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=E2=80=94=20?= =?UTF-8?q?=EC=BB=A4=EB=84=A5=EC=85=98=20=EC=A0=90=EC=9C=A0=EB=A5=BC=20SQL?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EC=A4=84=EC=9D=B8?= =?UTF-8?q?=EB=8B=A4=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회차 8 실측: 이 네이티브 쿼리가 트랜잭션 없이 돌아 OSIV 세션이 닫힐 때까지 커넥션을 쥐었다(점유 113~337ms, SQL 은 ~25ms). 풀 10개 ÷ 0.33s ≈ 30 RPS 가 매칭 처리량 상한의 정체. 트랜잭션 경계를 쿼리에 맞춰 점유를 SQL 시간으로 되돌린다. OSIV 전면 비활성화는 컨트롤러 lazy 접근 전수 확인이 필요해 별도 작업으로 남긴다. Co-authored-by: Claude Fable 5 --- .../candidate/MatchingCandidateRepositoryImpl.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java index 83cac21..8e5bc5f 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/repository/candidate/MatchingCandidateRepositoryImpl.java @@ -6,6 +6,7 @@ import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; import java.util.*; @@ -84,7 +85,17 @@ public class MatchingCandidateRepositoryImpl implements MatchingCandidateReposit private final EntityManager em; + /** + * readOnly 트랜잭션이 성능 수정이다 (회차 8). + * 이 메서드는 네이티브 쿼리라 트랜잭션 없이 돌면 Hibernate 의 + * release-after-transaction 이 발동할 트랜잭션이 없고, OSIV 세션이 + * 닫히는 요청 끝까지 커넥션을 쥔다 — 상대 프로필 Feign 호출까지 포함해서. + * 실측: 커넥션 점유 113~337ms (SQL 자체는 ~25ms), 풀 10개가 + * 10 ÷ 0.33s ≈ 30 RPS 에서 처리량 상한이 됐다. + * 트랜잭션 경계를 쿼리에 딱 맞추면 점유가 SQL 시간으로 줄어든다. + */ @Override + @Transactional(readOnly = true) public Optional findBestCandidate(MatchingCandidateSearchCondition condition) { Optional found = queryBestCandidate(condition); From bba342e69ada1002dbd1b7613947709493bf87c2 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Fri, 28 Aug 2026 16:43:36 +0900 Subject: [PATCH 54/66] =?UTF-8?q?fix:=20matching-service=20OSIV=20?= =?UTF-8?q?=EB=A5=BC=20=EB=81=88=EB=8B=A4=20=E2=80=94=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EA=B2=BD=EA=B3=84=EB=A1=9C=EB=8A=94=20?= =?UTF-8?q?=EC=BB=A4=EB=84=A5=EC=85=98=EC=9D=B4=20=EC=95=88=20=ED=92=80?= =?UTF-8?q?=EB=A6=B0=EB=8B=A4=20(=ED=9A=8C=EC=B0=A8=209=20=EB=B0=98?= =?UTF-8?q?=EC=A6=9D)=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회차 9 재측정: readOnly 트랜잭션을 붙여도 상한 29 RPS·점유 350ms 그대로. 체크아웃이 요청당 정확히 1회 — OSIV 가 첫 DB 접근에서 잡은 커넥션을 요청 끝까지 쥔다. 트랜잭션 종료는 커넥션을 OSIV 세션에 되돌릴 뿐 풀로 반납하지 않는다. 끄기 전 전수 확인: 웹 경로 트랜잭션 밖 lazy 접근 0건. 반증 과정은 perf-log 회차 9 에 기록. Co-authored-by: Claude Fable 5 --- docs/perf-log.md | 45 +++++++++++++++++++ .../src/main/resources/application.yml | 7 +++ 2 files changed, 52 insertions(+) diff --git a/docs/perf-log.md b/docs/perf-log.md index 04b69f5..ba939c8 100644 --- a/docs/perf-log.md +++ b/docs/perf-log.md @@ -669,3 +669,48 @@ release-after-transaction 이 발동할 트랜잭션 자체가 없다. 커넥션 `findBestCandidate` 에 `@Transactional(readOnly = true)`. 점유 330ms → ~30ms 면 풀이 정하는 상한이 30 → 수백 RPS 로 풀린다. 2. 그 다음은 호스트 CPU(6 JVM / 2 vCPU) — 코드가 아니라 인프라의 영역. + +## 회차 9 — @Transactional 가설 반증 (2026-08-28) + +회차 8의 처방("`findBestCandidate` 에 readOnly 트랜잭션 한 줄")을 적용하고 +(PR #91, 태그 8658877) 같은 조건으로 재측정했다. **가설이 틀렸다.** + +| 계단 | 달성 | p50 | p95 | 회차 8 p95 | +|---|---|---|---|---| +| 10 RPS | 100% | 186 | 637 | 282 | +| 25 RPS | 97% | 991 | 1406 | 1086 | +| 50 RPS | 55% (28) | 1789 | 2304 | 2070 | +| 100 RPS | 29% (29) | 3487 | 4061 | 3867 | + +상한은 그대로 ~29 RPS. 커넥션 점유도 그대로 220~425ms, pending 40. + +### 반증의 결정타 — 체크아웃 횟수 + +트랜잭션 경계가 먹혔다면 요청 하나가 짧은 체크아웃 3회(제외 목록 / +후보 쿼리 / INSERT)로 쪼개져야 한다. 실측은 **요청당 정확히 1회** +(계단 1: 요청 1,200개 ↔ 체크아웃 1,218회). 즉 트랜잭션을 몇 개로 +나누든 **OSIV 가 요청 중 첫 DB 접근에서 잡은 커넥션을 요청 끝까지 +놓지 않는다.** 트랜잭션 종료는 커넥션을 풀로 반납하는 게 아니라 +OSIV 세션에 되돌려줄 뿐이다. 회차 8의 "한 줄이면 된다"는 추론은 +release-after-transaction 의 동작 범위를 잘못 읽은 것이다. + +> 교훈: 처방은 적용 후 같은 조건 재측정으로만 확정된다. 이 회차가 +> 없었으면 "고쳤다"고 믿은 채 다음 병목을 찾아 헤맸을 것이다. + +### 부수 관측 + +- 계단 1 이 회차 8보다 나쁜 것(p95 282→637ms)은 측정 조건 차이다: + 회차 8은 JVM 이 5시간 워밍업된 상태, 회차 9는 배포 재기동 10분 뒤. + 단순 쿼리도 느려져 있었다(참가자 COUNT 10→29ms — RDS 버스트 크레딧 + 소진 의심, 다음 회차에서 함께 관찰). +- **배포도 도메인 장애에 걸렸다.** deploy 의 `EC2_HOST` 시크릿이 + 도메인이라 clientHold NXDOMAIN 으로 SSH 단계가 죽었다. 시크릿을 + 고정 IP 로 교체해 해결 — 도메인 복구 후에도 IP 유지가 안전하다. + +### 조치 — open-in-view: false (matching-service) + +커넥션을 트랜잭션 시간만 쥐게 하는 유일한 방법이 OSIV 를 끄는 것이다. +끄기 전 전수 확인: matching-service 웹 경로에서 트랜잭션 밖 lazy 접근 +0건 — 매칭 응답은 스칼라 + Feign 프로필 DTO 로만 조립되고, LAZY 인 +`hobbyCategories` 는 SQL 점수식과 @Transactional 컨슈머에서만 쓰인다. +(다른 5개 서비스의 OSIV 는 각자 전수 확인 후 별도로.) diff --git a/matching-service/src/main/resources/application.yml b/matching-service/src/main/resources/application.yml index 46cc0ea..772c965 100644 --- a/matching-service/src/main/resources/application.yml +++ b/matching-service/src/main/resources/application.yml @@ -32,6 +32,13 @@ spring: validation-timeout: 5000 jpa: + # OSIV 를 끈다 (회차 9 실측). 켜져 있으면 요청 중 첫 DB 접근에서 잡은 + # 커넥션을 "요청 끝"까지 놓지 않는다 — 트랜잭션 경계를 그어도 소용없음을 + # 체크아웃 횟수(요청당 정확히 1회, 점유 350~420ms vs 순수 SQL ~25ms)로 + # 확인했다. 풀 10개 ÷ 0.35s ≈ 29 RPS 가 매칭 처리량 상한이 된 원인. + # 끄기 전 전수 확인: 웹 경로에서 트랜잭션 밖 lazy 접근 0건 + # (LAZY 인 hobbyCategories 는 SQL 점수식·@Transactional 컨슈머에서만 사용). + open-in-view: false hibernate: ddl-auto: update show-sql: false From 77d7add954117f6e6b18ad4b16c85c6c18fcb623 Mon Sep 17 00:00:00 2001 From: Seunghwan Chun Date: Fri, 28 Aug 2026 17:41:12 +0900 Subject: [PATCH 55/66] =?UTF-8?q?perf:=20=EB=A7=A4=EC=B9=AD=20=ED=91=9C?= =?UTF-8?q?=EB=B3=B8=205,000=20=E2=86=92=202,000=20=E2=80=94=20RDS=20CPU?= =?UTF-8?q?=20=EB=B3=91=EB=AA=A9=20=EC=99=84=ED=99=94=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: 매칭 표본 5,000 → 2,000 — RDS CPU 병목 완화 (회차 10 진단) 상한 29 RPS 의 병목이 RDS CPU 로 확정됐다(스레드 덤프 + 동시성 실측, perf-log 회차 10). 후보 쿼리의 temp table·정렬 비용이 표본 크기에 비례하므로 60% 줄인다. 트레이드오프: 80점 이상 확보율 99.9% → 94.2% (회차 3 분포표) — 동점 그룹 무작위 선택 구조라 사용자 체감 없음. Co-Authored-By: Claude Fable 5 * test: 표본 크기 단언을 2,000 으로 갱신 Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../matching/domain/component/MatchingProcessor.java | 9 ++++++++- .../matching/domain/component/MatchingProcessorTest.java | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/matching-service/src/main/java/com/comatching/matching/domain/component/MatchingProcessor.java b/matching-service/src/main/java/com/comatching/matching/domain/component/MatchingProcessor.java index feb054b..de111db 100644 --- a/matching-service/src/main/java/com/comatching/matching/domain/component/MatchingProcessor.java +++ b/matching-service/src/main/java/com/comatching/matching/domain/component/MatchingProcessor.java @@ -52,7 +52,14 @@ public class MatchingProcessor { private static final int MAX_ALLOWED_AGE = 27; - private static final int SAMPLE_SIZE = 5_000; + /** + * 5,000 → 2,000 (회차 10). 상한 29 RPS 의 병목이 RDS CPU(db.t3.micro 2 vCPU, + * 6개 서비스 공유)로 확정됐고, 후보 쿼리의 temp table·정렬 대상이 표본 + * 크기에 비례한다. 회차 3 분포표 기준 80점 이상 확보율은 5,000=99.9% → + * 2,000=94.2% — 사용자는 점수를 보지 못하고 동점 그룹 무작위 선택 구조라 + * 체감 차이가 없는 반면 RDS CPU 는 표본에 비례해 돌아온다. + */ + private static final int SAMPLE_SIZE = 2_000; private final MatchingCandidateRepository candidateRepository; private final MatchingHistoryRepository historyRepository; diff --git a/matching-service/src/test/java/com/comatching/matching/domain/component/MatchingProcessorTest.java b/matching-service/src/test/java/com/comatching/matching/domain/component/MatchingProcessorTest.java index ef56753..cca6723 100644 --- a/matching-service/src/test/java/com/comatching/matching/domain/component/MatchingProcessorTest.java +++ b/matching-service/src/test/java/com/comatching/matching/domain/component/MatchingProcessorTest.java @@ -342,7 +342,7 @@ void passesMatchedHistoryAsExclusion() { void passesSamplingParameters() { MatchingCandidateSearchCondition condition = capture(request().build()); - assertThat(condition.sampleSize()).isEqualTo(5_000); + assertThat(condition.sampleSize()).isEqualTo(2_000); assertThat(condition.randomStart()) .isBetween(0, MatchingCandidate.RANDOM_KEY_START_BOUND - 1); } From 360285e36f6cf0049d8b3fa82e01f8772a0a0be5 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Fri, 28 Aug 2026 23:41:08 +0900 Subject: [PATCH 56/66] =?UTF-8?q?fix(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=EC=9D=98=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EB=B0=8F=20=EC=9E=AC=EA=B3=A0=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EC=9E=A5=20=20=20=20=20-=20=EC=9C=A0?= =?UTF-8?q?=EB=A3=8C=20=EB=A3=B0=EB=A0=9B=20=ED=8B=B0=EC=BC=93=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=97=90=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=20=20=20=20-=20=ED=95=9C=EC=A0=95=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=9E=AC=EA=B3=A0=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=97=90=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=20=20=20=20-=20=EB=AC=B4=EB=A3=8C=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20=EC=B0=B8=EC=97=AC=20=EC=9D=B4=EB=A0=A5=EC=97=90=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=C2=B7=EC=9C=A0=ED=98=95=C2=B7=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=9C=A0=EC=9D=BC=20=EC=A0=9C=EC=95=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20=20=20=20-=20=EB=8F=99=EC=8B=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B0=B8=EC=97=AC=EB=A5=BC=20ITEM-007=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=A1=9C=20=EB=B3=80=ED=99=98=20=20=20=20=20-=20?= =?UTF-8?q?=EB=AC=B4=EB=A3=8C=C2=B7=EC=9C=A0=EB=A3=8C=20=EB=A3=B0=EB=A0=9B?= =?UTF-8?q?=20=EB=B0=8F=20=ED=95=9C=EC=A0=95=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../item/repository/ItemRepository.java | 1 + .../roulette/entity/RouletteHistory.java | 16 +- .../repository/RouletteRewardRepository.java | 3 + .../roulette/service/RouletteServiceImpl.java | 19 +- .../service/RouletteConcurrencyTest.java | 236 ++++++++++++++++++ 5 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java diff --git a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java index d6fcbfc..54b9074 100644 --- a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java @@ -30,6 +30,7 @@ List findAllUsableItems( @Param("memberId") Long memberId, @Param("itemType") ItemType itemType); + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( Long memberId, ItemType itemType, diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index 281f059..9cb64c9 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -1,5 +1,6 @@ package com.comatching.item.domain.roulette.entity; +import java.time.LocalDate; import java.time.LocalDateTime; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -14,6 +15,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -22,6 +25,13 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "roulette_history", + uniqueConstraints = @UniqueConstraint( + name = "uk_roulette_history_member_type_date", + columnNames = {"member_id", "roulette_type", "participation_date"} + ) +) public class RouletteHistory { @Id @@ -41,6 +51,9 @@ public class RouletteHistory { private final LocalDateTime participatedAt = LocalDateTime.now(); + @Column(name = "participation_date") + private LocalDate participationDate; + @Builder public RouletteHistory( Long memberId, @@ -50,5 +63,6 @@ public RouletteHistory( this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; + this.participationDate = rouletteType == RouletteType.FREE ? LocalDate.now() : null; } -} \ No newline at end of file +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java index a63b718..31ddf96 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -4,13 +4,16 @@ import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface RouletteRewardRepository extends JpaRepository { List findAllByRouletteType(RouletteType rouletteType); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" SELECT rr FROM RouletteReward rr diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 9608d98..2bd9b9b 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -20,6 +20,7 @@ import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; import com.comatching.item.global.exception.ItemErrorCode; import lombok.AllArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -93,11 +94,19 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou .build()); }); - rouletteHistoryRepository.save(RouletteHistory.builder() - .memberId(memberInfo.memberId()) - .reward(rouletteReward) - .rouletteType(rouletteType) - .build()); + try { + rouletteHistoryRepository.save(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward) + .rouletteType(rouletteType) + .build()); + rouletteHistoryRepository.flush(); + } catch (DataIntegrityViolationException exception) { + if (rouletteType == RouletteType.FREE) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + } + throw exception; + } return new RouletteSpinResponse(rouletteReward.getRewardName()); } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java new file mode 100644 index 0000000..b6d45fc --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -0,0 +1,236 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; + +@DataJpaTest +@ContextConfiguration(classes = RouletteConcurrencyTest.JpaTestConfig.class) +@Import(RouletteServiceImpl.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@DisplayName("룰렛 동시성 테스트") +class RouletteConcurrencyTest { + + private static final int REQUEST_COUNT = 20; + private static final LocalDateTime NOT_EXPIRED = LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + @Autowired + private RouletteService rouletteService; + @Autowired + private RouletteRewardRepository rouletteRewardRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동시에 추첨해도 한정 보상은 준비된 재고만큼만 지급된다") + void limitedRewardIsNotGrantedBeyondItsStock() throws Exception { + int stock = 5; + Long limitedRewardId = inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.FREE, "한정 보상", 1, 10000, stock)).getId()); + inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.FREE, "기본 보상", 1, 10000, null))); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(index + 1L), RouletteType.FREE)); + + assertThat(failures).isEmpty(); + assertThat(rouletteRewardRepository.findById(limitedRewardId).orElseThrow().getRemainingCount()) + .isZero(); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) + .isEqualTo(stock); + assertThat(count("SELECT COUNT(*) FROM roulette_history")) + .isEqualTo(REQUEST_COUNT); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 무료 룰렛 동시 요청은 하루에 한 번만 성공한다") + void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { + Long memberId = 50L; + inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.FREE, "기본 보상", 1, 10000, null))); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(memberId), RouletteType.FREE)); + + assertThat(failures).hasSize(REQUEST_COUNT - 1) + .allSatisfy(failure -> { + assertThat(failure).isInstanceOf(BusinessException.class); + assertThat(((BusinessException)failure).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + }); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) + .isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 유료 룰렛 동시 요청은 보유 티켓 수만큼만 성공한다") + void paidRouletteSucceedsOnlyAsManyTimesAsAvailableTickets() throws Exception { + int ticketCount = 5; + Long memberId = 100L; + Long ticketId = inTransaction(() -> itemRepository.save(Item.builder() + .memberId(memberId) + .itemType(ItemType.ROULETTE_TICKET) + .quantity(ticketCount) + .expiredAt(NOT_EXPIRED) + .build()).getId()); + inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.PAID, "기본 보상", 1, 10000, null))); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(memberId), RouletteType.PAID)); + + assertThat(failures).hasSize(REQUEST_COUNT - ticketCount) + .allSatisfy(failure -> { + assertThat(failure).isInstanceOf(BusinessException.class); + assertThat(((BusinessException)failure).getErrorCode()).isEqualTo(ItemErrorCode.NOT_ENOUGH_ITEM); + }); + assertThat(itemRepository.findById(ticketId).orElseThrow().getQuantity()).isZero(); + assertThat(count("SELECT COUNT(*) FROM item_history WHERE member_id = ?", memberId)) + .isEqualTo(ticketCount); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) + .isEqualTo(ticketCount); + } + + private List runConcurrently(int threadCount, ThrowingIntConsumer task) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try { + for (int index = 0; index < threadCount; index++) { + int taskIndex = index; + futures.add(executor.submit(() -> { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) { + return new AssertionError("동시 실행 시작 신호를 기다리다 시간 초과"); + } + try { + task.accept(taskIndex); + return null; + } catch (Throwable throwable) { + return throwable; + } + })); + } + + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List failures = new ArrayList<>(); + for (Future future : futures) { + Throwable failure = future.get(30, TimeUnit.SECONDS); + if (failure != null) { + failures.add(failure); + } + } + return failures; + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .quantity(0) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .remainingCount(remainingCount) + .build(); + } + + private MemberInfo member(Long memberId) { + return new MemberInfo(memberId, "member" + memberId + "@example.com", "USER"); + } + + private long count(String sql, Object... args) { + Long result = jdbcTemplate.queryForObject(sql, Long.class, args); + return result == null ? 0L : result; + } + + private T inTransaction(java.util.concurrent.Callable callback) { + return new TransactionTemplate(transactionManager).execute(status -> { + try { + return callback.call(); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + }); + } + + @FunctionalInterface + private interface ThrowingIntConsumer { + void accept(int value) throws Exception; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = { + RouletteReward.class, + RouletteHistory.class, + Item.class, + ItemHistory.class + }) + @EnableJpaRepositories(basePackageClasses = { + RouletteRewardRepository.class, + RouletteHistoryRepository.class, + ItemRepository.class, + ItemHistoryRepository.class + }) + static class JpaTestConfig { + } +} From 357db62fab007e36884d17c990716f037d6bc3c7 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Mon, 31 Aug 2026 02:42:55 +0900 Subject: [PATCH 57/66] =?UTF-8?q?refactor(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EB=B0=8F=20=EC=B0=B8=EC=97=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EC=B2=98=EB=A6=AC=20=EB=8B=A8=EC=88=9C=ED=99=94=20?= =?UTF-8?q?=20=20=20=20-=20=EB=A3=B0=EB=A0=9B=20=EB=B3=B4=EC=83=81?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A8=EC=9D=BC=20=ED=96=89=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=20=20=20=20-=20=ED=92=80=EC=84=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=95=9C=20=ED=96=89=EC=9C=BC=EB=A1=9C=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EA=B6=8C=EA=B3=BC=20=EB=A7=A4=EC=B9=AD=EA=B6=8C=EC=9D=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=A7=80=EA=B8=89=20=20=20=20=20-=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=EC=95=84=EC=9D=B4=ED=85=9C=EB=B3=84=20EVE?= =?UTF-8?q?NT=20=EC=9D=B4=EB=A0=A5=20=EC=A0=80=EC=9E=A5=20=20=20=20=20-=20?= =?UTF-8?q?=EB=AC=B4=EB=A3=8C=20=EB=A3=B0=EB=A0=9B=20=EC=B0=B8=EC=97=AC=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=9D=BC?= =?UTF-8?q?=EC=9D=BC=20=EC=9C=A0=EC=9D=BC=20=EC=A0=9C=EC=95=BD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=20=20=20=20-=20=EB=B0=98=EB=B3=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EC=9D=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20TODO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20=20=20=20-=20=EB=8B=B9=EC=9D=BC=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roulette/entity/RouletteHistory.java | 18 ++- .../repository/RouletteHistoryRepository.java | 20 ++-- .../repository/RouletteRewardRepository.java | 4 +- .../roulette/service/RouletteServiceImpl.java | 103 +++++++++++------- .../service/RouletteRewardRepositoryTest.java | 53 ++++----- .../service/RouletteServiceImplTest.java | 58 +++++----- 6 files changed, 137 insertions(+), 119 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index 9cb64c9..bb1f2f7 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -16,7 +16,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -25,20 +24,14 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table( - name = "roulette_history", - uniqueConstraints = @UniqueConstraint( - name = "uk_roulette_history_member_type_date", - columnNames = {"member_id", "roulette_type", "participation_date"} - ) -) +@Table(name = "roulette_history") public class RouletteHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) + @Column(name = "member_id", nullable = false) private Long memberId; @ManyToOne(fetch = FetchType.LAZY) @@ -46,9 +39,10 @@ public class RouletteHistory { private RouletteReward reward; @Enumerated(EnumType.STRING) - @Column(nullable = false) + @Column(name = "roulette_type", nullable = false) private RouletteType rouletteType; + @Column(name = "participated_at",nullable = false) private final LocalDateTime participatedAt = LocalDateTime.now(); @Column(name = "participation_date") @@ -63,6 +57,8 @@ public RouletteHistory( this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; - this.participationDate = rouletteType == RouletteType.FREE ? LocalDate.now() : null; + this.participationDate = rouletteType == RouletteType.FREE + ? participatedAt.toLocalDate() + : null; } } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java index 8571973..ef20b79 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -1,20 +1,16 @@ package com.comatching.item.domain.roulette.repository; +import java.time.LocalDateTime; + import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.enums.RouletteType; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; public interface RouletteHistoryRepository extends JpaRepository { - @Query(""" - SELECT CASE WHEN COUNT(rh) > 0 THEN true ELSE false END - FROM RouletteHistory rh - WHERE rh.memberId = :memberId - AND rh.rouletteType = :rouletteType - AND rh.participatedAt >= CURRENT_DATE - """) - boolean existsTodayByMemberIdAndRouletteType( - @Param("memberId") Long memberId, - @Param("rouletteType") RouletteType rouletteType); + boolean existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + Long memberId, + RouletteType rouletteType, + LocalDateTime startAt, + LocalDateTime endAt + ); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java index 31ddf96..9909a0b 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -1,6 +1,7 @@ package com.comatching.item.domain.roulette.repository; import java.util.List; +import java.util.Optional; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -11,7 +12,6 @@ import org.springframework.data.repository.query.Param; public interface RouletteRewardRepository extends JpaRepository { - List findAllByRouletteType(RouletteType rouletteType); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" @@ -23,7 +23,7 @@ public interface RouletteRewardRepository extends JpaRepository 0) ORDER BY rr.id ASC """) - List findAvailableByRouletteTypeAndRouletteNumber( + Optional findAvailableByRouletteTypeAndRouletteNumber( @Param("rouletteType") RouletteType rouletteType, @Param("rouletteNumber") int rouletteNumber); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 2bd9b9b..18ebb51 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -1,7 +1,8 @@ package com.comatching.item.domain.roulette.service; +import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.List; +import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; import com.comatching.common.domain.enums.ItemType; @@ -20,15 +21,12 @@ import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; import com.comatching.item.global.exception.ItemErrorCode; import lombok.AllArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @AllArgsConstructor public class RouletteServiceImpl implements RouletteService { - private static final LocalDateTime ROULETTE_REWARD_EXPIRED_AT = - LocalDateTime.of(9999, 12, 31, 23, 59, 59); private final RouletteHistoryRepository rouletteHistoryRepository; private final RouletteRewardRepository rouletteRewardRepository; @@ -38,12 +36,19 @@ public class RouletteServiceImpl implements RouletteService { @Override @Transactional public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { + // TODO: 반복적인 코드 메서드로 따로 빼기 + // 타입이 무료인 경우 참여여부 검증 + 예외처리 + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + if (rouletteType == RouletteType.FREE && rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(memberInfo.memberId(), rouletteType)) { + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), rouletteType, todayStart, tomorrowStart)) { throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); } + // 유료 룰렛의 경우 티켓 감소 if (rouletteType == RouletteType.PAID) { Item rouletteTicket = itemRepository .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( @@ -56,58 +61,80 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou .memberId(memberInfo.memberId()) .itemType(ItemType.ROULETTE_TICKET) .historyType(ItemHistoryType.USE) - .quantity(-1) + .quantity(-1) // .description(ItemType.ROULETTE_TICKET.getName()) .build()); + + } + Optional rouletteReward; // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) - List rouletteRewards; do { int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); - rouletteRewards = rouletteRewardRepository + rouletteReward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); - } while (rouletteRewards.isEmpty()); + } while (rouletteReward.isEmpty()); - RouletteReward rouletteReward = rouletteRewards.get(0); - // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 - rouletteRewards.stream() - .forEach(RouletteReward::decreaseRemainingCount); // 남은 아이템 수 줄이기 - rouletteRewards.stream() - .filter(reward -> reward.getItemType() != null) - .forEach(reward -> { - itemRepository.save(Item.builder() + // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 + if (rouletteReward.get().getRewardName().equals("풀세트")) { + itemRepository.save(Item.builder() .memberId(memberInfo.memberId()) - .itemType(reward.getItemType()) - .quantity(reward.getQuantity()) - .expiredAt(ROULETTE_REWARD_EXPIRED_AT) + .itemType(ItemType.OPTION_TICKET) + .quantity(3) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .build()); + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberInfo.memberId()) + .itemType(ItemType.OPTION_TICKET) + .historyType(ItemHistoryType.EVENT) + .quantity(3) + .description(ItemType.OPTION_TICKET.getName()) .build()); - itemHistoryRepository.save(ItemHistory.builder() + itemRepository.save(Item.builder() + .memberId(memberInfo.memberId()) + .itemType(ItemType.MATCHING_TICKET) + .quantity(1) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .build()); + itemHistoryRepository.save(ItemHistory.builder() .memberId(memberInfo.memberId()) - .itemType(reward.getItemType()) + .itemType(ItemType.MATCHING_TICKET) .historyType(ItemHistoryType.EVENT) - .quantity(reward.getQuantity()) - .description(reward.getItemType().getName()) + .quantity(1) + .description(ItemType.MATCHING_TICKET.getName()) .build()); - }); - try { - rouletteHistoryRepository.save(RouletteHistory.builder() - .memberId(memberInfo.memberId()) - .reward(rouletteReward) - .rouletteType(rouletteType) - .build()); - rouletteHistoryRepository.flush(); - } catch (DataIntegrityViolationException exception) { - if (rouletteType == RouletteType.FREE) { - throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); - } - throw exception; + } else if (rouletteReward.get().getItemType() != null) { + itemRepository.save(Item.builder() + .memberId(memberInfo.memberId()) + .quantity(rouletteReward.get().getQuantity()) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .itemType(rouletteReward.get().getItemType()) + .build()); + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberInfo.memberId()) + .itemType(rouletteReward.get().getItemType()) + .historyType(ItemHistoryType.EVENT) + .quantity(rouletteReward.get().getQuantity()) + .description(rouletteReward.get().getItemType().getName()) + .build()); } - return new RouletteSpinResponse(rouletteReward.getRewardName()); + // 남은 아이템 수 감소 + rouletteReward.get().decreaseRemainingCount(); + + + rouletteHistoryRepository.save(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward.get()) + .rouletteType(rouletteType) + .build()); + + + return new RouletteSpinResponse(rouletteReward.get().getRewardName()); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 70d7d41..9737719 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -7,7 +7,8 @@ import java.sql.Timestamp; import java.time.LocalDate; -import java.util.List; +import java.time.LocalDateTime; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -72,29 +73,26 @@ void shouldFindRewardsAtBothBoundaries() { RouletteReward last = persist(reward(RouletteType.FREE, "마지막 보상", 5001, 10000, null)); flushAndClear(); - List numberOne = rouletteRewardRepository + Optional numberOne = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 1); - List numberTenThousand = rouletteRewardRepository + Optional numberTenThousand = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 10000); - assertThat(numberOne).extracting(RouletteReward::getId).containsExactly(first.getId()); - assertThat(numberTenThousand).extracting(RouletteReward::getId).containsExactly(last.getId()); + assertThat(numberOne.orElseThrow().getId()).isEqualTo(first.getId()); + assertThat(numberTenThousand.orElseThrow().getId()).isEqualTo(last.getId()); } @Test - @DisplayName("동일 범위의 풀세트 두 행을 ID 오름차순으로 조회한다") - void shouldFindFullSetRewardsOrderedById() { - RouletteReward matchingTicket = persist(reward( - RouletteType.PAID, "풀세트", ItemType.MATCHING_TICKET, 8201, 9200, null)); - RouletteReward optionTicket = persist(reward( - RouletteType.PAID, "풀세트", ItemType.OPTION_TICKET, 8201, 9200, null)); + @DisplayName("풀세트 단일 행을 조회한다") + void shouldFindSingleFullSetReward() { + RouletteReward fullSet = persist(reward( + RouletteType.PAID, "풀세트", null, 8201, 9200, null)); flushAndClear(); - List rewards = rouletteRewardRepository + Optional reward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 8500); - assertThat(rewards).extracting(RouletteReward::getId) - .containsExactly(matchingTicket.getId(), optionTicket.getId()); + assertThat(reward.orElseThrow().getId()).isEqualTo(fullSet.getId()); } @Test @@ -103,10 +101,10 @@ void shouldExcludeOutOfStockReward() { persist(reward(RouletteType.PAID, "상품권", 9701, 9900, 0)); flushAndClear(); - List rewards = rouletteRewardRepository + Optional reward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 9800); - assertThat(rewards).isEmpty(); + assertThat(reward).isEmpty(); } @Test @@ -116,10 +114,10 @@ void shouldSeparateRewardsByRouletteType() { persist(reward(RouletteType.PAID, "유료 보상", 1, 10000, null)); flushAndClear(); - List rewards = rouletteRewardRepository + Optional reward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 5000); - assertThat(rewards).extracting(RouletteReward::getId).containsExactly(freeReward.getId()); + assertThat(reward.orElseThrow().getId()).isEqualTo(freeReward.getId()); } @Test @@ -128,8 +126,7 @@ void shouldFindTodayFreeHistoryForSameMember() { persistHistory(1L, RouletteType.FREE); flushAndClear(); - boolean exists = rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + boolean exists = existsHistoryToday(1L, RouletteType.FREE); assertThat(exists).isTrue(); } @@ -145,8 +142,7 @@ void shouldNotFindPastFreeHistory() { history.getId()); entityManager.clear(); - boolean exists = rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + boolean exists = existsHistoryToday(1L, RouletteType.FREE); assertThat(exists).isFalse(); } @@ -157,8 +153,7 @@ void shouldNotFindAnotherMembersFreeHistory() { persistHistory(2L, RouletteType.FREE); flushAndClear(); - boolean exists = rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + boolean exists = existsHistoryToday(1L, RouletteType.FREE); assertThat(exists).isFalse(); } @@ -169,8 +164,7 @@ void shouldNotFindPaidHistoryAsFreeHistory() { persistHistory(1L, RouletteType.PAID); flushAndClear(); - boolean exists = rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + boolean exists = existsHistoryToday(1L, RouletteType.FREE); assertThat(exists).isFalse(); } @@ -217,6 +211,13 @@ private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) .build()); } + private boolean existsHistoryToday(Long memberId, RouletteType rouletteType) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + return rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberId, rouletteType, todayStart, todayStart.plusDays(1)); + } + private RouletteReward reward( RouletteType rouletteType, String rewardName, diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 0e6d2eb..bbe1b86 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -11,7 +11,6 @@ import static org.mockito.Mockito.times; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -61,7 +60,7 @@ class RouletteServiceImplTest { @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") void shouldGrantItemRewardAndReturnRewardName() { RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", ItemType.OPTION_TICKET, 2, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -85,26 +84,22 @@ void shouldGrantItemRewardAndReturnRewardName() { } @Test - @DisplayName("풀세트는 조회된 두 아이템을 모두 지급하고 대표 보상명을 반환한다") + @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") void shouldGrantEveryFullSetReward() { - RouletteReward matchingTicket = reward( - RouletteType.PAID, "풀세트", ItemType.MATCHING_TICKET, 1, 2); - RouletteReward optionTicket = reward( - RouletteType.PAID, "풀세트", ItemType.OPTION_TICKET, 3, 4); - Item rouletteTicket = givenRewards(RouletteType.PAID, List.of(matchingTicket, optionTicket)); + RouletteReward fullSet = reward(RouletteType.PAID, "풀세트", null, 0, 2); + Item rouletteTicket = givenReward(RouletteType.PAID, fullSet); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); assertThat(rouletteTicket.getQuantity()).isZero(); - assertThat(matchingTicket.getRemainingCount()).isEqualTo(1); - assertThat(optionTicket.getRemainingCount()).isEqualTo(3); + assertThat(fullSet.getRemainingCount()).isEqualTo(1); ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); then(itemRepository).should(times(2)).save(itemCaptor.capture()); assertThat(itemCaptor.getAllValues()) .extracting(Item::getItemType, Item::getQuantity) .containsExactly( - org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1), - org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, 3) + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, 3), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1) ); ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); then(itemHistoryRepository).should(times(3)).save(itemHistoryCaptor.capture()); @@ -112,12 +107,13 @@ void shouldGrantEveryFullSetReward() { .extracting(ItemHistory::getItemType, ItemHistory::getHistoryType, ItemHistory::getQuantity) .containsExactly( org.assertj.core.groups.Tuple.tuple(ItemType.ROULETTE_TICKET, ItemHistoryType.USE, -1), - org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1), - org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3) + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1) ); - assertHistorySavedWith(matchingTicket, RouletteType.PAID); + assertHistorySavedWith(fullSet, RouletteType.PAID); then(rouletteHistoryRepository).should(never()) - .existsTodayByMemberIdAndRouletteType(MEMBER.memberId(), RouletteType.PAID); + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.PAID), any(LocalDateTime.class), any(LocalDateTime.class)); assertThat(response.rewardName()).isEqualTo("풀세트"); } @@ -125,7 +121,7 @@ void shouldGrantEveryFullSetReward() { @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") void shouldRecordGiftCardWithoutGrantingItem() { RouletteReward reward = reward(RouletteType.PAID, "1만원권 상품권", null, 1, 3); - givenRewards(RouletteType.PAID, List.of(reward)); + givenReward(RouletteType.PAID, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); @@ -146,7 +142,7 @@ void shouldRecordGiftCardWithoutGrantingItem() { @DisplayName("꽝은 아이템을 지급하지 않고 룰렛 이력과 보상명을 남긴다") void shouldRecordNoPrizeWithoutGrantingItem() { RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -159,8 +155,10 @@ void shouldRecordNoPrizeWithoutGrantingItem() { @Test @DisplayName("이미 무료 룰렛에 참여한 회원은 예외가 발생하고 보상을 처리하지 않는다") void shouldRejectDuplicatedFreeRouletteParticipation() { - given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType( - MEMBER.memberId(), RouletteType.FREE)).willReturn(true); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(true); assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) .isInstanceOf(BusinessException.class) @@ -195,11 +193,9 @@ void shouldRejectWhenRouletteTicketDoesNotExist() { @DisplayName("선택된 범위의 보상이 비어 있으면 다시 추첨한다") void shouldRetryWhenSelectedRewardIsEmpty() { RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); - given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType( - MEMBER.memberId(), RouletteType.FREE)).willReturn(false); given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) - .willReturn(List.of(), List.of(reward)); + .willReturn(Optional.empty(), Optional.of(reward)); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -214,7 +210,7 @@ void shouldRetryWhenSelectedRewardIsEmpty() { void shouldStopWhenItemSaveFails() { RouletteReward reward = reward( RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); given(itemRepository.save(any(Item.class))).willThrow(new RuntimeException("item save failed")); assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) @@ -230,7 +226,7 @@ void shouldStopWhenItemSaveFails() { void shouldStopWhenItemHistorySaveFails() { RouletteReward reward = reward( RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); given(itemHistoryRepository.save(any(ItemHistory.class))) .willThrow(new RuntimeException("item history save failed")); @@ -246,7 +242,7 @@ void shouldStopWhenItemHistorySaveFails() { @DisplayName("룰렛 이력 저장이 실패하면 예외를 그대로 전달한다") void shouldPropagateRouletteHistorySaveFailure() { RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); given(rouletteHistoryRepository.save(any(RouletteHistory.class))) .willThrow(new RuntimeException("roulette history save failed")); @@ -285,18 +281,20 @@ void shouldNotDecreaseRemainingCountBelowZero() { assertThat(reward.getRemainingCount()).isZero(); } - private Item givenRewards(RouletteType rouletteType, List rewards) { + private Item givenReward(RouletteType rouletteType, RouletteReward reward) { Item rouletteTicket = null; if (rouletteType == RouletteType.PAID) { rouletteTicket = givenTicket(); } if (rouletteType == RouletteType.FREE) { - given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType(MEMBER.memberId(), rouletteType)) - .willReturn(false); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(rouletteType), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); } given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(rouletteType), anyInt())) - .willReturn(rewards); + .willReturn(Optional.of(reward)); return rouletteTicket; } From 45b4dc9b97d066dcada1374f26f9e16a0f0360d1 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Wed, 2 Sep 2026 19:56:55 +0900 Subject: [PATCH 58/66] =?UTF-8?q?feat(item):=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=8A=A4=ED=8E=98=EC=85=9C=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=EA=B3=BC=20=EC=B0=B8=EC=97=AC=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80=20=20=20-=20PAID=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=EC=9D=84=20SPECIAL=20=EB=A3=B0=EB=A0=9B?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=ED=8B=B0=EC=BC=93=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20=20=20-=20=EB=8B=B9?= =?UTF-8?q?=EC=9D=BC=20=EC=8A=B9=EC=9D=B8=20=EA=B2=B0=EC=A0=9C=EC=95=A1=20?= =?UTF-8?q?3,500=EC=9B=90=20=EC=9D=B4=EC=83=81=EC=9D=B8=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EB=A7=8C=20=EC=8A=A4=ED=8E=98=EC=85=9C=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20=EC=B0=B8=EC=97=AC=20=ED=97=88=EC=9A=A9=20=20=20-?= =?UTF-8?q?=20=EB=AC=B4=EB=A3=8C=C2=B7=EC=8A=A4=ED=8E=98=EC=85=9C=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=EC=9D=98=20=EB=8B=B9=EC=9D=BC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B0=B8=EC=97=AC=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=20=20-=20=EB=A3=B0=EB=A0=9B=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=20=EC=97=AC=EB=B6=80=EC=99=80=20=EB=8B=B9?= =?UTF-8?q?=EC=9D=BC=20=EB=88=84=EC=A0=81=20=EA=B2=B0=EC=A0=9C=EC=95=A1=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20=20=20-=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=EC=8B=A4=ED=96=89=20API=EB=A5=BC=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EB=B3=84=20=EA=B2=BD=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=20=20-=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=85=9C=20=EB=A3=B0=EB=A0=9B=EA=B3=BC=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/domain/enums/ItemType.java | 3 +- .../order/repository/OrderRepository.java | 14 ++ .../dto/response/RoulettePageResponse.java | 7 + .../{ => response}/RouletteSpinResponse.java | 2 +- .../domain/roulette/enums/RouletteType.java | 2 +- .../roulette/service/RouletteService.java | 5 +- .../roulette/service/RouletteServiceImpl.java | 62 ++--- .../item/global/exception/ItemErrorCode.java | 4 +- .../infra/controller/RouletteController.java | 24 +- .../service/RouletteConcurrencyTest.java | 56 ++--- .../service/RouletteRewardRepositoryTest.java | 19 +- .../service/RouletteServiceImplTest.java | 212 +++++++++++++----- .../controller/RouletteControllerTest.java | 78 ++++++- 13 files changed, 327 insertions(+), 161 deletions(-) create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java rename item-service/src/main/java/com/comatching/item/domain/roulette/dto/{ => response}/RouletteSpinResponse.java (52%) diff --git a/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java b/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java index 33ef652..a1e7c0c 100644 --- a/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java +++ b/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java @@ -7,8 +7,7 @@ @RequiredArgsConstructor public enum ItemType { MATCHING_TICKET("매칭권", "기본 옵션만으로 매칭을 할 수 있는 아이템"), - OPTION_TICKET("옵션권", "같은 학과 제외, 중요 옵션 선택이 가능해지는 아이템"), - ROULETTE_TICKET("룰렛티켓", "룰렛을 돌릴 수 있는 티켓") + OPTION_TICKET("옵션권", "같은 학과 제외, 중요 옵션 선택이 가능해지는 아이템") ; private final String name; diff --git a/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java b/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java index f7a4827..a2bc200 100644 --- a/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java @@ -81,6 +81,20 @@ long sumActivePendingQuantityByMemberIdAndItemType( @Param("now") LocalDateTime now ); + @Query(""" + SELECT COALESCE(SUM(o.expectedPrice), 0) + FROM Order o + WHERE o.memberId = :memberId + AND o.status = com.comatching.item.domain.order.enums.OrderStatus.APPROVED + AND o.decidedAt >= :startAt + AND o.decidedAt < :endAt + """) + long sumApprovedPriceByMemberIdAndDecidedAtBetween( + @Param("memberId") Long memberId, + @Param("startAt") LocalDateTime startAt, + @Param("endAt") LocalDateTime endAt + ); + List findAllByStatusOrderByRequestedAtDesc(OrderStatus status); Page findAllByStatusAndExpiresAtAfter(OrderStatus status, LocalDateTime now, Pageable pageable); diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java new file mode 100644 index 0000000..fe924bd --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java @@ -0,0 +1,7 @@ +package com.comatching.item.domain.roulette.dto.response; + +public record RoulettePageResponse( + boolean isPossible, + Long totalPay +) { +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java similarity index 52% rename from item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java rename to item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java index 644806f..bbfc093 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.roulette.dto; +package com.comatching.item.domain.roulette.dto.response; public record RouletteSpinResponse( String rewardName diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java index d74ed40..e58d33e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java @@ -2,5 +2,5 @@ public enum RouletteType { FREE, - PAID + SPECIAL } \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java index 55d606d..155bbed 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java @@ -1,9 +1,12 @@ package com.comatching.item.domain.roulette.service; import com.comatching.common.dto.member.MemberInfo; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.enums.RouletteType; public interface RouletteService { RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType); + + RoulettePageResponse roulettePage(MemberInfo memberInfo, RouletteType rouletteType); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 18ebb51..82463a7 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -13,7 +13,9 @@ import com.comatching.item.domain.item.enums.ItemHistoryType; import com.comatching.item.domain.item.repository.ItemHistoryRepository; import com.comatching.item.domain.item.repository.ItemRepository; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -28,48 +30,37 @@ @AllArgsConstructor public class RouletteServiceImpl implements RouletteService { + private static final long SPECIAL_ROULETTE_MINIMUM_PAYMENT = 3500L; + private final RouletteHistoryRepository rouletteHistoryRepository; private final RouletteRewardRepository rouletteRewardRepository; private final ItemRepository itemRepository; private final ItemHistoryRepository itemHistoryRepository; + private final OrderRepository orderRepository; @Override @Transactional public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { - // TODO: 반복적인 코드 메서드로 따로 빼기 - - // 타입이 무료인 경우 참여여부 검증 + 예외처리 LocalDateTime todayStart = LocalDate.now().atStartOfDay(); LocalDateTime tomorrowStart = todayStart.plusDays(1); - if (rouletteType == RouletteType.FREE && rouletteHistoryRepository + // 오늘 참여한 결과 더이상 불가능 + boolean isParticipatedToday = rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - memberInfo.memberId(), rouletteType, todayStart, tomorrowStart)) { - throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + memberInfo.memberId(), rouletteType, todayStart, tomorrowStart); + if (isParticipatedToday) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } - // 유료 룰렛의 경우 티켓 감소 - if (rouletteType == RouletteType.PAID) { - Item rouletteTicket = itemRepository - .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( - memberInfo.memberId(), ItemType.ROULETTE_TICKET, 1, LocalDateTime.now()) - .orElseThrow(() -> new BusinessException(ItemErrorCode.NOT_ENOUGH_ITEM)); - - rouletteTicket.decrease(1); - - itemHistoryRepository.save(ItemHistory.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.ROULETTE_TICKET) - .historyType(ItemHistoryType.USE) - .quantity(-1) // - .description(ItemType.ROULETTE_TICKET.getName()) - .build()); - - + // 결제액이 3500이하면 불가능 + if (rouletteType == RouletteType.SPECIAL + && orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberInfo.memberId(), todayStart, tomorrowStart) < SPECIAL_ROULETTE_MINIMUM_PAYMENT) { + throw new BusinessException(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE); } - Optional rouletteReward; // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) + Optional rouletteReward; do { int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); rouletteReward = rouletteRewardRepository @@ -137,4 +128,23 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou return new RouletteSpinResponse(rouletteReward.get().getRewardName()); } + + @Override + public RoulettePageResponse roulettePage(MemberInfo memberInfo, RouletteType rouletteType) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + boolean isParticipated = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), rouletteType, todayStart, tomorrowStart); + + + Long totalPay = rouletteType == RouletteType.SPECIAL + ? orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberInfo.memberId(), todayStart, tomorrowStart) + : null; + boolean isPossible = !isParticipated + && (rouletteType == RouletteType.FREE || totalPay >= SPECIAL_ROULETTE_MINIMUM_PAYMENT); + + return new RoulettePageResponse(isPossible, totalPay); + } } diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 3490cb8..8de5eed 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -15,7 +15,9 @@ public enum ItemErrorCode implements ErrorCode { TARGET_USER_NOT_FOUND("ITEM-004", HttpStatus.BAD_REQUEST, "대상 사용자를 찾을 수 없습니다."), USER_QUERY_FAILED("ITEM-005", HttpStatus.INTERNAL_SERVER_ERROR, "사용자 조회 중 오류가 발생했습니다."), DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), - ALREADY_PARTICIPATED_FREE_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "Free roulette already participated."), + ALREADY_PARTICIPATED_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "이미 룰렛에 참여하셨습니다"), + NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE("ITEM-008", HttpStatus.BAD_REQUEST, + "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."), ; private final String code; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java index b8ba595..15a9caf 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -3,15 +3,15 @@ import com.comatching.common.annotation.CurrentMember; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.service.RouletteService; +import io.swagger.v3.oas.annotations.Operation; +import jdk.jfr.Description; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @AllArgsConstructor @@ -19,12 +19,20 @@ public class RouletteController { private final RouletteService rouletteService; - @PostMapping("/spins") + @PostMapping("/{rouletteType}/spins") + @Operation(summary = "룰렛 돌리는 api", description = "") public ResponseEntity> spinRoulette( + @CurrentMember MemberInfo memberInfo, + @PathVariable RouletteType rouletteType + ) { + return ResponseEntity.ok(ApiResponse.ok(rouletteService.spinRoulette(memberInfo, rouletteType))); + } + + @GetMapping + public ResponseEntity> roulettePage( @CurrentMember MemberInfo memberInfo, @RequestParam RouletteType rouletteType ) { - RouletteSpinResponse response = rouletteService.spinRoulette(memberInfo, rouletteType); - return ResponseEntity.ok(ApiResponse.ok(response)); + return ResponseEntity.ok(ApiResponse.ok(rouletteService.roulettePage(memberInfo, rouletteType))); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java index b6d45fc..9449e3c 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -11,6 +10,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,18 +23,19 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; -import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.exception.BusinessException; import com.comatching.item.domain.item.entity.Item; import com.comatching.item.domain.item.entity.ItemHistory; import com.comatching.item.domain.item.repository.ItemHistoryRepository; import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -50,7 +51,6 @@ class RouletteConcurrencyTest { private static final int REQUEST_COUNT = 20; - private static final LocalDateTime NOT_EXPIRED = LocalDateTime.of(9999, 12, 31, 23, 59, 59); @Autowired private RouletteService rouletteService; @@ -62,30 +62,32 @@ class RouletteConcurrencyTest { private JdbcTemplate jdbcTemplate; @Autowired private PlatformTransactionManager transactionManager; + @MockitoBean + private OrderRepository orderRepository; @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) - @DisplayName("동시에 추첨해도 한정 보상은 준비된 재고만큼만 지급된다") - void limitedRewardIsNotGrantedBeyondItsStock() throws Exception { - int stock = 5; + @DisplayName("단일 행 보상은 동시 추첨 횟수만큼 재고가 차감된다") + void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { + int stock = REQUEST_COUNT; Long limitedRewardId = inTransaction(() -> rouletteRewardRepository.save( reward(RouletteType.FREE, "한정 보상", 1, 10000, stock)).getId()); - inTransaction(() -> rouletteRewardRepository.save( - reward(RouletteType.FREE, "기본 보상", 1, 10000, null))); List failures = runConcurrently(REQUEST_COUNT, index -> rouletteService.spinRoulette(member(index + 1L), RouletteType.FREE)); assertThat(failures).isEmpty(); - assertThat(rouletteRewardRepository.findById(limitedRewardId).orElseThrow().getRemainingCount()) - .isZero(); + RouletteReward savedReward = rouletteRewardRepository.findById(limitedRewardId).orElseThrow(); + assertThat(savedReward.getItemType()).isNull(); + assertThat(savedReward.getRemainingCount()).isZero(); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) - .isEqualTo(stock); + .isEqualTo(REQUEST_COUNT); assertThat(count("SELECT COUNT(*) FROM roulette_history")) .isEqualTo(REQUEST_COUNT); } @Test + @Disabled("참여 이력 조회와 저장 사이의 동시성 제어를 추가할 때 활성화") @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("동일 회원의 무료 룰렛 동시 요청은 하루에 한 번만 성공한다") void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { @@ -100,42 +102,12 @@ void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { .allSatisfy(failure -> { assertThat(failure).isInstanceOf(BusinessException.class); assertThat(((BusinessException)failure).getErrorCode()) - .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); }); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) .isOne(); } - @Test - @Transactional(propagation = Propagation.NOT_SUPPORTED) - @DisplayName("동일 회원의 유료 룰렛 동시 요청은 보유 티켓 수만큼만 성공한다") - void paidRouletteSucceedsOnlyAsManyTimesAsAvailableTickets() throws Exception { - int ticketCount = 5; - Long memberId = 100L; - Long ticketId = inTransaction(() -> itemRepository.save(Item.builder() - .memberId(memberId) - .itemType(ItemType.ROULETTE_TICKET) - .quantity(ticketCount) - .expiredAt(NOT_EXPIRED) - .build()).getId()); - inTransaction(() -> rouletteRewardRepository.save( - reward(RouletteType.PAID, "기본 보상", 1, 10000, null))); - - List failures = runConcurrently(REQUEST_COUNT, index -> - rouletteService.spinRoulette(member(memberId), RouletteType.PAID)); - - assertThat(failures).hasSize(REQUEST_COUNT - ticketCount) - .allSatisfy(failure -> { - assertThat(failure).isInstanceOf(BusinessException.class); - assertThat(((BusinessException)failure).getErrorCode()).isEqualTo(ItemErrorCode.NOT_ENOUGH_ITEM); - }); - assertThat(itemRepository.findById(ticketId).orElseThrow().getQuantity()).isZero(); - assertThat(count("SELECT COUNT(*) FROM item_history WHERE member_id = ?", memberId)) - .isEqualTo(ticketCount); - assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) - .isEqualTo(ticketCount); - } - private List runConcurrently(int threadCount, ThrowingIntConsumer task) throws Exception { ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch ready = new CountDownLatch(threadCount); diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 9737719..7e2a503 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -35,6 +35,7 @@ import com.comatching.item.domain.item.entity.ItemHistory; import com.comatching.item.domain.item.repository.ItemHistoryRepository; import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -65,6 +66,8 @@ class RouletteRewardRepositoryTest { @MockitoBean private ItemHistoryRepository itemHistoryRepository; + @MockitoBean + private OrderRepository orderRepository; @Test @DisplayName("난수 범위의 양 끝 1과 10000에 해당하는 보상을 조회한다") @@ -86,11 +89,11 @@ void shouldFindRewardsAtBothBoundaries() { @DisplayName("풀세트 단일 행을 조회한다") void shouldFindSingleFullSetReward() { RouletteReward fullSet = persist(reward( - RouletteType.PAID, "풀세트", null, 8201, 9200, null)); + RouletteType.SPECIAL, "풀세트", null, 8201, 9200, null)); flushAndClear(); Optional reward = rouletteRewardRepository - .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 8500); + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.SPECIAL, 8500); assertThat(reward.orElseThrow().getId()).isEqualTo(fullSet.getId()); } @@ -98,11 +101,11 @@ void shouldFindSingleFullSetReward() { @Test @DisplayName("재고가 소진된 제한 보상은 조회하지 않는다") void shouldExcludeOutOfStockReward() { - persist(reward(RouletteType.PAID, "상품권", 9701, 9900, 0)); + persist(reward(RouletteType.SPECIAL, "상품권", 9701, 9900, 0)); flushAndClear(); Optional reward = rouletteRewardRepository - .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 9800); + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.SPECIAL, 9800); assertThat(reward).isEmpty(); } @@ -111,7 +114,7 @@ void shouldExcludeOutOfStockReward() { @DisplayName("같은 난수 범위라도 요청한 룰렛 타입의 보상만 조회한다") void shouldSeparateRewardsByRouletteType() { RouletteReward freeReward = persist(reward(RouletteType.FREE, "무료 보상", 1, 10000, null)); - persist(reward(RouletteType.PAID, "유료 보상", 1, 10000, null)); + persist(reward(RouletteType.SPECIAL, "스페셜 보상", 1, 10000, null)); flushAndClear(); Optional reward = rouletteRewardRepository @@ -159,9 +162,9 @@ void shouldNotFindAnotherMembersFreeHistory() { } @Test - @DisplayName("같은 회원의 오늘 PAID 참여 이력은 FREE 참여로 조회하지 않는다") - void shouldNotFindPaidHistoryAsFreeHistory() { - persistHistory(1L, RouletteType.PAID); + @DisplayName("같은 회원의 오늘 SPECIAL 참여 이력은 FREE 참여로 조회하지 않는다") + void shouldNotFindSpecialHistoryAsFreeHistory() { + persistHistory(1L, RouletteType.SPECIAL); flushAndClear(); boolean exists = existsHistoryToday(1L, RouletteType.FREE); diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index bbe1b86..3f1275e 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -29,7 +29,9 @@ import com.comatching.item.domain.item.enums.ItemHistoryType; import com.comatching.item.domain.item.repository.ItemHistoryRepository; import com.comatching.item.domain.item.repository.ItemRepository; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -52,10 +54,102 @@ class RouletteServiceImplTest { private ItemRepository itemRepository; @Mock private ItemHistoryRepository itemHistoryRepository; + @Mock + private OrderRepository orderRepository; @InjectMocks private RouletteServiceImpl rouletteService; + @Test + @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 참여 가능하다") + void shouldAllowFreeRouletteWhenNotParticipatedToday() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.FREE); + + assertThat(response.isPossible()).isTrue(); + assertThat(response.totalPay()).isNull(); + then(orderRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여할 수 없다") + void shouldRejectFreeRouletteWhenAlreadyParticipatedToday() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(true); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.FREE); + + assertThat(response.isPossible()).isFalse(); + assertThat(response.totalPay()).isNull(); + then(orderRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should() + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("오늘 결제액이 3500원이고 스페셜 룰렛에 참여하지 않았으면 참여 가능하다") + void shouldAllowSpecialRouletteAtMinimumPayment() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3500L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + + assertThat(response.isPossible()).isTrue(); + assertThat(response.totalPay()).isEqualTo(3500L); + } + + @Test + @DisplayName("오늘 스페셜 룰렛에 이미 참여했으면 결제액이 충분해도 참여할 수 없다") + void shouldRejectSpecialRouletteWhenAlreadyParticipated() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(10000L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(true); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + + assertThat(response.isPossible()).isFalse(); + assertThat(response.totalPay()).isEqualTo(10000L); + } + + @Test + @DisplayName("오늘 결제액이 3500원 미만이면 스페셜 룰렛에 참여할 수 없다") + void shouldRejectSpecialRouletteWhenPaymentIsInsufficient() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3499L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + + assertThat(response.isPossible()).isFalse(); + assertThat(response.totalPay()).isEqualTo(3499L); + } + @Test @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") void shouldGrantItemRewardAndReturnRewardName() { @@ -86,12 +180,11 @@ void shouldGrantItemRewardAndReturnRewardName() { @Test @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") void shouldGrantEveryFullSetReward() { - RouletteReward fullSet = reward(RouletteType.PAID, "풀세트", null, 0, 2); - Item rouletteTicket = givenReward(RouletteType.PAID, fullSet); + RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", null, 0, 2); + givenReward(RouletteType.SPECIAL, fullSet); - RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); - assertThat(rouletteTicket.getQuantity()).isZero(); assertThat(fullSet.getRemainingCount()).isEqualTo(1); ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); then(itemRepository).should(times(2)).save(itemCaptor.capture()); @@ -102,39 +195,29 @@ void shouldGrantEveryFullSetReward() { org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1) ); ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); - then(itemHistoryRepository).should(times(3)).save(itemHistoryCaptor.capture()); + then(itemHistoryRepository).should(times(2)).save(itemHistoryCaptor.capture()); assertThat(itemHistoryCaptor.getAllValues()) .extracting(ItemHistory::getItemType, ItemHistory::getHistoryType, ItemHistory::getQuantity) .containsExactly( - org.assertj.core.groups.Tuple.tuple(ItemType.ROULETTE_TICKET, ItemHistoryType.USE, -1), org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3), org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1) ); - assertHistorySavedWith(fullSet, RouletteType.PAID); - then(rouletteHistoryRepository).should(never()) - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - eq(MEMBER.memberId()), eq(RouletteType.PAID), any(LocalDateTime.class), any(LocalDateTime.class)); + assertHistorySavedWith(fullSet, RouletteType.SPECIAL); assertThat(response.rewardName()).isEqualTo("풀세트"); } @Test @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") void shouldRecordGiftCardWithoutGrantingItem() { - RouletteReward reward = reward(RouletteType.PAID, "1만원권 상품권", null, 1, 3); - givenReward(RouletteType.PAID, reward); + RouletteReward reward = reward(RouletteType.SPECIAL, "1만원권 상품권", null, 1, 3); + givenReward(RouletteType.SPECIAL, reward); - RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); assertThat(reward.getRemainingCount()).isEqualTo(2); then(itemRepository).should(never()).save(any(Item.class)); - ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); - then(itemHistoryRepository).should().save(itemHistoryCaptor.capture()); - assertThat(itemHistoryCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); - assertThat(itemHistoryCaptor.getValue().getItemType()).isEqualTo(ItemType.ROULETTE_TICKET); - assertThat(itemHistoryCaptor.getValue().getHistoryType()).isEqualTo(ItemHistoryType.USE); - assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(-1); - assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.ROULETTE_TICKET.getName()); - assertHistorySavedWith(reward, RouletteType.PAID); + then(itemHistoryRepository).shouldHaveNoInteractions(); + assertHistorySavedWith(reward, RouletteType.SPECIAL); assertThat(response.rewardName()).isEqualTo("1만원권 상품권"); } @@ -163,7 +246,7 @@ void shouldRejectDuplicatedFreeRouletteParticipation() { assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) .isInstanceOf(BusinessException.class) .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) - .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE)); + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE)); then(itemRepository).shouldHaveNoInteractions(); then(rouletteRewardRepository).shouldHaveNoInteractions(); @@ -172,19 +255,43 @@ void shouldRejectDuplicatedFreeRouletteParticipation() { } @Test - @DisplayName("룰렛 티켓이 없으면 예외가 발생하고 추첨하지 않는다") - void shouldRejectWhenRouletteTicketDoesNotExist() { - given(itemRepository - .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( - eq(MEMBER.memberId()), eq(ItemType.ROULETTE_TICKET), eq(1), any(LocalDateTime.class))) - .willReturn(Optional.empty()); - - assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.PAID)) + @DisplayName("이미 스페셜 룰렛에 참여한 회원은 예외가 발생하고 결제액과 보상을 조회하지 않는다") + void shouldRejectDuplicatedSpecialRouletteParticipation() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(true); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL)) .isInstanceOf(BusinessException.class) .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) - .isEqualTo(ItemErrorCode.NOT_ENOUGH_ITEM)); + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE)); + then(orderRepository).shouldHaveNoInteractions(); then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("오늘 누적 결제액이 3500원 미만이면 예외가 발생하고 추첨하지 않는다") + void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3499L); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE)); + + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemRepository).shouldHaveNoInteractions(); then(itemHistoryRepository).shouldHaveNoInteractions(); then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); } @@ -254,7 +361,7 @@ void shouldPropagateRouletteHistorySaveFailure() { @Test @DisplayName("제한 재고는 한 개 차감한다") void shouldDecreaseLimitedRemainingCount() { - RouletteReward reward = reward(RouletteType.PAID, "상품권", null, 1, 2); + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", null, 1, 2); reward.decreaseRemainingCount(); @@ -274,37 +381,26 @@ void shouldNotChangeUnlimitedRemainingCount() { @Test @DisplayName("소진된 재고는 음수가 되지 않는다") void shouldNotDecreaseRemainingCountBelowZero() { - RouletteReward reward = reward(RouletteType.PAID, "상품권", null, 1, 0); + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", null, 1, 0); reward.decreaseRemainingCount(); assertThat(reward.getRemainingCount()).isZero(); } - private Item givenReward(RouletteType rouletteType, RouletteReward reward) { - Item rouletteTicket = null; - if (rouletteType == RouletteType.PAID) { - rouletteTicket = givenTicket(); - } - if (rouletteType == RouletteType.FREE) { - given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - eq(MEMBER.memberId()), eq(rouletteType), - any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + private void givenReward(RouletteType rouletteType, RouletteReward reward) { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(rouletteType), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + if (rouletteType == RouletteType.SPECIAL) { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3500L); } given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(rouletteType), anyInt())) .willReturn(Optional.of(reward)); - return rouletteTicket; - } - - private Item givenTicket() { - Item rouletteTicket = rouletteTicket(); - given(itemRepository - .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( - eq(MEMBER.memberId()), eq(ItemType.ROULETTE_TICKET), eq(1), any(LocalDateTime.class))) - .willReturn(Optional.of(rouletteTicket)); - return rouletteTicket; } private void assertHistorySavedWith(RouletteReward reward, RouletteType rouletteType) { @@ -333,12 +429,4 @@ private RouletteReward reward( .build(); } - private Item rouletteTicket() { - return Item.builder() - .memberId(MEMBER.memberId()) - .itemType(ItemType.ROULETTE_TICKET) - .quantity(1) - .expiredAt(LocalDateTime.now().plusDays(1)) - .build(); - } } diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java index b284f89..da6ca51 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -24,7 +25,8 @@ import com.comatching.common.exception.code.GeneralErrorCode; import com.comatching.common.exception.handler.GlobalExceptionHandler; import com.comatching.common.resolver.MemberInfoArgumentResolver; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.service.RouletteService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -50,13 +52,12 @@ void setUp() { } @Test - @DisplayName("POST /api/roulette/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") + @DisplayName("POST /api/roulette/{rouletteType}/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") void shouldSpinRouletteAndReturnRewardName() throws Exception { - given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.PAID))) + given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.SPECIAL))) .willReturn(new RouletteSpinResponse("2만원권 상품권")); - mockMvc.perform(post("/api/roulette/spins") - .param("rouletteType", "PAID") + mockMvc.perform(post("/api/roulette/SPECIAL/spins") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) @@ -66,16 +67,75 @@ void shouldSpinRouletteAndReturnRewardName() throws Exception { .andExpect(jsonPath("$.data.rewardName").value("2만원권 상품권")); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); - then(rouletteService).should().spinRoulette(memberCaptor.capture(), eq(RouletteType.PAID)); + then(rouletteService).should().spinRoulette(memberCaptor.capture(), eq(RouletteType.SPECIAL)); assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); } @Test - @DisplayName("rouletteType 요청 파라미터가 없으면 400을 반환한다") - void shouldRejectMissingRouletteType() throws Exception { - mockMvc.perform(post("/api/roulette/spins") + @DisplayName("지원하지 않는 rouletteType 경로 값이면 400을 반환한다") + void shouldRejectUnsupportedRouletteType() throws Exception { + mockMvc.perform(post("/api/roulette/PAID/spins") + .header("X-Member-Id", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GeneralErrorCode.TYPE_MISMATCH.getCode())); + + then(rouletteService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("GET /api/roulette는 FREE 룰렛 참여 가능 여부와 null 결제액을 반환한다") + void shouldReturnFreeRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class), eq(RouletteType.FREE))) + .willReturn(new RoulettePageResponse(true, null)); + + mockMvc.perform(get("/api/roulette") + .param("rouletteType", "FREE") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.isPossible").value(true)) + .andExpect(jsonPath("$.data.totalPay").doesNotExist()); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().roulettePage(memberCaptor.capture(), eq(RouletteType.FREE)); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("GET /api/roulette는 SPECIAL 룰렛 참여 가능 여부와 오늘 결제액을 반환한다") + void shouldReturnSpecialRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class), eq(RouletteType.SPECIAL))) + .willReturn(new RoulettePageResponse(false, 3499L)); + + mockMvc.perform(get("/api/roulette") + .param("rouletteType", "SPECIAL") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.isPossible").value(false)) + .andExpect(jsonPath("$.data.totalPay").value(3499)); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().roulettePage(memberCaptor.capture(), eq(RouletteType.SPECIAL)); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("GET /api/roulette는 rouletteType 쿼리 파라미터가 없으면 400을 반환한다") + void shouldRejectMissingRouletteTypeOnRoulettePage() throws Exception { + mockMvc.perform(get("/api/roulette") .header("X-Member-Id", "1")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(GeneralErrorCode.MISSING_REQUEST_PARAMETER.getCode())); From b3572cf504029139584490ac2fc02a9aacbbba4f Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 3 Sep 2026 17:07:05 +0900 Subject: [PATCH 59/66] =?UTF-8?q?fix(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=B0=B8=EC=97=AC=EC=9D=98=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=EC=A4=91=EB=B3=B5=20=EC=A7=80=EA=B8=89=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=20=20=20=20-=20FREE=EC=99=80=20SPECIAL=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EC=9D=B4=EB=A0=A5=EC=97=90=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=C2=B7=ED=83=80=EC=9E=85=C2=B7=EB=82=A0=EC=A7=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=9C=EC=95=BD=20=EC=A0=81=EC=9A=A9=20=20=20=20?= =?UTF-8?q?=20-=20=EC=B0=B8=EC=97=AC=20=EC=9D=B4=EB=A0=A5=EC=9D=84=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20flush=ED=95=98=EC=97=AC=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EC=9A=94=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?ITEM-007=EB=A1=9C=20=EB=B3=80=ED=99=98=20=20=20=20=20-=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=20=ED=9A=8C=EC=9B=90=EC=9D=98=20FREE=C2=B7SP?= =?UTF-8?q?ECIAL=20=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EC=83=81=20=EC=A4=91=EB=B3=B5=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20=EB=B0=A9=EC=A7=80=20=EA=B2=80=EC=A6=9D=20=20=20=20?= =?UTF-8?q?=20-=20=EB=82=A0=EC=A7=9C=EB=B3=84=20=EC=B0=B8=EC=97=AC=20?= =?UTF-8?q?=EC=9C=A0=EC=9D=BC=20=EC=A0=9C=EC=95=BD=EA=B3=BC=20=EB=8B=B9?= =?UTF-8?q?=EC=9D=BC=20=EC=8A=B9=EC=9D=B8=20=EA=B2=B0=EC=A0=9C=EC=95=A1=20?= =?UTF-8?q?=ED=95=A9=EC=82=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roulette/entity/RouletteHistory.java | 15 ++-- .../roulette/service/RouletteServiceImpl.java | 16 +++-- .../service/RouletteConcurrencyTest.java | 63 +++++++++++++++-- .../service/RouletteRewardRepositoryTest.java | 69 +++++++++++++++++++ 4 files changed, 149 insertions(+), 14 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index bb1f2f7..20ba3ae 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -16,6 +16,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -24,7 +25,13 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "roulette_history") +@Table( + name = "roulette_history", + uniqueConstraints = @UniqueConstraint( + name = "uk_roulette_history_member_type_date", + columnNames = {"member_id", "roulette_type", "participation_date"} + ) +) public class RouletteHistory { @Id @@ -45,7 +52,7 @@ public class RouletteHistory { @Column(name = "participated_at",nullable = false) private final LocalDateTime participatedAt = LocalDateTime.now(); - @Column(name = "participation_date") + @Column(name = "participation_date", nullable = false) private LocalDate participationDate; @Builder @@ -57,8 +64,6 @@ public RouletteHistory( this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; - this.participationDate = rouletteType == RouletteType.FREE - ? participatedAt.toLocalDate() - : null; + this.participationDate = participatedAt.toLocalDate(); } } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 82463a7..f6dbcaf 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -23,6 +23,7 @@ import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; import com.comatching.item.global.exception.ItemErrorCode; import lombok.AllArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -119,11 +120,16 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou rouletteReward.get().decreaseRemainingCount(); - rouletteHistoryRepository.save(RouletteHistory.builder() - .memberId(memberInfo.memberId()) - .reward(rouletteReward.get()) - .rouletteType(rouletteType) - .build()); + try { + rouletteHistoryRepository.save(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward.get()) + .rouletteType(rouletteType) + .build()); + rouletteHistoryRepository.flush(); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + } return new RouletteSpinResponse(rouletteReward.get().getRewardName()); diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java index 9449e3c..c9aee33 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -1,6 +1,9 @@ package com.comatching.item.domain.roulette.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; import java.util.ArrayList; import java.util.List; @@ -10,7 +13,6 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,6 +31,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.exception.BusinessException; import com.comatching.item.domain.item.entity.Item; @@ -59,6 +62,8 @@ class RouletteConcurrencyTest { @Autowired private ItemRepository itemRepository; @Autowired + private ItemHistoryRepository itemHistoryRepository; + @Autowired private JdbcTemplate jdbcTemplate; @Autowired private PlatformTransactionManager transactionManager; @@ -87,13 +92,12 @@ void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { } @Test - @Disabled("참여 이력 조회와 저장 사이의 동시성 제어를 추가할 때 활성화") @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("동일 회원의 무료 룰렛 동시 요청은 하루에 한 번만 성공한다") void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { Long memberId = 50L; - inTransaction(() -> rouletteRewardRepository.save( - reward(RouletteType.FREE, "기본 보상", 1, 10000, null))); + Long rewardId = inTransaction(() -> rouletteRewardRepository.save( + itemReward(RouletteType.FREE, "옵션권 1장", REQUEST_COUNT)).getId()); List failures = runConcurrently(REQUEST_COUNT, index -> rouletteService.spinRoulette(member(memberId), RouletteType.FREE)); @@ -106,6 +110,41 @@ void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { }); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) .isOne(); + assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) + .isEqualTo(REQUEST_COUNT - 1); + assertThat(itemRepository.count()).isOne(); + assertThat(itemHistoryRepository.count()).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 스페셜 룰렛 동시 요청은 하루에 한 번만 성공한다") + void specialRouletteSucceedsOnlyOnceForSameMember() throws Exception { + Long memberId = 51L; + Long rewardId = inTransaction(() -> rouletteRewardRepository.save( + itemReward(RouletteType.SPECIAL, "옵션권 1장", REQUEST_COUNT)).getId()); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(memberId), any(), any())) + .willReturn(3500L); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(memberId), RouletteType.SPECIAL)); + + assertThat(failures).hasSize(REQUEST_COUNT - 1) + .allSatisfy(failure -> { + assertThat(failure).isInstanceOf(BusinessException.class); + assertThat(((BusinessException)failure).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + }); + assertThat(count( + "SELECT COUNT(*) FROM roulette_history WHERE member_id = ? AND roulette_type = ?", + memberId, + RouletteType.SPECIAL.name())) + .isOne(); + assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) + .isEqualTo(REQUEST_COUNT - 1); + assertThat(itemRepository.count()).isOne(); + assertThat(itemHistoryRepository.count()).isOne(); } private List runConcurrently(int threadCount, ThrowingIntConsumer task) throws Exception { @@ -165,6 +204,22 @@ private RouletteReward reward( .build(); } + private RouletteReward itemReward( + RouletteType rouletteType, + String rewardName, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .itemType(ItemType.OPTION_TICKET) + .quantity(1) + .rangeStart(1) + .rangeEnd(10000) + .remainingCount(remainingCount) + .build(); + } + private MemberInfo member(Long memberId) { return new MemberInfo(memberId, "member" + memberId + "@example.com", "USER"); } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 7e2a503..49ecaa8 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -45,6 +46,7 @@ @DataJpaTest @ContextConfiguration(classes = RouletteRewardRepositoryTest.JpaTestConfig.class) @Import(RouletteServiceImpl.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @DisplayName("Roulette Repository 및 트랜잭션 테스트") class RouletteRewardRepositoryTest { @@ -172,6 +174,49 @@ void shouldNotFindSpecialHistoryAsFreeHistory() { assertThat(exists).isFalse(); } + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원의 같은 날짜와 같은 타입 참여 이력은 한 행만 저장된다") + void shouldRejectDuplicateHistoryForSameMemberTypeAndDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + saveHistoryInTransaction(10L, RouletteType.FREE, rewardId); + + assertThatThrownBy(() -> saveHistoryInTransaction(10L, RouletteType.FREE, rewardId)) + .isInstanceOf(DataIntegrityViolationException.class); + assertThat(countHistory(10L, RouletteType.FREE)).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원은 같은 날짜에 무료와 스페셜 룰렛에 각각 참여할 수 있다") + void shouldAllowFreeAndSpecialHistoryOnSameDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + + saveHistoryInTransaction(11L, RouletteType.FREE, rewardId); + saveHistoryInTransaction(11L, RouletteType.SPECIAL, rewardId); + + assertThat(countHistory(11L, RouletteType.FREE)).isOne(); + assertThat(countHistory(11L, RouletteType.SPECIAL)).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원은 날짜가 바뀌면 같은 타입 룰렛에 다시 참여할 수 있다") + void shouldAllowSameRouletteTypeOnNextDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + saveHistoryInTransaction(12L, RouletteType.FREE, rewardId); + LocalDate yesterday = LocalDate.now().minusDays(1); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ?, participation_date = ? WHERE member_id = ?", + Timestamp.valueOf(yesterday.atTime(12, 0)), + yesterday, + 12L); + + saveHistoryInTransaction(12L, RouletteType.FREE, rewardId); + + assertThat(countHistory(12L, RouletteType.FREE)).isEqualTo(2); + } + @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) @@ -199,6 +244,30 @@ private RouletteReward persist(RouletteReward reward) { return entityManager.persist(reward); } + private Long saveRewardInTransaction(RouletteType rouletteType) { + return new TransactionTemplate(transactionManager).execute(status -> + rouletteRewardRepository.saveAndFlush( + reward(rouletteType, "기본 보상", 1, 10000, null)).getId()); + } + + private void saveHistoryInTransaction(Long memberId, RouletteType rouletteType, Long rewardId) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + rouletteHistoryRepository.saveAndFlush(RouletteHistory.builder() + .memberId(memberId) + .reward(rouletteRewardRepository.findById(rewardId).orElseThrow()) + .rouletteType(rouletteType) + .build())); + } + + private long countHistory(Long memberId, RouletteType rouletteType) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM roulette_history WHERE member_id = ? AND roulette_type = ?", + Long.class, + memberId, + rouletteType.name()); + return count == null ? 0L : count; + } + private void flushAndClear() { entityManager.flush(); entityManager.clear(); From 3cb0a2e6b1c7c501069d16bf6c9051a92eca87c0 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Tue, 8 Sep 2026 11:55:50 +0900 Subject: [PATCH 60/66] =?UTF-8?q?test(item):=20=EC=8A=A4=ED=8E=98=EC=85=9C?= =?UTF-8?q?=20=EB=A3=B0=EB=A0=9B=20=EB=8B=B9=EC=9D=BC=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=20=EA=B2=B0=EC=A0=9C=EC=95=A1=20=EC=A1=B0=ED=9A=8C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=20=20-=20=EB=B3=B8=EC=9D=B8=EC=9D=98=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=EB=90=9C=20=EC=A3=BC=EB=AC=B8=EB=A7=8C=20=EB=88=84?= =?UTF-8?q?=EC=A0=81=20=EA=B2=B0=EC=A0=9C=EC=95=A1=EC=97=90=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=EB=90=98=EB=8A=94=EC=A7=80=20=EA=B2=80=EC=A6=9D=20=20?= =?UTF-8?q?=20-=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=EA=B0=84=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EC=9D=98=20=EC=8B=9C=EC=9E=91=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=C2=B7=EC=A2=85=EB=A3=8C=20=EC=A0=9C=EC=99=B8=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EA=B2=80=EC=A6=9D=20=20=20-=20=EB=8B=A4=EB=A5=B8?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B3=BC=20=EC=8A=B9=EC=9D=B8=20=EA=B1=B0?= =?UTF-8?q?=EC=A0=88=20=EC=A3=BC=EB=AC=B8=EC=9D=B4=20=ED=95=A9=EC=82=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=EB=90=98=EB=8A=94?= =?UTF-8?q?=EC=A7=80=20=EA=B2=80=EC=A6=9D=20=20=20-=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=20=EB=82=B4=EC=97=AD=EC=9D=B4=20=EC=97=86=EB=8A=94=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EC=9D=98=20=ED=95=A9=EA=B3=84=EA=B0=80=200=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpecialRoulettePaymentRepositoryTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java new file mode 100644 index 0000000..feb4d4a --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java @@ -0,0 +1,85 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ContextConfiguration; + +import com.comatching.item.domain.order.entity.Order; +import com.comatching.item.domain.order.entity.OrderItem; +import com.comatching.item.domain.order.repository.OrderRepository; + +@DataJpaTest +@ContextConfiguration(classes = SpecialRoulettePaymentRepositoryTest.JpaTestConfig.class) +@DisplayName("스페셜 룰렛 결제 금액 조회 테스트") +class SpecialRoulettePaymentRepositoryTest { + + @Autowired + private OrderRepository orderRepository; + + @Test + @DisplayName("오늘 승인된 본인 주문만 시작 포함 종료 제외 조건으로 합산한다") + void shouldSumOnlyMembersApprovedPaymentsWithinToday() { + Long memberId = 1L; + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + saveApprovedOrder(memberId, 1000, todayStart); + saveApprovedOrder(memberId, 2500, tomorrowStart.minusSeconds(1)); + saveApprovedOrder(memberId, 9000, todayStart.minusSeconds(1)); + saveApprovedOrder(memberId, 9000, tomorrowStart); + saveApprovedOrder(2L, 9000, todayStart.plusHours(1)); + saveRejectedOrder(memberId, 9000, todayStart.plusHours(1)); + + long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberId, todayStart, tomorrowStart); + + assertThat(totalPay).isEqualTo(3500L); + assertThat(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + 99L, todayStart, tomorrowStart)).isZero(); + } + + private void saveApprovedOrder(Long memberId, int expectedPrice, LocalDateTime decidedAt) { + Order order = order(memberId, expectedPrice, decidedAt); + order.approve(decidedAt); + orderRepository.save(order); + } + + private void saveRejectedOrder(Long memberId, int expectedPrice, LocalDateTime decidedAt) { + Order order = order(memberId, expectedPrice, decidedAt); + order.reject(decidedAt); + orderRepository.save(order); + } + + private Order order(Long memberId, int expectedPrice, LocalDateTime requestedAt) { + return Order.builder() + .memberId(memberId) + .productId(1L) + .productCode("SPECIAL-ROULETTE-TEST") + .requestedItemName("테스트 상품") + .requesterRealName("테스트 회원") + .requesterUsername("test-user") + .requestedPrice(expectedPrice) + .expectedPrice(expectedPrice) + .requestedAt(requestedAt) + .expiresAt(requestedAt.plusMinutes(10)) + .build(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = {Order.class, OrderItem.class}) + @EnableJpaRepositories(basePackageClasses = OrderRepository.class) + static class JpaTestConfig { + } +} From 48b239d947ea9253f845b226c784211b3cf5a2ae Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Tue, 8 Sep 2026 14:22:17 +0900 Subject: [PATCH 61/66] =?UTF-8?q?refactor(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=B3=B4=EC=83=81=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=20=20=20?= =?UTF-8?q?=20-=20=EB=A3=B0=EB=A0=9B=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=AC=B4=EB=A3=8C=C2=B7=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=85=9C=20=EC=B0=B8=EC=97=AC=20=EC=97=AC=EB=B6=80=EC=99=80=20?= =?UTF-8?q?=EB=8B=B9=EC=9D=BC=20=EA=B2=B0=EC=A0=9C=EC=95=A1=EC=9D=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EB=B0=98=ED=99=98=20=20=20=20=20-=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=ED=83=80=EC=9E=85=EB=B3=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20=EC=9D=91=EB=8B=B5=20DTO?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20=20=20=20=20-=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=B6=94=EC=B2=A8=EA=B3=BC=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=B3=84=EB=8F=84=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=20?= =?UTF-8?q?=20=20=20-=20=EB=B3=80=EA=B2=BD=EB=90=9C=20=EB=A3=B0=EB=A0=9B?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B3=84=EC=95=BD=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=C2=B7=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/RoulettePageResponse.java | 7 +- .../repository/RouletteRewardRepository.java | 10 ++ .../roulette/service/RouletteService.java | 2 +- .../roulette/service/RouletteServiceImpl.java | 132 ++++++++---------- .../item/global/exception/ItemErrorCode.java | 2 +- .../infra/controller/RouletteController.java | 5 +- .../service/RouletteServiceImplTest.java | 68 +++++---- .../controller/RouletteControllerTest.java | 49 ++++--- 8 files changed, 151 insertions(+), 124 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java index fe924bd..e3ff3f3 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java @@ -1,7 +1,8 @@ package com.comatching.item.domain.roulette.dto.response; public record RoulettePageResponse( - boolean isPossible, - Long totalPay + boolean isFreeParticipated, // 무료 참여 여부 + boolean isSpecialParticipated, // 스페셜 참여 여부 + long totalPay // 결제 금액 ) { -} +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java index 9909a0b..7fd8fd8 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -26,4 +26,14 @@ public interface RouletteRewardRepository extends JpaRepository findAvailableByRouletteTypeAndRouletteNumber( @Param("rouletteType") RouletteType rouletteType, @Param("rouletteNumber") int rouletteNumber); + + + @Query(""" + SELECT COUNT(rr) > 0 + FROM RouletteReward rr + WHERE rr.rouletteType = :rouletteType + AND (rr.remainingCount IS NULL OR rr.remainingCount > 0) + """) + boolean existsAvailableByRouletteType( + @Param("rouletteType") RouletteType rouletteType); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java index 155bbed..382eb34 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java @@ -8,5 +8,5 @@ public interface RouletteService { RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType); - RoulettePageResponse roulettePage(MemberInfo memberInfo, RouletteType rouletteType); + RoulettePageResponse roulettePage(MemberInfo memberInfo); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index f6dbcaf..12be3b5 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -53,104 +53,96 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } - // 결제액이 3500이하면 불가능 + // 결제액이 3500미만이면 불가능 if (rouletteType == RouletteType.SPECIAL && orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( memberInfo.memberId(), todayStart, tomorrowStart) < SPECIAL_ROULETTE_MINIMUM_PAYMENT) { throw new BusinessException(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE); } - // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) - Optional rouletteReward; - do { - int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); - rouletteReward = rouletteRewardRepository - .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); - } while (rouletteReward.isEmpty()); - - + RouletteReward rouletteReward = drawAvailableReward(rouletteType); // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 - if (rouletteReward.get().getRewardName().equals("풀세트")) { - itemRepository.save(Item.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.OPTION_TICKET) - .quantity(3) - .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) - .build()); - itemHistoryRepository.save(ItemHistory.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.OPTION_TICKET) - .historyType(ItemHistoryType.EVENT) - .quantity(3) - .description(ItemType.OPTION_TICKET.getName()) - .build()); - - itemRepository.save(Item.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.MATCHING_TICKET) - .quantity(1) - .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) - .build()); - itemHistoryRepository.save(ItemHistory.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.MATCHING_TICKET) - .historyType(ItemHistoryType.EVENT) - .quantity(1) - .description(ItemType.MATCHING_TICKET.getName()) - .build()); - - } else if (rouletteReward.get().getItemType() != null) { - itemRepository.save(Item.builder() - .memberId(memberInfo.memberId()) - .quantity(rouletteReward.get().getQuantity()) - .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) - .itemType(rouletteReward.get().getItemType()) - .build()); - itemHistoryRepository.save(ItemHistory.builder() - .memberId(memberInfo.memberId()) - .itemType(rouletteReward.get().getItemType()) - .historyType(ItemHistoryType.EVENT) - .quantity(rouletteReward.get().getQuantity()) - .description(rouletteReward.get().getItemType().getName()) - .build()); - } + grantReward(memberInfo.memberId(), rouletteReward); // 남은 아이템 수 감소 - rouletteReward.get().decreaseRemainingCount(); - + rouletteReward.decreaseRemainingCount(); + // 기록 남기기 try { rouletteHistoryRepository.save(RouletteHistory.builder() .memberId(memberInfo.memberId()) - .reward(rouletteReward.get()) + .reward(rouletteReward) .rouletteType(rouletteType) .build()); rouletteHistoryRepository.flush(); } catch (DataIntegrityViolationException exception) { throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } - - - return new RouletteSpinResponse(rouletteReward.get().getRewardName()); + return new RouletteSpinResponse(rouletteReward.getRewardName()); } @Override - public RoulettePageResponse roulettePage(MemberInfo memberInfo, RouletteType rouletteType) { + public RoulettePageResponse roulettePage(MemberInfo memberInfo) { LocalDateTime todayStart = LocalDate.now().atStartOfDay(); LocalDateTime tomorrowStart = todayStart.plusDays(1); - boolean isParticipated = rouletteHistoryRepository + + boolean isFreeParticipated = rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - memberInfo.memberId(), rouletteType, todayStart, tomorrowStart); + memberInfo.memberId(), RouletteType.FREE, todayStart, tomorrowStart); + + boolean isSpecialParticipated = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), RouletteType.SPECIAL, todayStart, tomorrowStart); + + long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberInfo.memberId(), todayStart, tomorrowStart); + + return new RoulettePageResponse(isFreeParticipated, isSpecialParticipated, totalPay); + } + + private RouletteReward drawAvailableReward(RouletteType rouletteType) { + Optional rouletteReward; + do { + int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); + rouletteReward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); + } while (rouletteReward.isEmpty() + && rouletteRewardRepository.existsAvailableByRouletteType(rouletteType)); + + if (rouletteReward.isEmpty()) { + throw new BusinessException(ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD); + } + return rouletteReward.get(); + } + + private void grantReward(Long memberId, RouletteReward rouletteReward) { + if (rouletteReward.getRewardName().equals("풀세트")) { + saveRewardItem(memberId, ItemType.OPTION_TICKET, 3); + saveRewardItem(memberId, ItemType.MATCHING_TICKET, 1); + } else if (rouletteReward.getItemType() != null) { + saveRewardItem(memberId, rouletteReward.getItemType(), rouletteReward.getQuantity()); + } + } - Long totalPay = rouletteType == RouletteType.SPECIAL - ? orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( - memberInfo.memberId(), todayStart, tomorrowStart) - : null; - boolean isPossible = !isParticipated - && (rouletteType == RouletteType.FREE || totalPay >= SPECIAL_ROULETTE_MINIMUM_PAYMENT); - return new RoulettePageResponse(isPossible, totalPay); + + // 보상을 저장하고 기록을 남겨주는 메서드 + private void saveRewardItem(Long memberId, ItemType itemType, int quantity) { + itemRepository.save(Item.builder() + .memberId(memberId) + .itemType(itemType) + .quantity(quantity) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .build()); + + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberId) + .itemType(itemType) + .historyType(ItemHistoryType.EVENT) + .quantity(quantity) + .description(itemType.getName()) + .build()); } } diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 8de5eed..736c0d8 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -18,7 +18,7 @@ public enum ItemErrorCode implements ErrorCode { ALREADY_PARTICIPATED_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "이미 룰렛에 참여하셨습니다"), NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE("ITEM-008", HttpStatus.BAD_REQUEST, "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."), - ; + NO_AVAILABLE_ROULETTE_REWARD("ITEM-009", HttpStatus.BAD_REQUEST, "지급 가능한 룰렛 보상이 없습니다"); private final String code; private final HttpStatus httpStatus; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java index 15a9caf..6c945b8 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -30,9 +30,8 @@ public ResponseEntity> spinRoulette( @GetMapping public ResponseEntity> roulettePage( - @CurrentMember MemberInfo memberInfo, - @RequestParam RouletteType rouletteType + @CurrentMember MemberInfo memberInfo ) { - return ResponseEntity.ok(ApiResponse.ok(rouletteService.roulettePage(memberInfo, rouletteType))); + return ResponseEntity.ok(ApiResponse.ok(rouletteService.roulettePage(memberInfo))); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 3f1275e..7be39c4 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -61,35 +61,35 @@ class RouletteServiceImplTest { private RouletteServiceImpl rouletteService; @Test - @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 참여 가능하다") - void shouldAllowFreeRouletteWhenNotParticipatedToday() { + @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 미참여 상태를 반환한다") + void shouldReturnFreeRouletteAsNotParticipated() { given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.FREE), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(false); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.FREE); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isTrue(); - assertThat(response.totalPay()).isNull(); - then(orderRepository).shouldHaveNoInteractions(); + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isZero(); } @Test - @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여할 수 없다") - void shouldRejectFreeRouletteWhenAlreadyParticipatedToday() { + @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여 상태를 반환한다") + void shouldReturnFreeRouletteAsParticipated() { given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.FREE), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(true); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.FREE); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isFalse(); - assertThat(response.totalPay()).isNull(); - then(orderRepository).shouldHaveNoInteractions(); + assertThat(response.isFreeParticipated()).isTrue(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isZero(); then(rouletteHistoryRepository).should() .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.FREE), @@ -97,56 +97,74 @@ void shouldRejectFreeRouletteWhenAlreadyParticipatedToday() { } @Test - @DisplayName("오늘 결제액이 3500원이고 스페셜 룰렛에 참여하지 않았으면 참여 가능하다") - void shouldAllowSpecialRouletteAtMinimumPayment() { + @DisplayName("오늘 결제액과 스페셜 룰렛 미참여 상태를 반환한다") + void shouldReturnPaymentAndSpecialRouletteAsNotParticipated() { given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(3500L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(false); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isTrue(); + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); assertThat(response.totalPay()).isEqualTo(3500L); } @Test - @DisplayName("오늘 스페셜 룰렛에 이미 참여했으면 결제액이 충분해도 참여할 수 없다") - void shouldRejectSpecialRouletteWhenAlreadyParticipated() { + @DisplayName("오늘 스페셜 룰렛에 이미 참여했으면 참여 상태와 결제액을 반환한다") + void shouldReturnSpecialRouletteAsParticipated() { given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(10000L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(true); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isFalse(); + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isTrue(); assertThat(response.totalPay()).isEqualTo(10000L); } @Test - @DisplayName("오늘 결제액이 3500원 미만이면 스페셜 룰렛에 참여할 수 없다") - void shouldRejectSpecialRouletteWhenPaymentIsInsufficient() { + @DisplayName("오늘 결제액이 3500원 미만이어도 결제액을 그대로 반환한다") + void shouldReturnPaymentBelowSpecialRouletteMinimum() { given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(3499L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(false); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isFalse(); + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); assertThat(response.totalPay()).isEqualTo(3499L); } @@ -303,6 +321,8 @@ void shouldRetryWhenSelectedRewardIsEmpty() { given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) .willReturn(Optional.empty(), Optional.of(reward)); + given(rouletteRewardRepository.existsAvailableByRouletteType(RouletteType.FREE)) + .willReturn(true); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java index da6ca51..30f7bf0 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -85,61 +85,66 @@ void shouldRejectUnsupportedRouletteType() throws Exception { } @Test - @DisplayName("GET /api/roulette는 FREE 룰렛 참여 가능 여부와 null 결제액을 반환한다") - void shouldReturnFreeRoulettePage() throws Exception { - given(rouletteService.roulettePage(any(MemberInfo.class), eq(RouletteType.FREE))) - .willReturn(new RoulettePageResponse(true, null)); + @DisplayName("GET /api/roulette는 무료·스페셜 룰렛 참여 여부와 오늘 결제액을 반환한다") + void shouldReturnRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(true, false, 3500L)); mockMvc.perform(get("/api/roulette") - .param("rouletteType", "FREE") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("GEN-000")) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.isPossible").value(true)) - .andExpect(jsonPath("$.data.totalPay").doesNotExist()); + .andExpect(jsonPath("$.data.isFreeParticipated").value(true)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(false)) + .andExpect(jsonPath("$.data.totalPay").value(3500)); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); - then(rouletteService).should().roulettePage(memberCaptor.capture(), eq(RouletteType.FREE)); + then(rouletteService).should().roulettePage(memberCaptor.capture()); assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); } @Test - @DisplayName("GET /api/roulette는 SPECIAL 룰렛 참여 가능 여부와 오늘 결제액을 반환한다") - void shouldReturnSpecialRoulettePage() throws Exception { - given(rouletteService.roulettePage(any(MemberInfo.class), eq(RouletteType.SPECIAL))) - .willReturn(new RoulettePageResponse(false, 3499L)); + @DisplayName("GET /api/roulette는 두 룰렛의 참여 여부를 독립적으로 반환한다") + void shouldReturnEachRouletteParticipationStatus() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(false, true, 3499L)); mockMvc.perform(get("/api/roulette") - .param("rouletteType", "SPECIAL") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("GEN-000")) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.isPossible").value(false)) + .andExpect(jsonPath("$.data.isFreeParticipated").value(false)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(true)) .andExpect(jsonPath("$.data.totalPay").value(3499)); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); - then(rouletteService).should().roulettePage(memberCaptor.capture(), eq(RouletteType.SPECIAL)); + then(rouletteService).should().roulettePage(memberCaptor.capture()); assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); } @Test - @DisplayName("GET /api/roulette는 rouletteType 쿼리 파라미터가 없으면 400을 반환한다") - void shouldRejectMissingRouletteTypeOnRoulettePage() throws Exception { - mockMvc.perform(get("/api/roulette") - .header("X-Member-Id", "1")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(GeneralErrorCode.MISSING_REQUEST_PARAMETER.getCode())); + @DisplayName("GET /api/roulette는 rouletteType 쿼리 파라미터를 요구하지 않는다") + void shouldNotRequireRouletteTypeOnRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(false, false, 0L)); - then(rouletteService).shouldHaveNoInteractions(); + mockMvc.perform(get("/api/roulette") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isFreeParticipated").value(false)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(false)) + .andExpect(jsonPath("$.data.totalPay").value(0)); } } From bfe6bb9c789c7099da52890cbf2b85a7ae0fb78e Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 10 Sep 2026 17:02:10 +0900 Subject: [PATCH 62/66] =?UTF-8?q?=E2=80=A2=20refactor(item):=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20=EB=B3=B4=EC=83=81=20=EA=B5=AC=EB=B6=84=EC=9D=84=20?= =?UTF-8?q?RewardType=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouletteReward의 itemType을 rewardType으로 교체 - 일반 아이템·풀세트·상품권·꽝을 RewardType으로 구분 - 기존 보상 지급 수량과 재고·이력 처리 로직 유지 - 룰렛 테스트 데이터를 RewardType 구조에 맞게 수정 --- .../roulette/entity/RouletteReward.java | 9 ++++--- .../item/domain/roulette/enums/PrizeType.java | 7 ----- .../domain/roulette/enums/RewardType.java | 18 +++++++++++++ .../roulette/service/RouletteServiceImpl.java | 9 ++++--- .../service/RouletteConcurrencyTest.java | 7 ++--- .../service/RouletteRewardRepositoryTest.java | 12 ++++----- .../service/RouletteServiceImplTest.java | 27 ++++++++++--------- 7 files changed, 53 insertions(+), 36 deletions(-) delete mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java index d95ae3e..ad73b0f 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java @@ -1,6 +1,6 @@ package com.comatching.item.domain.roulette.entity; -import com.comatching.common.domain.enums.ItemType; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import jakarta.persistence.Column; @@ -32,7 +32,8 @@ public class RouletteReward { private String rewardName; @Enumerated(EnumType.STRING) - private ItemType itemType; + @Column(nullable = false) + private RewardType rewardType; @Column(nullable = false) private int quantity; @@ -49,7 +50,7 @@ public class RouletteReward { public RouletteReward( RouletteType rouletteType, String rewardName, - ItemType itemType, + RewardType rewardType, int quantity, int rangeStart, int rangeEnd, @@ -57,7 +58,7 @@ public RouletteReward( ) { this.rouletteType = rouletteType; this.rewardName = rewardName; - this.itemType = itemType; + this.rewardType = rewardType; this.quantity = quantity; this.rangeStart = rangeStart; this.rangeEnd = rangeEnd; diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java deleted file mode 100644 index 890dc2a..0000000 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.comatching.item.domain.roulette.enums; - -public enum PrizeType { - ITEM, - GIFT_CARD, - NONE -} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java new file mode 100644 index 0000000..5091d4c --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java @@ -0,0 +1,18 @@ +package com.comatching.item.domain.roulette.enums; + +import com.comatching.common.domain.enums.ItemType; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RewardType { + MATCHING_TICKET(ItemType.MATCHING_TICKET), + OPTION_TICKET(ItemType.OPTION_TICKET), + FULL_SET(null), + GIFT_CARD(null), + NONE(null); + + private final ItemType itemType; +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 12be3b5..042dd40 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -18,6 +18,7 @@ import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; @@ -60,6 +61,7 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou throw new BusinessException(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE); } + // 난수로 추첨 RouletteReward rouletteReward = drawAvailableReward(rouletteType); // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 @@ -118,11 +120,12 @@ private RouletteReward drawAvailableReward(RouletteType rouletteType) { } private void grantReward(Long memberId, RouletteReward rouletteReward) { - if (rouletteReward.getRewardName().equals("풀세트")) { + RewardType rewardType = rouletteReward.getRewardType(); + if (rewardType == RewardType.FULL_SET) { saveRewardItem(memberId, ItemType.OPTION_TICKET, 3); saveRewardItem(memberId, ItemType.MATCHING_TICKET, 1); - } else if (rouletteReward.getItemType() != null) { - saveRewardItem(memberId, rouletteReward.getItemType(), rouletteReward.getQuantity()); + } else if (rewardType.getItemType() != null) { + saveRewardItem(memberId, rewardType.getItemType(), rouletteReward.getQuantity()); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java index c9aee33..facd00a 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -31,7 +31,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; -import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.exception.BusinessException; import com.comatching.item.domain.item.entity.Item; @@ -41,6 +40,7 @@ import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; @@ -83,7 +83,7 @@ void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { assertThat(failures).isEmpty(); RouletteReward savedReward = rouletteRewardRepository.findById(limitedRewardId).orElseThrow(); - assertThat(savedReward.getItemType()).isNull(); + assertThat(savedReward.getRewardType()).isEqualTo(RewardType.NONE); assertThat(savedReward.getRemainingCount()).isZero(); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) .isEqualTo(REQUEST_COUNT); @@ -197,6 +197,7 @@ private RouletteReward reward( return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) + .rewardType(RewardType.NONE) .quantity(0) .rangeStart(rangeStart) .rangeEnd(rangeEnd) @@ -212,7 +213,7 @@ private RouletteReward itemReward( return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) - .itemType(ItemType.OPTION_TICKET) + .rewardType(RewardType.OPTION_TICKET) .quantity(1) .rangeStart(1) .rangeEnd(10000) diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 49ecaa8..2582dd9 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -30,7 +30,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; -import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.MemberInfo; import com.comatching.item.domain.item.entity.Item; import com.comatching.item.domain.item.entity.ItemHistory; @@ -39,6 +38,7 @@ import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; @@ -224,7 +224,7 @@ void shouldAllowSameRouletteTypeOnNextDate() { void shouldRollbackSavedItemWhenItemHistorySaveFails() { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.executeWithoutResult(status -> rouletteRewardRepository.saveAndFlush( - reward(RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, 10000, 2))); + reward(RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, 10000, 2))); given(itemHistoryRepository.save(any(ItemHistory.class))) .willThrow(new RuntimeException("item history save failed")); @@ -297,13 +297,13 @@ private RouletteReward reward( int rangeEnd, Integer remainingCount ) { - return reward(rouletteType, rewardName, null, rangeStart, rangeEnd, remainingCount); + return reward(rouletteType, rewardName, RewardType.NONE, rangeStart, rangeEnd, remainingCount); } private RouletteReward reward( RouletteType rouletteType, String rewardName, - ItemType itemType, + RewardType rewardType, int rangeStart, int rangeEnd, Integer remainingCount @@ -311,8 +311,8 @@ private RouletteReward reward( return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) - .itemType(itemType) - .quantity(itemType == null ? 0 : 1) + .rewardType(rewardType) + .quantity(rewardType.getItemType() == null ? 0 : 1) .rangeStart(rangeStart) .rangeEnd(rangeEnd) .remainingCount(remainingCount) diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 7be39c4..5c927dc 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -34,6 +34,7 @@ import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; @@ -171,7 +172,7 @@ void shouldReturnPaymentBelowSpecialRouletteMinimum() { @Test @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") void shouldGrantItemRewardAndReturnRewardName() { - RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", ItemType.OPTION_TICKET, 2, null); + RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", RewardType.OPTION_TICKET, 2, null); givenReward(RouletteType.FREE, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -198,7 +199,7 @@ void shouldGrantItemRewardAndReturnRewardName() { @Test @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") void shouldGrantEveryFullSetReward() { - RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", null, 0, 2); + RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 0, 2); givenReward(RouletteType.SPECIAL, fullSet); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); @@ -227,7 +228,7 @@ void shouldGrantEveryFullSetReward() { @Test @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") void shouldRecordGiftCardWithoutGrantingItem() { - RouletteReward reward = reward(RouletteType.SPECIAL, "1만원권 상품권", null, 1, 3); + RouletteReward reward = reward(RouletteType.SPECIAL, "1만원권 상품권", RewardType.GIFT_CARD, 1, 3); givenReward(RouletteType.SPECIAL, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); @@ -242,7 +243,7 @@ void shouldRecordGiftCardWithoutGrantingItem() { @Test @DisplayName("꽝은 아이템을 지급하지 않고 룰렛 이력과 보상명을 남긴다") void shouldRecordNoPrizeWithoutGrantingItem() { - RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); givenReward(RouletteType.FREE, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -317,7 +318,7 @@ void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { @Test @DisplayName("선택된 범위의 보상이 비어 있으면 다시 추첨한다") void shouldRetryWhenSelectedRewardIsEmpty() { - RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) .willReturn(Optional.empty(), Optional.of(reward)); @@ -336,7 +337,7 @@ void shouldRetryWhenSelectedRewardIsEmpty() { @DisplayName("아이템 저장이 실패하면 아이템 이력과 룰렛 이력을 저장하지 않는다") void shouldStopWhenItemSaveFails() { RouletteReward reward = reward( - RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); + RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, null); givenReward(RouletteType.FREE, reward); given(itemRepository.save(any(Item.class))).willThrow(new RuntimeException("item save failed")); @@ -352,7 +353,7 @@ void shouldStopWhenItemSaveFails() { @DisplayName("아이템 이력 저장이 실패하면 룰렛 이력을 저장하지 않는다") void shouldStopWhenItemHistorySaveFails() { RouletteReward reward = reward( - RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); + RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, null); givenReward(RouletteType.FREE, reward); given(itemHistoryRepository.save(any(ItemHistory.class))) .willThrow(new RuntimeException("item history save failed")); @@ -368,7 +369,7 @@ void shouldStopWhenItemHistorySaveFails() { @Test @DisplayName("룰렛 이력 저장이 실패하면 예외를 그대로 전달한다") void shouldPropagateRouletteHistorySaveFailure() { - RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); givenReward(RouletteType.FREE, reward); given(rouletteHistoryRepository.save(any(RouletteHistory.class))) .willThrow(new RuntimeException("roulette history save failed")); @@ -381,7 +382,7 @@ void shouldPropagateRouletteHistorySaveFailure() { @Test @DisplayName("제한 재고는 한 개 차감한다") void shouldDecreaseLimitedRemainingCount() { - RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", null, 1, 2); + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 2); reward.decreaseRemainingCount(); @@ -391,7 +392,7 @@ void shouldDecreaseLimitedRemainingCount() { @Test @DisplayName("무제한 보상은 재고를 변경하지 않는다") void shouldNotChangeUnlimitedRemainingCount() { - RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); reward.decreaseRemainingCount(); @@ -401,7 +402,7 @@ void shouldNotChangeUnlimitedRemainingCount() { @Test @DisplayName("소진된 재고는 음수가 되지 않는다") void shouldNotDecreaseRemainingCountBelowZero() { - RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", null, 1, 0); + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 0); reward.decreaseRemainingCount(); @@ -434,14 +435,14 @@ private void assertHistorySavedWith(RouletteReward reward, RouletteType roulette private RouletteReward reward( RouletteType rouletteType, String rewardName, - ItemType itemType, + RewardType rewardType, int quantity, Integer remainingCount ) { return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) - .itemType(itemType) + .rewardType(rewardType) .quantity(quantity) .rangeStart(1) .rangeEnd(1) From 22f6b79ec7341cb277f4d1ed110177609e9525d8 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 10 Sep 2026 20:27:54 +0900 Subject: [PATCH 63/66] =?UTF-8?q?=20=20feat(item):=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EA=B6=8C=20=EB=8B=B9=EC=B2=A8=EC=9E=90=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A7=80=EA=B8=89=20=EC=99=84=EB=A3=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 룰렛 이력에 보상 지급 여부를 기록하고 즉시 지급 보상 상태 반영 - 미지급 상품권 당첨자 조회 및 지급 완료 처리 관리자 API 추가 - 상품권 당첨자용 사용자 DTO와 내부 일괄 조회 API 추가 - 당첨자 회원 정보 조회를 위한 Feign 연동 추가 - 동일 회원의 복수 당첨 이력을 당첨 건별로 반환 - 상품권 외 보상을 무제한 재고로 처리하고 소진 범위 재추첨 - 상품권 지급 및 회원 일괄 조회 테스트 보강 --- .../member/AdminGiftCardUserProfileDto.java | 9 + .../response/AdminGiftCardWinnerResponse.java | 34 +++ .../roulette/entity/RouletteHistory.java | 14 +- .../repository/RouletteHistoryRepository.java | 25 ++ .../repository/RouletteRewardRepository.java | 11 - .../service/AdminRouletteService.java | 14 ++ .../service/AdminRouletteServiceImpl.java | 104 ++++++++ .../roulette/service/RouletteServiceImpl.java | 56 +++-- .../item/global/exception/ItemErrorCode.java | 3 +- .../item/infra/client/UserAdminClient.java | 6 + .../controller/AdminRouletteController.java | 51 ++++ .../infra/controller/RouletteController.java | 3 +- .../service/AdminRouletteServiceImplTest.java | 226 ++++++++++++++++++ .../service/RouletteConcurrencyTest.java | 30 +-- .../service/RouletteRewardRepositoryTest.java | 67 +++++- .../service/RouletteServiceImplTest.java | 39 +-- .../AdminRouletteControllerTest.java | 133 +++++++++++ .../member/repository/MemberRepository.java | 12 + .../service/AdminMemberQueryService.java | 5 + .../service/AdminMemberQueryServiceImpl.java | 26 ++ .../InternalAdminUserController.java | 11 + .../AdminMemberQueryServiceImplTest.java | 56 +++++ .../InternalAdminUserControllerTest.java | 93 +++++++ 23 files changed, 962 insertions(+), 66 deletions(-) create mode 100644 common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java create mode 100644 item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java create mode 100644 item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java create mode 100644 user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java diff --git a/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java b/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java new file mode 100644 index 0000000..aa77eb9 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java @@ -0,0 +1,9 @@ +package com.comatching.common.dto.member; + +public record AdminGiftCardUserProfileDto( + Long id, + String email, + String realName, + String nickname +) { +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java new file mode 100644 index 0000000..e01dbfb --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java @@ -0,0 +1,34 @@ +package com.comatching.item.domain.roulette.dto.response; + +import java.time.LocalDateTime; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RouletteType; + +public record AdminGiftCardWinnerResponse( + Long historyId, + Long memberId, + String email, + String realName, + String nickname, + String rewardName, + RouletteType rouletteType, + LocalDateTime participatedAt +) { + public static AdminGiftCardWinnerResponse from( + RouletteHistory history, + AdminGiftCardUserProfileDto user + ) { + return new AdminGiftCardWinnerResponse( + history.getId(), + history.getMemberId(), + user.email(), + user.realName(), + user.nickname(), + history.getReward().getRewardName(), + history.getRouletteType(), + history.getParticipatedAt() + ); + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index 20ba3ae..5283173 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -55,15 +55,27 @@ public class RouletteHistory { @Column(name = "participation_date", nullable = false) private LocalDate participationDate; + @Column(name = "reward_granted", nullable = false) + // 일반 아이템과 풀세트는 룰렛 처리 중 지급이 끝나면 true가 된다. + // 상품권은 관리자가 실제 지급한 뒤 지급 완료 API를 호출하기 전까지 false로 남는다. + private boolean rewardGranted; + @Builder public RouletteHistory( Long memberId, RouletteReward reward, - RouletteType rouletteType + RouletteType rouletteType, + boolean rewardGranted ) { this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; this.participationDate = participatedAt.toLocalDate(); + this.rewardGranted = rewardGranted; + } + + public void markRewardAsGranted() { + // 관리자가 상품권을 실제 지급한 뒤 지급 완료 상태로 변경한다. + this.rewardGranted = true; } } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java index ef20b79..c8d52f3 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -1,10 +1,19 @@ package com.comatching.item.domain.roulette.repository; import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; public interface RouletteHistoryRepository extends JpaRepository { boolean existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( @@ -13,4 +22,20 @@ boolean existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParti LocalDateTime startAt, LocalDateTime endAt ); + + @EntityGraph(attributePaths = "reward") + // 상품권 당첨 이력 중 지급되지 않은 건만 당첨 시각 역순으로 조회한다. + List findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc( + RewardType rewardType + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + // 두 관리자가 같은 당첨 건을 동시에 지급 완료 처리하지 못하도록 이력 행을 잠근다. + @Query(""" + SELECT rh + FROM RouletteHistory rh + JOIN FETCH rh.reward + WHERE rh.id = :historyId + """) + Optional findByIdWithRewardForUpdate(@Param("historyId") Long historyId); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java index 7fd8fd8..0b370ca 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -1,6 +1,5 @@ package com.comatching.item.domain.roulette.repository; -import java.util.List; import java.util.Optional; import com.comatching.item.domain.roulette.entity.RouletteReward; @@ -26,14 +25,4 @@ public interface RouletteRewardRepository extends JpaRepository findAvailableByRouletteTypeAndRouletteNumber( @Param("rouletteType") RouletteType rouletteType, @Param("rouletteNumber") int rouletteNumber); - - - @Query(""" - SELECT COUNT(rr) > 0 - FROM RouletteReward rr - WHERE rr.rouletteType = :rouletteType - AND (rr.remainingCount IS NULL OR rr.remainingCount > 0) - """) - boolean existsAvailableByRouletteType( - @Param("rouletteType") RouletteType rouletteType); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java new file mode 100644 index 0000000..f4bd9d2 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java @@ -0,0 +1,14 @@ +package com.comatching.item.domain.roulette.service; + +import java.util.List; + +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; + +public interface AdminRouletteService { + + /** 아직 지급되지 않은 상품권 당첨자와 관리자 확인용 회원 정보를 반환한다. */ + List getUnpaidGiftCardWinners(); + + /** 관리자가 실제 상품권 지급을 끝낸 당첨 이력을 지급 완료 상태로 변경한다. */ + void markGiftCardAsGranted(Long historyId); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java new file mode 100644 index 0000000..e7f2f07 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java @@ -0,0 +1,104 @@ +package com.comatching.item.domain.roulette.service; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import com.comatching.item.infra.client.UserAdminClient; + +import feign.FeignException; +import feign.codec.DecodeException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminRouletteServiceImpl implements AdminRouletteService { + + private final RouletteHistoryRepository rouletteHistoryRepository; + private final UserAdminClient userAdminClient; + + @Override + @Transactional(readOnly = true) + public List getUnpaidGiftCardWinners() { + // rewardType이 GIFT_CARD이고 rewardGranted가 false인 이력만 최신순으로 가져온다. + List histories = rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD); + + // 미지급 당첨자가 없으면 user-service를 호출하지 않고 빈 명단을 반환한다. + if (histories.isEmpty()) { return List.of(); } + + // user-service에서 받은 회원 목록을 memberId로 찾을 수 있게 만들어 각 당첨 이력에 회원 정보를 붙인다. + Map usersById = getUsersByMemberId(histories); + + // 가져온 유저 정보 당첨 정보와 매핑 + return histories.stream() + .map(history -> AdminGiftCardWinnerResponse.from( + history, + getUserOrThrow(usersById, history.getMemberId()) + )) + .toList(); + } + + @Override + @Transactional + public void markGiftCardAsGranted(Long historyId) { + // 같은 당첨 이력에 대한 동시 요청을 직렬화해 지급 완료 상태가 중복 처리되지 않게 한다. + RouletteHistory history = rouletteHistoryRepository.findByIdWithRewardForUpdate(historyId) + .orElseThrow(() -> new BusinessException(GeneralErrorCode.NOT_FOUND)); + + // 예외처리 + if (history.getReward().getRewardType() != RewardType.GIFT_CARD) { + // 일반 아이템, 풀세트, 꽝 이력은 상품권 지급 완료 대상으로 사용할 수 없다. + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); + } + + // 미지급 상태일경우 지급처리 완료 + if (!history.isRewardGranted()) { + history.markRewardAsGranted(); + } + } + + private Map getUsersByMemberId(List histories) { + // 같은 회원이 여러 상품권에 당첨됐어도 user-service에는 회원 ID를 한 번만 전달한다. + List memberIds = histories.stream() + .map(RouletteHistory::getMemberId) + .distinct() + .toList(); + + try { + // 당첨자별 단건 호출을 피하고 현재 미지급 명단의 회원 정보를 한 번에 조회한다. + return userAdminClient.getUsersByIds(memberIds).stream() + .collect(Collectors.toMap(AdminGiftCardUserProfileDto::id, Function.identity())); + } catch (DecodeException ignored) { + throw new BusinessException(ItemErrorCode.USER_QUERY_FAILED); + } catch (FeignException ignored) { + throw new BusinessException(ItemErrorCode.USER_QUERY_FAILED); + } + } + + // user-service 배치 응답에서 당첨 이력의 memberId와 일치하는 회원 정보를 반환한다. + // 활성 회원 정보가 누락된 경우 정확한 지급 대상 명단을 만들 수 없으므로 ITEM-004 예외를 발생시킨다. + private AdminGiftCardUserProfileDto getUserOrThrow( + Map usersById, + Long memberId + ) { + AdminGiftCardUserProfileDto user = usersById.get(memberId); + if (user == null) { + throw new BusinessException(ItemErrorCode.TARGET_USER_NOT_FOUND); + } + return user; + } + +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 042dd40..aa10937 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -65,68 +65,96 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou RouletteReward rouletteReward = drawAvailableReward(rouletteType); // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 - grantReward(memberInfo.memberId(), rouletteReward); + boolean rewardGranted = grantReward(memberInfo.memberId(), rouletteReward); - // 남은 아이템 수 감소 + // 제한 재고만 감소시키며, remainingCount가 null인 무제한 보상은 유지한다. rouletteReward.decreaseRemainingCount(); // 기록 남기기 try { - rouletteHistoryRepository.save(RouletteHistory.builder() - .memberId(memberInfo.memberId()) - .reward(rouletteReward) - .rouletteType(rouletteType) - .build()); - rouletteHistoryRepository.flush(); + rouletteHistoryRepository.saveAndFlush(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward) + .rouletteType(rouletteType) + .rewardGranted(rewardGranted) + .build()); } catch (DataIntegrityViolationException exception) { throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } return new RouletteSpinResponse(rouletteReward.getRewardName()); } + @Override public RoulettePageResponse roulettePage(MemberInfo memberInfo) { + // 오늘 날짜 LocalDateTime todayStart = LocalDate.now().atStartOfDay(); LocalDateTime tomorrowStart = todayStart.plusDays(1); + // 오늘 무료 룰렛 참여 여부 boolean isFreeParticipated = rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( memberInfo.memberId(), RouletteType.FREE, todayStart, tomorrowStart); + // 오늘 유료 룰렛 참여 여부 boolean isSpecialParticipated = rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( memberInfo.memberId(), RouletteType.SPECIAL, todayStart, tomorrowStart); + // 오늘 결제액 long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( memberInfo.memberId(), todayStart, tomorrowStart); return new RoulettePageResponse(isFreeParticipated, isSpecialParticipated, totalPay); } + + + + + // 난수로 보상을 추첨하는 메서드 private RouletteReward drawAvailableReward(RouletteType rouletteType) { Optional rouletteReward; + do { + // 1 ~ 10000 사이의 난수 생성 int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); + + // 무제한(null)이거나 남은 수량이 1 이상인 보상을 조회한다. rouletteReward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); - } while (rouletteReward.isEmpty() - && rouletteRewardRepository.existsAvailableByRouletteType(rouletteType)); - if (rouletteReward.isEmpty()) { - throw new BusinessException(ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD); - } + // 선택한 범위의 제한 재고가 소진됐으면 새 난수로 다시 추첨한다. + } while (rouletteReward.isEmpty()); + + // 상품권 외 보상은 무제한 재고로 운영하며, 전체 보상 소진 시 처리 정책은 확정되지 않아 예외 처리를 비활성화한다. +// if (rouletteReward.isEmpty()) { +// throw new BusinessException(ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD); +// } return rouletteReward.get(); } - private void grantReward(Long memberId, RouletteReward rouletteReward) { + private boolean grantReward(Long memberId, RouletteReward rouletteReward) { RewardType rewardType = rouletteReward.getRewardType(); + + // 풀세트의 경우 if (rewardType == RewardType.FULL_SET) { saveRewardItem(memberId, ItemType.OPTION_TICKET, 3); saveRewardItem(memberId, ItemType.MATCHING_TICKET, 1); + + // 지급 완료 처리 + return true; + // 아닐경우 아이템 지급 } else if (rewardType.getItemType() != null) { saveRewardItem(memberId, rewardType.getItemType(), rouletteReward.getQuantity()); + + // 지급 완료 처리 + return true; } + + // 상품권은 관리자 지급 전이고, 꽝은 지급된 보상이 없으므로 false로 기록한다. + return false; } diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 736c0d8..63d4d6a 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -17,8 +17,7 @@ public enum ItemErrorCode implements ErrorCode { DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), ALREADY_PARTICIPATED_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "이미 룰렛에 참여하셨습니다"), NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE("ITEM-008", HttpStatus.BAD_REQUEST, - "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."), - NO_AVAILABLE_ROULETTE_REWARD("ITEM-009", HttpStatus.BAD_REQUEST, "지급 가능한 룰렛 보상이 없습니다"); + "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."); private final String code; private final HttpStatus httpStatus; diff --git a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java index 0d524ef..fb8f055 100644 --- a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java +++ b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java @@ -5,8 +5,11 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; @@ -23,4 +26,7 @@ PagingResponse getUsers( @GetMapping("/{memberId}") AdminUserProfileDto getUserDetail(@PathVariable Long memberId); + + @PostMapping("/bulk") + List getUsersByIds(@RequestBody List memberIds); } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java new file mode 100644 index 0000000..4965ff4 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java @@ -0,0 +1,51 @@ +package com.comatching.item.infra.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.service.AdminRouletteService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Admin Roulette API", description = "관리자 전용 룰렛 상품권 관리") +@RestController +@RequestMapping("/api/v1/admin/roulette") +@RequiredArgsConstructor +public class AdminRouletteController { + + private final AdminRouletteService adminRouletteService; + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "미지급 상품권 당첨자 조회", description = "상품권에 당첨됐지만 아직 지급되지 않은 이력을 최신순으로 조회합니다.") + @GetMapping("/gift-cards/unpaid") + public ResponseEntity>> getUnpaidGiftCardWinners( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(adminRouletteService.getUnpaidGiftCardWinners())); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "상품권 지급 완료 처리", description = "상품권 지급을 완료한 당첨 이력을 지급 완료 상태로 변경합니다.") + @PatchMapping("/gift-cards/{historyId}/grant") + public ResponseEntity> markGiftCardAsGranted( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long historyId + ) { + adminRouletteService.markGiftCardAsGranted(historyId); + return ResponseEntity.ok(ApiResponse.ok()); + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java index 6c945b8..81e338f 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -8,7 +8,6 @@ import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.service.RouletteService; import io.swagger.v3.oas.annotations.Operation; -import jdk.jfr.Description; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -20,7 +19,7 @@ public class RouletteController { private final RouletteService rouletteService; @PostMapping("/{rouletteType}/spins") - @Operation(summary = "룰렛 돌리는 api", description = "") + @Operation(summary = "룰렛 돌리는 api", description = "무료 룰렛과 유료 룰렛 (1일 1회 제한)") public ResponseEntity> spinRoulette( @CurrentMember MemberInfo memberInfo, @PathVariable RouletteType rouletteType diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java new file mode 100644 index 0000000..255a27a --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java @@ -0,0 +1,226 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import com.comatching.item.infra.client.UserAdminClient; + +import feign.FeignException; +import feign.codec.DecodeException; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminRouletteServiceImpl 테스트") +class AdminRouletteServiceImplTest { + + @Mock + private RouletteHistoryRepository rouletteHistoryRepository; + + @Mock + private UserAdminClient userAdminClient; + + @InjectMocks + private AdminRouletteServiceImpl adminRouletteService; + + @Test + @DisplayName("미지급 상품권 이력이 없으면 회원 조회 없이 빈 목록을 반환한다") + void shouldReturnEmptyListWithoutRequestingUsers() { + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of()); + + List responses = adminRouletteService.getUnpaidGiftCardWinners(); + + assertThat(responses).isEmpty(); + then(userAdminClient).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("같은 회원의 여러 당첨 이력은 회원을 한 번만 조회하고 당첨 건별로 반환한다") + void shouldRequestDistinctUsersAndReturnEveryWinningHistory() { + RouletteHistory newestHistory = history(101L, 1L, "2만원권 상품권", false); + RouletteHistory sameMemberHistory = history(100L, 1L, "1만원권 상품권", false); + RouletteHistory anotherMemberHistory = history(99L, 2L, "1만원권 상품권", false); + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(newestHistory, sameMemberHistory, anotherMemberHistory)); + given(userAdminClient.getUsersByIds(List.of(1L, 2L))).willReturn(List.of( + new AdminGiftCardUserProfileDto(2L, "second@example.com", "두번째", "두번"), + new AdminGiftCardUserProfileDto(1L, "first@example.com", "첫번째", "첫번") + )); + + List responses = adminRouletteService.getUnpaidGiftCardWinners(); + + then(userAdminClient).should().getUsersByIds(List.of(1L, 2L)); + assertThat(responses).hasSize(3); + assertThat(responses).extracting(AdminGiftCardWinnerResponse::historyId) + .containsExactly(101L, 100L, 99L); + assertThat(responses).extracting(AdminGiftCardWinnerResponse::memberId) + .containsExactly(1L, 1L, 2L); + assertThat(responses.get(0).email()).isEqualTo("first@example.com"); + assertThat(responses.get(0).realName()).isEqualTo("첫번째"); + assertThat(responses.get(0).nickname()).isEqualTo("첫번"); + assertThat(responses.get(0).rewardName()).isEqualTo("2만원권 상품권"); + assertThat(responses.get(0).rouletteType()).isEqualTo(RouletteType.SPECIAL); + assertThat(responses.get(0).participatedAt()).isEqualTo(newestHistory.getParticipatedAt()); + } + + @Test + @DisplayName("당첨 회원 정보가 응답에서 누락되면 대상 사용자 없음 예외가 발생한다") + void shouldThrowWhenWinnerProfileIsMissing() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", false); + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(history)); + given(userAdminClient.getUsersByIds(List.of(1L))).willReturn(List.of()); + + assertThatThrownBy(adminRouletteService::getUnpaidGiftCardWinners) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.TARGET_USER_NOT_FOUND)); + } + + @Test + @DisplayName("회원 응답 디코딩에 실패하면 사용자 조회 실패 예외로 변환한다") + void shouldConvertDecodeException() { + givenGiftCardHistory(); + given(userAdminClient.getUsersByIds(List.of(1L))).willThrow(mock(DecodeException.class)); + + assertUserQueryFailed(); + } + + @Test + @DisplayName("회원 서비스 호출에 실패하면 사용자 조회 실패 예외로 변환한다") + void shouldConvertFeignException() { + givenGiftCardHistory(); + given(userAdminClient.getUsersByIds(List.of(1L))).willThrow(mock(FeignException.class)); + + assertUserQueryFailed(); + } + + @Test + @DisplayName("미지급 상품권 이력을 지급 완료로 변경한다") + void shouldMarkGiftCardAsGranted() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", false); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + adminRouletteService.markGiftCardAsGranted(101L); + + assertThat(history.isRewardGranted()).isTrue(); + } + + @Test + @DisplayName("이미 지급된 상품권 이력은 그대로 유지한다") + void shouldKeepAlreadyGrantedGiftCardGranted() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", true); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + adminRouletteService.markGiftCardAsGranted(101L); + + assertThat(history.isRewardGranted()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 룰렛 이력이면 찾을 수 없음 예외가 발생한다") + void shouldThrowWhenHistoryDoesNotExist() { + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(404L)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> adminRouletteService.markGiftCardAsGranted(404L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.NOT_FOUND)); + } + + @Test + @DisplayName("상품권이 아닌 룰렛 이력은 지급 완료로 변경할 수 없다") + void shouldRejectNonGiftCardHistory() { + RouletteHistory history = history( + 101L, + 1L, + "옵션권 1장", + RewardType.OPTION_TICKET, + true + ); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + assertThatThrownBy(() -> adminRouletteService.markGiftCardAsGranted(101L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.INVALID_INPUT_VALUE)); + } + + private void givenGiftCardHistory() { + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(history(101L, 1L, "1만원권 상품권", false))); + } + + private void assertUserQueryFailed() { + assertThatThrownBy(adminRouletteService::getUnpaidGiftCardWinners) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.USER_QUERY_FAILED)); + } + + private RouletteHistory history( + Long historyId, + Long memberId, + String rewardName, + boolean rewardGranted + ) { + return history(historyId, memberId, rewardName, RewardType.GIFT_CARD, rewardGranted); + } + + private RouletteHistory history( + Long historyId, + Long memberId, + String rewardName, + RewardType rewardType, + boolean rewardGranted + ) { + RouletteReward reward = RouletteReward.builder() + .rouletteType(RouletteType.SPECIAL) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(rewardType == RewardType.GIFT_CARD ? 1 : 0) + .rangeStart(1) + .rangeEnd(10000) + .remainingCount(rewardType == RewardType.GIFT_CARD ? 1 : null) + .build(); + RouletteHistory history = RouletteHistory.builder() + .memberId(memberId) + .reward(reward) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(rewardGranted) + .build(); + ReflectionTestUtils.setField(history, "id", historyId); + return history; + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java index facd00a..b87df75 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -72,18 +72,20 @@ class RouletteConcurrencyTest { @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) - @DisplayName("단일 행 보상은 동시 추첨 횟수만큼 재고가 차감된다") - void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { + @DisplayName("단일 행 상품권은 동시 추첨 횟수만큼 재고가 차감된다") + void giftCardStockIsDecreasedForEveryConcurrentSpin() throws Exception { int stock = REQUEST_COUNT; Long limitedRewardId = inTransaction(() -> rouletteRewardRepository.save( - reward(RouletteType.FREE, "한정 보상", 1, 10000, stock)).getId()); + reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 10000, stock)).getId()); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween(any(), any(), any())) + .willReturn(3500L); List failures = runConcurrently(REQUEST_COUNT, index -> - rouletteService.spinRoulette(member(index + 1L), RouletteType.FREE)); + rouletteService.spinRoulette(member(index + 1L), RouletteType.SPECIAL)); assertThat(failures).isEmpty(); RouletteReward savedReward = rouletteRewardRepository.findById(limitedRewardId).orElseThrow(); - assertThat(savedReward.getRewardType()).isEqualTo(RewardType.NONE); + assertThat(savedReward.getRewardType()).isEqualTo(RewardType.GIFT_CARD); assertThat(savedReward.getRemainingCount()).isZero(); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) .isEqualTo(REQUEST_COUNT); @@ -97,7 +99,7 @@ void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { Long memberId = 50L; Long rewardId = inTransaction(() -> rouletteRewardRepository.save( - itemReward(RouletteType.FREE, "옵션권 1장", REQUEST_COUNT)).getId()); + itemReward(RouletteType.FREE, "옵션권 1장")).getId()); List failures = runConcurrently(REQUEST_COUNT, index -> rouletteService.spinRoulette(member(memberId), RouletteType.FREE)); @@ -111,7 +113,7 @@ void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) .isOne(); assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) - .isEqualTo(REQUEST_COUNT - 1); + .isNull(); assertThat(itemRepository.count()).isOne(); assertThat(itemHistoryRepository.count()).isOne(); } @@ -122,7 +124,7 @@ void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { void specialRouletteSucceedsOnlyOnceForSameMember() throws Exception { Long memberId = 51L; Long rewardId = inTransaction(() -> rouletteRewardRepository.save( - itemReward(RouletteType.SPECIAL, "옵션권 1장", REQUEST_COUNT)).getId()); + itemReward(RouletteType.SPECIAL, "옵션권 1장")).getId()); given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(memberId), any(), any())) .willReturn(3500L); @@ -142,7 +144,7 @@ void specialRouletteSucceedsOnlyOnceForSameMember() throws Exception { RouletteType.SPECIAL.name())) .isOne(); assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) - .isEqualTo(REQUEST_COUNT - 1); + .isNull(); assertThat(itemRepository.count()).isOne(); assertThat(itemHistoryRepository.count()).isOne(); } @@ -190,6 +192,7 @@ private List runConcurrently(int threadCount, ThrowingIntConsumer tas private RouletteReward reward( RouletteType rouletteType, String rewardName, + RewardType rewardType, int rangeStart, int rangeEnd, Integer remainingCount @@ -197,8 +200,8 @@ private RouletteReward reward( return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) - .rewardType(RewardType.NONE) - .quantity(0) + .rewardType(rewardType) + .quantity(rewardType == RewardType.GIFT_CARD ? 1 : 0) .rangeStart(rangeStart) .rangeEnd(rangeEnd) .remainingCount(remainingCount) @@ -207,8 +210,7 @@ private RouletteReward reward( private RouletteReward itemReward( RouletteType rouletteType, - String rewardName, - Integer remainingCount + String rewardName ) { return RouletteReward.builder() .rouletteType(rouletteType) @@ -217,7 +219,7 @@ private RouletteReward itemReward( .quantity(1) .rangeStart(1) .rangeEnd(10000) - .remainingCount(remainingCount) + .remainingCount(null) .build(); } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 2582dd9..c7209e3 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -8,6 +8,7 @@ import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -91,7 +92,7 @@ void shouldFindRewardsAtBothBoundaries() { @DisplayName("풀세트 단일 행을 조회한다") void shouldFindSingleFullSetReward() { RouletteReward fullSet = persist(reward( - RouletteType.SPECIAL, "풀세트", null, 8201, 9200, null)); + RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 8201, 9200, null)); flushAndClear(); Optional reward = rouletteRewardRepository @@ -103,7 +104,8 @@ void shouldFindSingleFullSetReward() { @Test @DisplayName("재고가 소진된 제한 보상은 조회하지 않는다") void shouldExcludeOutOfStockReward() { - persist(reward(RouletteType.SPECIAL, "상품권", 9701, 9900, 0)); + persist(reward( + RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 9701, 9900, 0)); flushAndClear(); Optional reward = rouletteRewardRepository @@ -125,6 +127,61 @@ void shouldSeparateRewardsByRouletteType() { assertThat(reward.orElseThrow().getId()).isEqualTo(freeReward.getId()); } + @Test + @DisplayName("미지급 상품권 이력만 당첨 시각 역순으로 조회한다") + void shouldFindOnlyUnpaidGiftCardHistoriesInLatestOrder() { + RouletteReward giftCard = persist(reward( + RouletteType.SPECIAL, "1만원권 상품권", RewardType.GIFT_CARD, 9701, 9900, 3)); + RouletteReward item = persist(reward( + RouletteType.SPECIAL, "옵션권 2장", RewardType.OPTION_TICKET, 1, 3900, null)); + RouletteHistory olderUnpaid = entityManager.persist(RouletteHistory.builder() + .memberId(20L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + RouletteHistory paidGiftCard = entityManager.persist(RouletteHistory.builder() + .memberId(21L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(true) + .build()); + entityManager.persist(RouletteHistory.builder() + .memberId(22L) + .reward(item) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + RouletteHistory newerUnpaid = entityManager.persist(RouletteHistory.builder() + .memberId(23L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + entityManager.flush(); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().atTime(10, 0)), + olderUnpaid.getId()); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().atTime(12, 0)), + newerUnpaid.getId()); + entityManager.clear(); + + List histories = rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD); + + assertThat(histories).extracting(RouletteHistory::getId) + .containsExactly(newerUnpaid.getId(), olderUnpaid.getId()); + assertThat(histories).allSatisfy(history -> { + assertThat(history.isRewardGranted()).isFalse(); + assertThat(history.getReward().getRewardType()).isEqualTo(RewardType.GIFT_CARD); + }); + assertThat(histories).extracting(RouletteHistory::getId) + .doesNotContain(paidGiftCard.getId()); + } + @Test @DisplayName("오늘 같은 회원의 FREE 참여 이력이 있으면 true를 반환한다") void shouldFindTodayFreeHistoryForSameMember() { @@ -224,7 +281,7 @@ void shouldAllowSameRouletteTypeOnNextDate() { void shouldRollbackSavedItemWhenItemHistorySaveFails() { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.executeWithoutResult(status -> rouletteRewardRepository.saveAndFlush( - reward(RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, 10000, 2))); + reward(RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, 10000, null))); given(itemHistoryRepository.save(any(ItemHistory.class))) .willThrow(new RuntimeException("item history save failed")); @@ -237,7 +294,7 @@ void shouldRollbackSavedItemWhenItemHistorySaveFails() { assertThat(rouletteHistoryRepository.count()).isZero(); assertThat(rouletteRewardRepository.findAll()) .extracting(RouletteReward::getRemainingCount) - .containsExactly(2); + .containsExactly((Integer)null); } private RouletteReward persist(RouletteReward reward) { @@ -275,7 +332,7 @@ private void flushAndClear() { private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) { RouletteReward historyReward = persist(reward( - RouletteType.FREE, "꽝", null, 1, 10000, 999999)); + RouletteType.FREE, "꽝", RewardType.NONE, 1, 10000, null)); return entityManager.persist(RouletteHistory.builder() .memberId(memberId) .reward(historyReward) diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 5c927dc..6fca4d0 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -192,19 +192,20 @@ void shouldGrantItemRewardAndReturnRewardName() { assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(2); assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.OPTION_TICKET.getName()); - assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(reward.getRemainingCount()).isNull(); + assertHistorySavedWith(reward, RouletteType.FREE, true); assertThat(response.rewardName()).isEqualTo("옵션권 2장"); } @Test @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") void shouldGrantEveryFullSetReward() { - RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 0, 2); + RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 0, null); givenReward(RouletteType.SPECIAL, fullSet); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); - assertThat(fullSet.getRemainingCount()).isEqualTo(1); + assertThat(fullSet.getRemainingCount()).isNull(); ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); then(itemRepository).should(times(2)).save(itemCaptor.capture()); assertThat(itemCaptor.getAllValues()) @@ -221,7 +222,7 @@ void shouldGrantEveryFullSetReward() { org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3), org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1) ); - assertHistorySavedWith(fullSet, RouletteType.SPECIAL); + assertHistorySavedWith(fullSet, RouletteType.SPECIAL, true); assertThat(response.rewardName()).isEqualTo("풀세트"); } @@ -236,7 +237,7 @@ void shouldRecordGiftCardWithoutGrantingItem() { assertThat(reward.getRemainingCount()).isEqualTo(2); then(itemRepository).should(never()).save(any(Item.class)); then(itemHistoryRepository).shouldHaveNoInteractions(); - assertHistorySavedWith(reward, RouletteType.SPECIAL); + assertHistorySavedWith(reward, RouletteType.SPECIAL, false); assertThat(response.rewardName()).isEqualTo("1만원권 상품권"); } @@ -250,7 +251,8 @@ void shouldRecordNoPrizeWithoutGrantingItem() { then(itemRepository).should(never()).save(any(Item.class)); then(itemHistoryRepository).shouldHaveNoInteractions(); - assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(reward.getRemainingCount()).isNull(); + assertHistorySavedWith(reward, RouletteType.FREE, false); assertThat(response.rewardName()).isEqualTo("꽝"); } @@ -270,7 +272,7 @@ void shouldRejectDuplicatedFreeRouletteParticipation() { then(itemRepository).shouldHaveNoInteractions(); then(rouletteRewardRepository).shouldHaveNoInteractions(); then(itemHistoryRepository).shouldHaveNoInteractions(); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -290,7 +292,7 @@ void shouldRejectDuplicatedSpecialRouletteParticipation() { then(rouletteRewardRepository).shouldHaveNoInteractions(); then(itemRepository).shouldHaveNoInteractions(); then(itemHistoryRepository).shouldHaveNoInteractions(); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -312,7 +314,7 @@ void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { then(rouletteRewardRepository).shouldHaveNoInteractions(); then(itemRepository).shouldHaveNoInteractions(); then(itemHistoryRepository).shouldHaveNoInteractions(); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -322,14 +324,12 @@ void shouldRetryWhenSelectedRewardIsEmpty() { given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) .willReturn(Optional.empty(), Optional.of(reward)); - given(rouletteRewardRepository.existsAvailableByRouletteType(RouletteType.FREE)) - .willReturn(true); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); then(rouletteRewardRepository).should(times(2)) .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt()); - assertHistorySavedWith(reward, RouletteType.FREE); + assertHistorySavedWith(reward, RouletteType.FREE, false); assertThat(response.rewardName()).isEqualTo("꽝"); } @@ -346,7 +346,7 @@ void shouldStopWhenItemSaveFails() { .hasMessage("item save failed"); then(itemHistoryRepository).shouldHaveNoInteractions(); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -363,7 +363,7 @@ void shouldStopWhenItemHistorySaveFails() { .hasMessage("item history save failed"); then(itemRepository).should().save(any(Item.class)); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -371,7 +371,7 @@ void shouldStopWhenItemHistorySaveFails() { void shouldPropagateRouletteHistorySaveFailure() { RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); givenReward(RouletteType.FREE, reward); - given(rouletteHistoryRepository.save(any(RouletteHistory.class))) + given(rouletteHistoryRepository.saveAndFlush(any(RouletteHistory.class))) .willThrow(new RuntimeException("roulette history save failed")); assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) @@ -424,12 +424,17 @@ private void givenReward(RouletteType rouletteType, RouletteReward reward) { .willReturn(Optional.of(reward)); } - private void assertHistorySavedWith(RouletteReward reward, RouletteType rouletteType) { + private void assertHistorySavedWith( + RouletteReward reward, + RouletteType rouletteType, + boolean rewardGranted + ) { ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(RouletteHistory.class); - then(rouletteHistoryRepository).should().save(historyCaptor.capture()); + then(rouletteHistoryRepository).should().saveAndFlush(historyCaptor.capture()); assertThat(historyCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); assertThat(historyCaptor.getValue().getReward()).isSameAs(reward); assertThat(historyCaptor.getValue().getRouletteType()).isEqualTo(rouletteType); + assertThat(historyCaptor.getValue().isRewardGranted()).isEqualTo(rewardGranted); } private RouletteReward reward( diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java new file mode 100644 index 0000000..574ee18 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java @@ -0,0 +1,133 @@ +package com.comatching.item.infra.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.AdminRouletteService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminRouletteController API 계약 테스트") +class AdminRouletteControllerTest { + + private MockMvc mockMvc; + + @Mock + private AdminRouletteService adminRouletteService; + + @InjectMocks + private AdminRouletteController adminRouletteController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(adminRouletteController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("GET /api/v1/admin/roulette/gift-cards/unpaid는 미지급 상품권 당첨자 목록을 반환한다") + void shouldReturnUnpaidGiftCardWinners() throws Exception { + LocalDateTime participatedAt = LocalDateTime.of(2026, 9, 10, 12, 30); + given(adminRouletteService.getUnpaidGiftCardWinners()).willReturn(List.of( + new AdminGiftCardWinnerResponse( + 101L, + 1L, + "winner@example.com", + "당첨자", + "행운", + "2만원권 상품권", + RouletteType.SPECIAL, + participatedAt + ) + )); + + mockMvc.perform(get("/api/v1/admin/roulette/gift-cards/unpaid") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data[0].historyId").value(101)) + .andExpect(jsonPath("$.data[0].memberId").value(1)) + .andExpect(jsonPath("$.data[0].email").value("winner@example.com")) + .andExpect(jsonPath("$.data[0].realName").value("당첨자")) + .andExpect(jsonPath("$.data[0].nickname").value("행운")) + .andExpect(jsonPath("$.data[0].rewardName").value("2만원권 상품권")) + .andExpect(jsonPath("$.data[0].rouletteType").value("SPECIAL")) + .andExpect(jsonPath("$.data[0].participatedAt[0]").value(2026)) + .andExpect(jsonPath("$.data[0].participatedAt[1]").value(9)) + .andExpect(jsonPath("$.data[0].participatedAt[2]").value(10)) + .andExpect(jsonPath("$.data[0].participatedAt[3]").value(12)) + .andExpect(jsonPath("$.data[0].participatedAt[4]").value(30)); + + then(adminRouletteService).should().getUnpaidGiftCardWinners(); + } + + @Test + @DisplayName("GET 미지급 상품권 당첨자 조회는 관리자 회원 헤더를 해석한다") + void shouldResolveAdminMemberHeaders() throws Exception { + given(adminRouletteService.getUnpaidGiftCardWinners()).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/admin/roulette/gift-cards/unpaid") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data").isEmpty()); + + then(adminRouletteService).should().getUnpaidGiftCardWinners(); + } + + @Test + @DisplayName("PATCH /api/v1/admin/roulette/gift-cards/{historyId}/grant는 상품권 지급 완료를 위임한다") + void shouldMarkGiftCardAsGranted() throws Exception { + mockMvc.perform(patch("/api/v1/admin/roulette/gift-cards/{historyId}/grant", 101L) + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)); + + then(adminRouletteService).should().markGiftCardAsGranted(101L); + } + + @Test + @DisplayName("상품권 지급 완료 경로의 historyId가 숫자가 아니면 400을 반환한다") + void shouldRejectInvalidHistoryId() throws Exception { + mockMvc.perform(patch("/api/v1/admin/roulette/gift-cards/not-a-number/grant") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GeneralErrorCode.TYPE_MISMATCH.getCode())); + + then(adminRouletteService).shouldHaveNoInteractions(); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java b/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java index 99544fd..51f570f 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java @@ -1,5 +1,6 @@ package com.comatching.user.domain.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -58,4 +59,15 @@ Optional findAdminMemberById( @Param("status") MemberStatus status, @Param("role") MemberRole role ); + + @Query("SELECT m FROM Member m " + + "JOIN FETCH m.profile p " + + "WHERE m.id IN :memberIds " + + "AND m.status = :status " + + "AND m.role = :role") + List findAdminMembersByIds( + @Param("memberIds") List memberIds, + @Param("status") MemberStatus status, + @Param("role") MemberRole role + ); } diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java index 6aca5a8..f06dbfc 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java @@ -1,7 +1,10 @@ package com.comatching.user.domain.member.service; +import java.util.List; + import org.springframework.data.domain.Pageable; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; @@ -10,4 +13,6 @@ public interface AdminMemberQueryService { PagingResponse getUsers(String keyword, Pageable pageable); AdminUserProfileDto getUserDetail(Long memberId); + + List getUsersByIds(List memberIds); } diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java index 63aefdd..6d697cc 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java @@ -1,5 +1,7 @@ package com.comatching.user.domain.member.service; +import java.util.List; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -8,6 +10,7 @@ import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; @@ -47,6 +50,29 @@ public AdminUserProfileDto getUserDetail(Long memberId) { return toAdminUserProfileDto(member); } + @Override + public List getUsersByIds(List memberIds) { + // 미지급 상품권 명단에 표시할 이메일, 실명, 닉네임을 회원 ID 목록으로 한 번에 조회한다. + // 반환 순서는 보장하지 않으며 호출 서비스가 회원 ID를 기준으로 원래 명단에 결합한다. + return memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + ) + .stream() + .map(this::toAdminGiftCardUserProfileDto) + .toList(); + } + + private AdminGiftCardUserProfileDto toAdminGiftCardUserProfileDto(Member member) { + return new AdminGiftCardUserProfileDto( + member.getId(), + member.getEmail(), + member.getRealName(), + member.getProfile().getNickname() + ); + } + private AdminUserProfileDto toAdminUserProfileDto(Member member) { return new AdminUserProfileDto( member.getId(), diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java index 73cb058..bddbec8 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java @@ -1,14 +1,19 @@ package com.comatching.user.infra.controller; +import java.util.List; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.user.domain.member.service.AdminMemberQueryService; @@ -34,4 +39,10 @@ public PagingResponse getUsers( public AdminUserProfileDto getUserDetail(@PathVariable Long memberId) { return adminMemberQueryService.getUserDetail(memberId); } + + @PostMapping("/bulk") + public List getUsersByIds(@RequestBody List memberIds) { + // item-service가 상품권 당첨자 명단을 만들 때 회원별 단건 조회를 반복하지 않도록 제공한다. + return adminMemberQueryService.getUsersByIds(memberIds); + } } diff --git a/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java b/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java index 77a279e..d0f5f44 100644 --- a/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import java.util.List; import java.util.Optional; @@ -19,6 +20,7 @@ import com.comatching.common.domain.enums.Gender; import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.user.domain.member.entity.Member; @@ -92,6 +94,60 @@ void shouldReturnAdminUserDetail() { assertThat(result.nickname()).isEqualTo("상세유저"); } + @Test + @DisplayName("회원 ID 목록으로 활성 일반 회원 정보를 한 번에 조회한다") + void shouldReturnUsersByIds() { + List memberIds = List.of(3L, 1L); + Member third = createMemberWithProfile( + 3L, + "third@test.com", + "세번째", + "셋", + Gender.MALE, + "https://third" + ); + Member first = createMemberWithProfile( + 1L, + "first@test.com", + "첫번째", + "하나", + Gender.FEMALE, + "https://first" + ); + given(memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + )).willReturn(List.of(third, first)); + + List results = adminMemberQueryService.getUsersByIds(memberIds); + + then(memberRepository).should().findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + ); + assertThat(results).containsExactly( + new AdminGiftCardUserProfileDto(3L, "third@test.com", "세번째", "셋"), + new AdminGiftCardUserProfileDto(1L, "first@test.com", "첫번째", "하나") + ); + } + + @Test + @DisplayName("조회된 회원이 없으면 빈 사용자 목록을 반환한다") + void shouldReturnEmptyUsersByIds() { + List memberIds = List.of(404L); + given(memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + )).willReturn(List.of()); + + List results = adminMemberQueryService.getUsersByIds(memberIds); + + assertThat(results).isEmpty(); + } + private static Member createMemberWithProfile( Long id, String email, diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java new file mode 100644 index 0000000..2f6e87f --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java @@ -0,0 +1,93 @@ +package com.comatching.user.infra.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.user.domain.member.service.AdminMemberQueryService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("InternalAdminUserController API 계약 테스트") +class InternalAdminUserControllerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private MockMvc mockMvc; + + @Mock + private AdminMemberQueryService adminMemberQueryService; + + @InjectMocks + private InternalAdminUserController internalAdminUserController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(internalAdminUserController).build(); + } + + @Test + @DisplayName("POST /api/internal/admin/users/bulk는 회원 ID 목록을 전달하고 사용자 정보를 반환한다") + void shouldReturnUsersByIds() throws Exception { + List memberIds = List.of(3L, 1L); + given(adminMemberQueryService.getUsersByIds(memberIds)).willReturn(List.of( + new AdminGiftCardUserProfileDto(3L, "third@example.com", "세번째", "셋"), + new AdminGiftCardUserProfileDto(1L, "first@example.com", "첫번째", "하나") + )); + + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberIds))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(3)) + .andExpect(jsonPath("$[0].email").value("third@example.com")) + .andExpect(jsonPath("$[0].realName").value("세번째")) + .andExpect(jsonPath("$[0].nickname").value("셋")) + .andExpect(jsonPath("$[1].id").value(1)) + .andExpect(jsonPath("$[1].email").value("first@example.com")); + + then(adminMemberQueryService).should().getUsersByIds(memberIds); + } + + @Test + @DisplayName("POST 일괄 회원 조회는 빈 ID 목록도 서비스에 그대로 전달한다") + void shouldDelegateEmptyMemberIds() throws Exception { + given(adminMemberQueryService.getUsersByIds(List.of())).willReturn(List.of()); + + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content("[]")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + + then(adminMemberQueryService).should().getUsersByIds(List.of()); + } + + @Test + @DisplayName("POST 일괄 회원 조회의 요청 본문이 배열이 아니면 400을 반환한다") + void shouldRejectMalformedMemberIds() throws Exception { + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberId\":1}")) + .andExpect(status().isBadRequest()); + + then(adminMemberQueryService).shouldHaveNoInteractions(); + } +} From 9ad60d02f43eccfb806f1c77a7a0930a9ea535be Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 10 Sep 2026 20:50:29 +0900 Subject: [PATCH 64/66] =?UTF-8?q?refactor(item):=20=EB=A3=B0=EB=A0=9B=20AP?= =?UTF-8?q?I=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20item-service=20prefix?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20=20=20=20=20-=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20API=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20/api/roulette?= =?UTF-8?q?=EC=97=90=EC=84=9C=20/api/items/roulette=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=20=20=20=20-=20Gateway=EC=9D=98=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20/api/items/**=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comatching/item/infra/controller/RouletteController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java index 81e338f..44d3a8f 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -14,7 +14,7 @@ @RestController @AllArgsConstructor -@RequestMapping("/api/roulette") +@RequestMapping("/api/items/roulette") public class RouletteController { private final RouletteService rouletteService; From d10b4d1104d5c604503c5d851ab304cc7d989223 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Sat, 12 Sep 2026 02:29:49 +0900 Subject: [PATCH 65/66] =?UTF-8?q?test(item):=20=EB=A3=B0=EB=A0=9B=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=B0=98=EC=98=81=20=20=20=20=20?= =?UTF-8?q?-=20RouletteControllerTest=20=EC=9A=94=EC=B2=AD=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20/api/items/roulette=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=20=20=20=20-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=AA=85=EC=9D=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=9C=20API=20=EA=B2=BD=EB=A1=9C=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RouletteControllerTest.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java index 30f7bf0..80af38d 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -52,12 +52,12 @@ void setUp() { } @Test - @DisplayName("POST /api/roulette/{rouletteType}/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") + @DisplayName("POST /api/items/roulette/{rouletteType}/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") void shouldSpinRouletteAndReturnRewardName() throws Exception { given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.SPECIAL))) .willReturn(new RouletteSpinResponse("2만원권 상품권")); - mockMvc.perform(post("/api/roulette/SPECIAL/spins") + mockMvc.perform(post("/api/items/roulette/SPECIAL/spins") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) @@ -76,7 +76,7 @@ void shouldSpinRouletteAndReturnRewardName() throws Exception { @Test @DisplayName("지원하지 않는 rouletteType 경로 값이면 400을 반환한다") void shouldRejectUnsupportedRouletteType() throws Exception { - mockMvc.perform(post("/api/roulette/PAID/spins") + mockMvc.perform(post("/api/items/roulette/PAID/spins") .header("X-Member-Id", "1")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(GeneralErrorCode.TYPE_MISMATCH.getCode())); @@ -85,12 +85,12 @@ void shouldRejectUnsupportedRouletteType() throws Exception { } @Test - @DisplayName("GET /api/roulette는 무료·스페셜 룰렛 참여 여부와 오늘 결제액을 반환한다") + @DisplayName("GET /api/items/roulette는 무료·스페셜 룰렛 참여 여부와 오늘 결제액을 반환한다") void shouldReturnRoulettePage() throws Exception { given(rouletteService.roulettePage(any(MemberInfo.class))) .willReturn(new RoulettePageResponse(true, false, 3500L)); - mockMvc.perform(get("/api/roulette") + mockMvc.perform(get("/api/items/roulette") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) @@ -109,12 +109,12 @@ void shouldReturnRoulettePage() throws Exception { } @Test - @DisplayName("GET /api/roulette는 두 룰렛의 참여 여부를 독립적으로 반환한다") + @DisplayName("GET /api/items/roulette는 두 룰렛의 참여 여부를 독립적으로 반환한다") void shouldReturnEachRouletteParticipationStatus() throws Exception { given(rouletteService.roulettePage(any(MemberInfo.class))) .willReturn(new RoulettePageResponse(false, true, 3499L)); - mockMvc.perform(get("/api/roulette") + mockMvc.perform(get("/api/items/roulette") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) @@ -133,12 +133,12 @@ void shouldReturnEachRouletteParticipationStatus() throws Exception { } @Test - @DisplayName("GET /api/roulette는 rouletteType 쿼리 파라미터를 요구하지 않는다") + @DisplayName("GET /api/items/roulette는 rouletteType 쿼리 파라미터를 요구하지 않는다") void shouldNotRequireRouletteTypeOnRoulettePage() throws Exception { given(rouletteService.roulettePage(any(MemberInfo.class))) .willReturn(new RoulettePageResponse(false, false, 0L)); - mockMvc.perform(get("/api/roulette") + mockMvc.perform(get("/api/items/roulette") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) From 5bd2c290daabf8f4a8d3cf97b7250080fcbc9d18 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Sat, 12 Sep 2026 19:17:52 +0900 Subject: [PATCH 66/66] =?UTF-8?q?fix(item):=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EA=B0=80=20=EC=97=86=EB=8A=94=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EA=B6=8C=20=EB=8B=B9=EC=B2=A8=EC=9E=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EB=B0=A9=EC=A7=80=20=20=20=20?= =?UTF-8?q?=20-=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20admin=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=20=20=20=20-=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EA=B0=80=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EB=8B=B9=EC=B2=A8=20=EC=9D=B4=EB=A0=A5=EC=9D=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=20=20=20=20-=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC?= =?UTF-8?q?=EC=99=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20import=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminRouletteService.java | 2 +- .../service/AdminRouletteServiceImpl.java | 18 +++--------------- .../controller/AdminRouletteController.java | 2 +- .../service/AdminRouletteServiceImplTest.java | 1 + .../AdminRouletteControllerTest.java | 2 +- 5 files changed, 7 insertions(+), 18 deletions(-) rename item-service/src/main/java/com/comatching/item/domain/{roulette => admin}/service/AdminRouletteService.java (90%) rename item-service/src/main/java/com/comatching/item/domain/{roulette => admin}/service/AdminRouletteServiceImpl.java (84%) diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteService.java similarity index 90% rename from item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java rename to item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteService.java index f4bd9d2..24069d1 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteService.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.roulette.service; +package com.comatching.item.domain.admin.service; import java.util.List; diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteServiceImpl.java similarity index 84% rename from item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java rename to item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteServiceImpl.java index e7f2f07..c562edb 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteServiceImpl.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.roulette.service; +package com.comatching.item.domain.admin.service; import java.util.List; import java.util.Map; @@ -44,9 +44,10 @@ public List getUnpaidGiftCardWinners() { // 가져온 유저 정보 당첨 정보와 매핑 return histories.stream() + .filter(history -> usersById.containsKey(history.getMemberId())) .map(history -> AdminGiftCardWinnerResponse.from( history, - getUserOrThrow(usersById, history.getMemberId()) + usersById.get(history.getMemberId()) )) .toList(); } @@ -88,17 +89,4 @@ private Map getUsersByMemberId(List usersById, - Long memberId - ) { - AdminGiftCardUserProfileDto user = usersById.get(memberId); - if (user == null) { - throw new BusinessException(ItemErrorCode.TARGET_USER_NOT_FOUND); - } - return user; - } - } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java index 4965ff4..c39d907 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java @@ -15,7 +15,7 @@ import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; -import com.comatching.item.domain.roulette.service.AdminRouletteService; +import com.comatching.item.domain.admin.service.AdminRouletteService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java index 255a27a..a76d93f 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Optional; +import com.comatching.item.domain.admin.service.AdminRouletteServiceImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java index 574ee18..58d6212 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java @@ -25,7 +25,7 @@ import com.comatching.common.resolver.MemberInfoArgumentResolver; import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; import com.comatching.item.domain.roulette.enums.RouletteType; -import com.comatching.item.domain.roulette.service.AdminRouletteService; +import com.comatching.item.domain.admin.service.AdminRouletteService; import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class)