[Spring] 헥사고날 아키텍처 도입기: 계층형에서 헥사고날 아키텍처로

도입한 이유

 좋은 설계는 변경에 유연하게 대응할 수 있도록 도와줍니다.

 

 기존의 코드는 계층형 아키텍처를 채택하면서 핵심 로직을 주로 도메인과 서비스에 몰아놓은 결과, 유지보수성이 낮았습니다. 특히, 일부 서비스는 많은 의존성 주입을 필요로 하며, 새로운 기능이 추가될수록 클래스가 점점 두꺼워졌습니다.

 프로젝트가 확장되면서, 강하게 결합된 부분을 변경해야 하는 경우가 생기면서 여러 사이드 이펙트를 겪게 되었습니다.

 

 따라서 큰 책임을 지닌 객체를 분리하고, 상속과 인터페이스 등을 활용하여 추상화를 도입하고, 다른 디자인 패턴을 적용함으로써 프로젝트의 유지보수성을 높이기로 결정했습니다.

 물론, 기존의 계층형 아키텍처도 특정 기술에 대한 의존성을 인터페이스를 통해 분리할 수 있습니다. 실제로 확장성을 늘리기 위해 고려할 것은 아키텍처 이전에 인터페이스와 상속을 적절하게 활용하는 것입니다.

 

  헥사고날 아키텍처를 도입한 이유는 어떤 레이어에 어떤 로직을 작성할지를 명확하게 그리고 더 작은 책임을 가질 수 있도록 분리하고 싶었기 때문입니다.

 기존 프로젝트의 문제는 도메인과 서비스에 많은 역할을 부여한 것입니다. 이를 분리하기 위한 명확한 기준을 만들고, 이 과정에서 헥사고날 아키텍처의 느슨한 결합을 강제하는 특징을 적용함으로써 추상화 기반 설계를 강화할 수 있습니다.

 

 헥사고날 아키텍처는 특정 기술의 의존성을 port-adapter를 통해 강제합니다.

 

이는 하위 계층에서 사용되는 기술이 상위 계층에 노출되지 않도록 합니다. 예를 들어, persistence 계층의 세부사항이 비지니스 로직에 노출되지 않도록 합니다. 이러한 구조는 하위 계층의 기술이 변경되거나 코드를 수정해도 다른 계층의 변경이 필요하지 않다는 장점이 있습니다. 만약 기술이 변경된다면 configuration 관련 부분만 수정하면 될 것입니다.

 

명확한 관심사의 분리

  • 외부와 연결에 문제가 생기면? 어댑터
  • 인터페이스는? 포트
  • 처리 중간에 EventBridge에 이벤트를 보내거나 트레이스 로그를 심고 싶다면? 서비스
  • 비지니스 로직이 제대로 동작하지 않는다면? 도메인 모델

 

 컨트롤러는 유스케이스 인터페이스만 의존하며, 이 인터페이스를 통해 비지니스 로직이 정의됩니다. 서비스에서 유스케이스를 구현합니다. 어댑터는 DB를 포함한 외부와의 소통을 담당합니다. 이는 비지니스 로직과 외부 요소를 분리함으로써 유연성과 테스트 용이성을 향상할 수 있습니다. 문제가 발생하면 관련된 레이어만 확인하면 됩니다.

 

기존 코드와 수정된 코드를 비교해 보자

이번에는 ‘이메일 발송 기능’을 계층형 아키텍처와 헥사고날 아키텍처의 경우를 나누어서 비교해 보겠습니다. 프로젝트에서는 구글 smtp 메일 서버를 사용해서 이메일을 발송합니다. 이는 다른 메일 서버로 변경 가능성이 있습니다.

 

 

계층형 아키텍처 적용 코드

// EmailService.java
@Service
@RequiredArgsConstructor
public class EmailService {
	private final JavaMailSender mailSender;
	private final String adminMail;
	private final String idormLogoImageUrl;
	private final EmailRepository emailRepository;
	
	// ...
	
	public void sendVerificationEmail(Email email) {
      try {
          MimeMessage mimeMessage = javaMailSender.createMimeMessage();

          mimeMessage.addRecipients(MimeMessage.RecipientType.TO, email.getEmail());
          mimeMessage.setSubject("[idorm] 인증 코드: " + email.getCode(), "utf-8");

          String emailContent = createEmailContent(email.getCode());

          mimeMessage.setText(emailContent, "utf-8", "html");
          mimeMessage.setFrom(adminMail);

          javaMailSender.send(mimeMessage);
      } catch (MessagingException e) {
          throw new CustomException(e, EMAIL_SENDING_ERROR);
      }
  }

  public String createVerificationCode() {
  	// 이메일 인증 코드 생성 로직
  }

