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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.recruitment.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
package com.example.recruitment.controller;

import com.example.recruitment.service.UserClient;
import com.example.recruitment.common.ApiResponse;
import com.example.recruitment.dto.RecruitmentDetailDto;
import com.example.recruitment.dto.RecruitmentRequestDto;
import com.example.recruitment.dto.RecruitmentResponseDto;
import com.example.recruitment.dto.order.OrderRequestDto;
import com.example.recruitment.entity.Recruitment;
import com.example.recruitment.entity.RecruitmentParticipant;
import com.example.recruitment.entity.User;
import com.example.recruitment.entity.Store;
import com.example.recruitment.exception.CustomException;
import com.example.recruitment.exception.ErrorCode;
import com.example.recruitment.repository.RecruitmentParticipantRepository;
import com.example.recruitment.repository.RecruitmentRepository;
import com.example.recruitment.repository.StoreRepository;
import com.example.recruitment.service.RecruitmentService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

@RestController
@RequiredArgsConstructor
Expand All @@ -25,78 +33,90 @@ public class RecruitmentController {
private final RecruitmentService recruitmentService;
private final RecruitmentRepository recruitmentRepository;
private final RecruitmentParticipantRepository participantRepository;
private final StoreRepository storeRepository;
private final UserClient userClient; // 유저 서비스 연동용 Feign Client

//모집글 생성 (Order 서버에 주문 생성 포함)
@PostMapping
public ResponseEntity<?> createRecruitment(@Valid @RequestBody RecruitmentRequestDto dto) {
recruitmentService.createRecruitment(dto);
return ResponseEntity.ok("모집글 생성 완료");
public ResponseEntity<ApiResponse<Map<String, Long>>> createRecruitment(@Valid @RequestBody RecruitmentRequestDto dto) {
Long recruitmentId = recruitmentService.createRecruitment(dto);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.created(Map.of("recruitmentId", recruitmentId), "모집글 생성 완료"));
}

//모집글 참여 (Order 서버에 주문 생성 포함)
@PostMapping("/{recruitmentId}/join")
public ResponseEntity<?> joinRecruitment(@PathVariable Long recruitmentId,
@RequestParam Long userId,
@RequestBody OrderRequestDto orderRequestDto) {
public ResponseEntity<ApiResponse<String>> joinRecruitment(@PathVariable Long recruitmentId,
@RequestParam Long userId,
@Valid @RequestBody OrderRequestDto orderRequestDto) {
recruitmentService.joinRecruitment(recruitmentId, userId, orderRequestDto);
return ResponseEntity.ok("모집글 참여 완료");
return ResponseEntity.ok(ApiResponse.ok(null, "모집글 참여 완료"));
}

// 모집글 전체 조회
@GetMapping
public List<Recruitment> getAll() {
return recruitmentRepository.findAll();
public ResponseEntity<ApiResponse<List<RecruitmentResponseDto>>> getAll() {
List<Recruitment> recruitments = recruitmentRepository.findAll();
List<RecruitmentResponseDto> response = recruitments.stream()
.map(RecruitmentResponseDto::new)
.toList();
return ResponseEntity.ok(ApiResponse.ok(response, "모든 모집글 조회 성공"));
}

// 상태별 조회
@GetMapping(params = "status")
public List<Recruitment> getByStatus(@RequestParam String status) {
return recruitmentRepository.findByStatus(status);
public ResponseEntity<ApiResponse<List<RecruitmentResponseDto>>> getByStatus(@RequestParam String status) {
List<Recruitment> recruitments = recruitmentRepository.findByStatus(status);
List<RecruitmentResponseDto> response = recruitments.stream()
.map(RecruitmentResponseDto::new)
.toList();
return ResponseEntity.ok(ApiResponse.ok(response, "상태별 모집글 조회 성공"));
}

// 모집글 상세 조회
@GetMapping("/{recruitmentId}")
public ResponseEntity<?> getRecruitmentDetail(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow();
public ResponseEntity<ApiResponse<RecruitmentDetailDto>> getRecruitmentDetail(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));

// ✅ 작성자 정보 → 유저 서비스에서 가져옴
RecruitmentDetailDto.UserDto writer = userClient.getUserById(recruitment.getUser().getId());

List<RecruitmentParticipant> participants =
participantRepository.findByRecruitmentId(recruitmentId);
List<User> participantUsers = participants.stream()
.map(RecruitmentParticipant::getUser)
// ✅ 참여자 정보
List<RecruitmentParticipant> participants = participantRepository.findByRecruitmentId(recruitmentId);
List<RecruitmentDetailDto.UserDto> participantUsers = participants.stream()
.map(p -> userClient.getUserById(p.getUser().getId()))
.toList();

RecruitmentDetailDto dto = new RecruitmentDetailDto();
dto.setId(recruitment.getId());
dto.setTitle(recruitment.getTitle());
dto.setDescription(recruitment.getDescription());
dto.setStatus(recruitment.getStatus());
dto.setDeadlineTime(recruitment.getDeadlineTime());
dto.setUser(recruitment.getUser());
dto.setStore(recruitment.getStore());
dto.setParticipants(participantUsers);

return ResponseEntity.ok(dto);
List<Long> orderIds = participants.stream()
.map(RecruitmentParticipant::getOrderId)
.toList();

RecruitmentDetailDto dto = new RecruitmentDetailDto(recruitment, writer, participantUsers, orderIds);

return ResponseEntity.ok(ApiResponse.ok(dto, "모집 상세 조회 성공"));
}
Comment on lines 73 to 94
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

외부 서비스 호출에 대한 오류 처리가 필요합니다.

현재 구현에는 몇 가지 중요한 문제가 있습니다:

  1. userClient.getUserById() 호출이 실패할 경우에 대한 예외 처리가 없습니다.
  2. 참여자 수만큼 개별적으로 API를 호출하면 성능 문제가 발생할 수 있습니다 (N+1 문제).

다음과 같은 개선사항을 제안합니다:

-        // ✅ 작성자 정보 → 유저 서비스에서 가져옴
-        RecruitmentDetailDto.UserDto writer = userClient.getUserById(recruitment.getUser().getId());
+        // ✅ 작성자 정보 → 유저 서비스에서 가져옴
+        RecruitmentDetailDto.UserDto writer;
+        try {
+            writer = userClient.getUserById(recruitment.getUser().getId());
+        } catch (Exception e) {
+            throw new CustomException(ErrorCode.EXTERNAL_SERVICE_ERROR, "사용자 정보를 가져올 수 없습니다.");
+        }

         // ✅ 참여자 정보
         List<RecruitmentParticipant> participants = participantRepository.findByRecruitmentId(recruitmentId);
-        List<RecruitmentDetailDto.UserDto> participantUsers = participants.stream()
-                .map(p -> userClient.getUserById(p.getUser().getId()))
-                .toList();
+        List<Long> participantUserIds = participants.stream()
+                .map(p -> p.getUser().getId())
+                .toList();
+        
+        List<RecruitmentDetailDto.UserDto> participantUsers;
+        try {
+            // 대량 조회 API가 있다면 사용을 권장합니다
+            participantUsers = userClient.getUsersByIds(participantUserIds);
+        } catch (Exception e) {
+            throw new CustomException(ErrorCode.EXTERNAL_SERVICE_ERROR, "참여자 정보를 가져올 수 없습니다.");
+        }

UserClient에 대량 조회 API가 있는지 확인해 보겠습니다:


🏁 Script executed:

#!/bin/bash
# UserClient 인터페이스에서 대량 조회 메서드가 있는지 확인
ast-grep --pattern 'interface UserClient {
  $$$
}'

# UserClient 구현체 찾기
fd -e java -x grep -l "UserClient" {} \; | grep -v "Controller"

Length of output: 202


🏁 Script executed:

#!/bin/bash
# UserClient 인터페이스에 다중 사용자 조회 메서드 존재 여부 확인
grep -n "getUsers" recruitment-service/src/main/java/com/example/recruitment/service/UserClient.java || echo "Bulk fetch 메서드 없음"

Length of output: 145


오류 처리 및 N+1 문제 완화 적용 필요

다음 두 가지 사항을 반드시 반영해주세요:

  • 외부 서비스 호출 실패 시 CustomException(ErrorCode.EXTERNAL_SERVICE_ERROR)로 예외 처리
  • 참여자 정보를 한 번에 조회할 수 있는 bulk API가 현재 없으므로,
    1. UserClientgetUsersByIds(List<Long> ids) 메서드 추가를 검토 또는
    2. 최소한 병렬 스트림(parallelStream())과 개별 try-catch로 호출하여 전체 처리 중 한 건 실패 시에도 다른 호출이 영향을 받지 않도록 방어 코드 삽입

수정 예시:

--- a/recruitment-service/src/main/java/com/example/recruitment/controller/RecruitmentController.java
+++ b/recruitment-service/src/main/java/com/example/recruitment/controller/RecruitmentController.java
@@ public ResponseEntity<ApiResponse<RecruitmentDetailDto>> getRecruitmentDetail(...) {
-    RecruitmentDetailDto.UserDto writer = userClient.getUserById(recruitment.getUser().getId());
+    RecruitmentDetailDto.UserDto writer;
+    try {
+        writer = userClient.getUserById(recruitment.getUser().getId());
+    } catch (Exception e) {
+        throw new CustomException(ErrorCode.EXTERNAL_SERVICE_ERROR, "작성자 정보를 가져올 수 없습니다.");
+    }
@@
-    List<RecruitmentDetailDto.UserDto> participantUsers = participants.stream()
-            .map(p -> userClient.getUserById(p.getUser().getId()))
-            .toList();
+    List<RecruitmentDetailDto.UserDto> participantUsers;
+    List<Long> participantIds = participants.stream()
+            .map(p -> p.getUser().getId())
+            .toList();
+    try {
+        // TODO: UserClient에 bulk 조회 메서드(getUsersByIds) 구현 시 대체
+        participantUsers = participantIds.parallelStream()
+            .map(id -> {
+                try {
+                    return userClient.getUserById(id);
+                } catch (Exception e) {
+                    // 실패한 경우 null 반환 후 필터링하거나 기본 값 처리
+                    return null;
+                }
+            })
+            .filter(Objects::nonNull)
+            .toList();
+    } catch (Exception e) {
+        throw new CustomException(ErrorCode.EXTERNAL_SERVICE_ERROR, "참여자 정보를 가져올 수 없습니다.");
+    }

추가로 UserClient 인터페이스에 다음 메서드 추가를 검토해주세요:

List<RecruitmentDetailDto.UserDto> getUsersByIds(List<Long> userIds);
  • 파일: recruitment-service/src/main/java/com/example/recruitment/controller/RecruitmentController.java
  • 파일: recruitment-service/src/main/java/com/example/recruitment/service/UserClient.java
🤖 Prompt for AI Agents
In
recruitment-service/src/main/java/com/example/recruitment/controller/RecruitmentController.java
around lines 73 to 94, the calls to userClient.getUserById() lack error handling
and cause N+1 performance issues. To fix this, wrap each external userClient
call in try-catch blocks that throw CustomException with
ErrorCode.EXTERNAL_SERVICE_ERROR on failure. To mitigate N+1, add a bulk fetch
method getUsersByIds(List<Long> ids) to UserClient and use it to retrieve all
participant users in one call instead of individual calls. Update the controller
to use this bulk method for participant user retrieval and handle exceptions
accordingly.


// 유저가 만든 모집글
@GetMapping("/user/{userId}/created-recruitments")
public List<Recruitment> getRecruitmentsCreatedByUser(@PathVariable Long userId) {
return recruitmentRepository.findByUserId(userId);
public ResponseEntity<ApiResponse<List<RecruitmentResponseDto>>> getRecruitmentsCreatedByUser(@PathVariable Long userId) {
List<Recruitment> list = recruitmentRepository.findByUserId(userId);
List<RecruitmentResponseDto> response = list.stream()
.map(RecruitmentResponseDto::new)
.toList();
return ResponseEntity.ok(ApiResponse.ok(response, "작성한 모집글 조회 성공"));
}

// 유저가 참여한 모집글
@GetMapping("/user/{userId}/joined-recruitments")
public List<Recruitment> getRecruitmentsJoinedByUser(@PathVariable Long userId) {
public ResponseEntity<ApiResponse<List<RecruitmentResponseDto>>> getRecruitmentsJoinedByUser(@PathVariable Long userId) {
List<RecruitmentParticipant> participantList = participantRepository.findByUserId(userId);
return participantList.stream()
List<RecruitmentResponseDto> response = participantList.stream()
.map(RecruitmentParticipant::getRecruitment)
.map(RecruitmentResponseDto::new)
.toList();
return ResponseEntity.ok(ApiResponse.ok(response, "참여한 모집글 조회 성공"));
}

// 모집 상태 업데이트
@PatchMapping("/{recruitmentId}/status")
public ResponseEntity<?> updateRecruitmentStatus(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow();
public ResponseEntity<ApiResponse<String>> updateRecruitmentStatus(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));

LocalDateTime now = LocalDateTime.now();
long participantCount = participantRepository.countByRecruitmentId(recruitmentId);

Expand All @@ -107,62 +127,70 @@ public ResponseEntity<?> updateRecruitmentStatus(@PathVariable Long recruitmentI
recruitment.setStatus("FAILED");
}
recruitmentRepository.save(recruitment);
return ResponseEntity.ok("상태가 " + recruitment.getStatus() + "로 변경되었습니다.");
return ResponseEntity.ok(ApiResponse.ok(null, "상태가 " + recruitment.getStatus() + "로 변경되었습니다."));
} else {
return ResponseEntity.ok("아직 마감 시간이 지나지 않았습니다.");
return ResponseEntity.ok(ApiResponse.ok(null, "아직 마감 시간이 지나지 않았습니다."));
}
}

// 주문 수락 상태 변경
@PatchMapping("/{recruitmentId}/accept")
public ResponseEntity<?> acceptRecruitment(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow();
public ResponseEntity<ApiResponse<String>> acceptRecruitment(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));

if (!"CONFIRMED".equals(recruitment.getStatus())) {
return ResponseEntity.badRequest().body("주문 수락은 CONFIRMED 상태에서만 가능합니다.");
return ResponseEntity.badRequest()
.body(ApiResponse.fail(4001, "주문 수락은 CONFIRMED 상태에서만 가능합니다.", HttpStatus.BAD_REQUEST));
}
recruitment.setStatus("ACCEPTED");
recruitmentRepository.save(recruitment);
return ResponseEntity.ok("상태가 ACCEPTED로 변경되었습니다.");
return ResponseEntity.ok(ApiResponse.ok(null, "상태가 ACCEPTED로 변경되었습니다."));
}

// 배달 완료 상태 변경
@PatchMapping("/{recruitmentId}/deliver")
public ResponseEntity<?> completeDelivery(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow();
public ResponseEntity<ApiResponse<String>> completeDelivery(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));

if (!"ACCEPTED".equals(recruitment.getStatus())) {
return ResponseEntity.badRequest().body("배달 완료는 ACCEPTED 상태에서만 가능합니다.");
return ResponseEntity.badRequest()
.body(ApiResponse.fail(4002, "배달 완료는 ACCEPTED 상태에서만 가능합니다.", HttpStatus.BAD_REQUEST));
}
recruitment.setStatus("DELIVERED");
recruitmentRepository.save(recruitment);
return ResponseEntity.ok("상태가 DELIVERED로 변경되었습니다.");
return ResponseEntity.ok(ApiResponse.ok(null, "상태가 DELIVERED로 변경되었습니다."));
}
Comment on lines 136 to 162
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

에러 코드 체계를 확인해 주세요.

현재 사용된 에러 코드(4001, 4002)가 프로젝트의 에러 코드 체계와 일치하는지 확인이 필요합니다.

ErrorCode enum에 정의된 에러 코드를 확인해 보겠습니다:

또한 상태 전환 검증 로직의 중복을 줄이기 위해 서비스 레이어에서 처리하는 것을 고려해보세요.


🏁 Script executed:

#!/bin/bash
# ErrorCode enum 파일 찾기 및 내용 확인
fd -e java "ErrorCode" -x cat {} \;

# ApiResponse.fail 메서드의 시그니처 확인
ast-grep --pattern 'class ApiResponse {
  $$$
  fail($$$) {
    $$$
  }
  $$$
}'

