본문 바로가기

프로젝트/오디

Odsay 대중교통 길찾기 API를 쓰며 느낀 3가지 단점

 

일전에 프로젝트에 도입할 대중교통 길찾기 API 비교글을 작성했었다.

 

당시 우리 팀은 Odsay API를 활용하기로 선택했었는데, 배우기 쉽고 구현하기 쉽다는 것이 가장 큰 이유였다. 그러나, 직접 API를 사용하며 몇가지 불편함을 느꼈는데, Odsay API 활용을 고려하고 계시는 분들께 도움이 되고자 글을 작성해보고자 한다.

 

불편한 점은 다음과 같았다.

1) 일일 호출 건수가 1000건으로 매우 적다.
2) 에러 시, 상태코드 200이 반환되며 에러 객체 형태가 가변적이다.
3) 호출 IP 주소를 직접 등록해주어야 한다.

 

그럼 Odsay API의 불편한 점들과 나름대로 생각해낸 극복방안을 소개해보고자 한다.

 


1) 일일 호출 건수가 1000건으로 매우 적다.

 

오디세이에 일일 호출 가능 건수는 1000건이다. 처음에는 이 호출 건수가 충분하다 생각했으나, 마지막 데모데이 날 부스 운영을 하며 결국 1000건이 넘어버렸고 API 제공이 중단되었다.

 

만약 구현 내용 중에 폴링처럼 많은 API 요청이 필요한 구현이 포함되는 경우 Odsay API 사용을 재고하는 것이 좋을 것 같다. 우리 팀의 경우 다음 2가지 경우에서 API 호출이 필요했다.

1) 약속 참여 시 : 출-도착지 소요 시간 계산
2) 약속 30분 전 부터 : 10분 간격으로 친구의 실시간 도착 예정 시간 갱신

 

여기서 2번째 기능의 경우 참여자 N명이라면 4*N 번 호출이 필요했고 결과적으로 호출량이 오버되었다.

 


2) 에러 시, 상태코드 200이 반환되며 에러 객체 형태가 가변적이다.

 

2-1) 에러 상황에도 200을 반환한다

Odsay는 에러 상황에도 상태 코드 200을 반환한다. 즉, body를 까보기 전에는 이 응답이 에러인지 정상응답인지 모른다.

 

따라서 ResponseErrorHandler 를 통해 예외를 처리할 수 없었다.

예외인지 알려면 response body를 까봐야 하는데 outputStream을 한번 소모해버리는 순간 다시 body를 확인할 수 없다는 이슈가 있었다.

 

> ResponseErrorHandler 예시

더보기
package roomescape.exception;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.ResponseErrorHandler;
import roomescape.exception.customexception.api.ApiBadRequestException;
import roomescape.exception.customexception.api.ApiException;
import roomescape.exception.customexception.api.ApiTimeOutException;
import roomescape.exception.dto.ThirdPartyErrorResponse;

import java.io.IOException;
import java.net.SocketTimeoutException;

@Component
public class ApiExceptionHandler implements ResponseErrorHandler {

    // 에러 인지 검증하는 코드
    @Override
    public boolean hasError(ClientHttpResponse response) {
        try {
            return response.getStatusCode().is4xxClientError() ||
                    response.getStatusCode().is5xxServerError();
        } catch (IOException exception) {
            throw new RuntimeException(exception);
        }
    }

   // 에러인지 확인했다면 어떻게 핸들링할 것인지 작성
    @Override
    public void handleError(@NonNull ClientHttpResponse response) {
        ThirdPartyErrorResponse error = getResponseBody(response);

        if (ApiBadRequestExceptions.isBadRequest(error.code())) {
            throw new ApiBadRequestException(error.message());
        }

        throw new ApiException("결제 과정에서 문제가 발생했습니다.");
    }

    private ThirdPartyErrorResponse getResponseBody(ClientHttpResponse response) {
        ObjectMapper objectMapper = new ObjectMapper();
        try {
            return objectMapper
                    .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                    .readValue(
                            response.getBody(),
                            ThirdPartyErrorResponse.class);
        } catch (IOException exception) {
            throw new RuntimeException(exception);
        }
    }
}

 

