프로젝트/PlayUs

7. FeignClient 사용 및 테스트

han1693516 2025. 5. 17. 16:30

 

참고문헌) 

https://medium.com/@lahirurajapakshe.stack/feign-client-vs-rest-client-a-comprehensive-guide-ad227272537a

https://ksh-coding.tistory.com/142

 

이번 프로젝트에서는 타 모듈과의 통신을 위해 FeignClient를 사용했다.

이전 프로젝트에서는 RestClient를 통해 통신했지만, 지금 MSA 환경에서는 Resilence4j를 보다 활용하기 쉬운 FeignClient 를 사용했다.

 

build.gradle 에 아래 의존성을 추가한다.

	implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
	implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'

 

test 쪽 property 에 다음을 입력한다. feign.[domain].url 은 타 service 의 도메인 값이다. k8s 환경에서 돌아갈 거여서 Service의 이름이 들어갈 걸로 예상하고 있다. 

resilience4j:
  circuitbreaker:
    configs:
      default:
        failure-rate-threshold: 50 # 실패 요청이 전체 요청 전체 넘으면 OPEN
        slow-call-rate-threshold: 80 # slow call이 전체 요청의 80퍼 넘으면 open
        slow-call-duration-threshold: 10s # 10s 넘어가면 slow call로 판단
        permitted-number-of-calls-in-half-open-state: 3 # half-open 상태에서 3개 요청 전부 성공하면 close, 아니면 open 으로 
        max-wait-duration-in-half-open-state: 0s 
        sliding-window-type: COUNT_BASED
        sliding-window-size: 10
        minimum-number-of-calls: 10
        wait-duration-in-open-state: 10s 
    instances:
      circuit:
        base-config: default # 위 설정한 config 를 default 에 저장

feign:
  user:
    url: http://user-dummy # user-service 의 domain

  match:
    url: http://match-dummy # match-service 의 domain

 

 

MatchFeignClient도 있지만, 여기서는 UserFeignClient 만 살펴보도록 하자.

@FeignClient 내 name 을 통해 bean 이름을 지정하고, url 내 정의된 도메인과 path (아래 모든 ), @xxMapping() 을 기반으로 request 를 날린다. 예를 들면, 우리가 userFeignClient.getPartyUserThumbnailUrls() 를 호출하면

POST http://user-dummy/user/api/thumbnails 로 날아간다. (이 때, Response dto는 보내는 측과 받는 측, 모두 같은 걸 써야 한다. library화해서 관리할 수 있다는데 짬이 나면...)

@FeignClient(name = "userFeignClient", url = "${feign.user.url}", path = "/user/api", fallback = UserFeignFallback.class)
@CircuitBreaker(name = "circuit")
public interface UserFeignClient {

     @PostMapping("/thumbnails")
     PartyUserThumbnailUrlListResponse getPartyUserThumbnailUrls(@RequestBody List<Long> userIdList);

     @PostMapping("/writers")
     List<PartyWriterInfoFeignResponse> getWriterInfo(@RequestBody List<Long> writerIdList);
}

 

만약, 상대 MSA 측에서 에러가 뜨거나 응답이 계속 느리게 와 circuitbreaker가 open 될 경우, 아래의 Fallback 클래스에 작성한 response가 대신 채워진다. 

@Slf4j
@Component
public class UserFeignFallback implements UserFeignClient {

    @Override
    public List<PartyWriterInfoFeignResponse> getWriterInfo(List<Long> writerIdList) {
        log.error("503 happened in UserFeignClient at fetching writer data!!!");
        return List.of(PartyWriterInfoFeignResponse.withServiceUnavailable());
    }

    @Override
    public PartyUserThumbnailUrlListResponse getPartyUserThumbnailUrls(List<Long> userIdList) {
        log.error("503 happened in UserFeignClient at fetching user thumbnail data!!!");
        return PartyUserThumbnailUrlListResponse.withServiceUnavailable();
    }
}

 

 

다음으로는 이 FeignClient를 테스트해보자. 위 FeignClient는 bean 으로 등록되고, 타 서버가 필요하기에테스트 환경에서 @SpringBootTest 가 필수이며 mock 서버를 따로 띄워야 한다. 

 

지금 Service, Repository 대한 테스트에는 CI 환경에서 보다 빠른 테스트를 위해 IntegrationTestSupport와 TestContainer를 통해 db 환경을 구성하고 있다. 하지만 여기에 Feign 테스트 위한 요소까지 작성하면 너무 지저분해질 것 같기에, db 연결과 mock 서버 구성을 분리하기 위해 새로운 Support 클래스를 만들도록 하자.