Length of output: 675


Error 코드 일관성 확인 및 중복 로직 개선 필요

현재 컨트롤러에서 사용 중인 에러 코드(4001, 4002)는 ErrorCode enum(2000, 2007, 2006, 3000)에 정의되어 있지 않아 프로젝트 전반의 에러 코드 체계와 일관성이 맞지 않습니다. 또한 상태 전환 검증 로직이 컨트롤러에 중복되어 있어 서비스 레이어로 이관해 재사용성을 높이는 것을 권장드립니다.

  • ErrorCode enum에 신규 상태 전환 오류 코드를 추가하거나, 기존 BAD_REQUEST(2000) 코드를 재사용하도록 수정
  • ApiResponse.fail 호출 시 코드·메시지·HTTP 상태를 enum 기반으로 통일
  • acceptRecruitment / completeDelivery의 상태 검증 로직을 서비스 레이어로 추출하여 중복 제거
🤖 Prompt for AI Agents
In
recruitment-service/src/main/java/com/example/recruitment/controller/RecruitmentController.java
lines 136 to 162, the error codes 4001 and 4002 used in ApiResponse.fail are not
defined in the project's ErrorCode enum, causing inconsistency. To fix this,
either add new error codes for these state transition errors to the ErrorCode
enum or reuse an existing appropriate code like BAD_REQUEST(2000). Also, move
the status validation logic from the controller methods acceptRecruitment and
completeDelivery into the service layer to remove duplication and improve
reusability. Update the ApiResponse.fail calls to use the enum-based error codes
and messages consistently.


