요약
Application은 Domain을 조작하고,
외부 시스템과의 통신은 Port를 통해 추상화하며,
그 Port를 구현한 실제 기술 계층이 Adapter이다.
DDD에 유리하고 객체지향 5원칙(SOLID)에 유리한 헥사고날 아키텍쳐를 도입할 기회가 있어 공부하게 되었다.

헥사고날 아키텍쳐란?
핵심 로직(도메인)을 중심에 두고, 외부(DB,UI,API)를 "어댑터)" 취급하는 것이다. 핵심은 "의존성 역전" 이라고 볼 수 있다.
구조: Adapter(In) → Port(In) → Domain ← Port(Out) ← Adapter(Out)
도메인 로직이 순수해지는 장점이 있다. 이 부분에 대해서는 예시를 보며 이해하는 게 쉽다.
계층형 아키텍쳐 예시
// Service가 Repository(인프라)에 직접 의존함
@Service
public class UserService {
private final UserRepository userRepository; // 기술 표준인 JPA 등에 직접 의존
public void register(UserDto dto) {
UserEntity entity = new UserEntity(dto.getName());
userRepository.save(entity); // 도메인 로직과 DB 저장이 뒤섞임
}
}
헥사고날 아키텍쳐 예시
//① Port (인터페이스)
//도메인이 외부와 소통하기 위한 통로를 먼저 정의합니다.
// 도메인 계층에 위치한 인터페이스 (외부는 이 규격을 맞춰야 함)
public interface SaveUserPort {
void save(User user);
}
//② Domain Service (핵심 로직)
//외부 기술을 전혀 모른 채, 오직 비즈니스 규칙에만 집중합니다.
public class RegisterUserService implements RegisterUserUseCase {
private final SaveUserPort saveUserPort; // 인터페이스(Port)에만 의존
public void register(User user) {
// 비즈니스 로직 수행...
saveUserPort.save(user);
}
}
//③ Adapter (구현체)
//실제 DB 기술이나 외부 라이브러리는 여기서 처리합니다.
@Repository
public class UserPersistenceAdapter implements SaveUserPort {
private final JpaUserRepository jpaRepository;
@Override
public void save(User user) {
// 도메인 모델을 DB 엔티티로 변환하여 저장
UserEntity entity = UserEntity.fromDomain(user);
jpaRepository.save(entity);
}
}
예시들을 설명하자면...
기존 아키텍처에서는 Service코드에 DB에 저장하는 코드까지 같이 존재했다. 간략한 예시이기 때문에 db에 저장하는 내용만 있지, 실 서비스들에서는 더욱 복잡한 내용이 얽히고 섥혀 있을것이다. Service에서는 이러한 작업들과 더불어, 도메인적인 내용(예: 잔액감소, 거래 등) 도 수행해야 한다.
하지만 헥사고날 아키텍처는 도메인 작업들 과 외부 작업을 아주 칼같이 분리한다. 어느정도냐면 도메인(엔티티)부분은 POJO로 작성을 진행하고, 라이브러리조차 사용하지 않게 한다.
그럼 외부랑 어떻게 소통하나?
라는 의문이 들 수 있다. 당연히 외부와 소통할 수 있다. 이 부분에서 자바의 다형성과 SOLID원칙 중 개방-폐쇄 원칙에 의거한다고 볼 수 있다.
외부와 소통하는 중간 다리역할을 “Port"라고 부른다.
내부에서 외부로 나가면 OutBoundPort 반대의 경우는 InBoundPort라고 부르고, 이들은 모두 interface로 구현되오있다.
이 인터페이스들을 구현하여 실제 외부와 접속하게 해주는 구현체들을 Adapter라고 부른다.
Port
public interface PaymentPort {
void requestPayment(Long userId, long amount);
}
public interface UsageRepository {
long getMonthlyUsage(Long userId);
}
Interface Implemention
@Component
public class TossPaymentAdapter implements PaymentPort {
public void requestPayment(Long userId, long amount) {
// Toss API 호출
}
}
@Repository
public class UsageJpaAdapter implements UsageRepository {
public long getMonthlyUsage(Long userId) {
return usageJpaRepository.sumByUser(userId);
}
}
이런 식으로, Port를 구현하여 사용하게 된다. 또한 자바의 IoC와 DI를 활용하여, Port에 적절한 구현체를 할당할 수 있다.
그럼 Port와 Entity는 어떻게 엮이나?
Entity
public class Usage {
private Long userId;
private long amount;
private long price;
public Usage(Long userId, long amount) {
this.userId = userId;
this.amount = amount;
this.price = calculatePrice(amount);
}
private long calculatePrice(long amount) {
return amount * 10;
}
public long getPrice() {
return price;
}
}
Port
public interface PaymentPort { // 외부 결제 시스템 호출용 포트(Outbound)
void requestPayment(Long userId, long amount);
}
public interface UsageRepository { // DB 저장용 포트(Outbound)
void save(Usage usage);
}
public interface ProcessUsageUseCase { // 사용량 저장 포트(Inbound)
void processUsage(Long userId, long amount);
}
UseCase(Application)
public class BillingService implements ProcessUsageUseCase {// Implement inbound usecase
private final UsageRepository usageRepository;
private final PaymentPort paymentPort;
public BillingService(
UsageRepository usageRepository,
PaymentPort paymentPort
) {
this.usageRepository = usageRepository;
this.paymentPort = paymentPort;
}
@Override
public void processUsage(Long userId, long amount) {
Usage usage = new Usage(userId, amount);
usageRepository.save(usage);
paymentPort.requestPayment(userId, usage.getPrice());
}
}
UseCase(우리가 잘 아는 Service클래스)를 활용하여, Port와 Entity를 활용하여 작업한다. Service역할을 하기 때문에, 트랜잭션 관리도 Service(UseCase)안에서 작동한다.
위 예시는 Input Port를 만들어서 호출하지만 실제로는 Input Port를 따로 안 만들고, Application Service를 직접 Controller가 호출하기도 한다.
계층 구조
위 구조대로 구현하면 의존 관계는 다음과 같아진다.
Controller
↓
UseCase (Application Service)
↓
Domain Entity
또한, 데이터가 나가는 방향은 다음과 같다
UseCase → Port → Adapter
따라서 Spring JPA, Controller 등은 Adapter에 구현하게 된다(Spring JPA는 Outbound, Controller는 Inbound).
한마디로 정리하면, 비즈니스는 안에, 기술은 밖에 라는 원칙이다.
여기까지 읽었다면 다음 그림을 이해하기 쉬울 것이다

