
포트-어댑터 패턴이란?
핵심 비즈니스 로직을 외부 기술로부터 격리하는 아키텍처 스타일이다.
소프트웨어 개발 간에는 비즈니스 로직과 외부 기술 연계를 위한 코드가 혼재되는 지점이 있다.
이 경우 기술 스택을 교체할 때 필연적으로 해당 코드를 수정해야 하는 문제점이 있다.
포트 어댑터 패턴은 이 문제를 구조적으로 해결한다.
비즈니스 로직은 인터페이스(포트) 에만 의존하고,
실제 기술 연계를 위한 구현은 구현체(어댑터) 가 담당한다.
즉, 명확한 역할 분리를 통해 비즈니스 로직과 기술 연계의 영역을 서로 독립적으로 유지하는 것이
포트 어댑터 패턴의 핵심이다.
비즈니스 로직과 외부 기술 연계의 영역이 혼재되어 있는 사례
일반적으로 레이어드 아키텍처에서 비즈니스 로직과 외부 기술 연계가 혼재되어 있는 예시를 접할 수 있다.
레이어드 아키텍처는 하나의 애플리케이션을 역할에 따라 계층으로 나누는 아키텍처 스타일이다.
일반적으로 아래 4개 계층으로 구성된다.
Presentation Layer (프레젠테이션 계층)
- 사용자 인터페이스와 상호작용하는 계층
- 예: 웹 컨트롤러, API 엔드포인트
Service Layer (애플리케이션 계층)
- 애플리케이션의 주요 기능과 비즈니스 로직을 담당하는 계층
- 예: 서비스 클래스
Repository Layer (도메인 계층)
- 도메인 모델과 데이터베이스 간의 상호작용을 담당하는 계층
- 예: 리포지토리 인터페이스와 구현체
Database Layer (데이터베이스 계층)
레이어드 아키텍처에서는 각 계층을 바로 아래 계층에만 의존하는 것을 원칙으로 한다.
Controller는 Service를 호출하고, Service는 Repository를 호출하는 방식이다.
Spring에서도 가장 보편적으로 자리잡은 아키텍처 구조이다.
@Controller, @Service, @Repository로 클래스를 나누는 구조가 바로 이 아키텍처를 적용한 예시이다.
하지만, 실제 코드에서는 계층 간의 의존성이 명확하게 지켜지지 않는 경우가 많다.
Service가 Repository 구현체를 직접 참조하는 아래 코드를 예시로 들어보자.
// 레이어드 아키텍처 — 문제 상황
@Service
public class PaymentService {
// 구현체를 직접 참조
private final InicisPaymentClient paymentClient;
public void pay(Order order) {
paymentClient.requestPayment(order.getAmount());
}
}
이 코드는 Service에서 InicisPaymentClient를 직접 참조해 결제를 처리하는 구조이다.
InicisPaymentClient는 KG이니시스 전용 구현체로, 해당 SDK에 종속된 메서드나 예외 처리 방식이 담겨있다.
이를 Service가 직접 알고 있다는 것은, "비즈니스 로직이 KG이니시스라는 특정 기술 구현에 의존한다"는 것이다.
현 구조를 다이어그램으로 표현하면 아래와 같다.
graph TD
A[비즈니스 로직<br/>PaymentService] -->|직접 참조| B[InicisPaymentClient<br/>KG이니시스]
나중에 토스페이먼츠로 결제 대행사를 변경해야 한다면,
PaymentService 코드를 직접 수정해야 한다.
InicisPaymentClient가 TossPaymentClient로 바뀌고, 메서드 시그니처도 다를 수 있어 비즈니스 로직까지 함께 수정해야 하는 상황이 생긴다.
참조하는 곳이 많을수록 수정 범위는 걷잡을 수 없이 커진다.
개선 방법: 포트와 어댑터
인터페이스를 포트라고 부르는 이유는 USB 포트와 같은 역할을 하기 때문이다.
노트북 옆면의 USB 포트를 생각해보면 이해가 쉽다.
USB 포트는 "이 규격을 맞추면 무엇이든 꽂을 수 있다" 는 약속이다.
마우스, 키보드, 충전기, 이들은 모두 포트의 규격에 맞추면, 노트북 내부 회로는 바뀌지 않는다.
코드에서 포트는 인터페이스, 어댑터는 그 인터페이스의 구현체다.
비즈니스 로직은 포트에만 의존하고, 어댑터는 그 규격만 맞추면 언제든 교체할 수 있다.
// 포트 — 인터페이스 정의
public interface PaymentClient {
void pay(int amount);
}
// 어댑터 A — KG이니시스 구현체
@Component
public class InicisPaymentClient implements PaymentClient {
@Override
public void pay(int amount) {
// KG이니시스 SDK 호출
}
}
// 어댑터 B — 토스페이먼츠 구현체
@Component
public class TossPaymentClient implements PaymentClient {
@Override
public void pay(int amount) {
// 토스페이먼츠 SDK 호출
}
}
이제 PaymentService는 인터페이스에만 의존한다.
// 비즈니스 로직 — 포트에만 의존
@Service
@RequiredArgsConstructor
public class PaymentService {
// 인터페이스 타입으로 선언 — KG이니시스인지 토스페이먼츠인지 알 필요 없음
private final PaymentClient paymentClient;
public void pay(Order order) {
paymentClient.pay(order.getAmount());
}
}
결제 대행사를 바꿔야 한다면?
PaymentService는 단 한 줄도 건드리지 않는다.
주입되는 어댑터만 교체하면 된다.
개선 후의 구조를 다이어그램으로 표현하면 아래와 같다.
graph TD
subgraph 도메인 영역
A[비즈니스 로직<br/>PaymentService]
B[포트 인터페이스<br/>PaymentClient]
end
subgraph 어댑터 영역
C[InicisPaymentClient<br/>KG이니시스]
D[TossPaymentClient<br/>토스페이먼츠]
end
A -->|인터페이스만 참조| B
B --> C
B --> D
어댑터 선택 (Spring 의존성 주입)
같은 인터페이스를 구현하는 구현체가 여러 개일 때, Spring은 어떤 어댑터를 주입할지 알 수 없다.
이때 두 가지 방법으로 주입할 어댑터를 지정할 수 있다.
@Primary — 여러 구현체 중 기본으로 주입할 어댑터를 지정한다.@Qualifier — 주입받는 쪽에서 어댑터 이름을 명시적으로 지정한다. 단, 주입받는 코드에 구현체 이름이 노출되어 교체 시 수정이 필요하다.
일반적으로 @Primary를 사용하는 것이 교체 비용이 낮다.
// @Primary — 기본 어댑터로 KG이니시스를 지정
@Primary
@Component
public class InicisPaymentClient implements PaymentClient {
@Override
public void pay(int amount) {
// KG이니시스 SDK 호출
}
}
// 토스페이먼츠로 교체할 경우 @Primary를 이쪽으로 이동
@Component
public class TossPaymentClient implements PaymentClient {
@Override
public void pay(int amount) {
// 토스페이먼츠 SDK 호출
}
}
PaymentService는 어떤 어댑터가 주입되는지 여전히 알 필요가 없다.
결제 대행사를 교체할 때 @Primary의 위치만 옮기면 된다.
Spring 포트-어댑터 패턴 적용 사례
[참고]
하기 구현 예시의 Publisher는 Spring의 이벤트 메커니즘을 의미하는 것이 아니다.
Spring의 `ApplicationEventPublisher`와 `@EventListener`를 먼저 떠올릴 수 있지만,
포트-어댑터 패턴을 적용한 인터페이스이다.
이벤트를 발행하고 리스너가 처리하는 구조는 개념적으로 비슷하지만,
여기서 말하는 Publisher는 외부 시스템과의 통신을 위한 인터페이스(포트)이다.
아래는 LLM API을 순서대로 호출하는 파이프라인 기능을 개발하며,
각 태스크의 진행 상황을 처리하는 실시간으로 클라이언트에 전달하기 위한 서비스 로직에 포트-어댑터 패턴을 적용한 사례이다.
Publisher는 그 분리 지점에 위치한 포트 인터페이스이며,
Polling, SSE(Server-Sent Events), WebSocket 등 여러 전송 방식에 따른 구현체가 존재할 수 있다.
Publisher(포트) 인터페이스 정의
// 포트 — 이벤트 발행 인터페이스
public interface JobEventPublisher {
void onTaskStart(String jobId, String taskId, int seq);
void onTaskComplete(String jobId, String taskId, int seq);
void onJobComplete(String jobId);
void onJobFailed(String jobId, String errorMessage);
}
Publisher는 파이프라인 서비스가 태스크의 시작과 완료, 전체 작업의 완료 또는 실패 이벤트를 발행하기 위한 인터페이스이다.
SSE, WebSocket, Polling 등 다양한 방식으로 구현할 수 있지만, 서비스 로직은 이 인터페이스만을 바라본다.
서비스의 비즈니스 로직
// 서비스의 비즈니스 로직 — 포트에만 의존
@Slf4j
@Service
@RequiredArgsConstructor
public class JobPipelineService {
private final JobEventPublisher eventPublisher;
@Async("jobExecutor")
public void run(String jobId, List<Task> tasks) {
try {
for (Task task : tasks) {
eventPublisher.onTaskStart(jobId, task.getId(), task.getSeq());
String result = callLlmApi(task.getPrompt());
eventPublisher.onTaskComplete(jobId, task.getId(), task.getSeq());
}
eventPublisher.onJobComplete(jobId);
} catch (Exception e) {
eventPublisher.onJobFailed(jobId, e.getMessage());
}
}
}
JobPipelineService는 JobEventPublisher 인터페이스에만 의존한다.
각 요청이 시작될 때마다 onTaskStart를 호출하고, 완료될 때마다 onTaskComplete를 호출한다.
즉, 서비스 로직은 이벤트 발행의 구체적인 방식에 대해 전혀 알 필요가 없다.
어댑터 구현 (SSE 전송방식 적용)
// 어댑터 — SSE 구현체
@Slf4j
@Component
public class SseJobEventPublisher implements JobEventPublisher {
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
public SseEmitter connect(String jobId) {
SseEmitter emitter = new SseEmitter(300_000L);
emitters.put(jobId, emitter);
emitter.onCompletion(() -> emitters.remove(jobId));
emitter.onTimeout(() -> emitters.remove(jobId));
return emitter;
}
@Override
public void onTaskComplete(String jobId, String taskId, int seq) {
SseEmitter emitter = emitters.get(jobId);
if (emitter == null) return;
try {
emitter.send(SseEmitter.event()
.name("task-complete")
.data(Map.of("taskId", taskId, "seq", seq)));
} catch (IOException e) {
emitters.remove(jobId);
}
}
// ... onTaskStart, onJobComplete, onJobFailed 동일한 방식으로 구현
}
graph LR
A[파이프라인<br/>JobPipelineService] -->|onTaskComplete 호출| B[JobEventPublisher<br/>포트]
B --> C[SseJobEventPublisher<br/>SSE 어댑터]
B --> D[WebSocketJobEventPublisher<br/>웹소켓 어댑터]
B --> E[PollingJobEventPublisher<br/>폴링 어댑터]
어댑터 선택 (Spring 의존성 주입)
전송 방식이 결정되면 해당 구현체에 @Primary를 붙여 기본 어댑터로 지정한다.
전송 방식이 바뀌더라도 JobPipelineService는 단 한 줄도 수정하지 않는다.@Primary의 위치만 옮기면 된다.
// SSE 방식을 기본 어댑터로 지정
@Primary
@Component
public class SseJobEventPublisher implements JobEventPublisher {
// ...
}
// WebSocket 방식으로 교체할 경우 @Primary를 이쪽으로 이동
@Component
public class WebSocketJobEventPublisher implements JobEventPublisher {
// ...
}
결국 포트 어댑터 패턴의 핵심은 서비스가 각 외부 기술의 스펙을 전혀 모른다는 것이다.
서비스는 비즈니스 로직에만 집중하고, 기술적인 세부 사항은 어댑터가 흡수한다.
나중에 웹소켓이나 폴링으로 바꾸거나, 세 가지를 동시에 사용해야 한다면 어댑터만 추가하면 된다.
'DEV > Spring' 카테고리의 다른 글
| CORS와 프록시 — CorsConfig / SecurityConfig / Proxy (작성중) (0) | 2026.03.22 |
|---|---|
| [Spring] 이메일 코드 발송 서비스 개발 간 @Async 적용 (0) | 2026.03.19 |
| [Spring] SecurityConfig Spring Method Security 적용하기 (0) | 2024.02.20 |
| [트러블슈팅][Spring] 의존성 주입 간 순환참조 문제 해결 방법 (0) | 2024.02.20 |
| [트러블슈팅][Spring] ApplicationRunner RuntimeException 발생 시 shutdown되는 오류 해결 (0) | 2024.02.20 |