// 모집글 삭제
@DeleteMapping("/{recruitmentId}")
public ResponseEntity<?> deleteRecruitment(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow();
public ResponseEntity<ApiResponse<String>> deleteRecruitment(@PathVariable Long recruitmentId) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));
recruitmentRepository.delete(recruitment);
return ResponseEntity.ok("모집글이 삭제되었습니다.");
return ResponseEntity.ok(ApiResponse.ok(null, "모집글이 삭제되었습니다."));
}

// 모집글 수정
@PutMapping("/{recruitmentId}")
public ResponseEntity<?> updateRecruitment(@PathVariable Long recruitmentId,
@Valid @RequestBody RecruitmentRequestDto dto) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId).orElseThrow();
public ResponseEntity<ApiResponse<String>> updateRecruitment(@PathVariable Long recruitmentId,
@Valid @RequestBody RecruitmentRequestDto dto) {
Recruitment recruitment = recruitmentRepository.findById(recruitmentId)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));

if (!recruitment.getUser().getId().equals(dto.getUserId())) {
return ResponseEntity.status(403).body("작성자만 수정할 수 있습니다.");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.fail(4031, "작성자만 수정할 수 있습니다.", HttpStatus.FORBIDDEN));
}

recruitment.setTitle(dto.getTitle());
recruitment.setDescription(dto.getDescription());
recruitment.setDeadlineTime(dto.getDeadlineTime());

