Page객체를 DTO 대신 가져올 경우

  @GetMapping
  public ResponseEntity<Page<ProductEntity>> getProductList( ...
  • 결과값이 이상하게 나오거나
  • JSON으로 변경시 오류가 나거나 (HttpMessageNotReadableException)
  • Redis 등에 직렬화/역직렬화 시 문제가 발생
  • 필요없는 필드도 많음 

 

PagedModel

Spring Web에서 권장하는 Page용 DTO 객체

직렬화, 역직렬화 시 발생하는 문제 최소화

new PageModel<>(page)

 

 

 

❗️ @Transactional(readOnly=true) 어노테이션 필수

 

 

'Back-End > Spring' 카테고리의 다른 글

API 테스트를 통한 문서 자동화  (1) 2025.04.13
스케줄러 서비스 분리  (0) 2025.04.04
Custom exception  (0) 2025.03.26
Feign Client LoadBalancer 의존성 누락  (1) 2025.03.21

유광열 튜터님 특강

 

웹 테스트 기본 - API 테스트

보통 포스트맨, 인텔리제이 HTTP, Talend, Insomnia 등을 이용 → 가내수공업

단위테스트도 중요하지만 웹 개발자는 API테스트를 가장 많이 함

 


 

API 테스트 클래스 어노테이션

@SpringBootTest
  • 스프링부트 애플리케이션 전체 컨텍스트를 로드하여 통합테스트 수행 - 일부만 가져올 수도 있지만 귀찮으니까..
  • 실제 애플리케이션 환경과 유사하게 테스트 진행 가능
@AutoConfigureMockMvc
  • 실제 서버를 실행하지 않고도 MVC 관련 기능 (컨트롤러, 필터, 인터셉터 등)을 테스트 할 수 있도록 MockMvc 객체 자동으로 구성
  • HTTP 요청과 응답을 모의로 검증
@Transactional
  • 각 테스트 메서드를 트랜잭션 내에서 실행
  • 테스트가 끝난 후 데이터가 롤백 (메서드마다)
@ActiveProfiles("dev")
  • 테스트 실행시 사용할 설정파일 적용 (ex. application-dev.yml)

 

 

의존성 주입

// 필수

@Autowired
private MockMvc mockMvc;

@Autowired
private ObjectMapper objectMapper;
  • MockMvc : HTTP 요청과 응답 모의 검증 객체
  • ObjectMapper: 자바 - JSON 변경, 요청이나 응답 body 가공

 

 

더미 데이터 추가

ㅇㅇ

 

 


Given : 주어진 값 (ex. RequestDto)
When : 어떤 작업을 수행
Then : 기대



GET 

  @Test
  @DisplayName("단일 상품 조회 성공")
  public void testProductGetByProductIdSuccess() throws Exception {

    UUID productId = UUID.fromString("550e8400-e29b-41d4-a716-446655440000");
    mockMvc.perform( // When
            MockMvcRequestBuilders.get("/v1/products/{product_id}",
                    productId)
                .header("X-USER-ID", 1L)
                .header("X-USER-ROLE", "ROLE_COMPANY")
        )
        .andExpectAll( // Then
            MockMvcResultMatchers.status().isOk(),
            MockMvcResultMatchers.jsonPath("$.code").value(0),
            MockMvcResultMatchers.jsonPath("$.message").value("단일 상품 조회 성공"),
            MockMvcResultMatchers.jsonPath("$.data").doesNotExist()
        )
        .andDo(MockMvcResultHandlers.print());
  }

 

perform (When)

API 테스트에서 통신을 Mocking

 

andExpect, andExpectAll (Then)

andExpectAll을 사용할 경우 여러 조건을 포함할 수 있음

 

 

POST

  @Test
  @DisplayName("상품 생성 성공")
  public void testProductPostSuccess() throws Exception {
    UUID shopId = UUID.randomUUID();

    // Request DTO Mocking
    ReqProductPostDtoApiV1 dto = ReqProductPostDtoApiV1.builder()
        .product(Product.builder()
            .name("testProduct1")
            .price(BigDecimal.valueOf(12000))
            .stock(100)
            .shopId(shopId)
            .build())
        .build();

    // DI한 ObjectMapper를 이용해 DTO를 String으로
    // Given
    String dtoJson = objectMapper.writeValueAsString(dto);

    
    mockMvc.perform( // When
            MockMvcRequestBuilders.post("/v1/products")
                .content(dtoJson)
                .contentType(MediaType.APPLICATION_JSON)
                .header("X-USER-ID", 1L)
                .header("X-USER-ROLE", "ROLE_COMPANY")
        )
        .andExpectAll( // Then
            MockMvcResultMatchers.status().isOk(),
            MockMvcResultMatchers.jsonPath("$.code").value(0),
            MockMvcResultMatchers.jsonPath("$.message").value("상품 등록 성공"),
            MockMvcResultMatchers.jsonPath("$.data").doesNotExist()
        )
        .andDo(MockMvcResultHandlers.print());
  }

 

 

💡 로그인 유지

로그인은 테스트 할때 중복이 있음.

@Test 붙이지 않고 메서드로 만들어 놓고 사용

  private MvcResult login() throws Exception {
    ReqAuthPostLoginDtoApiV1 reqDto = ReqAuthPostLoginDtoApiV1.builder()
        .user(ReqAuthPostLoginDtoApiV1.User.builder()
            .username("testuser1")
            .password("1234")
            .build())
        .build();
    
    String reqDtoJson = objectMapper.writeValueAsString(reqDto);
    
    return mockMvc.perform(MockMvcRequestBuilders.post("/v1/auth/login")
                            .content(reqDtoJson)
                            .contentType(MediaType.APPLICATION_JSON)
        )
        .andExpectAll(
            MockMvcResultMatchers.status().isOk()
        )
        .andReturn();
  }
  @Test
  @DisplayName("상품 생성 성공")
  public void testProductPostSuccess() throws Exception {

    ApiResponse<ResAuthPostLoginDtoApiV1> resLoginDto = objectMapper.readValue(
        login().getResponse().getContentAsString(),
        
        new TypeReference<>() {}
    );
        
        ...
     
    mockMvc.perform(
            MockMvcRequestBuilders.post("/v1/products")
                .header(HttpHeaders.AUTHORIZATION,
                    "Bearer " + resLoginDto.getData().getAccessJwt())
                .content(dtoJson)
                
       ...

 

'Back-End > Spring' 카테고리의 다른 글

[Spring] Page 대신 PagedModel  (0) 2025.04.26
스케줄러 서비스 분리  (0) 2025.04.04
Custom exception  (0) 2025.03.26
Feign Client LoadBalancer 의존성 누락  (1) 2025.03.21

 

아키텍처 설계: 클라이언트-서비스 관계

클라이언트 - 컨트롤러 - 서비스

컨트롤러와 서비스가 1:1 관계를 가져야 함

 

그런데 스케줄러가 개입한다면?

스케줄러(클라이언트) - 서비스

스케줄러가 클라이언트 역할을 하기 때문에, 스케줄러가 접근하는 서비스는 컨트롤러가 접근하는 서비스와 별개로 구성하는 것이 바람직함

 

기존 코드

- 스케줄러가 컨트롤러를 통해 접근하는 서비스에 접근하고 있음

- 배송 담당자 할당 기능은 컨트롤러와 직접 연관이 없음에도 DeliveryService에 포함되어 있음

- 스케줄러가 여러 서비스에 의존하여 의존성이 복잡함

public interface DeliveryService {
    ...
    void assignPendingDeliveries();
    ...
}


public interface DeliveryRouteService {
    ...
    void assignPendingDeliveries();
    ...
}

 

@Component
public class DeliveryAssignmentScheduler {
    private final DeliveryService deliveryService;
    private final DeliveryRouteService deliveryRouteService;
    
    ...
    
    @Scheduled(fixedRate = 60000)
    public void runAssignmentScheduler() {
    	...
        deliveryService.assignPendingDeliveries();
        deliveryRouteService.assignPendingDeliveries();
    }
}

 

 

 

 

 

개선 

: 스케줄러 전용 서비스 분리

- 스케줄러 관련된 코드를 모아둔 별도의 schedule 패키지 생성

- 배송 담당자 할당만 전담하는 DeliveryAssigmentService 인터페이스/구현체 생성

- 기존 배송 담당자 할당 로직을 새 서비스로 이동

- 스케줄러의 의존성을 새로운 서비스로 단일화

// schedule 패키지 내의 스케줄러
@Component
public class DeliveryAssignmentScheduler {
    private final DeliveryAssignmentService deliveryAssignmentService;
    
    ...
    
    @Scheduled(fixedRate = 60000)
    public void runAssignmentScheduler() {
    	...
        deliveryAssignmentService.assignCompanyDeliveryManager();
        deliveryAssignmentService.assignHubDeliveryManager();
    }
}


// schedule 패키지 내의 할당 서비스
public class DeliveryAssignmentService {
    void assignCompanyDeliveryManager();
    void assignHubDeliveryManager();
}

 

이상적으로는 스케줄러(클라이언트) - 워커(컨트롤러) - 서비스 구조가 좋지만, 이번 리팩토링에서는 워커 없이 진행

 

 

효과

- 클라이언트 - 서비스 관계 명확

  • 웹 요청: 클라이언트 - 컨트롤러 - 서비스
  • 스케줄러: 스케줄러 - 서비스

- 컨트롤러와 연관된 서비스는 컨트롤러 관련 기능만 포함하게 됨

- 스케줄러가 단일 서비스에만 의존하게 되어 의존성이 단순해짐

- 스케줄링 기능이 새롭게 추가되어도 기존 서비스에 영향을 주지 않음

'Back-End > Spring' 카테고리의 다른 글

[Spring] Page 대신 PagedModel  (0) 2025.04.26
API 테스트를 통한 문서 자동화  (1) 2025.04.13
Custom exception  (0) 2025.03.26
Feign Client LoadBalancer 의존성 누락  (1) 2025.03.21

'Back-End > Spring' 카테고리의 다른 글

[Spring] Page 대신 PagedModel  (0) 2025.04.26
API 테스트를 통한 문서 자동화  (1) 2025.04.13
스케줄러 서비스 분리  (0) 2025.04.04
Feign Client LoadBalancer 의존성 누락  (1) 2025.03.21

✅ 문제 상황

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'deliveryAssignmentScheduler' defined in file [/Users/t2024-m0206/Documents/git/chill/mono-repo/com.sparta.logistics.delivery-service/build/classes/java/main/com/sparta/logistics/delivery_service/application/service/DeliveryAssignmentScheduler.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'deliveryService' defined in file [/Users/t2024-m0206/Documents/git/chill/mono-repo/com.sparta.logistics.delivery-service/build/classes/java/main/com/sparta/logistics/delivery_service/application/service/DeliveryService.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'deliveryRouteService' defined in file [/Users/t2024-m0206/Documents/git/chill/mono-repo/com.sparta.logistics.delivery-service/build/classes/java/main/com/sparta/logistics/delivery_service/application/service/DeliveryRouteService.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'com.sparta.logistics.delivery_service.infrastructure.client.HubRouteClient': FactoryBean threw exception on object creation

 

HubRouteClient 빈 생성 과정에서 문제 발생

 

 

✅  원인 분석

배송서비스에는 다른 서비스들과 달리 스케줄러가 포함되어 있음

주기적으로 Feign Client를 통해 다른 서비스를 호출하는 메서드를 실행함

@Scheduled(fixedRate = 60000) //1분
    public void runAssignmentScheduler() {
        if(shutdown) return;

        try {
            deliveryService.assignPendingDeliveries();
            deliveryRouteService.assignPendingDeliveries();
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }

 

Feign Client는 서비스 이름으로 API를 호출, 이 서비스 이름을 실제 서버 주소로 변환하기 위해 로드밸런서 필요

@FeignClient(name = "hub-service")
public interface HubRouteClient {
    @GetMapping("/api/hub-routes")
    List<HubRouteListResponseDto> getHubRouteList(@RequestParam UUID startHubId,
                                                 @RequestParam UUID endHubId);
}

 

✅ 문제 해결 시도

 

@Lazy 어노테이션 사용

DeliveryRouteService가 DeliveryService를 의존하고, DeliveryService가 또 다른 클라이언트들을 의존하는 방식에서 순환 의존성이 있을 수 있다고 생각함

→ @Lazy 어노테이션을 사용해 의존성을 지연로딩

빈의 초기화를 애플리케이션 시작 시점이 아닌 실제 사용되는 시점까지 지연 가능

@Service
@RequiredArgsConstructor
public class DeliveryRouteService {
    private final DeliveryRouteRepository deliveryRouteRepository;
    
    @Lazy // 지연 로딩 적용
    private final HubRouteClient hubRouteClient;
    
    @Lazy // 지연 로딩 적용
    private final DeliveryManagerClient deliveryManagerClient;
    
    ...
}

 

✅ 문제 원인

Caused by: java.lang.IllegalStateException: No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-loadbalancer?

 

 

 

 

 

 

✅ 해결 방법

로드밸런서 의존성 명시적으로 추가

 

 

 

🤔 왜 로드밸런싱을 추가했어야 했는가?

 

배운점

중요한 기능은 항상 명시적으로 의존성 선언하기

전이적 의존성에 의존하지 않기

@Lazy는 순환 참조 문제에는 효과적이나, 의존성 누락 문제는 해결할 수 없음

 

스케줄러 사용 시 주의점:

  • 스케줄러는 애플리케이션 초기화 단계에서 의존성 문제를 빠르게 드러냄
  • 외부 서비스 호출을 포함하는 스케줄러 사용 시 관련 의존성 신중히 검토
  • @Lazy로 초기화를 지연시켜도 스케줄러 작동 시점에는 모든 의존성이 필요함

 

### 중요한 발견: 순환 참조가 아닌 의존성 누락

처음에는 순환 참조 문제로 의심하여 @Lazy 어노테이션을 적용했으나, 로드밸런서 의존성을 추가한 후에는 @Lazy 어노테이션 없이도 정상적으로 작동했습니다. 이는 실제 문제가 순환 참조가 아니라 순수하게 의존성 누락이었음을 의미합니다.

이는 오류 메시지를 정확히 해석하는 것의 중요성을 보여줍니다. "Unsatisfied dependency" 오류는 순환 참조 문제일 수도 있지만, 이 경우에는 의존성 누락을 나타내는 것이었습니다. 결국 명확한 오류 메시지 해석과 근본 원인 분석이 문제 해결의 핵심이었습니다.

 

 

 

 

Spring Cloud 2020.0.0 이후 버전부터는 spring-cloud-starter-netflix-eureka-client가 자동으로 spring-cloud-starter-loadbalancer를 포함

 

근데? 나는? 

Caused by: java.lang.IllegalStateException: No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-loadbalancer?

 

Spring Cloud Feign 클라이언트를 사용할 때 로드밸런싱 기능이 필요한데 관련 의존성이 누락됐다고한다..

 

Feign Client는 @FeignClient(name="service-name") 어노테이션을 통해 서비스 이름으로 요청을 보냄

로드밸런서는 이 서비스의 이름을 실제 서비스 인스턴스 URL로 변환하고 여러 인스턴스 간의 요청을 분산시킴.

 

Spring Cloud Feign → LoadBalancer → Service Discovery

 

@Scheduled 어노테이션은 Spring의 TaskScheduler 호출

Spring Cloud 환경에서 Feign 클라이언트를 주기적으로 호출

 

 

스케줄러가 다른 서비스를 호출하는 Feign Client를 자동으로 실행함

스케줄러: 자동으로 주기적인 서비스 호출 → 애플리케이션 시작 시점부터 로드밸런서가 필요

 

시스템 초기화 순서:

  • 애플리케이션 시작 → 스케줄러 초기화 → TaskScheduler 빈 생성 → Feign Client 초기화 → 로드밸런서 필요
  • 이 흐름에서 로드밸런서가 없으면 Feign Client 초기화가 실패하고, 결국 애플리케이션 시작이 실패합니다.

일반 API 컨트롤러에서는 사용자가 요청할 때만 이런 호출이 발생하므로, 애플리케이션 시작 시점에 로드밸런서 의존성이 확인되지 않을 수 있습니다. 하지만 스케줄러는 애플리케이션 시작 시점에 초기화되므로, 로드밸런서 의존성이 즉시 확인됩니다.

결론적으로, 스케줄러가 직접 로드밸런서를 필요로 하는 것이 아니라, 스케줄러가 호출하는 코드(Feign Client)가 로드밸런서를 필요로 합니다. 그리고 스케줄러는 애플리케이션 시작 시점에 이 의존성을 즉시 드러나게 만드는 역할을 한 것입니다.

 

'Back-End > Spring' 카테고리의 다른 글

[Spring] Page 대신 PagedModel  (0) 2025.04.26
API 테스트를 통한 문서 자동화  (1) 2025.04.13
스케줄러 서비스 분리  (0) 2025.04.04
Custom exception  (0) 2025.03.26

+ Recent posts