@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport extends OpenFeignClientTestSupport {

    private static final String MYSQL_VERSION = "mysql:8.0.32";
    private static final String REDIS_VERSION = "redis:7.0.12";
    private static final String MONGO_VERSION = "mongodb/mongodb-community-server:latest";

    private static final MySQLContainer<?> mySQL;
    private static final GenericContainer redis;
    private static final GenericContainer readMongo;
    private static final GenericContainer chatMongo;

    private static final int REDIS_PORT = 6379;
    private static final int MONGO_PORT = 27017;

    @MockitoBean
    protected S3Service s3Service;

    static {
        mySQL = new MySQLContainer<>(MYSQL_VERSION)
                .waitingFor(Wait.forListeningPort())
                .withStartupTimeout(Duration.ofSeconds(60))
                .withReuse(true);

        redis = new GenericContainer(DockerImageName.parse(REDIS_VERSION))
                .withExposedPorts(REDIS_PORT)
                .withReuse(true);

        readMongo = new GenericContainer(DockerImageName.parse(MONGO_VERSION))
                .withExposedPorts(MONGO_PORT)
                .withReuse(true);

        chatMongo = new GenericContainer(DockerImageName.parse(MONGO_VERSION))
                .withExposedPorts(MONGO_PORT)
                .withReuse(true);

        mySQL.start();
        redis.start();
        readMongo.start();
        chatMongo.start();
    }

    @DynamicPropertySource
    public static void dynamicConfiguration(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mySQL::getJdbcUrl);
        registry.add("spring.datasource.username", mySQL::getUsername);
        registry.add("spring.datasource.password", mySQL::getPassword);

        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> String.valueOf(redis.getMappedPort(REDIS_PORT)));

        registry.add("spring.data.mongodb.read.uri",
                () -> String.format("mongodb://%s:%d/read_db", readMongo.getHost(), readMongo.getMappedPort(MONGO_PORT)));
        registry.add("spring.data.mongodb.read.port", () -> String.valueOf(readMongo.getMappedPort(MONGO_PORT)));

        registry.add("spring.data.mongodb.chat.uri",
                () -> String.format("mongodb://%s:%d/chat_db", chatMongo.getHost(), chatMongo.getMappedPort(MONGO_PORT)));
        registry.add("spring.data.mongodb.chat.port", () -> String.valueOf(chatMongo.getMappedPort(MONGO_PORT)));

        registry.add("spring.data.mongodb.auto-index-creation", () -> "true");
    }
}

 

 

Mock 서버 위해 build.gradle 에 다음 요소를 추가해주자.

testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock'

 

 

@AutoConfigureWireMock(port = 0) // mock 서버 중 랜덤한 포트 사용
@TestPropertySource(properties = {
        "feign.user.url=http://localhost:${wiremock.server.port}", // UserFeignClient 가 사용할 url 지정
        "feign.match.url=http://localhost:${wiremock.server.port}", // MatchFeignClient 가 사용할 url 지정
})
public abstract class OpenFeignClientTestSupport {

    @Autowired
    protected WireMockServer wireMockServer;

    @Autowired
    protected ObjectMapper objectMapper; // objectMapper 통해 request 객체 직렬화 위해 사용

    @BeforeEach
    void setUp() {
        wireMockServer.stop();
        wireMockServer.start();
    }

    @AfterEach
    void tearDown() {
        wireMockServer.resetAll();
    }
}

 

 

이 UserFeignClient를 테스트한 코드이다. IntegrationTestSupport 는 OpenFeignClientTest도 상속받기에, mock 서버도 적용 가능하다. 

     - stubFor() 를 통해 테스트 중 지정한 method 및 url 로 요청을 보낼 때, 어떤 response가 올 지 정의하도록 하자.

     

class UserFeignClientTest extends IntegrationTestSupport {

    @Autowired
    UserFeignClient userFeignClient;

    @DisplayName("사용자의 썸네일 URL을 가져올 수 있다.")
    @Test
    void getPartyUserThumbnailUrls() throws JsonProcessingException {
        // given
        List<Long> userIdList = List.of(1L, 2L);
        PartyUserThumbnailUrlListResponse expectedResponse = PartyUserThumbnailUrlListResponse.of(List.of("http://user-thumb", "http://user2-thumb"));
        stubFor(post(urlEqualTo("/user/api/thumbnails"))
                .withRequestBody(equalToJson(objectMapper.writeValueAsString(userIdList)))
                .willReturn(aResponse()
                        .withStatus(HttpStatus.OK.value())
                        .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                        .withBody(objectMapper.writeValueAsString(expectedResponse))
                ));

        // when
        PartyUserThumbnailUrlListResponse result = userFeignClient.getPartyUserThumbnailUrls(userIdList);

        // then
        assertThat(result.thumbnailUrls())
                .hasSize(2)
                .containsExactly("http://user-thumb", "http://user2-thumb");
    }

