후라이

Ports and Adapter 패턴 (Hexagonal) 본문

Spring

Ports and Adapter 패턴 (Hexagonal)

힐안 2025. 2. 17. 20:04

1. 개요 

Ports & Adapter 아키텍처란?

: 비즈니스 로직을 외부 시스템(웹, DB, 메시지 큐 등)과 독립적으로 유지하기 위한 아키텍처 패턴

핵심은 어플리케이션의 중심에 핵심 비즈니스 로직을 두고, 외부 시스템과의 연결을 유연하게 한다는 것

 

왜 Ports & Adapter 아키텍처를 사용하나?

: 전통적인 레이어드 아키텍처(Presentation - Domain - Persistence)의 문제점을 해결

레이어드 아키텍처의 의존성 방향은 Controller -> Service -> Repository -> DataBase

이 구조에서 Service는 Repository에 강하게 의존하게 된다.

즉, Service는 특정 구현체(JPA, JDBC, MongoDB 등)와 직접적으로 결합되어버린다.

 

🔴 문제점

  • 만약 JPA에서 MongoDB로 변경하려면?
    -> Service 내부 코드를 변경해야 함
  • 테스트하려고 하는데 DB가 필요하다면?
    -> Repository를 Mock으로 대체하기 어려움
  • 비즈니스 로직(Service)이 영속성 기술을 알아야 함
    -> Repository가 특정 ORM/JDBC 기술에 종속되면서 유지보수가 어려워짐 

🟢 Ports & Adapter 적용

  • 비즈니스 로직과 외부 기술을 분리 → DB, API, UI 변경에도 핵심 로직 영향 없음
  • 테스트가 용이함 → 가짜 Out-Port 구현(Mock Repository)으로 유닛 테스트 가능
  • 확장성이 높음 → JPA 대신 NoSQL, API 호출로 쉽게 변경 가능

2. 주요 개념

In-Port & Out-Port (포트)

  • In-Port(입력 포트)
    - 애플리케이션 핵심 로직을 호출하기 위한 인터페이스
    - Controller, CLI,  메시지 큐 등이 In-Port를 호출
  • Out-Port(출력 포트)
    - 애플리케이션이 외부 시스템(DB, API 등)에 접근할 때 사용하는 인터페이스
    - Out-Port는 실제 구현을 모르고, 단지 "이런 동작이 필요하다"는 인터페이스만 정의(In-Port도 동일)
    - Repository, API Client 등이 Out-Port의 구현체가 됨 

Adapter (어댑터)

  • Inbound Adapter(입력 어댑터)
    사용자의 요청을 In-Port로 전달하는 역할 즉, In-Port의 구현체
    ex) Controller(Spring @RestController), CLI, 이벤트 리스너 등
  • Outbound Adapter(출력 어댑터)
    Out-Port를 구현해서 실제 외부 시스템과 통신하는 역할, Out-Port의 구현체
    ex) JPA Repsitory, API 호출용 HTTP Client 등 

3. 예제 코드로 이해

💡 "사용자가 회원 정보를 조회"하는 기능을 Ports & Adapter 아키텍처로 구현

 

3.1) In-Port 정의(어플리케이션이 제공하는 기능을 정의하는 인터페이스)

public interface GetUserUseCase {
	UserResponse getUserById(Long userId);
}
  • GetUserUseCase는 어플리케이션에서 제공하는 "회원 조회"기능을 정의한 인터페이스이다.
  • Controller 입장에서는 이 인터페이스를 호출하고, 내부 로직은 몰라도 된다.

 

3.2) Out-Port 정의(외부 시스템과의 연결을 위한 인터페이스)

public interface UserRepository {
	Optional<User> findById(Long userId);
}
  • UserRepository는 DB에서 사용자 정보를 가져오는 역할이다.
  • 실체 구현은 모르고, 단순히 findById를 호출하면 유저 데이터를 가져올 거야 라고만 인지한다.

 

이제 인터페이스를 구현했으니 실제 서비스를 구현해보자.

 

3.3) In-Port 구현 (Service)

@Service
public class GetUserService implements GetUserUseCase {
    private final UserRepository userRepository;

    public GetUserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserResponse getUserById(Long userId) {
        User user = userRepository.findById(userId)
                                  .orElseThrow(() -> new UserNotFoundException(userId));
        return new UserResponse(user);
    }
}

 

  • GetUserService는 In-Port(GetUserUseCase)의 구현체
  • UserRepository(Out-Port)를 사용하여 데이터를 가져온다.
  • 핵심 비즈니스 로직이 이 서비스에 구현된다.

 

3.4) Out-Port 구현 (JPA Adapter)

@Repository
public class UserJpaRepositoryAdapter implements UserRepository {
    private final JpaUserRepository jpaRepository;

    public UserJpaRepositoryAdapter(JpaUserRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public Optional<User> findById(Long userId) {
        return jpaRepository.findById(userId);
    }
}
  • UserRepository(Out-Port)를 실제 JPA Repository로 연결
  • 어플리케이션 핵심 로직은 JPA의 존재를 몰라도 된다.

 

3.5) Inbound Adapter (Controller - 외부 요청을 받아 In-Port 호출)

@RestController
@RequestMapping("/users")
public class UserController {
    private final GetUserUseCase getUserUseCase;

    public UserController(GetUserUseCase getUserUseCase) {
        this.getUserUseCase = getUserUseCase;
    }

    @GetMapping("/{userId}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long userId) {
        return ResponseEntity.ok(getUserUseCase.getUserById(userId));
    }
}
  • UserController는 Inbound Adapter이다.
  • GetUserUseCase를 호출해서 비즈니스 로직을 수행하게 된다.
  • 실제 DB에 어떻게 접근하는지는 신경쓰지 않는다.
Outbound Adapter는 Out-Port를 구현하여 실제 외부 시스템과 연결하는 것으로 예) UserJpaRepsitoryAdapter를 의미

전체 구조 정리

┌────────────────────────────────┐
│        Web / API (Controller)  │  →  Inbound Adapter
└────────────────────────────────┘
                 │
                 ▼
┌────────────────────────────────┐
│  Application (UseCase, Service)│  →  In-Port 구현체
└────────────────────────────────┘
                 │
                 ▼
┌────────────────────────────────┐
│     Persistence (Repository)   │  →  Out-Port 인터페이스
└────────────────────────────────┘
                 │
                 ▼
┌────────────────────────────────┐
│        Database (JPA, SQL)      │  →  Outbound Adapter
└────────────────────────────────┘

 


그러나 단점,

- 코드량 증가 (더 많은 클래스 & 인터페이스)

- 구현의 복잡성 증가 -> 기존에는 userRepository.findById(id) 이렇게 바로 호출하면 됐는데,

                                           이제는 Port를 통해 Adapter를 거쳐야 하는 구조가 됨.

'Spring' 카테고리의 다른 글

Spring Security + JWT  (1) 2025.02.21
@RequestPart 알아보기  (0) 2025.02.12
열어봐요 @RequestBody로 데이터 받는 과정  (0) 2025.02.10
@RequestBody 와 <form> 태그  (0) 2025.02.05
Spring Pageable 안 쓰면 바보  (0) 2025.01.31