1. Feign Client 란?
Spring Cloud Open Feign은 마이크로서비스 아키텍처에서 서비스 간의 HTTP 기반 통신을 간소화하는 데 사용되는 선언적 웹 서비스 클라이언트입니다.
Netflix에서 개발되었으며, Spring Cloud에서는 이를 Spring 애플리케이션에 쉽게 통합할 수 있도록 지원해주고 있습니다.
Feign의 주요 목적은 마이크로서비스 간의 통신 코드를 최소화하고, 서비스 호출을 간단하고 선언적인 방식으로 구현하는 것입니다.
주요 특징
- 선언적 REST 클라이언트
- Feign을 사용하면 인터페이스에 어노테이션을 추가함으로써 외부 RESTful 서비스를 쉽게 호출할 수 있습니다.
- 개발자는 HTTP 요청을 보내고 응답을 처리하는 로우 레벨의 세부 사항을 신경 쓸 필요가 없습니다.
- 통합된 로드 밸런싱
- Feign은 Spring Cloud Netflix의 Ribbon과 통합되어 클라이언트 측 로드 밸런싱을 제공합니다.
- 이를 통해 서비스 인스턴스 간에 요청을 자동으로 분산할 수 있습니다.
- 서킷 브레이커 지원
- Hystrix와의 통합을 통해, Feign 클라이언트는 네트워크 실패나 지연과 같은 문제가 발생했을 때 회복력 있는 통신을 구현할 수 있습니다.
- 서킷 브레이커 패턴을 사용하여 실패한 서비스 호출을 자동으로 중단하고, 대체 로직을 실행할 수 있습니다.
- 간결한 구성
- Feign 클라이언트는 인터페이스 기반으로 작동하며, 어노테이션을 통해 HTTP 요청을 구성합니다.
- 이는 코드의 가독성과 유지 보수성을 향상시킵니다.
- 스프링 클라우드와의 긴밀한 통합
- Eureka와 같은 서비스 디스커버리와의 통합을 통해, Feign 클라이언트는 서비스의 물리적 주소를 몰라도 서비스 이름으로 통신할 수 있습니다.
Feign Client는 HTTP 기반 통신을 하고 있기 때문에, Feign Client를 호출하게 되면 네트워크 지연, 서버 내부 로직처리, 데이터베이스 통신 등 한번 호출할 때마다 많은 시간이 소요될 수도 있습니다.
그렇기 때문에 Feign Client를 여러 번 호출할 경우에 사용할 수 있는 최적화 방법에 대해 알아보겠습니다.
2. 다중 호출에서 일괄 처리 하는 방법
[Project A]에 있는 Product(제품)와 [Project B]에 있는 Wish(찜)라는 entity를 예시로 다중 호출을 일괄 처리하는 방법에 대해 설명드리겠습니다.
제품 목록을 불러오는 API를 만든다고 생각해 봅시다.
[Project A] Product Entity
@Entity
@Getter
@NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
해당 제품을 찜(wish) 했을 경우에는 Wish 테이블에 해당 제품을 찜한 회사의 ID와 제품의 ID 가 기록된다고 해봅시다.
[Project B] Wish Entity
@Entity
@NoArgsConstructor
public class Wish {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long companyId;
private Long productId;
}
제품 목록을 불러오는 API에서는 제품의 ID, 제품의 이름, 제품의 찜 개수를 불러오기를 원합니다.
@Getter
public class ProductRes {
private Long id;
private String name;
private Long wishCount;
@Builder
public ProductRes(Long id, String name, Long wishCount) {
this.id = id;
this.name = name;
this.wishCount = wishCount;
}
}
제품의 찜 개수는 위에서 언급한 대로 Project 가 A(Product)와 B(Wish)로 각각 다르기 때문에 Feign Client로 찜에 대한 정보를 가져올 것입니다.
1) Feign Client를 다중 호출 하는 경우
public List<ProductRes> getProductList() {
List<ProductRes> productResList = new ArrayList<>();
// [Project A] 상품 목록을 조회
List<Product> productList = productRepository.findAll();
for (Product product : productList) {
// FeignClient를 이용하여 WishService의 countByProductId를 호출
Long wishCount = wishClient.getWishCountByProductId(product.getId());
ProductRes productRes = ProductRes.builder()
.id(product.getId())
.name(product.getName())
.wishCount(wishCount)
.build();
productResList.add(productRes);
}
return productResList;
}
해당 코드를 간단하게 설명하면,
1. [Project A]에 있는 productList를 조회합니다.
2. productList의 수만큼 각 제품마다 wishClient로 [Project B]에 있는 wishController를 호출하여 각 제품별 찜 개수를 호출합니다.
이렇게 제품의 개수만큼 Feign Client를 호출하게 되면 1번에서 언급한 것과 같이 성능에 안 좋은 영향을 끼치게 됩니다.
그래서 아래와 같이 일괄처리하여 성능을 개선할 수 있습니다.
2) Feign Client 일괄처리 하는 경우
public List<ProductRes> getOptimizationProductList() {
List<Product> productList = productRepository.findAll();
// 제품의 id 목록을 추출
List<Long> productIds = productList.stream()
.map(Product::getId)
.collect(Collectors.toList());
// 제품 id 목록으로 한번에 FeignClient 호출
List<WishCountDto> wishCounts = wishClient.getWishCountsByProductIds(productIds);
List<ProductRes> productResList = new ArrayList<>();
for (Product product : productList) {
// productId에 해당하는 wishCount를 찾아서 ProductRes에 추가하고 없으면 0 추가
Long wishCount = wishCounts.stream()
.filter(map -> product.getId().equals(map.getProductId()))
.map(map -> map.getWishCount())
.findFirst()
.orElse(0L);
ProductRes productRes = ProductRes.builder()
.id(product.getId())
.name(product.getName())
.wishCount(wishCount)
.build();
productResList.add(productRes);
}
return productResList;
}
위의 코드는
1. 제품의 ID 목록을 추출하고,
2. 제품의 ID 목록으로 한 번에 Feign Client를 호출해서
3. 각 제품 ID에 맞는 wishCount를 매핑합니다.
위의 코드들의 핵심인 wishClient를 살펴보겠습니다.
[Project A] WishClient
@FeignClient(name = "wish-service", url = "http://localhost:9090")
public interface WishClient {
@GetMapping("/products/{productId}/wish-count")
Long getWishCountByProductId(@PathVariable("productId") Long productId);
@GetMapping("/products/wish-counts")
List<WishCountDto> getWishCountsByProductIds(@RequestParam("productIds") List<Long> productIds);
}
wishClient는 [Project B] WishController에 정의되어 있는 API를 간편하게 호출할 수 있게 해 줍니다.
위에 있는 getWishCountByProductId는 productId에 해당하는 wishCount를 호출합니다.
밑에 있는 getWishCountByProductIds는 productId 목록들을 파라미터로 보낸 후 productId와 wishCount를 매핑한 List <WishCountDto>를 반환합니다.
[Project A] WishCountDto
@Getter
public class WishCountDto {
private Long productId;
private Long wishCount;
}
이어서 [Project A]에서 호출한 [Project B]의 API 가 어떻게 구성되어 있는지 보겠습니다.
[Project B] WishController
WishController는 [Project A]의 wishClient와 같습니다.
@RestController
@RequiredArgsConstructor
public class WishController {
private final WishService wishService;
@GetMapping("/products/{productId}/wish-count")
public Long countByProductId(@PathVariable Long productId) {
return wishService.countByProductId(productId);
}
@GetMapping("/products/wish-counts")
public List<WishCountDto> countByProductIds(@RequestParam List<Long> productIds) {
return wishService.countByProductIds(productIds);
}
}
[Project B] WishService
@Service
@RequiredArgsConstructor
public class WishService {
private final WishRepository wishRepository;
public Long countByProductId(Long productId) {
return wishRepository.countByProductId(productId);
}
public List<WishCountDto> countByProductIds(List<Long> productIds) {
return wishRepository.countByProductIdIn(productIds);
}
}
wishService는 간단하게 작성하였습니다.
[Project B] WishRepository
@Repository
public interface WishRepository extends JpaRepository<Wish, Long> {
Long countByProductId(Long productId);
@Query("SELECT new com.example.wish.dto.WishCountDto(w.productId, COUNT(w)) " +
"FROM Wish w " +
"WHERE w.productId IN :productIds " +
"GROUP BY w.productId")
List<WishCountDto> countByProductIdIn(List<Long> productIds);
}
일괄 처리의 핵심 부분입니다.
밑의 countByProductIdIn의 where절을 보시면 wish 테이블에서 productId 가 포함되어 있으면서(IN 쿼리),
productId로 GROUP BY를 하여 productId 별 count를 매핑해서 반환해주고 있습니다.
이렇게 작성하게 되면 한 번의 쿼리로 여러 개의 productId의 count를 모두 매핑할 수 있게 됩니다.
3. JMH를 사용한 성능 벤치마크 비교
개선한 코드의 성능을 비교하기 위해 더미 데이터 100개를 넣고 JMH(Java Microbenchmark Harness)로 벤치마킹 테스트를 해보았습니다.

