헥사고날 아키텍처는 Alistair Cockburn이 제안한 소프트웨어 아키텍처이다. 헥사고날 아키텍처의 주요 목표는 3-layer 아키텍처와 크게 다르지 않다. 비즈니스 로직을 외부 시스템으로부터 분리시켜 비즈니스 로직의 순수성을 지킴으로써 확장성 있는 유연한 소프트웨어 애플리케이션을 구성하는 것이다.
MVC 패턴을 적용한 Model 2 아키텍처는 애플리케이션의 구조를 Presentation, Business Logic, Persistence 세 개의 레이어로 나누고, 여기에 MVC 패턴을 적용하여 Presentation Layer와 Business Logic Layer, 그리고 Persistence 데이터 접근 Layer을 분리하는 방식의 아키텍처이다. 헥사고날 아키텍처는 도메인과 관련된 Business Logic을 모듈화하여 Business Logic을 외부 시스템으로부터 분리하는 데 집중한다. Business Logic을 순수한 도메인 모듈로 만들고 외부 시스템과 Decoupling 관계를 유지함으로써 외부 시스템을 유연하게 교체할 수 있도록 한다.
헥사고날 아키텍처는 Port와 Adapter 아키텍처라고도 불리는 만큼 Port와 Adapter가 아키텍처의 중요한 역할을 맡는다.
Port는 특정 기술에 의존하지 않은 Entrypoint로 볼 수 있다. Port는 Application과 상호작용할 수 있는 interface를 정의한다. Port는 USB 포트에 비유할 수 있다.
Adapter는 Port와 외부 시스템 사이에 위치하여 특정 기술, 혹은 기술에 종속적인 시스템이 Port와 호환되도록 연결하는 역할을 한다. USB 어댑터를 생각하면 되는데, C형 단자든 아이폰 단자든 USB 어댑터에 연결하여 USB 포트에 접속시킴으로써 호환되도록 하는 것과 같은 맥락이다.
Port와 Adapter는 객체지향의 다형성 원리에 의해 코드로 표현될 수 있다. 직접 코드를 보는 것이 헥사고날 아키텍처를 이해하는 데 도움이 될 것이다.
public interface MessageOutputPort {
void send(String message);
}
public class KafkaAdapter implements MessageOutputPort {
private KafkaProducer kafkaProducer;
@Override
public void send(String message) {
this.kafkaProducer.send(message);
}
}
public interface MemberCreationUseCase {
void createUser(String name, String email);
}
public class MemberCreationUseCaseImpl {
private UserRepository userRepository;
private MessageOutputPort outputPort;
@Override
public void createUser(String name, String email) {
User user = new User(name, email);
this.userRepository.save(user);
this.outputPort.send("User has been created.");
}
}
MemberCreationUseCaseImpl이 의존하고 있는 객체는 MessageOutputPort라는 Port이다. Kafka가 아닌 RabbitMQ와 같은 다른 메시지 브로커를 사용한다고 한다면 Adapter만 바꾸면 된다.
AWS S3는 돈이 든다. 로컬 개발 환경에서 AWS S3를 활용하는 건 큰 부담이 될 수 있다. 반대로, 클라우드 배포 환경에서 로컬에 파일을 저장하는 건 현실적이지 않다.
로컬 개발 환경에서는 로컬 PC에, 클라우드 배포 환경에서는 AWS S3와 같은 오브젝트 스토리지에 파일을 저장해야 한다. 그런데 로컬 환경에서 개발이 끝나고 배포할 때마다 일일이 AWS S3를 활용하는 모듈을 사용하도록 설정하는 건 너무 번거로운 일이다.
Spring Profile을 활용하여 헥사고날 아키텍처를 더욱 잘 활용할 수 있다.
public interface FileOutputPort {
void upload(String filename, byte[] binaryData);
}
@Component
@Profile("local-dev")
public class LocalFileAdapter implements FileOutputPort {
@Override
public void upload(String filename, byte[] binaryData) {
// Save file
}
}
@Component
@Profile("deploy")
public class S3Adapter implements FileOutputPort {
private S3Client s3Client;
@Override
public void upload(String filename, byte[] binaryData) {
// Upload file onto S3
}
}
@Service
public class MemberCreationUseCaseImpl implements MemberCreationUseCase {
private MemberRepository memberRepository;
private FileOutputPort fileOutputPort;
@Override
public Long createMember(MemberCreationDto dto) {
Member member = dto.toEntity();
String filename = dto.getFilename();
byte[] fileAsBinaryData = dto.getBinaryData();
this.fileOutputPort.upload(filename, fileAsBinaryData);
Member savedMember = this.memberRepository.save(member);
return savedMember.getId(); // Returns auto-generated entity id
}
}
위와 같이 FileOutputPort를 구현하는 두 개의 Adapter, LocalFileAdapter
와 S3Adapter
를 구현하고 각각 Spring Profile을 local-dev
, deploy
로 선언한다. 그리고 환경변수나 Command line argument를 활용해 Spring Profile을 활성화함으로써 두 Adapter 중에 MemberCreationUseCaseImpl에 의해 사용될 Adapter를 선택한다.
이를 통해 로컬 개발 환경에서는 LocalFileAdapter
를 사용하고 배포 환경에서는 S3Adapter
를 사용할 수 있게 되는 것이다.