헥사고날 아키텍처 (Hexagonal Achitecture)

100 Views
한국어
#헥사고날#Hexagonal#설계

헥사고날 아키텍처란?

헥사고날 아키텍처는 포트라는 내/외부 연결 규격을 정의해서, 규격에만 맞다면 유연하게 교체가능하게 하는 아키텍처로 Alistair Cockburn 에 의해 제안되었습니다.

 

헥사고날 아키텍처의 장단점

장점

  1. 유연성: 외부 시스템이나 인프라와의 의존성을 낮추어, 구성 요소를 쉽게 교체하거나 업데이트할 수 있습니다.
  2. 테스트 용이성: 비즈니스 로직을 독립적으로 테스트할 수 있어 품질 향상과 개발 속도 향상에 도움이 됩니다.
  3. 유지보수성: 책임이 분리되어 있어, 코드의 이해와 수정이 용이하며, 변화에 빠르게 대응할 수 있습니다.

단점

  1. 복잡성: 구현을 위해서 포트를 정의하고 어댑터를 구현해야합니다. 단순구현보다 복잡성이 올라갑니다.
  2. 초기 개발 시간 증가: 초기에 아키텍처를 구축할 때 시간과 노력이 필요합니다.

 

뻔한 설명은 여기까지입니다.

 

헥사고날은 육각형이 아니다!

이런식으로 여러가지 도표와 어딜가든 비슷한 설명들은 우리를 헷갈리게 만듭니다.
이런 육각형 그림들이 더 복잡해보이게 합니다. 육각형이 아닙니다! 관련 전혀 없어요.

 

이제부터 진짜 헥사고날 아키텍처가 무엇인지 설명해드리겠습니다.

 

그 이전에 우리는 소프트웨어가 무엇인지 이해해야합니다.

 

소프트웨어는 무엇인가?

지금 살아가는 세상에 다양한 소프트웨어가 있습니다. 윈도우 맥과 같은 운영체제도 있고, 단순한 2D게임 부터 복잡하고 화려한 3D게임도 있습니다. 자판기에도 소프트웨어가 있죠. 전등과 전등스위치도 일종의 프로그램 입니다.

제각각의 소프트웨어들 무슨 공통점이 있을까요?

입력 -> 처리 -> 출력 의 과정을 가지고 있다는겁니다. 

 

모든 소프트웨어의 본질 입니다. 입력, 처리, 출력으로 구성되어 있죠. 

  • 전등
    • 입력: 스위치
    • 처리: 회로
    • 출력: 전등
  • 자판기
    • 입력: 구매 버튼
    • 처리: 결제 처리
    • 출력: 음료수 반출
  • 컴퓨터
    • 입력: 키보드. 마우스
    • 처리: 게임, 동영상
    • 출력: 모니터, 스피커

 

모든 소프트웨어는 위와 같은 구조로 되어 있습니다. 그리고 입력, 처리, 출력을 서로 연결하는 요소를 포트라고 하죠. USB 2.0, USB Type-C , HDMI 2.0 과 같은 것들이죠. 

이해가 쉽게 컴퓨터를 예를 들어 설명하겠습니다.

 

포트가 중요하다.

컴퓨터에는 다양한 포트가 존재합니다. 

이런 다양한 포트들은 단하나의 기기를 연결하기 위한게 아닙니다.

컴퓨터 포트 종류 HDMI, DP, DVI, VGA(RGB) 알아보기 : 네이버 블로그

USB 2.0 포트에는 마우스, 스피커, 마이크, 키보드 등 다양한 기기를 연결할 수 있죠. 각자 다른 기계들이지만 연결을 위해 포트 규격을 공유하고 합의해서 포트 규격에 맞게 기기를 제조하기 때문입니다.

덕분에 매번 다른 기기를 컴퓨터에 연결할 때마다 컴퓨터를 분해하고 개조하지 않아도 됩니다. 마우스마다 키보드마다 연결 포트가 다르다면 ... 포트에 맞게 본체도 바꿔줘야겠죠. 어마어마하게 불편합니다.

