외부 API 의존성을 줄여보자

디자인 패턴을 도입하여 DIP 원칙을 준수하는 코드 리팩토링 과정

어떤 문제점일까?

@Service
@RequiredArgsConstructor
public class SearchBookUseCase {

    private final KakaoApiClient kakaoApiClient;

    public SearchBookResponse search(String query, String sort, int size, int page, String target) {
        return SearchBookResponse.from(
                kakaoApiClient.searchBooks(query, sort, page, size, target), page);
    }
}

현재 프로젝트 내에서 책 검색 API의 유스케이스 레이어의 구현의 모습이다. 전체적인 흐름을 다이어그램으로 파악해보자.

[AS-IS] Dependency

고수준 모듈(비즈니스 로직)이 저수준 모듈(외부 API 클라이언트)에 직접 의존하는 상황이다.

이러한 상황이 왜 문제가 되는 것일까?

  • 모종의 이유로 카카오 API를 더 이상 사용할 수 없는 상황이라면?

  • 검색 조건에 따라 다른 API가 필요한 상황이라면?

변경에 유연하게 대응하지 못하는 상황이며 DIP(Dependency Inversion Principle) 원칙을 준수하지 못한다.

추상화가 아닌 구현체에 직접 의존하는 문제를 해결하여 의존성을 제어해보자. 또한 이 과정에서 디자인 패턴을 도입하면 응집도를 높히고 결합도를 낮출 수 있을 것이라고 판단했다. 다양한 디자인 패턴 중 어댑터 패턴(Adapter Pattern)을 적용해보고자 한다.

[TO-BE] Dependency

어댑터 패턴 (Adapter Pattern)?

어댑터는 호환되지 않는 인터페이스를 가진 객체들이 협업할 수 있도록 하는 구조적 디자인 패턴이다.

위의 사진처럼 중간의 어댑터를 통해 자동차가 레일에서 굴러갈 수 있는 상황을 떠오르면 된다. 해외 여행시 많이 들고다니는 여행용 어댑터를 떠올려도 어떤 역할을 하는지 이해할 수 있을 것이다.

이러한 면에서 어댑터가 해당 구조를 개선하기에 가장 적합하다고 생각했다. 물론 퍼사드 패턴이나 전략 패턴 등 다양한 방식으로 해당 문제를 해결할 수 있지만, 의존성을 낮추는데 있어서 하나의 표준을 정하고 어떤 클라이언트를 도입하더라도 해당 표준에 맞게만 응답해주면 결합도가 낮아지는 좋은 애플리케이션이 될 것이라고 생각했다.

클라이언트는 오직 표준화된 인터페이스만 알면 되며 실제로 어떤 구현체(카카오, 네이버, 알라딘 등)가 들어오더라도 표준화된 응답만 맞추어 결합도가 낮은 유연한 구조가 된다.

리팩토링을 진행해보자.

전체적인 흐름을 3-Tier 아키텍처 순으로 흐름을 파악해보자.

  1. Presentation (Controller)

  2. Business Logic (Service / UseCase)

  3. Data Access (API Client / Third-Party API)

중간에 어댑터 레이어가 들어갈 것이다.

1. Presentation (Controller)

AS-IS

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class SearchBookController implements SearchBookApiSpec {

    private final SearchBookUseCase searchBookUseCase;

    @GetMapping("/books")
    public ApiResponse<SearchBookResponse> searchBook(
            @RequestParam(required = true) String query,
            @RequestParam(defaultValue = "accuracy", required = false) String sort,
            @RequestParam(defaultValue = "1", required = false) int page,
            @RequestParam(defaultValue = "10", required = false) int size,
            @RequestParam(required = false) String target) {

        return ApiResponse.success(searchBookUseCase.search(query, sort, size, page, target));
    }
}

Controller에서도 Request Parameter가 카카오 검색 조건에 맞게 설정을 해뒀었다. 해당 API를 설계할 때 다음과 같은 규칙을 정했었다.

Query String
설명
Required
Default

query

검색 질의어

O

page

결과 페지 번호

O

1

size

한 페이지에 보여질 문서 수

X

10

sort

결과 문서 정렬 방식

X

accuracy

target

검색 필드 제한

X

해당 API 스펙을 프론트엔드 개발자분과 정할 때 단순히 query , page 정보만 백엔드로 요청하면 될 것 같다고 하셨었다. 이러한 점을 생각해보면, 필수 값이 아닌 것은 외부 API로 갈아끼운다고 가정했을 때 해당 Controller Layer에서 처리하는게 맞는지에 대한 의문이 들었다.

내가 해당 구조를 개선하는 이유는 의존성을 관리하기 위해서다. 지금 현 구조는 Controller가 외부 API의 파라미터 체계에 종속된 상황이지 않은가? 이러한 구조를 개선할 필요를 느꼈다.

네이버 책 검색시에는 아래와 같은 파라미터 정보가 필요하다고 한다.

이러한 정보를 기준으로 검색어(query)페이지 번호(page) 정보만 클라이언트에게 요청받는 구조로 변경했다.

