외부 API 의존성 분리 및 장애 대응 체계 구축하기

반응형

강결합 되어 있던 외부 API...

현재 진행하고 있는 약속 관리 프로젝트 '오디'는 약속에 참여한 참여자들 별로 도착 예정 시간 (ETA) 목록을 주고

약속 장소에 늦지 않으려면 언제 출발해야할지를 알려주는 출발 알림 기능을 제공하고 있다.

해당 기능을 구현하기 위해 참여자의 출발지 위, 경도와 약속 장소 위,경도를 받아 '오디세이'라는 외부 API를 사용해 소요 시간을 계산하고 있었다.

 

문제 상황

하지만 여기서 문제가 발생한다.

우아한테크코스에서는 각 프로젝트 팀들이 만든 서비스를 체험하기 위한 ‘런칭 페스티벌 행사’가 있다.

이 날 너무 많은 약속방을 만들고 참가하는 바람에 Odsay API의 일 제한량인 1,000건을 초과해버렸다.

이게 끝이면 좋겠지만 오디세이 API는 일 제한량을 초과할 경우 3일간 이용할 수 없다.

따라서 해당 API만 사용하고 있던 우리 서비스는 메인 기능인 ETA와 출발 알림 기능을 제공할 수 없었고

결국 3일간 서비스를 이용할 수 없는 상황에 닥치게 된다.

 

필자는 외부 API 결합도를 낮추고자 다음과 같은 조건에 부합하는

새로운 외부 API를 추가해 장애 발생에 유연하게 대처 가능하도록 의존성을 낮추고자 하였다.

  • 현재 우리 서비스는 수도권의 20 30 세대를 타겟층으로 잡아 대중교통 기반 소요시간 위주로 계산해주고 있음.
  • API 호출 건수가 넉넉하고 과금이 되지 않아야함.

추가로 알아본 외부 API는 네이버, 카카오, TMap, Google이 있었고 위 조건을 고려해 Google의 Distance Matrix API를 적용하였다.

  • 네이버 : 현재 자동차 길찾기 외에는 아직 지원 X
  • 카카오 : 자동차 길찾기만 제공
  • TMap : 일 10건만 무료 이용 가능 (너무 작다;;)
  • Google Distance Matrix API : 모든 교통 수단에 대한 소요시간을 응답

위와 같은 이유 뿐아니라

Google의 API는 유료이지만 신규 사용자인 경우 90일 동안 $300(약 40만) 크레딧을 제공해 가장 적합한 외부 API였다.


여러 외부 API를 List로 관리해 장애 대응하기

그럼 어떻게 외부 API의 의존성을 낮춰 관리했는지 간단하게 알아보자.

 

RestClient 추상화

소요시간 계산을 위해 사용한 Odsay와 Google을 아래와 같이 추상화한 인터페이스를 구현하도록 했다

public interface RouteClient {

    RouteTime calculateRouteTime(Coordinates origin, Coordinates target);

    ClientType getClientType();
}
public class OdsayRouteClient implements RouteClient {

    private final RestClient restClient;
    private final String apiKey;

    public OdsayRouteClient(RestClient.Builder routeRestClientBuilder, String apiKey) {
        this.restClient = routeRestClientBuilder.baseUrl("https://api.odsay.com/v1/api/searchPubTransPathT").build();
	this.apiKey = apiKey;
    }
    
    ... 생략
}
public class GoogleRouteClient implements RouteClient {

private final RestClient restClient;

    private final String apiKey;

    public GoogleRouteClient(RestClient.Builder routeRestClientBuilder, String apiKey) {
        this.restClient = routeRestClientBuilder.baseUrl("https://maps.googleapis.com").build();
        this.apiKey = apiKey;
    }
    
    ... 생략
}

 

인터페이스 의존성 설정 클래스

그 다음 설정 클래스에서 위 RouteClient들을 @Bean 등록해주고

순서를 유지하는 List로 관리하게 위해 @Order로 다형성 Bean의 의존성 순서를 지정해주었다.