때문에 포트라는 내외부 통신규격을 딱 정해두고 포트에 맞게 기기를 설계하고 제조하는 것이죠.

 

포트가 주는 치명적 매력

자 소프트웨어도 똑같습니다.

  •  포트의 규격을 정의하고 그 포트에 맞게 소프트웨어를 개발하여 새 소프트웨어로 교체하고 싶을 때 새 소프트웨어도 포트에 맞게 개발하여 간편하게 바꿔 낄 수 있습니다. ( 유지보수성, 유연성 )
  • 테스트의 기준이 포트 규격이기 때문에  새 소프트웨어로 교체 했을 때 이전 소프트웨어랑 테스트 기준이 같습니다. 테스트가 용이해지는 것이죠. (테스트 용이성)

위와 같은 형태를 소프트웨어로 만든게 헥사고날 아키텍처 입니다.

 

헥사고날 아키텍처의 구성요소

헥사고날 아키텍처는 딱 정해진 형태가 없습니다. 핵심 철학으로 프로젝트 성격에 맞게 조금씩 변형해서 사용합니다. 일반적으로 부르는 용어도 조금씩 다릅니다. 같은 대상을 나타내는 용어가 다르다는 것이죠. 팀에서 활용한다면 용어를 합의하고 개발해야 할 것입니다. 

 

구성요소

아래 5개의 구성요소가 헥사고날 아키텍처의 전부 입니다. 
아래 5개의 구성요소에 프로젝트 성격에 맞게 다른 것들을 추가해서 사용하게 됩니다.

  • Presentation: 어플리케이션으로 요청, 접속, 전달, 통신을 위해 외부로 노출되는 엔드포인트
  • Inbound Port: 외부 소프트웨어로부터 어플리케이션으로 요청, 접속, 전달, 통신하기위해 맞춰야하는 공통 규격 정의
  • Outbound Port: 어플리케이션에서 외부 소프트웨어로 요청, 접속, 전달, 통신하기위해 맞춰야하는 공통 규격 정의
  • Inbound Adapter: Inbound Port를 통한 외부 소프트웨어로부터의 요청, 접속, 전달, 통신을 처리하는 구현체 입니다.
  • Outbound Adapter: Outbound Port를 통한 외부 소프트웨어로 요청, 접속, 전달, 통신을 처리하는 구현체 입니다.   

 

헥사고날 아키텍처의 처리 흐름

헥사고날 아키텍처의 입력, 처리, 출력까지 일련의 과정입니다.

 

Presentation Layer

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    @PostMapping
    public ResponseEntity<UserDto> createUser(@RequestBody CreateUserRequest request) {
        User user = userService.createUser(request.getName(), request.getEmail());
        return ResponseEntity.ok(UserDto.from(user));
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(UserDto.from(user));
    }
}

Presentation, 즉 외부로 노출되는 부분입니다. 외부에서 이 노출된 부분으로 요청을 하게 됩니다.

 

Inbound Port

public interface UserService {
    User createUser(String name, String email);
    User findById(Long id);
    void deleteUser(Long id);
}
public class User {
    private Long id;
    private String name;
    private String email;
    private LocalDateTime createdAt;
    
    public User(String name, String email) {
        this.name = name;
        this.email = email;
        this.createdAt = LocalDateTime.now();
    }
}


유저 관련 동작을 위한 양식입니다. createUser 동작을 위해서는 nameemail 이 필요하고 User 를 반환합니다.
interface 를 어떤 방식으로 구현하든 동작은 올바르게 해야합니다. 
Presentation 예시로는 Restful API를 받는 Controller 로 동작하고 있으나, gRPC 로 받든 Kafka Consume 으로 받든 TCP Socket 통신으로 받든 저 인터페이스를 통해 함수 호출하면 동일한 동작이 보장되는 것이죠. 😋

 

Inbound Adapter

@Service
@Transactional
public class UserServiceImpl implements UserService {
    
    private final UserRepository userRepository;
    private final EmailService emailService;
    