위의 결과는 loopFeignClient(다중 호출)와 optimizationFeignClient(일괄 처리)를 avgt Mode(평균 시간)로 각각 5번씩 호출한 결과입니다.
Score는 벤치마크 결과 점수로 연산 하나를 수행하는 데 걸리는 평균 시간(ms/op)을 말하며, Error는 표준 오차를 의미합니다.
즉, 다중 호출은 평균적으로 77.042 ± 39.167 (ms) 걸렸고, 일괄 처리는 3.837 ± 0.320 (ms) 걸린 것입니다.
결과를 요약해 보면, 다중호출을 했을 때 보다 일괄처리로 성능 최적화를 했을 때 77ms → 3.8ms로 약 20배 정도 개선되었고,
다중호출의 경우에는 성능 편차가 ±39.167로 높은 반면, 일괄처리 했을 경우에는 ±0.320로 일관된 성능을 기대할 수 있습니다.
4. 정리
마지막으로 정리하면 핵심 부분은 다음과 같습니다.
public List<ProductRes> getOptimizationProductList() {
List<Product> productList = productRepository.findAll();
// 제품의 id 목록을 추출
List<Long> productIds = productList.stream()
.map(Product::getId)
.collect(Collectors.toList());
// 제품 id 목록으로 한번에 FeignClient 호출
List<WishCountDto> wishCounts = wishClient.getWishCountsByProductIds(productIds);
List<ProductRes> productResList = new ArrayList<>();
for (Product product : productList) {
// productId에 해당하는 wishCount를 찾아서 ProductRes에 추가하고 없으면 0 추가
Long wishCount = wishCounts.stream()
.filter(map -> product.getId().equals(map.getProductId()))
.map(map -> map.getWishCount())
.findFirst()
.orElse(0L);
ProductRes productRes = ProductRes.builder()
.id(product.getId())
.name(product.getName())
.wishCount(wishCount)
.build();
productResList.add(productRes);
}
return productResList;
}
1. Feign Client로 여러 번 호출해야 하는 부분을 List로 추출하고,
2. In쿼리로 한 번에 조회하여 Key, Value의 형태로 값을 매핑해 주시면 됩니다.
@Repository
public interface WishRepository extends JpaRepository<Wish, Long> {
@Query("SELECT new com.example.wish.dto.WishCountDto(w.productId, COUNT(w)) " +
"FROM Wish w " +
"WHERE w.productId IN :productIds " +
"GROUP BY w.productId")
List<WishCountDto> countByProductIdIn(List<Long> productIds);
}
이러한 방법으로 성능 최적화를 하면

