Hexagonal Architecture 완전 가이드: Ports와 Adapters로 구현하기
핵사고날 아키텍처(Hexagonal Architecture)란?
출처: https://medium.com/@luishrsoares/whats-hexagonal-architecture-6da22d4ab600
- 애플리케이션의 핵심 로직을 외부 요소들로부터 격리시키는 것에 중점을 둔 아키텍처입니다.
- 레이어 간의 원하지 않는 종속성이나 비즈니스 로직으로 인한 사용자 인터페이스 코드의 오염과 같은 객체 지향 소프트웨어 설계의 알려진 구조적 함정을 피하기 위해 Alistair Cockburn에 의해 발명되었습니다.
- Ports and Adapters Architecture 라고도 불립니다.
Hexagonal Architecture의 주요 개념
- Central Domain
애플리케이션의 핵심 비즈니스 로직이 위치합니다.
순수하게 비즈니스 규칙에만 집중하며, 특정 Infrastructure나 framework, 외부 시스템에 대한 정보를 포함하지 않습니다. - Ports
ports는 애플리케이션의 핵심 비즈니스 로직과 시스템의 외부 요소(db, web server, messaging system) 사이의 인터페이스를 정의합니다.
핵심 로직이 필요로하는 작업과 외부에 제공해야 하는 기능을 명세화합니다.
- 예시로는 JPA에서 Book이라는 Entity와의 연동시 사용할 interface를 구현한 아래의 코드와 같습니다.
public interface BookRepository{
Optional<Book> findById(Long id);
void save(Book book);
}
- 인바운드 포트: 외부에서 내부 서비스로 들어올 때 사용될 인터페이스입니다.
- 아웃바운드 포트: 외부 API, 데이터 등에 사용될 인터페이스입니다.
- Adapters
Ports에 정의된 인터페이스를 구체적인 외부 시스템 또는 기술에 맞게 구현합니다.
핵심 로직과 실제 외부 세계 사이의 "번역자" or "중개자" 역할이라고 보면 됩니다.
- 예시로는 위에서 Ports의 예를 구현한 코드를 이용하였습니다.
public class JpaBookRepository extends JpaRepository<Book, Long> implements BookRepository{}
- 인바운드 어댑터 - 외부 애플리케이션/서비스와 내부 비즈니스 영역(인바운드 포트) 간 데이터 교환을 조정(ex: Controller ...)
- 아웃바운드 어댑터 - 내부 비즈니스 영역(아웃바운드 포트)과 외부 애플리케이션/서비스 간 데이터 교환을 조정(RestApi, DB, Messaging System ...)
Hexagonal Architecture (또는 Ports and Adapters Architecture) 사용 이유
1. 유연성: 핵심 비즈니스 로직이 특정 Infrastructure나 framework에 결합되지 않기 때문에 해당 부분들을 쉽게 변경하거나 업그레이드 할 수 있습니다.
예를 들어, DB로부터 사용자 정보를 가져오는 애플리케이션을 생각해보겠습니다.
초기에는 in-memory DB를 사용했지만, 나중에는 실제 RDBMS로 이전하고 싶을 수 있습니다.
- Domain Layer
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User{
private Long id;
private String name;
}
public interface UserRepository{
User save(User user);
Optional<User> findById(Long id);
}
- Application Layer
@RequiredArgsContructor
public class UserService{
private final UserRepository userRepository;
public User createUser(User user){
return userRepository.save(user);
}
public Optional<User> getUserById(Long id){
return userRepository.findById(id);
}
}
- Infrastructure Layer(기존 in-memeory)
public class InMemoryUserRepository implements UserRepository{
private Map<Long, User> storage = new HashMap<>();
private AtomicLong counter = new AtomicLong();
@Override
public User save(User user){
Long id = counter.incrementAndGet();
user.setId(id);
storage.put(id, user);
return user;
}
@Override
public Optional<User> findById(Long id){
return Optional.ofNullable(storage.get(id));
}
}
- Infrastructure Layer(RDBMS로 변경)
@Repository
public class JpaUserRepository implements UserRepository{
@PersistenceContext
private EntityManager entityManager;
@Override
public User save(User user){
entityManager.persist(user);
return user;
}
@Override
public Optional<User> findById(Long id){
return Optional.ofNullable(entityManager.find(User.class, id));
}
}
in-memory에서 RDBMS로 변경시 'UserService'는 데이터베이스의 종류나 구현 방식을 알 필요가 없습니다.
핵심 로직은 'UserRepository' 인터페이스에 의존하며, 이를 구현하는 실제 저장소는 언제든지 변경될 수 있습니다.
이것이 Hexagonal Architecture에서 얻을 수 있는 유연성입니다.
2. 테스트 용이성: 핵심 로직은 외부 요소와 독립적이므로, 단위 테스트를 작성하고 실행하는 것이 더 쉽습니다.
핵심 비즈니스 로직은 외부 Infrastructure 와의 결합 없이 순수하게 동작합니다.
이로 인해 단위 테스트를 작성할 때, 필요한 의존성을 쉽게 모킹하거나 대체할 수 있습니다.
이는 테스트의 속도를 빠르게 하며, 실제 외부 시스템을 사용하는 통합 테스트와 구분하여 핵심 로직의 정확성을 더 효과적으로 검증 할 수 있습니다.
- 예시
- Domain Layer
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User{
private Long id;
private String name;
}
public interface UserRepository{
User save(User user);
Optional<User> findById(Long id);
}
- Application Layer
@RequiredArgsContructor
public class UserService{
private final UserRepository userRepository;
public User createUser(User user){
return userRepository.save(user);
}
public Optional<User> getUserById(Long id){
return userRepository.findById(id);
}
}
- UserService Test
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
public class UserServiceTest{
@Mock
private UserRepository userRepository;
private UserService userService;
@BeforeEach
public void setUp(){
MockitoAnnotations.initMocks(this);
userService = new UserService(userRepository);
}
@Test
public void testCreateUser(){
// Given
String name = "John";
User mockUser = new User(1L, name);
when(userRepository.save(any(User.class)))
.thenReturn(mockUser);
// When
User user = userService.createUser(name);
// Then
assertEquals(1L, user.getId());
assertEquals(name, user.getName());
verify(userRepository).save(any(User.class));
}
}
3. 유지보수성: 애플리케이션의 구조가 체계적이고 일관적이므로, 코드의 유지보수성이 향상됩니다.
결합도 감소: 핵심 로직과 외부 시스템 사이의 결합도가 낮아집니다. 변경이나 확장이 필요할 때 해당 영역만 수정하면 되므로 전체 시스템에 대한 리스크가 줄어듭니다.
코드 이해성 향상: 코드의 주요 구성 요소와 그들 사이의 관계가 명확하므로, 새로운 개발자나 팀원이 코드베이스에 익숙해지는 데 시간이 적게 걸립니다.
재사용성: 동일한 포트를 사용하는 다양한 어댑터를 쉽게 교체하거나 추가할 수 있습니다.
예시
Domain Layer
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User{
private Long id;
private String name;
}
public interface UserRepository{
User save(User user);
Optional<User> findById(Long id);
}
- Application Layer
@RequiredArgsContructor
public class UserService{
private final UserRepository userRepository;
public User createUser(User user){
return userRepository.save(user);
}
public Optional<User> getUserById(Long id){
return userRepository.findById(id);
}
}
- Infrastructure Layer(새로운 Redis 어댑터 추가)
@RequiredArgsConstructor
public class RedisCachingUserRepository implements UserRepository{
private final UserRepository underlyingRepository;
private final RedisClient redisClient;
@Override
public User save(User user){
User savedUser = underlyingRepository.save(user);
redisClient.set(savedUser.getId().toString(), savedUser);
return savedUser;
}
@Override
public Optional<User> findById(Long id){
User cachedUser = redisClient.get(id.toString(), User.class);
if (cachedUser != null){
return Optional.of(cachedUser);
}
return underlyingRepository.findById(id);
}
}
이렇게 새로운 어댑터를 추가하더라도 'UserService'나 핵심 로직에는 아무런 변경이 필요하지 않습니다.
Hexagonal Architecture의 이러한 구조는 유지보수성을 크게 향상시키며, 변경이나 확장을 더 쉽게 만듭니다.
4. 플러그인 아키텍처: 다양한 입력과 출력 방식(다양한 DB, 프레임워크, UI ...)을 쉽게 플러그인처럼 추가하거나 제거할 수 있습니다.
플러그인 아키텍처는 특정 구성 요소나 기능을 애플리케이션에 "플러그인" 처럼 쉽게 추가하거나 제거할 수 있게 설계된 아키텍처를 의미합니다.
- 예시
- Domain Layer
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class User{
private Long id;
private String name;
}
public interface UserRepository{
User save(User user);
Optional<User> findById(Long id);
}
- Application Layer
@RequiredArgsContructor
public class UserService{
private final UserRepository userRepository;
public User createUser(User user){
return userRepository.save(user);
}
public Optional<User> getUserById(Long id){
return userRepository.findById(id);
}
}
- Infrastructure Layer (다양한 Notification 어댑터)
// Email notification plugin
@RequiredArgsConstructor
public class EmailNotification implements Notification{
private final EmailService emailService;
@Override
public void send(String message){
emailService.sendEmail("noreply@test.com", "Welcome to our platform!", message);
}
}
// SMS notification plugin
@RequiredArgsConstructor
public class SmsNotification implements Notification{
private final SmsService smsService;
@Override
public void sned(String message){
smsService.sendSms("+1234567890", message);
}
}
위 예제에서는 'Notification' 인터페이스는 알림을 보내는 다양한 방식의 플러그인을 위한 포트로 동작합니다.
애플리케이션은 이 포트를 통해 알림을 보내며, 구체적인 방식(Email, SMS)은 선택된 어댑터에 따라 달라집니다.
새로운 알림 방식을 도입하려면 새로운 어댑터만 추가하면 됩니다.
Layered Architecture와 Hexagonal Architecture 차이점
Layered Architecture
구조
- Presentation Layer: UI와 사용자 상호 작용을 담당합니다.
- Application Layer(또는 Service Layer): 사용 사례와 비즈니스 로직을 실행합니다.
- Domain Layer: 비즈니스 엔티티와 규칙을 포함합니다.
- Data Layer: 데이터베이스와의 상호 작용을 담당합니다.
장점
- 명확하게 분리된 계층으로 인해 초기 개발자들이 쉽게 이해하고 개발할 수 있습니다.
- 각 계층은 특정한 책임을 갖기 때문에 모듈화 및 재사용성이 좋습니다.
단점
- 가장 큰 문제점은 계층간의 강한 종속성입니다. 예를 들어, Presentation Layer는 Application Layer에, Application Layer는 Domain Layer에 의존하게 됩니다.
- 외부 시스템과의 통신이 주로 Data Access Layer에서만 이루어지기 때문에, 다양한 외부 시스템과의 연동이 필요할 경우 유연성이 떨어 질 수 있습니다.
Hexagonal Architecture (Ports and Adapters Architecture)
구조
- Innermost Hexagon: 핵심 비즈니스 로직이 위치합니다.
- Ports: 애플리케이션과 외부와의 상호 작용을 정의합니다(Interface)
- Adapters: 특정 외부 기술(DB, WebServer, Message Queue ...)를 위한 구현체입니다.
장점
- 외부 시스템과의 연동이 중앙의 비즈니스 로직에서 완전히 분리됩니다. 이를 통해 핵심 로직의 테스트와 유지보수가 쉬워집니다.
- 다양한 외부 시스템과의 연동이 필요할 경우에도 Hexagonal Architecture는 유연하게 대응 가능합니다.
단점
- Layered Architecture 보다는 상대적으로 학습 곡선이 존재할 수 있습니다.
아키텍처를 사용하기 좋은 상황
- Layered Architecture: 초기 개발 속도가 중요하거나 팀이 전통적인 계층화된 아키텍처에 익숙할 경우 좋습니다. 빠른 프로토타이핑이나 MVP 개발에 적합할 수 있습니다.
- Hexagonal Architecture: 애플리케이션이 다양한 외부 시스템과의 연동을 요구하거나, 유지 보수와 확장성이 중요한 경우에 적합합니다.
- 예를 들어, 같은 비즈니스 로직을 다양한 UI(WEB, Mobile, Desktop) 또는 다양한 데이터 소스 (RDBMS, NoSQL, 웹 서비스 등)에 적용해야 할 때 유용합니다.
예시
소셜 미디어 앱 개발
- Layered Architecture: 초기 MVP를 빠르게 출시하려면, Layered Architecture를 사용해 프론트엔드, 백엔드 서비스, 데이터베이스를 명확하게 분리하여 개발 할 수 있습니다.
- Hexagonal Architecture: 나중에 앱이 성장하면서 다양한 알림 매커니즘이 필요하거나 다양한 데이터베이스나 서드파티 서비스와의 통합이 필요해진다면, Hexagonal Architecture를 통해 이러한 외부 시스템과의 통합을 유연하게 관리할 수 있습니다.