(완전 무료로 일 1,000건이 제공되는 오디세이 API를 먼저 사용하기 위해서 List로 관리하였다.)

@Configuration
@RequiredArgsConstructor
public class RouteConfig {

    @Bean
    @Order(1)
    public RouteClient odysayRouteClient(
		RestClient.Builder routeRestClientBuilder
		@Value("{odsay.api-key}") String odsayApiKey
    ) {
        return new OdsayRouteClient(routeRestClientBuilder, odsayApiKey);
    }

    @Bean
    @Order(2)
    public RouteClient googleRouteClient(
    		RestClient.Builder routeRestClientBuilder, 
               @Value("${google.maps.api-key}") String googleApiKey
    ) {
        return new GoogleRouteClient(routeRestClientBuilder, googleApiKey);
    }
    ... 생략
}

 

소요 시간 계산을 담당하는 Service 클래스

다형성을 가진 RouteClient Bean을 사용하는 Service 클래스는 다음과 같다.

@Slf4j
@Service
@RequiredArgsConstructor
public class RouteService {

    private final List<RouteClient> routeClients;

    public RouteTime calculateRouteTime(Coordinates origin, Coordinates target) {
        for (RouteClient client : routeClients) {
            try {
                RouteTime routeTime = calculateTime(client, origin, target);
                log.info("{} API를 사용한 소요 시간 계산 성공", routeClient.getClass().getSimpleName());
                return routeTime;
            } catch (Exception exception) {
                log.warn("{} API 에러 발생 :  ", routeClient.getClass().getSimpleName(), exception);
            }
        }
        log.error("모든 RouteClient API 사용 불가");
        throw new OdyServerErrorException("서버에 장애가 발생했습니다.");
    }
		... 생략
}

이렇게 전략 패턴을 적용하여 오디세이 API의 장애가 발생하더라도 우리 서비스에 전파되지 않고

Google API를 사용해 원할하게 서비스가 이용가능하도록 개선하였다.

또한 새로운 RouteClient가 추가되거나 변경하더라도 해당 로직은 변화없이 구현체만 추가/수정하면되므로 확장성도 챙길 수 있다.

 

List 자료형의 의존성 관리 문제점...

위 방식으로 문제가 완전히 개선된 건 아니였다.

List로 RouteClient를 관리하고 있기 때문에 첫 번째 RouteClient가 항상 실패하는 상황에서도

매번 호출 후 에러를 뱉고 다음 RouteClient로 부터 요청 응답을 받는다.

이로 인해 다음과 같은 문제가 발생한다.

  • 반복적인 쌓이는 너무 긴 에러 로그
  • 타임아웃 대기 시간만큼 불필요한 지연 발생
  • 불필요한 네트워크 연결로 인한 응답 지연

이 문제를 해결하기 위해 장애가 발생한 요소를 뒤로 추가한다던지 다른 자료형을 사용해보려 했지만

일정 시간 이후에 다시 사용가능한 상태가 되더라도 현재 사용하고 있는 요소에 장애가 발생해야지만 다시 사용할 수 있는 문제가 있다.

로컬 캐시로 TTL을 적용할 수도 있겠지만 현재 우리 서비스는 트래픽 부하 분산을 위해 ELB가 적용되어 있다.

따라서 분산된 서버에서도 이 상태 공유가 필요했다.


Redis를 사용한 외부 API 쿨다운 (서킷 브레이커 패턴) 적용

위 문제를 해결하기 위해서 Redis를 도입해 에러가 발생한 요소에 TTL을 설정해 쿨다운을 적용하는 서킷 브레이커 패턴을 적용했다.

이 목차를 읽기전에 Redis에 대한 이해가 필요하니 궁금하다면 아래 포스팅을 참고하자.

2023.09.11 - [◼ CS 기초 지식/[데이터베이스]] - [Redis] 레디스란? 특징, 활용예시, 비교 정리

 