TO-BE

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class SearchBookController implements SearchBookApiSpec {

    private final SearchBookUseCase searchBookUseCase;

    @GetMapping("/books")
    public ApiResponse<SearchBookResponse> searchBook(
             @RequestParam String query,
             @RequestParam(defaultValue = "1") int page) {

        return ApiResponse.success(searchBookUseCase.search(query, page));
    }
}

이로써 Controller Layer가 외부 API의 파라미터 체계에 종속되지 않고, 오직 비즈니스에 필요한 최소한의 정보(query, page)만을 다루도록 책임을 단순화할 수 있었다. 결과적으로 Controller의 역할이 명확해지고, 향후 외부 API가 변경되더라도 Controller 코드를 수정하지 않아도 되는 유연한 구조가 되었다

2. Business Logic (Service / UseCase)

AS-IS

@Service
@RequiredArgsConstructor
public class SearchBookUseCase {

    private final KakaoApiClient kakaoApiClient;

    public SearchBookResponse search(String query, String sort, int size, int page, String target) {
        return SearchBookResponse.from(
                kakaoApiClient.searchBooks(query, sort, page, size, target), page);
    }
}

가장 이상하다고 느꼈던 부분은 비즈니스 로직을 담당하는 유스케이스 레이어가 외부 클라이언트(KakaoApiClient)에 직접 의존하고 있다는 점이었다. 즉, 고수준 모듈이 저수준 구현에 직접 의존하는 구조였다. 이러한 구조의 문제점은 유연하지 못한 설계이며 객체지향의 원칙에도 위배된다는 점이다.

TO-BE

@Service
@RequiredArgsConstructor
public class SearchBookUseCase {
    
    // 직접 외부 API 클라이언트에 의존하지 않는다! 
    private final BookSearchAdapter bookSearchAdapter;

    public SearchBookResponse search(String query, int page) {
        return SearchBookResponse.from(bookSearchAdapter.search(query, page));
    }
}

어댑터 패턴을 도입한 후에는, 유스케이스 레이어가 오직 추상화된 인터페이스(BookSearchAdapter)에만 의존하게 되어 실제로 어떤 외부 API를 사용하든 내부 구현에 신경 쓸 필요가 없게 되었다.

이는 "객체지향의 사실과 오해" 내용 중에서 '자율적인 객체'의 개념과도 비슷하다. 각 객체는 자신의 역할에만 집중하고, 협력 객체의 내부 동작에는 관심을 두지 않는다.

3. Adapter

어댑터라는 새로운 레이어가 등장한다.

어댑터를 통해 비즈니스 로직에만 필요한 정보만 담은 형태로 응답을 추상화하여 전달해주는게 목표인 것이다.

어댑터 인터페이스 정의

public interface BookSearchAdapter {

    SearchBookDTO search(String query, int page);
}

공통 응답 형태 정의

public record SearchBookDTO(List<Book> books, PageInfo pageInfo) {

    public record Book(
        String title,
        String author,
        LocalDate publishedAt,
        String thumbnail) {}

    public record PageInfo(
        boolean isEnd, 
        int pageableCount, 
        int totalCount, 
        int page) {}
}

이제 이 공통 응답 형태(SearchBookDTO)로 어댑터 레이어에서는 외부 API 클라이언트에서 받은 값을 변환해주는 과정을 거치기만 하면 된다. 새로운 외부 API 도입시 search 라는 메서드를 통해 공통된 응답을 반환하는 어댑터만 구현을 해주면 되는 것이다.

카카오 책 검색 어댑터

@Component
@RequiredArgsConstructor
public class KakaoBookAdapter implements BookSearchAdapter {

    private final KakaoApiClient kakaoApiClient;

    private static final int DEFAULT_SIZE = 10;
    private static final String DEFAULT_SORT = "accuracy";


    @Override
    public SearchBookDTO search(String query, int page) {
        KakaoSearchBookDTO response = kakaoApiClient.searchBooks(
                query,
                page,
                DEFAULT_SIZE,
                DEFAULT_SORT);

        return convertResponse(response, page);
    }

    private SearchBookDTO convertResponse(KakaoSearchBookDTO dto, int page) {

        return new SearchBookDTO(
                convertBooks(dto.documents()),
                convertPageInfo(dto.meta(), page));
    }
    
    // 내부 변환 메서드
}

응답 형태 변환을 어댑터 내부에서 구현한 이유

어댑터 패턴을 도입하면서 어떤 영역이 이 책임을 가지는게 맞는 건지에 대한 고민을 많이 했다. 같은 팀원 분도 코드리뷰를 진행하면서 DTO 내부에서 처리하지 않고, 어댑터에서 처리한 이유에 대해서 궁금해하셨다.

각각의 클라이언트(카카오, 네이버, 알라딘 등)마다 응답 형태가 다른데, 변환 과정을 SearchBookDTO 내부에 담고 있다면, DTO의 역할을 정확하게 준수하는 것인가에 대한 의문이 생겼다.