if (dto.getStoreId() != null) {
recruitment.setStore(recruitment.getStore());
Store store = storeRepository.findById(dto.getStoreId())
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND));
recruitment.setStore(store);
}

recruitmentRepository.save(recruitment);
return ResponseEntity.ok("모집글이 수정되었습니다.");
return ResponseEntity.ok(ApiResponse.ok(null, "모집글이 수정되었습니다."));
}
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,72 @@
package com.example.recruitment.dto;

import com.example.recruitment.entity.Recruitment;
import com.example.recruitment.entity.Store;
import com.example.recruitment.entity.User;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.List;

@Getter @Setter
@Getter
@Setter
public class RecruitmentDetailDto {

private Long id;
private String title;
private String description;
private String status;
private LocalDateTime deadlineTime;
private User user;
private Store store;
private List<User> participants;

private UserDto user;
private StoreDto store;
private List<UserDto> participants;
private List<Long> orderIds;

public RecruitmentDetailDto(
Recruitment recruitment,
UserDto writer,
List<UserDto> participantUsers,
List<Long> orderIds
) {
this.id = recruitment.getId();
this.title = recruitment.getTitle();
this.description = recruitment.getDescription();
this.status = recruitment.getStatus();
this.deadlineTime = recruitment.getDeadlineTime();
this.user = writer; // ✅ 유저 서비스로부터 가져온 작성자 정보
this.store = new StoreDto(recruitment.getStore());
this.participants = participantUsers;
this.orderIds = orderIds;
}

@Getter
@Setter
public static class UserDto {
private Long id;
private String name;
private String email;

public UserDto(Long id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
}

@Getter
@Setter
public static class StoreDto {
private Long id;
private String name;
private String category;
private String location;

public StoreDto(Store store) {
this.id = store.getId();
this.name = store.getName();
this.category = store.getCategory();
this.location = store.getLocation();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ public class RecruitmentRequestDto {
private String category;

// 주문용 메뉴 정보
@NotNull(message = "메뉴 정보는 필수입니다.")
@Size(min = 1, message = "최소 하나 이상의 메뉴를 선택해야 합니다.")
private List<OrderRequestDto.MenuDto> menus;

//OrderRequestDto 변환 메서드
// OrderRequestDto 변환 메서드
public OrderRequestDto toOrderRequestDto() {
OrderRequestDto dto = new OrderRequestDto();
dto.setUserId(this.userId);
Expand Down
Loading
Loading