위 방법을 적용해서 얻을 수 있는 장점은 다음과 같았다.

  • 장애 API 자동 감지 및 우회
  • 쿨다운 동안만 다른 RouteClient에게 요청 응답을 받도록 개선
  • 분산 된 서버들도 쿨다운을 공유받아 적절한 RouteClient에 요청/응닫
  • 평균 응답 시간 1.3초 -> 0.6초로 개선

우선 필자가 서킷 브레이커 패턴을 적용한 방법은 다음과 같다.

1. 사용자가 한 명일 경우에도 장애 발생을 감지하기 위해 30분 내에 총 3번의 RouteClient 에러 발생 시 즉시 3시간의 쿨다운 시작한다.

(적절한 TTL을 찾기 위해 모니터링하여 수정해나갈 예정)

2. 쿨다운 중이 아닌 RouteClient만 필터링하여 사용한다.

 

그럼 이제 어떻게 적용했는지 간단히 알아보자.

 

Redis 설정

아래 redis 의존성을 추가해주자.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

application.yml에 아래 설정을 추가한다.

spring:
  data:
    redis:
      host: localhost # 호스트에 맞춰 변경
      port: 6379
      connect-timeout: 3000

 

Redis 설정 클래스를 추가한다.

@Configuration
@EnableCaching
public class RedisConfig {


    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;
    
	// 단일 Redis 구성을 위해 Standalone 아키텍처 설정 등록함
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setHostName(host);
        configuration.setPort(port);
        return new LettuceConnectionFactory(configuration);
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        redisTemplate.setDefaultSerializer(new StringRedisSerializer());
        return redisTemplate;
    }

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofHours(1L));
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory cf) {
        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(cf)
                .cacheDefaults(redisCacheConfiguration())
                .build();
    }
}

 

Redis Docker Compose 파일

로컬에서 테스트 해보기 위해 Redis를 Docker로 실행시켰다.

현재 필자의 로컬은 MySQL DB도 Docker로 띄우고 있어서 Compose 파일로 작성했는데

Redis만 실행할 것이라면 image만 받아서 docker run해도 무방하다.

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: redis-local
    restart: always
    ports:
      - "6379:6379"
    command: >
      redis-server 
      --save "" // RDB 비활성화
      --appendonly yes // AOF 활성화
      --auto-aof-rewrite-percentage 0 // AOF 재작성 OFF
    environment:
      TZ: Asia/Seoul

 

커스텀 RedisTemplate

필자는 실패 횟수를 count로 관리하고 쿨다운 여부를 판단하기 할 것이라 했다.

RedisTemplate만으로도 충분히 구현가능하지만 null에 안전하기 위한 로직을 작성할 필요가 있었고

재사용성을 높이기 위해 다음과 같이 커스텀한 RedisTemplate을 추가하였다.

@Component
@RequiredArgsConstructor
public class CustomRedisTemplate extends RedisTemplate<String, String> {

    @Autowired
    public CustomRedisTemplate(RedisConnectionFactory connectionFactory) {
        this.setKeySerializer(RedisSerializer.string());
        this.setValueSerializer(RedisSerializer.string());
        this.setHashKeySerializer(RedisSerializer.string());
        this.setHashValueSerializer(RedisSerializer.string());
        this.setConnectionFactory(connectionFactory);
        this.afterPropertiesSet();
    }

    public int increment(String key) {
        return Optional.ofNullable(opsForValue().increment(key))
                .map(Long::intValue)
                .orElse(0);
    }

    public int getKeyCount(String key) {
        return Optional.ofNullable(opsForValue().get(key))
                .map(Integer::parseInt)
                .orElse(0);
    }
}

 

이제 설정한 Redis를 사용해볼 차례이다.

필자는 각각의 역할에 맞는 장애 횟수를 카운팅하고 차단 여부를 판단할 RouteClientCircuitBreaker

현재 이용가능한 RouteClient를 반환해줄 RouteClientManager클래스들을 생성해주었다.

 