아직 잘 와닿지 않는다(글 쓰고 있는 내가..). 한가지 예시를 더 보자. 이 예시는 위 사진과 같은 출처를 지닌다.
RentParam을 RentalTarget으로 변경하여 메서드를 호출하는 예제
public class RentalController {
private final TotalRentalService totalRentalService;
// ...
public Response<RentalHistoryView> rent(@RequestBody RentParam param) {
// ...
totalRentalService.rent(param.toRentTarget());
// ...
}
}
RentParam을 RentTarget로 변경하여 rent()메서드를 호출한다.
public class TotalRentalServiceImpl implements TotalRentalService {
private final CustomerRepository customerRepository;
private final RentalRepository rentalRepository;
private final InventoryService inventoryService;
private final RentalHistoryRepository rentalHistoryRepository;
// ...
public RentalHistory rent(RentalTarget target) {
//...
StoredItem rentedItem = inventoryService.rent(rental, borrower)
.orElseThrow(AlreadyRentedException::new);
// (StoredItem->Item 으로 변환하는 작업)...
return history;
}
}
rental 변수와, borrower를 통해서 외부와 통신을 한 뒤, outbound port를 활용하여 데이터를 가져오게 된다.
(InventoryService가 Port 역할을 하고, HttpInventoryService가 Port를 구현한 Adapter가 된다).
public class HttpInventoryService implements InventoryService {
// ...
@Override
public Optional<StoredItem> rent(Rental rental, Customer borrower) {
// ... HTTP 통신
// ... JSON 역직렬화
return Optional.of(storedItem);
}
}
이때, HTTP요청을 하고, JSON 역직렬화를 통해서 StoredItem을 가져오게 된다. 그러나 외부 제공자가 StoredItem이라는 구조를 직접 제공하고 이를 활용하는 구조가 되버린다.
// 기존 JSON
{ "itemId": "ID", "itemStatus": "AVAILABLE", "rentalId": "RID", rentalName": "NAME" }
// 향상된 JSON
{ "item": { "id": "ID", "status": "AVAILABLE" }, "rental": { "id": "RID", "name": "NAME" } }
만일 JSON 형식이, 이렇게 바뀐다고 가정하자. 그러나 아뿔싸! 내 모든 코드는 StoredItem이라는 DTO에 맞춰져 있고, 이를 수정하기 위해서 엄청난 사이드이펙트가 발생할 수 있다. 여기서 하고싶은 말은, "외부 DTO에 내부 구현을 의존한 게 잘못" 이라는 것이다.
public class HttpInventoryService implements InventoryService {
// ...
@Override
public Optional<Item> rent(Rental rental, Customer borrower) {
// ... HTTP 통신
// ... JSON 역직렬화
return Optional.of(storedItem.toItem());
}
}
그렇기 때문에, 외부에 의존을 하는 것이 아니라 "내부에 의존을 해야 하는" 것이다. 만약 이 Adapter부분에서 내부(Item)에 의존하게 구성했다고 생각해보자.
public class TotalRentalServiceImpl implements TotalRentalService {
private final CustomerRepository customerRepository;
private final RentalRepository rentalRepository;
private final InventoryService inventoryService;
private final RentalHistoryRepository rentalHistoryRepository;
// ...
public RentalHistory rent(RentalTarget target) {
//...
Item rentedItem = inventoryService.rent(rental, borrower)
.orElseThrow(AlreadyRentedException::new);
// ...
return history;
}
}
이 구조였다면, Outbound 가 변경(제공사 변경, JSON 구조 변경) 되는 상황에서도 UseCase 구현체(Application)인 TotalRentalServeiceImpl을 유지할 수 있다. 이것이 헥사고날 아키텍쳐의 장점이라고 볼 수 있다.
구조 정리
Controller (주어댑터)
↓
TotalRentalService (입력 포트)
↓
TotalRentalServiceImpl (Application / UseCase 구현)
↓
InventoryService (출력 포트)
↓
HttpInventoryService (출력 어댑터)'Backend' 카테고리의 다른 글
| [개선] 🔒GDGOC admin 페이지 암호화 적용 건 (1) | 2025.08.31 |
|---|---|
| [객체지향][OOAD] Lotto 미션을 통한 객체지향 연습 (0) | 2025.01.07 |