  private String createEmailContent(String verificationCode) {
	// 이메일 html 템플릿 생성 로직
  }
}

 

 

 기존의 계층형 아키텍처에서는 EmailService 클래스에서 구글 smtp 관련 코드, EmailRepository에 대한 의존성까지 작성되어 있습니다. 이 경우에 core인 EmailService는 변경 가능성이 있는 외부 기술에 의존하게 됩니다. 

 

 예를 들어서, 만약 구글 메일 서버에서 다른 서버로 의존성이 변경된다면, 혹은 Jpa가 아닌 다른 Repository로 변경된다면, 혹은 이메일 인증 번호 생성하는 방식이 변경된다면, 이는 서비스 내의 코드의 변경을 일으킵니다.

 

 

같은 예시를 이번에는 헥사고날을 적용해서 변경해 보았습니다. 

 

아래는 변경된 이메일 패키지 구조입니다.

 

.
├── adapter
│   ├── in
│   │   └── web
│   │       └── EmailController.java
│   └── out
│       ├── EmailResponseCode.java
│       ├── api
│       │   ├── GoogleMailClient.java
│       │   └── MockMailClient.java
│       ├── exception
│       │   ├── DuplicatedEmailException.java
│       │   ├── EmailServerErrorException.java
│       │   ├── ExpiredEmailVerificationCodeException.java
│       │   ├── InvalidEmailCharacterException.java
│       │   ├── InvalidVerificationCodeException.java
│       │   └── NotFoundEmailException.java
│       └── persistence
│           ├── DeleteEmailAdapter.java
│           ├── EmailRepository.java
│           ├── LoadEmailAdapter.java
│           └── SaveEmailAdapter.java
├── application
│   ├── DefaultGenerateVerificationCode.java
│   ├── EmailService.java
│   └── port
│       ├── in
│       │   ├── EmailUseCase.java
│       │   └── dto
│       │       ├── EmailSendRequest.java
│       │       └── EmailVerifyRequest.java
│       └── out
│           ├── DeleteEmailPort.java
│           ├── GenerateVerificationCodePort.java
│           ├── LoadEmailPort.java
│           ├── SaveEmailPort.java
│           └── SendEmailPort.java
└── entity
    ├── Email.java
    └── EmailStatus.java

 

 

위의 패키지는 다음과 같이 의존성을 가지고 있습니다.

 

 

헥사고날 아키텍처 적용 코드

// EmailService.java
@Service
@RequiredArgsConstructor
public class EmailService implements EmailUseCase {

	private final GenerateVerificationCodePort verificationCodePort;
	private final SaveEmailPort saveEmailPort;

	@Override
	@Transactional
	public void sendVerificationEmail(EmailSendRequest request) {
		...
		Email email = new Email(request.email(), generateVerificationCode());
		saveEmailPort.save(email);
		sendEmailPort.send(email);
	}

	private String generateVerificationCode() {
		return verificationCodePort.generate();
	}
}

// SendEmailPort.java
public interface SendEmailPort {
    void send(Email email);
}

// GoogleMailClient.java
@Component
@Profile("dev & prod")
public class GoogleMailClient implements SendEmailPort {

	private final JavaMailSender mailSender;
	private final String adminMail;
	private final String idormLogoImageUrl;

	// ...
	
	@Override
	@Async
	public void send(final Email email) {
		MimeMessage message = mailSender.createMimeMessage();
		try {
			message.setSubject("[idorm] 인증 코드: " + email.getCode());
			message.setText(generateEmailContent(email.getCode()), "UTF-8", "html");
			message.addRecipient(Message.RecipientType.TO, new InternetAddress(email.getEmail(), "아이돔", "UTF-8"));
		} catch (MessagingException | UnsupportedEncodingException e) {
			throw new EmailServerErrorException();
		}
		mailSender.send(message);
	}
	
	private String generateEmailContent(String verificationCode) {
		// 이메일 html 템플릿 생성
	}
}

// MockMailClient.java
@Component
@Profile("!dev & !prod")
public class MockMailClient implements SendEmailPort {
    @Override
    public void send(Email email) {
        // no-op
    }
}

 

 

 헥사고날 아키텍처에서는 OutputPort 인터페이스(SendEmailPort, SaveEmailPort)를 통해 외부 시스템과의 의존성을 분리합니다.

 

 외부 이메일 서버와 연결하는 로직은 GoogleMailClient 또는 MockMailClient 클래스에서 처리하며, OutputPort 인터페이스를 통해 메시지를 전송합니다.

 

 또는 DB 접근 로직은 SaveEmailPort.java 인터페이스를 통해 외부 기술에 접근하며, 로직은 SaveEmailAdapter.java에서 레포지토리 의존성을 가지고 DB 관련 로직을 처리하게 됩니다.

 

 기존의 코드는 이메일 서버 교체 시, EmailService를 수정해야 합니다. 반면에 헥사고날의 경우엔, OutputPort 인터페이스를 구현한 새로운 어댑터를 만들어 주입하면 됩니다.(기존: GoogleMailClient, 테스트: MockMailClient, 대체: NaverMailClient)

 

 이렇게 헥사고날 아키텍처는 외부 시스템과의 연결에서 유연성과 확장성을 제공합니다.

 

 

 또 다른 장점으로는 쉬운 테스트를 가능하게 합니다.

  • 자기 역할만 Port 기반 모킹을 통해 테스트
  • 비지니스 로직은 의존성이 없기 때문에 mocking이 거의 없다.

 본인이 담당하는 부분이 명확하기 때문에 그 부분만 테스트를 하면 되고 더 안쪽은 테스트하지 않아도 됩니다.

 

 또한 핵심 비지니스 로직을 담당하는 엔티티 비지니스 로직에서는 외부 의존성이 없기 때문에 모킹이 거의 없고, 거의 순수하게 비지니스 로직만 체크할 수 있습니다.

 

