JAVA

외부 연동 방식 변경과 어댑터 패턴 적용

경딩 2025. 8. 3. 01:27

외부 연동 방식 변경과 어댑터 패턴 적용

최근 회사에서 외부 시스템과의 연동 방식이 변경되었다.
기존에는 DB를 직접 조회해서 데이터를 받아오는 방식이었지만, 변경 이후에는 REST API를 통해 외부 데이터를 호출하도록 요구사항이 바뀌었다.

이런 구조 변경은 일반적으로 기존 코드에 많은 영향을 줄 수 있다.
하지만 나는 이 상황에서 기존 비즈니스 로직을 전혀 수정하지 않고도 외부 연동 방식만 바꿔치기할 수 있도록 어댑터 패턴(Adapter Pattern) 을 적용해보았다.


어댑터 패턴이란?

어댑터 패턴은 호환되지 않는 인터페이스를 가진 클래스를 함께 동작하게 해주는 구조 패턴이다. 기존 인터페이스를 클라이언트가 기대하는 형태로 변환해줌으로써, 서로 다른 클래스들이 함께 동작할 수 있도록 도와준다.

실생활 예를 들면, 한국의 220V 콘센트에 110V 제품을 사용하려면 어댑터가 필요한 것과 같은 원리이다.

어댑터 패턴의 구성 요소

  • Target Interface: 클라이언트가 사용하려는 인터페이스
  • Adapter: Target 인터페이스를 구현하면서 기존 클래스를 감싸는 역할
  • Adaptee: 기존에 존재하는 클래스 (호환되지 않는 인터페이스)
  • Client: Target 인터페이스를 사용하는 코드
 
 
Client → Target Interface ← Adapter → Adaptee

 

어댑터 패턴의 장점

  • 기존 코드를 변경하지 않고 원하는 인터페이스 구현체를 만들어 재사용할 수 있다.
  • 기존 코드가 하던 일과 특정 인터페이스 구현체로 변환하는 작업을 각기 다른 클래스로 분리하여 관리할 수 있다.

어댑터 패턴의 단점

  • 새 클래스가 생겨 복잡도가 증가할 수 있다. 경우에 따라서는 기존 코드가 해당 인터페이스를 구현하도록 수정하는 것이 좋은 선택이 될 수 있다.

코딩으로 학습하는 GoF의 디자인 패턴 에서 발췌한 예시로 어뎁터 패턴을 이해해보자.

 

 

 

어뎁터를 적용해보자.

 

클라이언트가 어떤 인터페이스르 기반으로 코드를 사용하고있는가 살펴보기.

 


/**
 * security 가 제공해주는 코드
 * 어뎁터 패턴의 구성요소: client - target 인터페이스를 사용하는 코드
 */
public class LoginHandler {

    UserDetailsService userDetailsService; // target 인터페이스

    public LoginHandler(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    public String login(String username, String password) {
        UserDetails userDetails = userDetailsService.loadUser(username); // target 인터페이스
        if (userDetails.getPassword().equals(password)) {
            return userDetails.getUsername();
        } else {
            throw new IllegalArgumentException();
        }
    }
}

 

Client 즉 LoginHandler 가 사용하는  UserDetailsService, UserDetails 인터페이스가 곧 target 인터페이스이다.

 

UserDetailsService 은 loadUser 라는 하나의 메소드를 가진다.


/**
 * security 가 제공해주는 코드
 */
public interface UserDetailsService {

    UserDetails loadUser(String username);

}

 

이 인터페이스의 규약은 문자열 하나를 전달하면 문자열에 해당하는 UserDetails 타입에 인스턴스를 리턴받아야 한다.


/**
 * security 가 제공해주는 코드
 * target 인터페이스 - 클라이언트가 사용하려는 인터페이스
 */
public interface UserDetails {

    String getUsername();

    String getPassword();

}

 

UserDetails 에는 username 과 password 를 알아야 하는 두개의 규약이 정의되어 있다.

 

그럼 어뎁티에 해당하는  AccountService, Account 을 어떻게 UserDetailsService, UserDetails 와 연결할까?

해당 부분에 대해 집중해서 생각해보자.

 

AccountUserDetailsService

@Override
public UserDetails loadUser(String username) {
    Account accountByUsername = accountService.findAccountByUsername(username);
    return null;
}

 