RouteClientCircuitBreaker - 장애 횟수를 카운팅하고 차단 여부를 판단

@Slf4j
@Component
@RequiredArgsConstructor
public class RouteClientCircuitBreaker {

    private static final int MAX_FAIL_COUNT = 3;
		... 생략

    private final CustomRedisTemplate redisTemplate;

    public void recordFailCountInMinutes(RouteClient routeClient) {
        ...
        int failCount = redisTemplate.increment(failClientKey);
        redisTemplate.expire(failClientKey, FAIL_MINUTES_TTL);
        ... 생략
    }

    public void determineBlock(RouteClient routeClient) {
        ...
        String blockKey = RouteClientKey.getBlockKey(routeClient);
        if (exceedFailCount(failClientKey)) {
            block(blockKey);
            clearFailCount(failClientKey);
        }
        ... 생략
    }

    private boolean exceedFailCount(String failCountKey) {
        return redisTemplate.getKeyCount(failCountKey) >= MAX_FAIL_COUNT;
    }

    private void block(String blockKey) {
        redisTemplate.opsForValue().set(blockKey, "1");
        redisTemplate.expire(blockKey, BLOCK_HOUR_TTL);
    }

    private void clearFailCount(String failCountKey) {
        redisTemplate.unlink(failCountKey);
    }

    public boolean isBlocked(RouteClient routeClient) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(RouteClientKey.getBlockKey(routeClient)));
    }
}

 

RouteClientManager - 현재 이용가능한 RouteClient를 반환

@Slf4j
@Component
@RequiredArgsConstructor
public class RouteClientManager {

    private final List<RouteClient> routeClients;
    private final RouteClientCircuitBreaker routeClientCircuitBreaker;

    public List<RouteClient> getAvailableClients() {
        // routeClientCircuitBreaker를 사용해 routeclients 요소 중 block되지 않은 요소를 반환
    }

		... 생략
}

 

개선된 RouteService

@Slf4j
@Service
@RequiredArgsConstructor
public class RouteService {

    private final RouteClientManager routeClientManager;
    private final ApiCallService apiCallService;
    private final RouteClientCircuitBreaker routeClientCircuitBreaker;

    public RouteTime calculateRouteTime(Coordinates origin, Coordinates target) {
		    List<RouteClient> availableClients = routeClientManager.getAvailableClients();
        for (RouteClient client : routeClients) {
            try {
                RouteTime routeTime = calculateTime(client, origin, target);
                log.info("{} API를 사용한 소요 시간 계산 성공", routeClient.getClass().getSimpleName());
                return routeTime;
            } catch (Exception exception) {
                log.warn("{} API 에러 발생 :  ", routeClient.getClass().getSimpleName(), exception);
                routeClientCircuitBreaker.recordFailCountInMinutes(routeClient);
                routeClientCircuitBreaker.determineBlock(routeClient);
            }
        }
        log.error("모든 RouteClient API 사용 불가");
        throw new OdyServerErrorException("서버에 장애가 발생했습니다.");
    }
		... 생략
}

 

적용 후 실행한 모습이다.

의도한데로 3번의 실패 시 쿨다운(차단)을 적용하고 다음 RouteClient에 요청을 보내는 것을 볼 수 있다.

 

관련 없는 내용이지만 혹시나 필요할때가 생길까봐 남겨놓는…

원래 Redis의 Bits 자료형을 사용해 에러가 발생한 시간의 분을 ‘0 ~ 59분’ 별로 체크해 TTL을 설정하고

최근 ~분 이내 실패 횟수를 초과하면 차단하는 방식을 사용하려했다.

하지만 분 단위가 아닌 초단위에 연속으로 실패가 있을 경우에는 문제가 있어

key 값에 대한 counter를 증감하고 일정 시간의 TTL을 부여하는 형식으로 변경했다.

(새로운 자료형을 적용해보려 했지만 필자의 요구사항에는 적합하지 않았음)

만약 bits 자료형을 사용할 일(일일 방문자 수 등)이 있다면 다음과 같이 쓸 수 있을 것 같아서 남겨 놓는다.