ResponseErrorHandler를 활용하려면 hasError 단계에서 body를 한번 열어보아야 하는데 이 과정에서 outputStream이 소모되었다. 따라서 에러가 아니더라도 body에 저장되어 있는 데이터를 애플리케이션 로직 내에서 활용하지 못한다는 단점이 생겼다.


2-2) 에러 객체 형태가 가변적이다.

페어 였던 조조와 여러가지 입력값을 넣어보던 와중 에러 객체의 형태가 가변적이라는 문제를 발견했다.

 

예를 들어 인증키 오류의 경우 상태 코드 200과 함께 다음과 같은 에러 객체가 반환된다.

{
  "error": [
    {
      "code": "500",
      "message": "[ApiKeyAuthFailed] ApiKey authentication failed."
    }
  ]
}

 

 

경로를 찾고자 하는 출발 - 도착지가 700m 이내인 경우 다음의 에러 객체가 반환된다.

{
  "error": {
    "msg": "출, 도착지가 700m이내입니다.",
    "code": "-98"
  }
}

 

보다시피 위에서는 에러 메시지 필드가 "message"로 밑에서는 "msg"로 반환되고 있다.

또한, 위에서는 배열 형태로 밑에서는 단일 에러 객체로 반환되고 있다.

 

이는 디버깅을 위해 에러 메시지를 바인딩 해야 하는 입장에서 신경써야 할 포인트가  더해지는 느낌이었다.


3) 호출 IP 주소를 직접 등록해주어야 한다. 

Odsay는 API를 호출하는 서버 ip 주소를 직접 등록해주어야 한다.

서버 ip는 5가지까지 등록이 가능하다.

따라서, 개발 환경과 배포 환경이 5가지 이상으로 많아질 때 문제가 된다.

Odsay 호출이 불가능한 환경이 몇가지 생기게 되기 때문에, 내 로컬에서는 통과하는 Odsay 호출 테스트가 다른 로컬환경에서는 통과하지 않는 경우가 생긴다.

 

또한 github actions을 활용한 CI 과정에서 build & test를 진행하려 하고, test 중 odsay api를 직접 호출하는 테스트가 있다면 테스트가 깨진다. CI 호출 과정에서 스크립트를 github action runner 서버에서 실행하게 되는데 이 서버의 주소는 odsay에 등록되지 않았기 때문이다.

 


그럼 어떻게 대처하고 있을까?

 

Lv3까지는 아이디어의 발산과 구현이 우선시되었으므로 아직 유지/운영에 대한 대안은 명확히 나오지 않았다.

그러나, 현 시점에서 우리가 조금이라도 봉합해놓은 1차적 대안과 내가 생각한 몇가지 임시책들은 다음과 같다.

 


1) 적은 호출 상한량 => 여러 계정을 통한 로드 밸런싱

 

사용자가 몰린 데모데이 때를 예외 사항으로 보고 실제 출시 이후, 우리 서비스의 DAU를 20-25정도로만 잡는다면 사실 1000건의 호출 제한이 적은 편은 아니다. 그러나 서비스가 잘되어 트래픽이 몰리는 상황을 고려한다면 우선 Odsay 계정을 여러개 만들어 놓고 각 계정에 트래픽을 분담하는 방식을 생각해볼 수 있다.

 

즉 현재 1계정이 분담하는 대중교통 소요시간 호출을 다음과 같이 개선가능하다

 

그러나,  이러한 밸런싱 형태는 근본적인 문제 해결이 아닐 수 있다. 트래픽이 많아지면 여러 계정을 생성하여 분담한다는 단순한 생각에 기반한 해결책이기에 어느 임계선에서는 오히려 관리 포인트가 많아지는 결과를 초래할 수 있을 것 같다.

 

따라서 앞서 우리가 고려했던 Google Maps나 Tmap API를 다시한번 고려해보아도 좋을 것 같다. 마침  RouteClient도  변경하기 쉬운 interface로 선언해두었으니 코드 변경도 크지 않으리라 생각한다.

 


 

2) 에러 시, 상태코드 200이 반환되며 에러 객체 형태가 가변적이다. => JsonDeserializer 활용

 