성능이 77ms → 4ms로 약 20배 정도 개선 되었으며, 성능 편차도 ±0.320로 일관된 성능을 기대해 볼 수 있을 것입니다.
위의 테스트 결과는 Local 환경에서 테스트한 것이고, 실제 서버에 올려서 Feign Client를 호출했을 경우에는 Latency 가 더 증가될 것이기 때문에 더 많은 성능 최적화 효과를 얻을 수 있을 것입니다.
'Spring Boot' 카테고리의 다른 글
[Spring Boot] Database Lock (Pessimistic Lock, Optimistic Lock, Named Lock) (0) | 2024.03.16 |
---|---|
[Spring Boot] @RequiredArgsConstructor를 통한 의존성 주입(DI) 과정 (0) | 2024.02.04 |
1. Feign Client 란?
Spring Cloud Open Feign은 마이크로서비스 아키텍처에서 서비스 간의 HTTP 기반 통신을 간소화하는 데 사용되는 선언적 웹 서비스 클라이언트입니다.
Netflix에서 개발되었으며, Spring Cloud에서는 이를 Spring 애플리케이션에 쉽게 통합할 수 있도록 지원해주고 있습니다.
Feign의 주요 목적은 마이크로서비스 간의 통신 코드를 최소화하고, 서비스 호출을 간단하고 선언적인 방식으로 구현하는 것입니다.
주요 특징
- 선언적 REST 클라이언트
- Feign을 사용하면 인터페이스에 어노테이션을 추가함으로써 외부 RESTful 서비스를 쉽게 호출할 수 있습니다.
- 개발자는 HTTP 요청을 보내고 응답을 처리하는 로우 레벨의 세부 사항을 신경 쓸 필요가 없습니다.
- 통합된 로드 밸런싱
- Feign은 Spring Cloud Netflix의 Ribbon과 통합되어 클라이언트 측 로드 밸런싱을 제공합니다.
- 이를 통해 서비스 인스턴스 간에 요청을 자동으로 분산할 수 있습니다.
- 서킷 브레이커 지원
- Hystrix와의 통합을 통해, Feign 클라이언트는 네트워크 실패나 지연과 같은 문제가 발생했을 때 회복력 있는 통신을 구현할 수 있습니다.
- 서킷 브레이커 패턴을 사용하여 실패한 서비스 호출을 자동으로 중단하고, 대체 로직을 실행할 수 있습니다.
- 간결한 구성
- Feign 클라이언트는 인터페이스 기반으로 작동하며, 어노테이션을 통해 HTTP 요청을 구성합니다.
- 이는 코드의 가독성과 유지 보수성을 향상시킵니다.
- 스프링 클라우드와의 긴밀한 통합
- Eureka와 같은 서비스 디스커버리와의 통합을 통해, Feign 클라이언트는 서비스의 물리적 주소를 몰라도 서비스 이름으로 통신할 수 있습니다.
Feign Client는 HTTP 기반 통신을 하고 있기 때문에, Feign Client를 호출하게 되면 네트워크 지연, 서버 내부 로직처리, 데이터베이스 통신 등 한번 호출할 때마다 많은 시간이 소요될 수도 있습니다.
그렇기 때문에 Feign Client를 여러 번 호출할 경우에 사용할 수 있는 최적화 방법에 대해 알아보겠습니다.
2. 다중 호출에서 일괄 처리 하는 방법
[Project A]에 있는 Product(제품)와 [Project B]에 있는 Wish(찜)라는 entity를 예시로 다중 호출을 일괄 처리하는 방법에 대해 설명드리겠습니다.
제품 목록을 불러오는 API를 만든다고 생각해 봅시다.
[Project A] Product Entity
@Entity
@Getter
@NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
해당 제품을 찜(wish) 했을 경우에는 Wish 테이블에 해당 제품을 찜한 회사의 ID와 제품의 ID 가 기록된다고 해봅시다.
[Project B] Wish Entity
@Entity
@NoArgsConstructor
public class Wish {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long companyId;
private Long productId;
}
제품 목록을 불러오는 API에서는 제품의 ID, 제품의 이름, 제품의 찜 개수를 불러오기를 원합니다.
@Getter
public class ProductRes {
private Long id;
private String name;
private Long wishCount;
@Builder
public ProductRes(Long id, String name, Long wishCount) {
this.id = id;
this.name = name;
this.wishCount = wishCount;
}
}
제품의 찜 개수는 위에서 언급한 대로 Project 가 A(Product)와 B(Wish)로 각각 다르기 때문에 Feign Client로 찜에 대한 정보를 가져올 것입니다.
1) Feign Client를 다중 호출 하는 경우
public List<ProductRes> getProductList() {
List<ProductRes> productResList = new ArrayList<>();
// [Project A] 상품 목록을 조회
List<Product> productList = productRepository.findAll();
for (Product product : productList) {
// FeignClient를 이용하여 WishService의 countByProductId를 호출
Long wishCount = wishClient.getWishCountByProductId(product.getId());
ProductRes productRes = ProductRes.builder()
.id(product.getId())
.name(product.getName())
.wishCount(wishCount)
.build();
productResList.add(productRes);
}
return productResList;
}
해당 코드를 간단하게 설명하면,
1. [Project A]에 있는 productList를 조회합니다.
2. productList의 수만큼 각 제품마다 wishClient로 [Project B]에 있는 wishController를 호출하여 각 제품별 찜 개수를 호출합니다.
이렇게 제품의 개수만큼 Feign Client를 호출하게 되면 1번에서 언급한 것과 같이 성능에 안 좋은 영향을 끼치게 됩니다.
그래서 아래와 같이 일괄처리하여 성능을 개선할 수 있습니다.
2) Feign Client 일괄처리 하는 경우
public List<ProductRes> getOptimizationProductList() {
List<Product> productList = productRepository.findAll();
// 제품의 id 목록을 추출
List<Long> productIds = productList.stream()
.map(Product::getId)
.collect(Collectors.toList());
// 제품 id 목록으로 한번에 FeignClient 호출
List<WishCountDto> wishCounts = wishClient.getWishCountsByProductIds(productIds);
List<ProductRes> productResList = new ArrayList<>();
for (Product product : productList) {
// productId에 해당하는 wishCount를 찾아서 ProductRes에 추가하고 없으면 0 추가
Long wishCount = wishCounts.stream()
.filter(map -> product.getId().equals(map.getProductId()))
.map(map -> map.getWishCount())
.findFirst()
.orElse(0L);
ProductRes productRes = ProductRes.builder()
.id(product.getId())
.name(product.getName())
.wishCount(wishCount)
.build();
productResList.add(productRes);
}
return productResList;
}
위의 코드는
1. 제품의 ID 목록을 추출하고,
2. 제품의 ID 목록으로 한 번에 Feign Client를 호출해서
3. 각 제품 ID에 맞는 wishCount를 매핑합니다.
위의 코드들의 핵심인 wishClient를 살펴보겠습니다.
[Project A] WishClient
@FeignClient(name = "wish-service", url = "http://localhost:9090")
public interface WishClient {
@GetMapping("/products/{productId}/wish-count")
Long getWishCountByProductId(@PathVariable("productId") Long productId);
@GetMapping("/products/wish-counts")
List<WishCountDto> getWishCountsByProductIds(@RequestParam("productIds") List<Long> productIds);
}
wishClient는 [Project B] WishController에 정의되어 있는 API를 간편하게 호출할 수 있게 해 줍니다.
위에 있는 getWishCountByProductId는 productId에 해당하는 wishCount를 호출합니다.
밑에 있는 getWishCountByProductIds는 productId 목록들을 파라미터로 보낸 후 productId와 wishCount를 매핑한 List <WishCountDto>를 반환합니다.
[Project A] WishCountDto
@Getter
public class WishCountDto {
private Long productId;
private Long wishCount;
}
이어서 [Project A]에서 호출한 [Project B]의 API 가 어떻게 구성되어 있는지 보겠습니다.
[Project B] WishController
WishController는 [Project A]의 wishClient와 같습니다.
@RestController
@RequiredArgsConstructor
public class WishController {
private final WishService wishService;
@GetMapping("/products/{productId}/wish-count")
public Long countByProductId(@PathVariable Long productId) {
return wishService.countByProductId(productId);
}
@GetMapping("/products/wish-counts")
public List<WishCountDto> countByProductIds(@RequestParam List<Long> productIds) {
return wishService.countByProductIds(productIds);
}
}
[Project B] WishService
@Service
@RequiredArgsConstructor
public class WishService {
private final WishRepository wishRepository;
public Long countByProductId(Long productId) {
return wishRepository.countByProductId(productId);
}
public List<WishCountDto> countByProductIds(List<Long> productIds) {
return wishRepository.countByProductIdIn(productIds);
}
}
wishService는 간단하게 작성하였습니다.
[Project B] WishRepository
@Repository
public interface WishRepository extends JpaRepository<Wish, Long> {
Long countByProductId(Long productId);
@Query("SELECT new com.example.wish.dto.WishCountDto(w.productId, COUNT(w)) " +
"FROM Wish w " +
"WHERE w.productId IN :productIds " +
"GROUP BY w.productId")
List<WishCountDto> countByProductIdIn(List<Long> productIds);
}
일괄 처리의 핵심 부분입니다.
밑의 countByProductIdIn의 where절을 보시면 wish 테이블에서 productId 가 포함되어 있으면서(IN 쿼리),
productId로 GROUP BY를 하여 productId 별 count를 매핑해서 반환해주고 있습니다.
이렇게 작성하게 되면 한 번의 쿼리로 여러 개의 productId의 count를 모두 매핑할 수 있게 됩니다.
3. JMH를 사용한 성능 벤치마크 비교
개선한 코드의 성능을 비교하기 위해 더미 데이터 100개를 넣고 JMH(Java Microbenchmark Harness)로 벤치마킹 테스트를 해보았습니다.

