이번에 MSA 프로젝트를 진행하면서 각 서비스끼리 `FeignClient`를 통해 호출하는 경우가 많았다. 그 중 배송담당자 CRUD 기능을 구현하는 과정에서 FeignClient의 N+1 호출 문제가 발생하게 되었다. 이를 해결한 방법을 기록해보려고 한다.
1. 배송담당자 전체 조회 기능 구현
마스터 관리자는 배송담당자 목록을 전체 조회할 수 있어야 했다.
1.1 배송담당자 도메인 설계
배송담당자는 특정 허브에 속할 수 있기 때문에, 배송담당자와 허브는 `다대일 관계`로 설계했다.
하지만 MSA 아키텍처를 사용하므로, 객체 자체로 연관관계를 매핑하는 대신 `간접 참조`를 사용해 Hub ID만 Shipper 도메인에 넣어주었다.
@Getter
@Entity
@Table(name = "p_shipper")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Shipper extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ShipperType type;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
@Column(nullable = false, name = "hub_id")
private UUID hubId;
...
}
1.2 배송담당자 findAll 구현하기
배송담당자 전체조회를 구현해보자.
`ShipperController.java`
@GetMapping
@PreAuthorize("hasAuthority('MASTER_MANAGER')")
public Response<List<ShipperReadResponse>> findAll() {
return Response.success((shipperService.findAll()));
}
Shipper 객체에는 허브 객체 대신 허브 ID가 존재하기 때문에 따로 허브 이름들을 조회해야 한다.
처음에는 Shipper 목록을 조회한 뒤, 각각의 hubId를 가지고 `FeignClient`를 호출해 허브 이름을 가져오는 방식으로 구현하려고 했다.
그런데 이 방식은 N+1 문제를 발생시켰다. 예를 들어, 배송담당자가 5명일 경우 각각의 허브 이름을 5번 호출해야 하므로, 허브 서비스에 N번의 API 호출이 발생하게 된 것이다.
결국 허브 서비스에서 배송담당자 인원 수만큼의 추가 N 쿼리가 발생하는 것이다.
1.3 FeignClient N+1 호출 문제 해결하기
N+1 문제를 해결하기 위해 어떻게 해야 할지 고민하던 중, 여러 방법을 고려해봤다.
1.3.1 중복 데이터 넣기
첫 번째 방법은 Shipper 도메인에 hubId 뿐만 아니라 hubName도 추가하는 것이었다.
하지만 이 방법은 허브 테이블의 데이터가 변경될 때마다 배송담당자 데이터들 역시 업데이트가 필요하다.
이를 구현하려면 `Kafka`나 `RabbitMQ` 같은 메시지 큐 시스템을 이용해 데이터 변경을 전달하는 방식이 필요하지만, 이 방식은 오버엔지니어링이 될 것 같아서 제외했다.
1.3.2 HubId 리스트로 HubNames 한 번에 조회하기
결국 선택한 방법은 HubId 리스트를 모아서 한 번에 허브 이름을 조회하는 방식이었다.
JPA에서 IN 쿼리를 이용해 N+1 문제를 해결하는 방법에서 아이디어를 얻었다. 이와 비슷하게 FeignClient로 한 번의 호출에 여러 Hub ID를 전달하고, 이에 대한 허브 이름을 한 번에 받아오도록 했다.
위 그림과 같이 shipper service에서 hub id를 리스트로 한 번에 넘겨서
IN 쿼리를 통해 해당 hub id들이 속하는 hub들의 hub name들을 한 번에 조회한다.
@PostMapping("/hub-names")
public Response<HubNameFindResponse> findHubName(@RequestBody HubNameFindRequest request) {
return Response.success(hubService.findHubName(request));
}
@Transactional(readOnly = true)
public HubNameFindResponse findHubName(HubNameFindRequest request) {
final List<Hub> hubs = hubRepository.findByIdIn(
request.getHubIds().stream().map(hubId -> UUID.fromString(hubId)).collect(
Collectors.toList()));
return HubNameFindResponse.fromEntity(hubs);
}
이렇게 허브 서비스에 새로운 API를 구현하였다.
먼저, 허브 ID들을 문자열로 받아오고, 이를 map 함수를 사용해 UUID 형태로 변환했다.
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class HubNameFindResponse {
HashMap<UUID, String> hubNames = new HashMap<>();
public static HubNameFindResponse fromEntity(List<Hub> hubs) {
HubNameFindResponse response = new HubNameFindResponse();
hubs.forEach(hub -> response.hubNames.put(hub.getId(), hub.getName()));
return response;
}
}
허브 리스트를 `HashMap`으로 변환해 `허브 ID`를 키로, `허브 이름`을 값으로 설정했다.
이렇게 변환된 `HashMap` 덕분에, 배송 담당자의 소속 허브 ID에 따른 허브 이름을 쉽게 추가할 수 있었다.
완성된 Shipper 서비스 코드는 다음과 같다.
public List<ShipperReadResponse> findAll() {
// Shipper 목록 조회
final List<Shipper> shippers = shipperRepository.findAll();
final List<String> hubIds = shippers.stream()
.map(Shipper::getHubId).map(UUID::toString)
.toList();
List<ShipperReadResponse> responses = shippers
.stream()
.map(ShipperReadResponse::fromEntity)
.collect(Collectors.toList());
// FeignClient response 받아오기
JsonNode hubNameResponse = hubClient.getHubNames(HubNameFindRequest.from(hubIds));
JsonNode hubNamesNode = hubNameResponse.path("result").path("hubNames");
HashMap<UUID, String> hubNames = new HashMap<>();
// 받아온 response HashMap 에 매핑
if (hubNamesNode.isObject()) {
Iterator<Entry<String, JsonNode>> fields = hubNamesNode.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
UUID key = UUID.fromString(field.getKey());
String value = field.getValue().asText();
hubNames.put(key, value);
}
}
// 반환할 response 에 허브 이름들 추가
responses.forEach(response ->
response.updateHubName(hubNames.get(response.getHubId())));
return responses;
}
`JsonNode`로 값을 받아오는 방식이 마음에 들지 않았다. 이 부분은 반드시 리팩토링이 필요할 것 같다.
결과적으로, 배송 담당자 수만큼 N번의 FeignClient 호출을 해야 했던 문제가 단 한 번의 호출로 해결되었다.
덕분에 성능이 눈에 띄게 개선되었다!
1.4 개선 사항
벌크성 API를 사용할 때 주의해야 할 점이 있다. 바로 개수 제한을 두어야 한다는 것이다.
한 번에 너무 많은 데이터를 요청하게 되면 네트워크 트래픽이 과도하게 증가해 시스템에 과부하가 발생할 수 있다. 따라서 `페이징 처리`나 `배치 처리`를 통해 벌크 요청을 적절한 크기로 나누어 전송하는 것이 중요하다.
'공부 > Project' 카테고리의 다른 글
[Circular dependency between the following tasks] 순환 종속성 문제 해결과 Mapper 활용 (0) | 2024.11.07 |
---|---|
웹 프로젝트 서버 배포 흐름도 (0) | 2024.09.25 |
Gateway, Spring Security, JWT 사용하여 인증 및 인가 처리하기 (0) | 2024.09.22 |
배달 레전드 프로젝트 회고 (정적 팩토리 메서드, 전략 패턴, 퍼사드 패턴, QueryDSL @Query 차이, Page, JPA N+1 문제) (3) | 2024.09.07 |
Springboot 키워드 검색 API 구현하기 (QueryDSL) (1) | 2024.09.01 |