뛰까라는 개인 프로젝트를 하고 있다고 앞서 말했었다. 그 전에 tothebook이라는 지금도 완성하고 싶은 개인 프로젝트가 있는데, 그때도 고민했던 부분이 오늘 포스팅하는 것과 동일하다. 결국엔 하나의 api에 문제가 생겼을 때, 다른 api로 대체하여 장애 상황이 발생되어도 서비스 운영에 영향이 없게 만드는 것이다.
즉, availability(가용성)를 고려하여 failover를 구현하는 것이다.
failover를 구현하기 위해 선택했고 고민했던 과정들을 작성하고자 한다.
간단한 클래스 다이어그램을 그려보자면 아래와 같이 그릴 수 있다.
WeatherController
에 현재 시간, 위치 기준 날씨를 요청한다.WeatherController
는 WeatherService
에게 요청을 위임한다.WeatherService
는 KMAWeatherClient
를 호출한다.KMAWeatherClient
에서 오류가 발생하면 AccuWeatherClient
를 호출한다.구상한 로직대로 구현해보자면 코드는 아래와 같아진다.
public class WeatherService {
public WeatherResponse getWeather(LocalDateTime dateTime, String latitude, String longitude, String cityCode) {
WeatherResponse response = null;
try {
// 1. 기상청 API 호출
response = Feign.builder()
.target(KMAWeatherClient.class, "기상청 url")
.getWeather("servicekey", 1, 8, "JSON", "20240502", "1401", latitude, longitude);
} catch (FeignException e) {
log.error(e.getMessage());
try {
// 2. 기상청 API 오류 발생 시 AccuWeather API 호출
response = Feign.builder()
.target(AccuWeatherClient.class, "아큐웨더 url")
.getWeather(cityCode, "apikey", "ko-kr", true, true);
} catch (FeignException ex) {
// 3. AccuWeather도 오류가 발생하면 종료한다.
log.error(ex.getMessage());
}
}
return response;
}
}
한 눈에 봐도 이해할 수 있겠지만 위의 코드는 문제점이 많다.
WeatherService
는 필수적으로 변경되어 OCP를 위반한다.FeignException
오류가 발생하면 아큐웨더 api를 호출해야 한다. 문제는 기상청 api의 오류 응답은 Http status가 200이기 때문에 FeignException
을 발생시키려면 ErrorDecoder
를 재정의 해줘야만 한다.그럼, 위의 문제점들을 해결하기 위한 시행착오를 겪어보고자 한다.
첫번째로 생각한건 두 feignClient
가 getWeather
라는 공통된 행위를 수행하니 하나의 추상화 계층을 만들어보자는 생각이었다. 흔히 보는 OCP의 예제가 inteface를 통해 다형성을 구현하는 것들이었으니까. 바로 아래와 같은 구조였다.
WeatherController
에 날씨를 요청한다.WeatherController
는 WeatherService
에게 요청을 위임한다.WeatherService
는 WeatherClient
를 상속 받은 KMAWeatherClient
와 AccuWeatherClient
를 빈으로 등록하여 순서대로 호출한다.위와 같은 구조를 생각한 당시 나 :
할만한데?
그리고 구현 해보려니 역시 할만하지 않았다.
public interface WeatherClient {
WeatherResponse getWeather(String serviceKey, int pageNo, int numOfRows, String dataType, String baseDate, String baseTime, String nx, String ny ...);
}
왜냐하면 위에서 말한 문제점이 해소되지 않았기 때문이다.
요청과 응답이 다른 행위를 메소드명만 같다고 interface로 추상화를 하는 것이, 정말 정답일까?
그래서 다음에 떠오른 것이 바로 템플릿 메소드 패턴이다.
템플릿 메서드를 떠올린 이유는 아래와 같다.
코드로 살펴보자.
public class WeatherService {
private List<WeatherClientTemplate> weatherClientTemplates;
public WeatherResponse getWeather(LocalDateTime dateTime, ...) {
for (WeatherClientTemplate template : weatherClientTemplates) {
try {
return template.getWeather(dateTime, ...);
} catch (FeignException e) {
log.error(e.getMessage());
}
}
throw new RuntimeException("최종 오류");
}
}
public abstract class WeatherClientTemplate {
public WeatherResponse getWeather(LocalDateTime dateTime, ...) {
try {
return getWeather(dateTime, ...);
} catch (FeignException e) {
log.error(e.getMessage());
}
}
protected abstract WeatherResponse fetchWeather(LocalDateTime dateTime, ...);
}
@Component
@Order(value = 1)
@RequiredArgsConstructor
public class KMAWeatherClientTemplate extends WeatherClientTemplate {
private final KMAWeatherClient kmaWeatherClient;
@Override
protected WeatherResponse fetchWeather(LocalDateTime dateTime, ...) {
KMAWeatherClientResponse response
= kmaWeatherClient.getWeather(
"servicekey",
1,
8,
"JSON",
"20240502",
"1401",
latitude,
longitude
);
return KMAWeatherClientResponse.from(response);
}
}
@Component
@Order(value = 2)
@RequiredArgsConstructor
public class AccuWeatherClientTemplate extends WeatherClientTemplate {
private final AccuWeatherClient accuWeatherClient;
@Override
protected WeatherResponse fetchWeather(LocalDateTime dateTime, ...) {
AccuWeatherClientResponse response =
accuWeatherClient
.getWeather(cityCode, "apikey", "ko-kr", true, true)
.get(0);
return AccuWeatherClientResponse.from(response);
}
}
템플릿 메소드 패턴으로 변경하니 보다 좀 객체지향적인 것 같다.
Feign client를 build 해야했던 부분도 빈으로 호출할 수 있게 되었다. 요청과 응답이 서로 달라도 각 Template 객체에서 WeatherRespose
로 변환할 수 있게 되었다. 또한 오류가 발생하면 다른 template 빈을 호출하는 것도 가독성이 향상되었다.
그런데, 다시 코드를 살펴보자. 어떠한 중복이 있는 것 같다.
바로 WeatherService
와 추상 클래스인 WeatherClientTemplate
이다. 각 클래스의 getWeather
메소드를 다시 살펴보자
public WeatherResponse getWeather(LocalDateTime dateTime, ...) {
for (WeatherClientTemplate template : weatherClientTemplates) {
try {
return template.getWeather(dateTime, ...);
} catch (FeignException e) {
log.error(e.getMessage());
}
}
throw new RuntimeException("최종 오류");
}
public WeatherResponse getWeather(LocalDateTime dateTime, ...) {
try {
return getWeather(dateTime, ...);
} catch (FeignException e) {
log.error(e.getMessage());
}
}
잘 보이지 않는다면 WeatherService 클래스의 for each문을 제거해보겠다.
public WeatherResponse getWeather(LocalDateTime dateTime, ...) {
try {
return template.getWeather(dateTime, ...);
} catch (FeignException e) {
log.error(e.getMessage());
}
}
그리고 생각해본다. WeatherClientTemplate
가 아니어도 WeatherService
에서 예외는 캐치되었을 것이다. WeatherService
의 디폴트 메서드는 정말 필요했던 것일까? 훅메서드의 구조와 흐름을 제어할 필요가 있었을까?
템플릿 메소드 패턴은 충분한 해결책이었지만 아쉬운 점이 있었다. 현재 프로세스 로직에서 알고리즘 구조를 재정의하고 흐름을 제어할 필요까지 있을까? 라는 의문이 든다.
더 알맞은 디자인 패턴이 있을 거 같다는 생각에 WeatherClientTemplate
클래스는 제거하고 WeatherService
라는 클라이언트가 동적으로 알고리즘을 선택하는 전략 패턴을 적용해보자.
WeatherResponse
, Weather
, KMAWeatherReponse
, AccuWeatherResponse
클래스는 무시하고 로직을 구성하는 클래스에만 집중해보자. 이 부분에 대해서는 아래에서 별도로 설명하겠다.
public class WeatherService {
private final List<WeatherClient> weatherClients;
public WeatherResponseDTO getWeather(LocalDateTime dateTime, ...) {
for (WeatherClient weatherClient : weatherClients) {
try {
return WeatherResponseDTO.from(weatherClient.getWeather(dateTime, ...));
} catch (FeignException | WeatherException e) {
log.error("날씨 API 요청 중 에러 발생 : {}", e.getMessage());
}
}
throw new WeatherException.ExternalAPIException("외부 API에서 오류가 발생하였습니다.");
}
}
public interface WeatherStrategy {
Weather getWeather(LocalDateTime dateTime, ...);
}
@Component
@Order(value = 1)
@RequiredArgsConstructor
public class KMAWeatherStrategy implements WeatherStrategy {
private final KMAWeatherFeignClient weatherFeignClient;
@Override
public Weather getWeather(LocalDateTime dateTime, ...) {
KMAWeatherClientResponseDTO response =
weatherFeignClient.getWeather
(
"servicekey",
1,
8,
"JSON",
"20240502",
"1401",
latitude,
longitude
);
KMAWeatherResultCode.checkErrorCode(response.getResponse().getHeader().getResultCode());
return KMAWeatherClientResponseDTO.from(response,new Location(latitude, longitude));
}
}
@Component
@Order(value = 2)
@RequiredArgsConstructor
public class AccuWeatherStrategy implements WeatherStrategy {
private final AccuWeatherFeignClient weatherFeignClient;
@Override
public Weather getWeather(LocalDateTime dateTime, ...) {
validated(latitude, longitude, cityCode);
return AccuWeatherClientResponseDTO.from(
weatherFeignClient.getWeather(cityCode, "apikey", "ko-kr", true, true).get(0),
new Location(latitude, longitude)
);
}
}
코드를 수정했으니 위에서 나온 문제들을 되짚어 보자.
- OCP를 위반하나?
- try catch 문의 중첩으로 가독성이 떨어지는가?.
- WeatherResponse 객체가 모든 open API의 응답을 포함하는가?
- 메소드의 불필요한 중복이 일어나는가? - 템플릿 메소드 패턴
1. OCP를 위반하지 않는다.
클라이언트인 WeatherService는 날씨 open api가 추가되어도 영향을 받지 않는다. WeatherStrategy
를 구현한 객체만 추가되면 되기 때문이다.
2. try catch문의 중첩으로 가독성이 떨어지지 않는다.
try catch문은 이제 중첩되지 않는다. 클래스에 선언한 @Order
에 따라 WeatherStrategy
가 순차적으로 실행되기 때문이다.
3. open api의 응답 객체가 헤비해진다.
최초의 WeatherReponse
는 open api의 모든 응답을 담는 클래스였다.
이제는 KMAWeatherClientResponseDTO
,AccuWeatherClientResponseDTO
가 open api 응답을 받고 비즈니스에 맞는 데이터만 Weather
로 변환된다. 즉, 알맞게 모델링 되었다.
4. 메소드의 불필요한 중복이 일어나는가?
메소드 템플릿 패턴을 적용했을 때 getWeather
메소드 호출이 중복되었다. 하지만 전략 패턴을 적용한 지금은 메소드가 중복되지 않는다.
이렇게 최종적으로 전략 패턴을 적용하여 failover 구현을 완료하게 되었다.
이건 추가로 고민했던 부분이다. 위의 코드 기준으로는 날씨를 조회하여 데이터를 저장하지 않는다. 그렇기 때문에 Weather
라는 도메인 객체의 필요성에 대한 고민을 하게 되었다.
바로 저 도메인 모델이다. DB에 데이터를 저장할 때는 너무 당연하게 도메인 모델을 설계했다.
하지만 DB가 없는 상태로 구현을 하고 클래스 다이어그램을 그리다보니 그런 생각이 들었다.
그냥
ClientResponse
모델을WeatherResponse
객체로 변환하고 끝내면 안 되나? 왜Weather
가 필요하지?
그러자 더 본질적인 생각이 나를 덮쳤다.
아마 위의 질문 중 일부에 대한 답변을 아는 사람들이 있을 것이다. 나 또한 세번째 질문에 대한 답변을 아주 잘 알고 있다.
뷰의 요구사항이 변경될 가능성이 더 높기 때문이다. 즉, 저수준이라는 소리다.
그렇기 때문에 DTO라는 객체로 변환하여 뷰의 요구사항을 충족시킨다.
도메인 모델은 결국 무엇일까? 의미를 찾던 중 아래와 같은 정의를 발견하게 되었다.
도메인 모델: 비즈니스 도메인의 개념, 규칙, 데이터를 추상화한 모델
도메인 모델의 정의가 정립되자 생각이 전환되었다.
도메인 모델의 필요성이 아니라, DTO 필요성에 고민을 해야했던 것이다.
dduikka 프로젝트의 날씨 도메인의 맥락에 맞는 집합이 Weather 도메인 모델이었으니까, 당연스럽게 만들었던 아래 DTO에 대한 고민을 다시 하게 되었다.
WeatherResponse
의 필요성
WeatherResponse
는 위에서 말했다시피 뷰라는 저수준 레이어에 대한 요구사항을 충족시키는 DTO다. 뷰는 특히나 변경 가능성이 크다. 고로 WeatherResponse
DTO는 필요하다.
KMAWeatherResponse
와 AccuWeatherResponse
WeatherResponse의 필요성
KMAWeatherResponse
와 AccuWeatherResponse
는 필요할까? 아시다시피 open api의 반환 값은 아주 드물게 변경된다. 그러니 그냥 Weather
에 통합시켜도 되지 않을까? 라는 생각을 할 수도 있다.
그에 대한 생각을 정리해본다.
우선 첫째로 각 응답은 하나로 통일하기 어렵다.
Weather
라는 프로젝트의 맥락에 맞춰 추상화한 도메인 모델로 만드는 것은 불가능하다고 생각한다.두번째 우리가 핸들링할 수 없는 값이다.
이러한 이유로 DTO와 도메인 모델을 유지하게 되었다.
이번에 디자인 패턴을 활용하여 객체지향적으로 문제를 해결할 수 있었다.
디자인 패턴을 공부할 때마다 와닿지 않았었는데 이번 프로젝트로 좀 더 이해도가 올라간 기분이 든다.
남은 글 목록이다. 다음 글은 날씨 저장에 대해 다뤄보도록 하겠다.
참고 문헌
https://f-lab.kr/insight/understanding-and-applying-ddd