Hexagonal 아키텍처와 MVC 패턴 비교
아키텍처의 설계는 유지 보수가 잘 되어야 기술적 부채를 줄여줄 수 있다. 이는 곧 프로젝트의 초기 단계에서부터 설계의 중요성을 깨닫고 훈련해야함을 의미한다.
들어가기에 앞서서, 기존의 계층 구조와 MVC 패턴의 차이점부터 먼저 언급하고 가겠다. 주제와 연관은 없지만 함께 공부한 내용이라 넣어봤다.
MVC와 계층 구조의 차이점
MVC 패턴과 Layered Architecture가 그냥 비슷한 개념으로 생각해왔다. 그러다가 커뮤니티에서 차이점에 대해 이야기하는 글을 보게 되었고, 좀 더 깊게 찾아봤다.
MVC 패턴은 주로 비즈니스 로직과 화면(View)를 구분하는데 중점을 두고 Model, View, Controller 로 구성된다. 반면 3tier 계층 구조는 Presentation, Application, Data 계층으로 나뉜다.
이 관계를 흔히 Controller → Service → Repository라는 큰 틀로 대입해서 비슷한 구조로 이해하기 쉽다.
핵심은 기능의 논리적, 물리적 분리와 각 계층간의 독립성이다.각 계층의 역할과 책임을 분리해서 애플리케이션의 유지 보수성을 높이는게 목적이다.
이 둘 간의 관계는 유사한 성격으로 보기 쉽지만, 위상적으로 다르다.
3tier 계층 구조는 클라이언트가 절대 직접적으로 데이터 계층과 연결되지 않는다. 개념적으로 항상 모든 통신은 중간 계층을 거치는 선형 구조(Linear Architecture)이다.
하지만 MVC 패턴은 View가 Controller를 업데이트하고, Controller가 Model을, View가 다시 Model을 직접 업데이트하는 삼각형 구조(Triangle Architecture)를 가진다.
MVC 패턴은 항상 삼각형일 수도, 아닐 수도 있지만 n계층 구조는 항상 선형이어야 한다.
그렇다고 계층 구조와 MVC 패턴이 일대일 대응하도록 설계하는건 좋은 설계라고 볼 수 없다. 서비스, 도메인, 레포지토리는 다양한 곳에서 재사용되는게 좋기 때문이다.
예를 들어서, 고객 화면과 관리자 화면에서 각각 주문 내역을 조회하는 경우
UserOrderController → UserOrderService → UserOrderRepository
AdminOrderController → AdminOrderService → AdminOrderRepository
이런 연통 배관 패턴은 같은 코드의 중복을 발생시키기 때문이다.
Hexagonal Architecture의 이해
포트-어댑터 아키텍처(Ports and Adapters Architecture)라고도 불린다.
Hexagonal 구조는 응용 프로그램의 비즈니스 로직을 외부 세계로부터 격리시켜서 유연하고 테스트하기 쉬운 구조를 만드는 것을 목표로 한다.

나갈때 Port, 들어올때 Adapter
장점 :
- 유연성 : 외부 시스템이나 인프라의 의존성이 낮아 업데이트가 용이
- 테스트 용이 : 비즈니스 로직을 독립적으로 테스트할 수 있음
- 유지보수성 : 코드의 이해와 수정이 용이하며 변화에 빠르게 대응
단점:
- 구현 복잡성 : 포트와 어댑터를 구성하고 관리하는데 약간의 복잡함
- 초반 개발 시간 증가 : 처음 아키텍쳐 구축시 시간과 노력이 필요
Apache Kafka같은 외부 시스템에 연결할때 특히 유용하다.
기존 아키텍처의 경우, UserService 로직에서 Kafka와 연결되면서 외부 의존성이 커진다. 그러나 헥사고날 구조의 경우는 RabbitMQ로 바꾼다 해도 Adapter를 교체하는 방식으로 로직 수정없이 대응할 수 있다.
Hexagonal Architecture와 MVC 패턴의 비교
MVC 패턴은 비즈니스 로직과 외부 서비스의 로직 결합도를 완전히 분리하지 못한다는 단점이 있다. 외부 Framework 단에서 접근하는 로직을 분리하지 못해서 해당 프레임 워크에 대한 의존성을 가지게 된다.
이를 Hexagonal Architecture로 변경하면 외부 프레임워크에 대한 의존성을 신경쓰지 않아도 된다.
@RequiredArgsConstructor
@PersistanceAdapter
@Slf4j
public class MembershipPersistanceAdapter implements RegisterMembershipPort, FindMembershipPort, ModifyMembershipPort {
private final MembershipRepository membershipRepository;
//private final VaultAdapter vaultAdapter;
@Override
public MembershipJpaEntity createMembership(Membership.MembershipName membershipName, Membership.MembershipAccount membershipAccount, Membership.MembershipPassword membershipPassword, Membership.MembershipAddress membershipAddress, Membership.MembershipEmail membershipEmail, Membership.MembershipIsValid membershipIsValid, Membership.Friends friends, Membership.WantedFriends wantedFriends, Membership.RefreshToken refreshToken, Membership.MembershipRole membershipRole, Membership.MembershipProvider membershipProvider, Membership.MembershipProviderId membershipProviderId) {
}
@Override
public MembershipJpaEntity findMembership(Membership.MembershipId membershipId) {
}
@Override
public MembershipJpaEntity modifyMembership(Membership.MembershipId membershipId, Membership.MembershipName membershipName,Membership.MembershipAccount membershipAccount, Membership.MembershipPassword membershipPassword, Membership.MembershipAddress membershipAddress, Membership.MembershipEmail membershipEmail, Membership.MembershipIsValid membershipIsValid, Membership.Friends friends, Membership.WantedFriends wantedFriends, Membership.RefreshToken refreshToken, Membership.MembershipRole membershipRole, Membership.MembershipProvider membershipProvider, Membership.MembershipProviderId membershipProviderId) {
}
}
RegisterMembershipPort류의 Interface들을 Service에서 사용한다.
Hexagonal 구조에선 프레임워크를 변경하더라도 해당 Adapter만 수정해주면 Service 로직을 수정할 필요가 없어진다.
(RabbitMQ → Apache Kafka, RDBMS → NoSQL 등의 예시)
또한, Port는 TestCase를 @Mock 객체 생성하는데도 유리하다.
@Test
public void testCreateUser() {
// Arrange
String userName = "Test User";
String detail = "Detail";
User expectedUser = User.builder()
.userName(userName)
.detail(detail)
.build();
when(fileReaderOutputPort.read()).thenReturn(userName);
when(userOutputPort.save(any())).thenReturn(expectedUser);
// Act
User actualUser = userInputPort.createUser(detail);
// Assert
assertEquals(expectedUser, actualUser);
}
단위 테스트에서도 Mock 객체 생성에 무리 없고 테스트 가능한 코드가 된다.