개발/대용량트래픽

Hexagonal Architecture 완전 가이드: Ports와 Adapters로 구현하기

난중후니 2024. 1. 25. 21:12
728x90
반응형

핵사고날 아키텍처(Hexagonal Architecture)란?

출처: https://medium.com/@luishrsoares/whats-hexagonal-architecture-6da22d4ab600

  • 애플리케이션의 핵심 로직을 외부 요소들로부터 격리시키는 것에 중점을 둔 아키텍처입니다.
  • 레이어 간의 원하지 않는 종속성이나 비즈니스 로직으로 인한 사용자 인터페이스 코드의 오염과 같은 객체 지향 소프트웨어 설계의 알려진 구조적 함정을 피하기 위해 Alistair Cockburn에 의해 발명되었습니다.
  • Ports and Adapters Architecture 라고도 불립니다.

Hexagonal Architecture의 주요 개념

  1. Central Domain
    애플리케이션의 핵심 비즈니스 로직이 위치합니다.
    순수하게 비즈니스 규칙에만 집중하며, 특정 Infrastructure나 framework, 외부 시스템에 대한 정보를 포함하지 않습니다.
  2. Ports
    ports는 애플리케이션의 핵심 비즈니스 로직과 시스템의 외부 요소(db, web server, messaging system) 사이의 인터페이스를 정의합니다.
    핵심 로직이 필요로하는 작업과 외부에 제공해야 하는 기능을 명세화합니다.
  • 예시로는 JPA에서 Book이라는 Entity와의 연동시 사용할 interface를 구현한 아래의 코드와 같습니다.
public interface BookRepository{
    Optional<Book> findById(Long id);
    void save(Book book);
}
  • 인바운드 포트: 외부에서 내부 서비스로 들어올 때 사용될 인터페이스입니다.
  • 아웃바운드 포트: 외부 API, 데이터 등에 사용될 인터페이스입니다.
  1. 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

구조
  1. Presentation Layer: UI와 사용자 상호 작용을 담당합니다.
  2. Application Layer(또는 Service Layer): 사용 사례와 비즈니스 로직을 실행합니다.
  3. Domain Layer: 비즈니스 엔티티와 규칙을 포함합니다.
  4. 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를 통해 이러한 외부 시스템과의 통합을 유연하게 관리할 수 있습니다.
728x90
반응형