위의 결과는 loopFeignClient(다중 호출)와 optimizationFeignClient(일괄 처리)를 avgt Mode(평균 시간)로 각각 5번씩 호출한 결과입니다.
Score는 벤치마크 결과 점수로 연산 하나를 수행하는 데 걸리는 평균 시간(ms/op)을 말하며, Error는 표준 오차를 의미합니다.
즉, 다중 호출은 평균적으로 77.042 ± 39.167 (ms) 걸렸고, 일괄 처리는 3.837 ± 0.320 (ms) 걸린 것입니다.
결과를 요약해 보면, 다중호출을 했을 때 보다 일괄처리로 성능 최적화를 했을 때 77ms → 3.8ms로 약 20배 정도 개선되었고,
다중호출의 경우에는 성능 편차가 ±39.167로 높은 반면, 일괄처리 했을 경우에는 ±0.320로 일관된 성능을 기대할 수 있습니다.
4. 정리
마지막으로 정리하면 핵심 부분은 다음과 같습니다.
public List<ProductRes> getOptimizationProductList() {
List<Product> productList = productRepository.findAll();
// 제품의 id 목록을 추출
List<Long> productIds = productList.stream()
.map(Product::getId)
.collect(Collectors.toList());
// 제품 id 목록으로 한번에 FeignClient 호출
List<WishCountDto> wishCounts = wishClient.getWishCountsByProductIds(productIds);
List<ProductRes> productResList = new ArrayList<>();
for (Product product : productList) {
// productId에 해당하는 wishCount를 찾아서 ProductRes에 추가하고 없으면 0 추가
Long wishCount = wishCounts.stream()
.filter(map -> product.getId().equals(map.getProductId()))
.map(map -> map.getWishCount())
.findFirst()
.orElse(0L);
ProductRes productRes = ProductRes.builder()
.id(product.getId())
.name(product.getName())
.wishCount(wishCount)
.build();
productResList.add(productRes);
}
return productResList;
}
1. Feign Client로 여러 번 호출해야 하는 부분을 List로 추출하고,
2. In쿼리로 한 번에 조회하여 Key, Value의 형태로 값을 매핑해 주시면 됩니다.
@Repository
public interface WishRepository extends JpaRepository<Wish, Long> {
@Query("SELECT new com.example.wish.dto.WishCountDto(w.productId, COUNT(w)) " +
"FROM Wish w " +
"WHERE w.productId IN :productIds " +
"GROUP BY w.productId")
List<WishCountDto> countByProductIdIn(List<Long> productIds);
}
이러한 방법으로 성능 최적화를 하면

성능이 77ms → 4ms로 약 20배 정도 개선 되었으며, 성능 편차도 ±0.320로 일관된 성능을 기대해 볼 수 있을 것입니다.
위의 테스트 결과는 Local 환경에서 테스트한 것이고, 실제 서버에 올려서 Feign Client를 호출했을 경우에는 Latency 가 더 증가될 것이기 때문에 더 많은 성능 최적화 효과를 얻을 수 있을 것입니다.
'Spring Boot' 카테고리의 다른 글
[Spring Boot] Database Lock (Pessimistic Lock, Optimistic Lock, Named Lock) (0) | 2024.03.16 |
---|---|
[Spring Boot] @RequiredArgsConstructor를 통한 의존성 주입(DI) 과정 (0) | 2024.02.04 |