    @Override
    public User createUser(String name, String email) {
        // 비즈니스 로직
        if (userRepository.existsByEmail(email)) {
            throw new DuplicateEmailException("Email already exists");
        }
        
        User user = new User(name, email);
        User savedUser = userRepository.save(user);
        
        // 이메일 발송
        emailService.sendWelcomeEmail(savedUser.getEmail());
        
        return savedUser;
    }
    
    @Override
    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found"));
    }
    
    @Override
    public void deleteUser(Long id) {
        userRepository.deleteById(id);
    }
}

Inbound AdapterInbound Port의 구현체 입니다. 여기서 UserRepositoryEmailServiceOutbound Port 입니다.

 

Outbound Port

public interface UserRepository {
    User save(User user);
    Optional<User> findById(Long id);
    boolean existsByEmail(String email);
    void deleteById(Long id);
}

public interface EmailService {
    void sendWelcomeEmail(String email);
    void sendPasswordResetEmail(String email, String token);
}

Inbound Port가 외부에서 어플리케이션으로 요청을 받기 위한 인터페이스라면, Outbound Port는 어플리케이션이 외부로 요청하기 위한 인터페이스 입니다.

외부 소프트웨어가 어플리케이션의 요청을 받기 위한 인터페이스라고 생각하면 이해가 쉽습니다. (실제 동작은 살짝 다름)

 

Outbound Adapter

@Repository
public class JpaUserRepository implements UserRepository {
    
    private final UserJpaRepository jpaRepository;
    
    @Override
    public User save(User user) {
        UserEntity entity = UserEntity.from(user);
        UserEntity saved = jpaRepository.save(entity);
        return saved.toDomain();
    }
    
    @Override
    public Optional<User> findById(Long id) {
        return jpaRepository.findById(id)
            .map(UserEntity::toDomain);
    }
    
    @Override
    public boolean existsByEmail(String email) {
        return jpaRepository.existsByEmail(email);
    }
    
    @Override
    public void deleteById(Long id) {
        jpaRepository.deleteById(id);
    }
}

@Component
public class SmtpEmailService implements EmailService {
    
    private final JavaMailSender mailSender;
    
    @Override
    public void sendWelcomeEmail(String email) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(email);
        message.setSubject("Welcome!");
        message.setText("Welcome to our service!");
        
        mailSender.send(message);
    }
    
    @Override
    public void sendPasswordResetEmail(String email, String token) {
        // 비밀번호 재설정 이메일 로직
    }
}

Outbound AdapterOutbound Port를 구현한 구현체입니다. 실제로 각 외부 소프트웨어가 동작할 수 있게 그에 맞는 요청 로직을 구현합니다.

 

헥사고날 아키텍처는 사실 간단하다.

이게 다입니다.

어떤 방식으로 요청하든 일관적으로 모두 처리할 수 있습니다.

어떤 DB에 연결하든 DB에서 가져오던 데이터를 API로 바꿔서 API로 데이터를 가져 올 수도 있습니다.

Business Logic 변경 없이! ( 이게 매우 중요 )

 

헥사고날 아키텍처는

  1. 환경의 변화에 대한 내성이 매우 강력합니다. (헥사고날 아키텍처는 가장 내부에서 실제 판단하는 로직을 분리하고 격리하여 외부 환경에 영향을 받지 않게합니다.)
  2. Business Logic 을 완전 격리 했으므로, 완전 격리형 테스트인 Unit Test  작성에 매우 적합합니다.

 

헥사고날 아키텍처의 본질

PortAdapter 는 그저 용어일 뿐이고 위에 설명한 모든 것들은 많은 방법 중 하나입니다. 절대적인건 없습니다.

정말 중요하고 치밀하게 테스트되어야 하며 정확해야하는 로직을 외부환경에 영향받지 않고 강하면서 유연하게 만드는게 헥사고날 아키텍처의 본질이라 할 수 있습니다.

보통 헥사고날 아키텍처 라고 하면 육각형을 떠올리기 마련인데 사실 육각형과 관련 없습니다.

정말로 중요한 것은 바뀌지 않아야할 것과 바꿀 수도 있는 것을 철저하게 분리하는 것이죠. 


흥미롭게 읽으셨다면 좋아요 눌러주세요!

Related Posts