HJW's IT Blog

Port & Adapter 패턴 도입기 본문

개발 개념

Port & Adapter 패턴 도입기

kiki1875 2025. 9. 7. 17:03

0. 배경

저는 핵사고날 / 클린 / Port & Adapter 패턴에 대한 이해도가 부족하며 개인적인 회고를 위해 쓴 글임을 밝힙니다.

 

프로잭트를 진행하며 깊은 고민에 빠졌다. 지난 프로잭트들을 통해 뛰어난 유지보수성과 확장성을 위해선, OOP를 준수하는 코드의 작성이 필수에 가깝다는것을 배웠다.

 

서비스 코드를 작성하던 중, 한가지 의문점이 생겼다. DIP는 고수준 모듈이 저수준 모듈에 '직접' 의존해선 안된다는 원칙이다. 하지만 다음 코드를 보면 해당 서비스 클래스는 '직접' 저장소를 의존하고 있다.

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

  private final MemberRepository memberRepository; // JPA 직접 의존

  public Member findById(UUID id) {
    return memberRepository.findById(id)
        .orElseThrow(() -> new MemberException(ErrorCode.USER_NOT_FOUND));
  }
}

 

그렇다면 이런 의문이 들 수도 있다.

MemberRepository는 인터페이스로 선언하지 않나요?

그렇다. 인터페이스로 선언한다. 하지만 여전히 나는 DIP를 위배한 코드라 생각한다. 이유는 다음과 같다.

  1. 인터페이스를 참조하지만 결국 이 서비스 코드는 JPA를 알고 있어야 한다.
  2. 저장소가 바뀐다면 코드의 세부 구현이 바뀌어야 한다.
  3. DIP를 지키기 위해선, 어떤 저장소를 쓰는지 서비스가 몰라야 한다는 것이 핵심이다.

그렇다면 DIP를 준수하는 코드를 작성하기 위해 해야할 것은 단순하다. 서비스가 어떤 저장소를 쓰는지 모르게 구현하면 된다. 이를 달성하기 위해 Port & Adapter를 도입하기로 하였다.

1. Port & Adapter 란

우선 두가지 큰 개념을 짚고 넘어가자.

 

Port : 도메인과 외부 세계의 경계에 있는 인터페이스로, 입력을 정의하는 인바운드 포트와 출력을 정의하는 아웃바운드 포트가 존재한다.

  • 이러한 port 는 필요한 동작만 정의한다.
  • ex) "정보를 저장할 무언가가 필요하다"
  • ex) "메일을 발송할 무언가가 필요하다"
public interface MemberRepositoryPort {  
  Optional<Member> findByEmail(String email);  
  Optional<Member> findById(UUID uuid);  
  Member save(Member member);  
}
  • 사실 JpaRepository 도 일종의 port 이다. 하지만 이는 제한된 port로, RDB간의 데이터소스 변경은 가능하지만 외부 API를 호출하는등의 변경은 불가하다

Adapter : 위에서 언급한 Port 의 실제 구현체이다. 저수준의 모듈을 감싸서 Port 의 계약대로 동작하게 만든다.

  • ex) JPA로 동작하는 Adapter
  • ex) MongoDB로 동작하는 Adapter
  • ex) 외부 API로 동작하는 Adapter
@Repository  
@RequiredArgsConstructor  
public class MemberRepositoryAdapter implements MemberRepositoryPort{  

  private final MemberRepository memberRepository;  
  @Override  
  public Optional<Member> findByEmail(String email) {  
    return memberRepository.findByEmail(email);  
  }  

  @Override  
  public Optional<Member> findById(UUID uuid) {  
    return memberRepository.findById(uuid);  
  }  

  @Override  
  public Member save(Member member) {  
    return memberRepository.save(member);  
  }  
}

3. 더 큰 그림: 아키텍처로의 확장

물론 제가 구현한 것이 온전한 헥사고날 아키텍처는 아닙니다

 

Port & Adapter 패턴은 단순히 한두 클래스에 적용되는 것을 넘어, 헥사고날 아키텍처나 클린 아키텍처와 같은 큰 그림의 아키텍처 스타일에 자연스럽게 통합될 수 있다.

3.1 헥사고날 아키텍처

 

헥사고날 아키텍처는 애플리케이션의 핵심 비즈니스 로직(도메인)을 육각형의 형태로 중앙에 배치하고, 외부 시스템과의 상호작용은 포트와 어댑터를 통해 이루어지도록 한다.

 

기존에 사용하던 아키텍처의 방향성을 보자.

Service → JPA Repository → Database

 

이와 같이 서비스가 직접 외부 시스템을 알고 있는 구조이다. 하지만 리팩토링한 구조는,