public void setBit(String key) {
		int failedMinutes = LocalDateTime.now().getMinute();
		redisTemplate.opsForValue().setBit(key, failedMinutes, true);
}

public int getBitCount(String key, int startBit, int endBit) {
    int bitRange = endBit - startBit + 1;
    BitFieldSubCommands commands = BitFieldSubCommands.create()
            .get(BitFieldSubCommands.BitFieldType.unsigned(bitRange))
            .valueAt(startBit);

    List<Long> result = opsForValue().bitField(key, commands);
    return Long.bitCount(result.get(0));
}

테스트로 검증하기

추상화한 RouteClient들을 Bean으로 등록해 관리하고 있다.

현재 확장 가능하도록 설계된 코드를 확장성 있게 테스트하기 위해선 어떻게 작성 해야할까?

 

BaseRouteClientTest

우선 다음과 같이 각 RouteClient들에 공통으로 사용되는 메서드를 템플릿화 하여 상위 클래스BaseRouteClientTest에 정의하고

구현체마다 세부 동작 사항을 다르게 구현하도록 ‘템플릿 메서드 패턴’을 적용하였다.

이제 새로운 RouteClient가 추가되거나 변경되더라도 해당 클래스만 상속받아 구현하면 된다.

public abstract class BaseRouteClientTest {

    @Autowired
    protected MockRestServiceServer mockServer;

    @Autowired
    protected RestClient.Builder restClientBuilder;

    protected RouteClient routeClient;

    @BeforeEach
    void setUp() {
        this.mockServer = MockRestServiceServer.bindTo(restClientBuilder).build();
        this.routeClient = createRouteClient();
    }

    protected abstract RouteClient createRouteClient();
}

 

MockRestServiceServer를 사용해 RestClient를 테스트한 이유

@RestClientTest를 사용해 REST 클라이언트 테스트에 필요한 빈만 로드해 더 가볍게 테스트를 구성하려 했으나,

특정 구현체만 선택적으로 Bean으로 등록하더라도 모든 구현체들을 Bean 등록하려해 충돌이 발생했다.

따라서 좀 더 세밀한 설정과 커스터마이징이 가능한 MockRestServiceServer를 사용해 테스트를 구성했다.

MockRestServiceServer를 사용하면 위 처럼 수동으로 MockServer를 설정하고 초기화 해주어야한다.

 

TestRouteConfig - 테스트용 RouteClient 설정

테스트용 설정은 다음과 같이 Stub으로 구현하였다.

테스트 상에서도 실제 API를 쏘게 테스트한다면 아주 정확한 검증이되기야하겠지만 API 호출도 돈이고 제한이 있다.

따라서 로컬 애플리케이션에서 API 호출을 검증하고 테스트 코드로는 Stub을 사용해 API 호출에 영향을 주지 않도록 작성했다.

@TestConfiguration
public class TestRouteConfig {

    @Bean
    @Order(1)
    @Qualifier("odsay")
    public RouteClient odsayRouteClient() {
        return new StubOdsayRouteClient();
    }

    @Bean
    @Order(2)
    @Qualifier("google")
    public RouteClient googleRouteClient() {
        return new StubGoogleRouteClient();
    }
}

 

RouteClient 구현체 Test 코드

이제 각 구현체들은 BaseRouteClientTest을 상속받고 세부 동작을 재정의하여 테스트를 작성하면 된다.

참고로 MockRestServiceServer는 실제로 외부 API를 호출하는 것이 아닌 모의 환경을 만들어 테스트하는 것이다.

필자의 경우에는 외부 API에서 응답으로 주는 상태코드에 따라 처리하는 로직에 대해 테스트하였다.

@RestClientTest(GoogleRouteClient.class)
public class GoogleRouteClientTest extends BaseRouteClientTest {

    private static final String BASE_URI = "https://maps.googleapis.com/maps/api/distancematrix/json?";