 accountService.findAccountByUsername(username); 를 호출하면 accountService 는 userDetails 를 모른다.

따라서 또 하나의 어뎁터에 해당하는 클래스를 정의해주자.

 


public class AccountUserDetails implements UserDetails {

    Account account;

    public AccountUserDetails(Account account) {
        this.account = account;
    }

    @Override
    public String getUsername() {
        return account.getName();
    }

    @Override
    public String getPassword() {
        return account.getPassword();
    }
}

 

최종 테스트


public class App {

    public static void main(String[] args) {
        UserDetailsService userDetailsService = new AccountUserDetailsService(new AccountService());
        LoginHandler loginHandler = new LoginHandler(userDetailsService);
        String login = loginHandler.login("admin", "admin");
        System.out.println("login = " + login);
    }
}

 

 

어뎁터에 해당하는 코드를 별도로 만들면서 기존의 코드는 전혀 건들리지 않았다.

 

어뎁티와 타켓 인터페이스 코드를 손댈수 없을때 어뎁터는 유용한 선택이다.

어뎁티 수정이 가능할 경우 해당 타겟 인터페이스를 상속받게 하면 별도의 어뎁터 클래스를 생성하지 않아도 되지만 단일책임 원칙에 위배된다.

상황에 맞게 적절히 선택하여 쓰자.


 

 

어댑터 패턴 적용 이유

이전 방식은 다음과 같은 문제를 가지고 있었다:

  • 외부 연동 방식이 바뀌면 기존 코드도 함께 수정해야 함
  • 테스트 환경에서는 실제 DB나 API에 의존할 수밖에 없음
  • 비즈니스 로직과 외부 시스템 연동 로직이 강하게 결합되어 있음

이를 개선하기 위해 외부 데이터 조회 로직을 인터페이스로 추상화하고,
DB 기반과 API 기반 구현체를 각각 어댑터 클래스로 분리했다.
그 결과, 비즈니스 로직은 고수준 모듈에서 외부 구현체에 의존하지 않게 되었고,
스프링의 DI 기능을 통해 원하는 구현체만 주입해주면 쉽게 전환이 가능했다.

 

실 적용 사례

LinkUserImporter (Client)
       |
       | depends on
       ▼
HrUserSource<T> (Target Interface)
       ▲
       | implements
       |
+------------------------------+
| ApiHrUserSource             |  ← Adapter
| DbHrUserSource              |  ← Adapter
+------------------------------+
       |
       | 내부에서 API 호출 or DB 쿼리
       ▼
외부 API or DB (Adaptee)

 

  • LinkUserImporter는 HrUserSource<T>만 바라보며 동작 (→ Client)
  • HrUserSource<T>는 어떤 방식으로든 외부 사용자 목록을 불러올 수 있는 통일된 인터페이스 (→ Target)
  • ApiHrUserSource, DbHrUserSource는 각각 REST API, DB에 맞게 HrUserSource를 구현 (→ Adapter)
  • 각각의 Adapter 내부에서 호출하는 API, 쿼리 등 실제 구현체는 (→ Adaptee)

 


장점

  • 구현체만 교체하면 비즈니스 로직은 그대로 유지 가능
    외부 연동 방식을 바꿔도 @Component만 바꾸면 된다.
  • 유닛 테스트 용이
    외부 연동을 모킹(mocking)하기 쉽고, 테스트 코드 작성이 간단해진다.
  • SOLID 원칙 중 DIP(Dependency Inversion Principle)를 만족
    고수준 모듈이 저수준 구현체에 의존하지 않게 되었다.

단점

  • 추상화 계층이 늘어나면서 클래스 수가 증가함
    어댑터, 인터페이스, DTO 변환 코드 등으로 인해 초기에 구조가 복잡해질 수 있다.

결론

장단점을 고려해보았을 때, 유지보수성과 확장성 측면에서 어댑터 패턴의 장점이 훨씬 크다고 판단했다.
향후 외부 연동 방식이 또 변경되거나, 새로운 연동 대상이 추가되더라도 기존 코드는 거의 손대지 않고 대응할 수 있는 유연한 구조를 갖추게 되었다.