둘의 역할을 명확하게 기준이 모호해지는 느낌을 받아 정리를 해봤다.

어댑터(Adapter)

  • 특정 외부 API의 응답을 표준 형태로 변환하는 책임

  • 변환 로직은 외부 클라이언트마다 다르게 구현 될 가능성이 있음.

    • 어댑터 내부 메서드를 통해 변환 로직을 숨겨 캡슐화 하는게 좋다고 판단

DTO(Data Transfer Object)

  • 단순 데이터를 담는 역할만을 수행

  • 변환 로직을 가지게 될 경우, 외부 API 변경 및 도입시 매번 수정이 필요함

이러한 결론을 기반으로 어댑터 내부에서 공통된 인터페이스이자 표준 형태인 SearchBookDTO 에 맞게 응답해주는 방식으로 구현을 했다.

4. Data Access (API Client / Third-Party API)

외부 API에서 데이터에 접근하는 영역은 별도의 변경이 필요하지 않다.

기존 코드에서 클라이언트가 넘겨주는 값 그대로 DTO 형태로 가지고 있다가, Controller 레이어로 넘겨주기 전에 해당 형태를 변환하는 과정을 거쳤기 때문이다.

@FeignClient(
        name = "kakao-book-api",
        url = "https://dapi.kakao.com/v3",
        configuration = KakaoFeignClientConfig.class)
public interface KakaoApiClient {

    /**
     * 카카오 책 검색 API
     *
     * @param query  검색어 (필수)
     * @param sort   정렬 방식 (accuracy, recency) - 기본값 accuracy
     * @param page   결과 페이지 번호 (1 ~ 50) - 기본값 1
     * @param size   한 페이지에 보여질 문서의 개수 (1 ~ 50) - 기본값 10
     */
    @GetMapping("/search/book")
    KakaoSearchBookDTO searchBooks(
            @RequestParam(value = "query", required = true) String query,
            @RequestParam(value = "page", defaultValue = "1", required = false) int page,
            @RequestParam(value = "size", defaultValue = "10", required = false) int size,
            @RequestParam(value = "sort", defaultValue = "accuracy", required = false) String sort);
}

결론

변경된 구조

외부 API 호출을 담당하는 모듈(client) 의 구조가 다음과 같이 변경되었다.

기존 패키지 구조

com
└── dnd
    └── sbooky
        └── clients
            ├── config
            │   ├── FeignClientConfig.java
            │   └── KakaoProperties.java
            └── kakao
                ├── KakaoApiClient.java
                ├── KakaoFeignClientConfig.java
                └── response
                    └── KakaoSearchBookResponseDTO.java

변경된 패키지 구조

com
└── dnd
    └── sbooky
        └── clients
            ├── api
            │   ├── BookSearchAdapter.java 
            │   └── response
            │       └── SearchBookDTO.java
            ├── config
            │   └── FeignClientConfig.java
            └── kakao
                ├── KakaoApiClient.java
                ├── KakaoBookAdapter.java
                ├── KakaoFeignClientConfig.java
                ├── KakaoProperties.java
                └── dto
                    └── KakaoSearchBookDTO.java

  • BookSearchAdapter 인터페이스를 도입하여 검색 API의 추상화 계층 추가

  • KakaoBookAdapter 를 구현하여 카카오 API 호출 및 응답 변환 처리

  • SearchBookUseCase가 특정 구현체(KakaoApiClient)에 의존하지 않도록 리팩토링

    • SearchBookResponseDTO를 통해 API 응답을 표준화된 형식으로 통합

또한, 향후 네이버, 알라딘 등 다른 책 검색 API 추가 시 OCP(Open-Close Principle) 원칙을 준수할 수 있도록 개선했다.

더 나아가서

실 사용자들이 많은 상황은 아니지만, 안정적인 시스템을 구현하는 것에 관심이 많아 장애에 대응하는 방법들에 대해 찾아보는 것에 많은 흥미를 느끼고 있다.

이런 고민(장애 대응, 고가용성, 폴백 등)의 출발점은 결국 의존성을 얼마나 잘 분리하고 낮추느냐에 있다고 생각이 든다. 외부 API를 직접 의존하는 구조에서 장애가 곧 서비스 전체의 장애로 이어질 수 밖에 없다.

의존성을 추상화하고 결합도를 낮추다보면 장애 상황에서도 폴백(fallback) 처리나 다양한 확장 전략을 유연하게 적용할 수 있을 것이라고 판단이 된다.


객체지향에 대한 책을 읽고 직접 코드로 녹여내는 과정에서 많은 생각이 들었다. 과거에는 이걸 실전에 어떻게 써먹으라는 거지? 디자인 패턴이라는걸 내가 쓸 일이 있을까? 했지만, 다양한 관점에서 문제를 볼 수 있게 하고 개선점을 찾아 좀 더 유연한 설계를 할 수 있도록 도와주는 점에서 부족한 점도 많고, 배울 점도 많은 것 같다.

Last updated

Was this helpful?