먼저 예외 상황과 정상응답 상황을 모두 binding 하기 위해 다음과 같은 Optional 필드를 가진 dto를 만들어주었다.

public record OdsayResponse(
        Optional<String> code,
        Optional<String> message,
        OptionalLong minutes
) {

}

 

만약 error가 발생하면 code와 message에 에러 코드와 메시지가 바인딩된다.만약 정상 응답을 반환한다면 minutes에 소요시간이 반환된다.

 

이제 커스텀 역직렬화도구를 통해 예외 상황/ 정상 응답 상황에 따른 binding 로직을 만들어주었다.

 

private Optional<String> find(JsonNode node, String... fieldName) {
        for (String field : fieldName) {
            JsonNode nodeName = node.findPath(field);
            if (!nodeName.isMissingNode()) {
                return Optional.of(nodeName.textValue());
            }
        }
        return Optional.empty();
}

먼저 JsonNode에서 String 필드 이름을 기반으로 fieldName을 가진 node가 있는지 탐색한다.만약 있다면 node의 문자열 값을 넣은 Optional을 반환, 없다면 Optional.empty()를 반환한다.

 

두 지점의 대중 교통 소요시간인 minutes의 경우 하드코딩을 통해 로직을 구성해주었다.마음에 드는 코드는 아니었지만 당시 마감일까지 우리 페어가 별다른 해결책을 찾지 못했다. 우선 기능 동작이 되는 것이 우선이었던 것 같다.

private OptionalLong findMinutes(JsonNode node) {
    try {
        long minutes = node.get("result")
                .get("path")
                .get(0)
                .get("info")
                .get("totalTime")
                .asLong();
        return OptionalLong.of(minutes);
    } catch (NullPointerException exception) {
        return OptionalLong.empty();
    }
}

 

 

이를 기반으로 response dto를 binding하는 로직은 다음과 같다.

private OdsayResponse parse(JsonNode node) {
    Optional<String> code = find(node, "code"); //code라는 필드명이 있는지 확인하고 있으면 반환
    Optional<String> message = find(node, "message", "msg"); //message나 msg라는 필드명 탐색
    OptionalLong minutes = findMinutes(node); //minutes라는 필드명이 있는지 탐색한다.
    return new OdsayResponse(code, message, minutes);
}

 

 

그렇게 완성된 dto 역직렬화도구는 다음과 같다.

package com.ody.route.mapper;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.ody.common.exception.OdyServerErrorException;
import com.ody.route.dto.OdsayResponse;
import java.io.IOException;
import java.util.Optional;
import java.util.OptionalLong;
import org.springframework.boot.jackson.JsonComponent;

@JsonComponent
public class OdsayResponseDeserializer extends JsonDeserializer<OdsayResponse> {

    @Override
    public OdsayResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) {
        try {
            JsonNode node = jsonParser.getCodec()
                    .readTree(jsonParser);
            return parse(node);
        } catch (IOException exception) {
            throw new OdyServerErrorException(exception.getMessage());
        }
    }

    private OdsayResponse parse(JsonNode node) {
        Optional<String> code = find(node, "code");
        Optional<String> message = find(node, "message", "msg");
        OptionalLong minutes = findMinutes(node);
        return new OdsayResponse(code, message, minutes);
    }

    private Optional<String> find(JsonNode node, String... fieldName) {
        for (String field : fieldName) {
            JsonNode nodeName = node.findPath(field);
            if (!nodeName.isMissingNode()) {
                return Optional.of(nodeName.textValue());
            }
        }
        return Optional.empty();
    }

    private OptionalLong findMinutes(JsonNode node) {
        try {
            long minutes = node.get("result")
                    .get("path")
                    .get(0)
                    .get("info")
                    .get("totalTime")
                    .asLong();
            return OptionalLong.of(minutes);
        } catch (NullPointerException exception) {
            return OptionalLong.empty();
        }
    }
}

 

 

이후, OdsayResponseMapper를 통해 에러 handling을 수행했다.

[정상 응답 시] 
- minutes 값 반환