    @Value("${google.maps.api-key}")
    private String testApiKey;

    @DisplayName("버스, 지하철을 이용한 소요시간 계산 요청 성공 시, 가장 빠른 소요 시간을 분으로 변환하여 반환한다.")
    @Test
    void calculateRouteTimeWhenStatusOK() {
        Coordinates origin = new Coordinates("37.505419", "127.050817");
        Coordinates target = new Coordinates("37.515253", "127.102895");

        String response = """
                {
                    "rows": [{
                        "elements": [{
                            "status": "OK",
                            "duration": {
                                "value": 600
                            }
                        }]
                    }],
                    "status": "OK"
                }
                """;
        setMockServer(origin, target, response);

        RouteTime result = routeClient.calculateRouteTime(origin, target);

        assertThat(result.getMinutes()).isEqualTo(10);
        mockServer.verify();
    }

		... 생략

    private void setMockServer(Coordinates origin, Coordinates target, String response) {
        mockServer.expect(requestTo(makeUri(origin, target)))
                .andExpect(method(HttpMethod.GET))
                .andRespond(MockRestResponseCreators.withSuccess(response, MediaType.APPLICATION_JSON));
    }

    private String makeUri(Coordinates origin, Coordinates target) {
        return UriComponentsBuilder.fromHttpUrl(BASE_URI)
                .queryParam("destinations", mapCoordinatesToUrl(target))
                .queryParam("origins", mapCoordinatesToUrl(origin))
                .queryParam("mode", "transit")
                .queryParam("transit_mode", "bus%7Csubway")
                .queryParam("key", testApiKey)
                .build()
                .toUriString();
    }

    private String mapCoordinatesToUrl(Coordinates coordinates) {
        return coordinates.getLatitude() + "," + coordinates.getLongitude();
    }

    @Override
    protected RouteClient createRouteClient() {
        return new GoogleRouteClient(restClientBuilder, testApiKey);
    }
}

 

RouteServiceTest

RouteService에서는 List로 관리되는 Bean을 잘 선택하는지 확인하기 위해 다음과 같이 테스트를 구성하였다.

@Import(TestRouteConfig.class)
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
class RouteServiceTest {

    @MockBean
    @Qualifier("odsay")
    private RouteClient odsayRouteClient;

    @MockBean
    @Qualifier("google")
    private RouteClient googleRouteClient;
    
		@MockBean
    private RouteClientCircuitBreaker routeClientCircuitBreaker;

    @Autowired
    private RouteService routeService;
		
		... 생략

    @DisplayName("OdsayRouteClient에 에러가 발생하면 그 다음 요소인 Google API를 사용해 소요시간을 반환한다.")
    @Test
    void calculateRouteTimeByGoogleRouteClient() {
				... 생략
        when(odsayRouteClient.calculateRouteTime(origin, target))
                .thenThrow(new RuntimeException("Odsay API 에러 발생"));
                
        when(googleRouteClient.calculateRouteTime(origin, target))
				        .thenReturn(new RouteTime(18));

        long result = routeService.calculateRouteTime(origin, target).getMinutes();
        RouteTime expectRouteTime = new RouteTime(18);

        assertAll(
                () -> verify(odsayRouteClient).calculateRouteTime(origin, target),
                () -> verify(googleRouteClient).calculateRouteTime(origin, target)
        );
    }
		... 생략
}

 

Redis 로직 테스트하기

redis에 접근하는 RouteClientCircuitBreaker와 RouteClientManager를 테스트하기 위해 테스트 컨테이너를 사용했다.

Redis 테스트 컨테이너를 설정하는 것 외에는 테스트 코드를 작성하는 것은

일반적인 테스트 코드 작성과 크게 다르지 않아 생략하였다.

테스트 컨테이너를 사용한 이유와 설정 방법에 대해서는 아래 포스팅에 설명해두었으니 이를 참고하자.

2024.11.14 - [◼ JAVA/Spring] - [Spring] TestContainers로 Redis 테스트하기