Service → Port(인터페이스) ← Adapter → 외부 시스템

 

이와 같이 서비스는 모든 외부와의 소통을 port + adapter 조합으로 하게 된다. 즉, 도메인 레이어는 자신이 무엇을 해야 할 지만 알고 어떻게 해야 하는지는 전혀 모르는 구조가 될 수 있다.

 

// 도메인 코어 (육각형 중심) 예시
@Service
public class OrderService {
    private final MemberRepositoryPort memberPort;      // 회원 조회용 포트
    private final PaymentPort paymentPort;              // 결제 처리용 포트
    private final NotificationPort notificationPort;    // 알림 발송용 포트

    public void processOrder(Order order) {
        Member member = memberPort.findById(order.getMemberId());  // 어떤 DB인지 모름
        PaymentResult result = paymentPort.pay(order.getAmount()); // 어떤 결제사인지 모름  
        notificationPort.send(member.getEmail(), "주문완료");       // 어떤 방식인지 모름
    }
}

 

OrderService는 MemberRepositoryPort, PaymentPort, NotificationPort와 같은 추상적인 포트에만 의존한다. 이를 통해 도메인 코어는 자신이 '무엇을' 해야 하는지만 알고, '어떻게' 하는지는 전혀 모르는 구조**를 가지게 됩다. 이것이 헥사고날 아키텍처의 핵심이다.

3.2 클린 아키텍처

클린 아키텍처의 핵심은 다음과 같다.

  1. 의존성은 외부 -> 내부로만 향한다
  2. 비즈니스 로직은 외부 레이어에 대해 전혀 몰라야 한다
  3. Use-Case 레이어가 전체 흐름을 제어한다

우리 코드로 다시 매핑하면:

  • Member = 엔티티
  • MemberService = 유스케이스
  • MemberRepositoryPort = 인터페이스 (Port)
  • MemberRepositoryAdapter = 어댑터 (Adapter)
  • JPA Repository = 프레임워크

즉, 안쪽 레이어는 바깥쪽을 전혀 모르며 화살표는 내부로만 향한다.

4. Pros & Cons

이러한 Port & Adapter 패턴을 구현하게 된다면 장점도 있지만, 단점도 분명 있다. 각각 살펴보자.

장점

  • 유연성과 확장성이 증가한다. 저장소, 외부 API 호출 스팩등이 변경되어도 서비스 코드의 변경 / 수정 없이 adapter 만 교채하면 된다
  • DIP를 준수할 수 있다. 고수준(service) 모듈이 저수준(repository)에 직접 의존하지 않으며 행위만 알고 있으면 된다.
  • 테스트 용이성이 증가한다. 포트를 통한 mock 용이성, 단위 및 통합 테스트가 쉬워진다
  • 도메인 중심의 설계가 가능해진다. 도메인 로직이 저장소의 세부 구현으로 부터 벗어나 온전한 비즈니스 로직에 집중할 수 있다.

단점

  • 복잡성이 증가한다. 당연한 말이지만, 단순 숫자 계산만으로도 하나의 JpaRepository 를 사용하위해 추가적인 port 클래스와 adapter 클래스가 필요해진다.
  • 추상화 비용이 발생한다. port 와 adapter 패턴을 쓰게 되면 오히려 코드를 이해하기 어렵게 만들 수 있다.
  • 학습 곡선이 높다. 이러한 패턴에 익숙하거나 사용해야 할 이유를 모른다면 일관성 없는 코드 발생할 수 있다.

5. 마무리

지금 수준의 프로젝트에서 이런 패턴을 적용하는것은 오버엔지니어링이 맞다. 단순한 프로젝트라면 JpaRepository 직접 주입해도 문제 없으며 오히려 더 직관적이고 간결한 코드일 수 있다.

 

Port & Adapter 패턴은 분명 은탄환이 아니다. 하지만 적절한 상황에서 도입한다면, 코드의 유지보수성과 테스트 용이성을 크게 향상시킬 수 있는 강력한 도구임에는 틀림없다. 무엇보다 이 패턴을 통해 진정한 DIP를 실현할 수 있었고, 도메인 중심의 설계가 무엇인지 깊이 이해할 수 있었다.

 

하지만 중요한 것은 패턴 자체가 아니라, 우리가 해결하고자 하는 문제와 추구하는 가치에 맞는 적절한 도구를 선택하는 것이다.

'개발 개념' 카테고리의 다른 글

3 Layered vs Clean Architecture  (0) 2025.01.31
PRG Pattern 은 왜 사용할까?  (0) 2025.01.16
Builder Pattern 에 대한 고찰  (0) 2025.01.10
SOLID 와 TradeOff  (0) 2025.01.09
상속보단 합성을 사용하자  (0) 2025.01.06