    @DisplayName("사용자가 존재하지 않을 수 있다.")
    @Test
    void getPartyUserThumbnailurls_INVALID_PK() throws JsonProcessingException {
        // given
        List<Long> userIdList = List.of(1L, 2L);
        ErrorResponse errorResponse = ErrorResponse.notFoundError("사용자가 존재하지 않습니다!");
        stubFor(post(urlEqualTo("/user/api/thumbnails"))
                .withRequestBody(equalToJson(objectMapper.writeValueAsString(userIdList)))
                .willReturn(aResponse()
                        .withStatus(HttpStatus.NOT_FOUND.value())
                        .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                        .withBody(objectMapper.writeValueAsString(errorResponse))
                ));

        // when // then
        assertThatThrownBy(() -> userFeignClient.getPartyUserThumbnailUrls(userIdList))
                .isInstanceOf(FeignException.class)
                .hasMessageContaining("사용자가 존재하지 않습니다!");
    }

    @DisplayName("사용자의 썸네일 URL이 비어 있을 수 있다.")
    @Test
    void getPartyUserThumbnailUrls_EMPTY_THUMB() throws JsonProcessingException {
        // given
        List<Long> userIdList = List.of(1L, 2L);
        PartyUserThumbnailUrlListResponse expectedResponse = PartyUserThumbnailUrlListResponse.of(List.of());
        stubFor(post(urlEqualTo("/user/api/thumbnails"))
                .withRequestBody(equalToJson(objectMapper.writeValueAsString(userIdList)))
                .willReturn(aResponse()
                        .withStatus(HttpStatus.OK.value())
                        .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                        .withBody(objectMapper.writeValueAsString(expectedResponse))
                ));

        // when
        PartyUserThumbnailUrlListResponse result = userFeignClient.getPartyUserThumbnailUrls(userIdList);

        // then
        assertThat(result.thumbnailUrls()).isEmpty();
    }

    @DisplayName("직관팟 작성자의 정보를 가져올 수 있다.")
    @Test
    void getWriterInfo() throws JsonProcessingException {
        // given
        List<Long> writerIdList = List.of(1L, 2L);
        List<PartyWriterInfoFeignResponse> expectedResponse = List.of(
                PartyWriterInfoFeignResponse.of(1L, "user1", "남성", "http://user1-thumb"),
                PartyWriterInfoFeignResponse.of(2L, "user2", "여성", "http://user2-thumb")
        );

        stubFor(post(urlEqualTo("/user/api/writers"))
                .withRequestBody(equalToJson(objectMapper.writeValueAsString(writerIdList)))
                .willReturn(aResponse()
                        .withStatus(HttpStatus.OK.value())
                        .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                        .withBody(objectMapper.writeValueAsString(expectedResponse))
                ));

        // when
        List<PartyWriterInfoFeignResponse> result = userFeignClient.getWriterInfo(writerIdList);

        // then
        assertThat(result).hasSize(2)
                .extracting("id", "writerName", "writerGender", "writerThumbnailUrl")
                .containsExactly(
                        tuple(1L, "user1", "남성", "http://user1-thumb"),
                        tuple(2L, "user2", "여성", "http://user2-thumb")
                );
    }

    @DisplayName("직관팟 작성자가 존재하지 않을 수 있다.")
    @Test
    void getWriterInfo_INVALID_PK() throws JsonProcessingException {
        // given
        List<Long> writerIdList = List.of(1L, 2L);
        ErrorResponse errorResponse = ErrorResponse.notFoundError("사용자가 존재하지 않습니다!");
        stubFor(post(urlEqualTo("/user/api/writers"))
                .withRequestBody(equalToJson(objectMapper.writeValueAsString(writerIdList)))
                .willReturn(aResponse()
                        .withStatus(HttpStatus.NOT_FOUND.value())
                        .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                        .withBody(objectMapper.writeValueAsString(errorResponse))
                ));

        // when // then
        assertThatThrownBy(() -> userFeignClient.getWriterInfo(writerIdList))
                .isInstanceOf(FeignException.class)
                .hasMessageContaining("사용자가 존재하지 않습니다!");
    }

}