고려했던 트레이드오프 사항

core 내부에 JPA 의존성을 준 이유

 헥사고날의 가장 큰 장점 중 하나는 헥사고날 아키텍처에서 도메인은 POJO이며, DB 연관관계를 위한 불필요한 의존이나 필드가 존재하지 않는다는 것입니다. 도메인에는 JPA 연관관계 편의 메서드 등이 존재하지 않고, 도메인을 비지니스 로직에만 집중하는 형태로 가져갈 수 있습니다. 그렇기 때문에 헥사고날 아키텍처는 도메인 내부에 java 이외의 의존성을 배제합니다.

 

 하지만, core 내부에 JPA 의존이 존재하도록 결정했습니다. 해당 프로젝트에서 Spring과 JPA는 다른 framework로 변경될 여지가 거의 없다고 생각했기 때문입니다.

 변경 가능성은 예상불가이기에 고려하는 것이 좋지만, JPA는 많은 생산성과 편리성을 줍니다.

 

따라서, 아래 2개의 트레이드오프를 고려해 보았습니다.

  • JPA의 dirty checking 기능을 사용하지 않고, 변경이 발생할 때마다 저장을 한다. 엔티티와 도메인을 분리해서 DB 접근해야 할 때마다 매퍼를 통해 엔티티로 변환하고 접근 후 응답이 필요하다면 도메인으로 변환한다.
  • JPA의 변경 가능성을 감수하고 JPA가 주는 이점을 채택한다.

 

결론적으로, 기술의 변경 가능성을 고려해 보았을 때, 낮은 변경 가능성을 감수하고 JPA 기능을 사용하는 것이 좋다고 생각했습니다.

 

입력 유효성 검증의 위치

 헥사고날에서는 입력 유효성 검증을 도메인 로직으로 생각하지 않고, 유효성 검증을 core 내부 in-port의 매개변수(Service의 Request) 객체에서 진행합니다.

 

 그러나 이 방식대로라면, 도메인에서는 입력 값에 대한 유효성 검증을 하지 않기 때문에, 도메인 생성 시 생성되는 값에 대한 무결성이 보장되지 않는다고 생각합니다.

 따라서 기본적인 유효성 검사는 Bean Validation API를 사용했으며, 그리고 이는 ArgumentResolver에 의해 검증 로직이 실행되기 때문에 Spring에 의존하게 됩니다. 그리고 도메인 생성 시 생성자를 통해 구체적인 유효성 검증을 하도록 구현했습니다.

 

결론

헥사고날의 핵심은 '유지보수성'입니다.

 

헥사고날을 도입하면서, 코어에 외부 기술에 대한 의존성을 제거함으로써 유연하게 외부 기술에 대한 코드를 변경할 수 있게 되었습니다. 또한, 계층형보다 더 많은 패키지와 파일을 만들면서 기존의 3 계층보다 더 명확하게 역할을 분리할 수 있게 되었습니다.

 

그러나 헥사고날로 변경하면서 이런 구조로 바꾸기 위해 생각보다 더 큰 시간이 들었고, 일반적인 헥사고날 아키텍처를 그대로 적용하기엔, 오히려 더 생산성이 저하되는 문제가 있었습니다. 따라서 현재 적용해야 하는 프로젝트의 상황에 맞게 트레이드오프 사항을 진지하게 따져보고 결정하는 것의 중요성을 배웠습니다.

 

끝으로 port-adapter 구조의 또 다른 큰 장점으로, 헥사고날 아키텍처의 창시자인 Alistair Cockburn이 말하기를 '테스트 작성의 편의성'이라 주장하고 있습니다. 다음에는 헥사고날의 요소 별 테스트를 작성해 보는 시간을 가지겠습니다.

 

출처:

https://reflectoring.io/spring-hexagonal/

https://www.youtube.com/watch?v=MKfSLrwLex8
만들면서 배우는 클린 아키텍처