[예외 상황 발생 시]
- 에러 code가 존재 + code가 500인 경우 > 커스텀 500 에러 반환
- 에러 code가 존재 + code가 -98인 경우 > 소요시간 0 반환
- 그외 에러 code가 존재하는 경우 > 커스텀 400에러 반환

 

package com.ody.route.mapper;

import com.ody.common.exception.OdyBadRequestException;
import com.ody.common.exception.OdyServerErrorException;
import com.ody.route.dto.OdsayResponse;
import java.util.Optional;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class OdsayResponseMapper {

    private static final String CLOSE_LOCATION_CODE = "-98"; //출발지-도착지가 700m 이내일 때
    private static final String ODSAY_SERVER_ERROR = "500";
    private static final String EMPTY_MESSAGE = "";
    private static final long ZERO_TIME = 0L;

    public static long mapMinutes(OdsayResponse response) {
        if (response == null) {
            throw new OdyServerErrorException("response is null");
        }

        if (isCloseLocation(response)) {
            return ZERO_TIME;
        }

        if (response.code().isPresent()) {
            checkOdsayException(response);
        }

        return response.minutes().orElseThrow(() -> {
            log.error("OdsayResponse minutes is Empty: {}", response);
            return new OdyServerErrorException("서버 에러");
        });
    }

    private static boolean isCloseLocation(OdsayResponse response) {
        Optional<String> code = response.code();
        return code.isPresent() && CLOSE_LOCATION_CODE.equals(code.get());
    }

    private static void checkOdsayException(OdsayResponse response) {
        if (isServerErrorCode(response)) {
            log.error("ODsay 500 에러: {}", response);
            throw new OdyServerErrorException("서버 에러");
        }

        throw new OdyBadRequestException(
                response.message()
                        .orElse(EMPTY_MESSAGE)
        );
    }

    private static boolean isServerErrorCode(OdsayResponse response) {
        Optional<String> code = response.code();
        return code.isPresent() && ODSAY_SERVER_ERROR.equals(code.get());
    }
}

 

 

결론적으로 static helper 클래스와 하드코딩을 통한 역직렬화 도구를 통해 예외를 handling 해주었다. OOP를 지키며 코딩하기보다 예상치 못한 에러 상황을 `봉합해놓았다`는게 정확한 표현 같다.

 

이후 프로젝트를 진행하며 ResponseBody를 캐싱할 수 있는 ContentChacingResponseWrapper 등의 개념을 배운만큼  Lv4에는 에러 handling에 대한 책임이 응집도 있게 분리되도록  더 나은 해결책을 찾을 수 있었으면 좋겠다. 

 


3) 호출 IP 주소를 직접 등록해주어야 한다.  > Odsay 호출 테스트 disabled

 

3번째 문제는 사실 봉합하지도 못했다. 우선 등록해놓은 서버 주소에서 테스트를 완료한 이후 CI/CD 과정에서는 테스트를 disabled 처리하여 CI 과정에서 스크립트가 터지는 것을 막아놓은 상태이다.

 


마무리

 

그럼 Odsay 대중교통 API 를 활용하며 느꼈던 3가지 불편함과 나의 극복기를 살펴보자.

 

나는 Odsay를 사용하며 다음 3가지에서 불편함을 느꼈다.

1) 일일 호출 건수가 1000건으로 매우 적다.
2) 에러 시, 상태코드 200이 반환되며 에러 객체 형태가 가변적이다.
3) 호출 IP 주소를 직접 등록해주어야 한다.

 

그리고 각각의 대처방안은 다음과 같았다.

1) 적은 호출 제한량 > 여러 계정을 통한 트래픽 분담 예정
2) 가변적인 오류 객체 반환 > 커스텀 역직렬화 도구 + mapper를 통한 error handling
3) 호출 IP 주소를 직접 등록 > 호출 테스트 disabled 등 극복하지 못함

 

 

이렇게 정리해놓고 보니 참 아쉬움이 많이 남는 대처가 아닌가 싶다.

조금 더 나은 대처 방안은 없었을까? 아니, 같은 대처라도 과연 다음과 같은 구조가 최선의 대응책이었을까?

 

LV4에는 위의 문제를 더 깊게 고민하며 조금은 나은 해결책으로 글을 작성해보았으면 좋겠다.