멀티모듈 + 헥사고날 아키텍처 프로젝트에 적용하기

개발하는 구황작물·2024년 10월 10일
0

디프만

목록 보기
2/6

디프만에서 진행하는 팀 프로젝트에서 멀티모듈과 헥사고날 아키텍처를 적용하기로 하였습니다.

단일 모듈 + 레이어드 아키텍처에서 멀티모듈 + 헥사고날 아키텍처로 전환하면서 겪었던 과정에 대해 공유하도록 하겠습니다.

1. 개선된 레이어드 아키텍처

우리가 보통 사용하는 아키텍처는 아래와 같은 레이어드 아키텍처 일 것입니다.

간단하게 레이어드 아키텍처를 설명하자면 소프트웨어 시스템을 관심사 별로 여러 계층으로 분리한 아키텍처를 뜻합니다.

  • Presentation 계층(Controller) : 클라이언트와 직접적으로 연결되는 부분으로 사용자의 요청/응답을 처리합니다.
  • Application 계층(Service) : 비즈니스 로직을 구현하는 부분입니다.
  • Infrastructure 계층(Repository) : 데이터베이스와 상호작용을 하는 부분입니다.

레이어드 아키텍처는 적용이 쉽다는 장점이 있으나 아래와 같은 단점이 있습니다.

  • Persistence 계층이 최상위 의존성을 가지고 있어 데이터베이스 설계 위주로 개발을 하게 됩니다.

  • 이로 인해 Presentation 계층의 의존도가 높아지게 되고 해당 계층의 변화는 전체 어플리케이션에 영향을 주게 됩니다. (가령 JPA -> JDBC로 바꾸게 된다면 Entity 부터 Service 까지 많은 곳에 영향을 주게 될 것입니다. )

  • 비즈니스 로직을 주로 서비스에서 다루게 되어 서비스의 책임이 커지게 됩니다.
    너무 많은 의존성으로 인해 Unit 테스트를 하기 어려워집니다.

이러한 문제점이 있다는 사실을 팀 내에서 알고 있어 기존의 레이어드 아키텍처를 적용하기 보단 헥사고날 아키텍처를 도입하자는 의견이 나왔으나 러닝커브를 고려하여 점진적으로 헥사고날 아키텍처를 적용하자는 의견이 나왔습니다.

Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트 를 참고하였습니다.

개선된 레이어드 아키텍처는 기존의 레이어드 아키텍처와 비슷하나 차이점이 있습니다.

  1. 영속성 객체에서 도메인을 분리하였습니다.
  2. Service가 구체화 된 Repository에 직접 의존하는 대신, 추상화 된 Repository Interface에 의존하도록 설계하였습니다. (의존성 역전)
public interface MemberRepository { // 추상화 된 Repository
    Member save(Member member);
    ...
}
@Repository
@RequiredArgsConstructor // 추상화 된 Repository 구체화
public class MemberRepositoryImpl implements MemberRepository {
 private final MemberJpaRepository memberJpaRepository;

 @Override
 public Member save(Member member) {
  return memberJpaRepository.save(MemberEntity.from(member)).toModel();
 }
  ...
}
@RequiredArgsConstructor
@Transactional
@Service
public class MemberServiceImpl implements MemberService {
 private final MemberRepository memberRepository; // 추상화 된 Repository에 의존

 @Override
 public Member save(MemberCreateDto memberCreate) {
  ...
 }
}
  1. Controller 도 마찬가지로 구체화 된 Service에 의존하지 않고 추상화된 Service Interface에 의존하도록 설계하였습니다.
public interface MemberService {
    Member save(MemberCreateDto memberCreate);
    ...
}
@Tag(name = "사용자(members)")
@RestController
@RequestMapping("/member")
@RequiredArgsConstructor
public class MemberController {
 private final MemberService memberService;

 @PostMapping
 public void save(@RequestBody MemberCreateDto memberCreate) {
  return memberService.save(memberCreate);
 }
}

개선된 레이어드 아키텍처 덕분에 아래와 같은 장점을 얻게 되었습니다.

  • 영속성 객체와 도메인을 분리시킬 수 있게 되어 도메인 객체가 비즈니스 로직에만 집중할 수 있게 되었고 ORM 종속성을 최소화 할 수 있게 되었습니다.

  • 서비스에 많은 책임이 쏠리는 것을 방지하게 될 수 있었습니다.

  • 데이터베이스 연결 없이도 테스트를 할 수 있게 되어 테스트 작성이 쉬워졌습니다.

2. 멀티모듈 도입

이후 저희는 멀티모듈을 도입하기로 하였습니다.

기능이 추가될 수록 동일한 코드가 필요해질 때가 발생 (ex : Batch) 했기 때문입니다.

결국 상의 결과 아래와 같은 구조를 가지기로 하였습니다.

├── module-presentation  # API 게이트웨이 서버
├── module-batch  # 배치 서버
├── module-independent  # 독립 모듈
├── module-domain  # 도메인 모듈
└── module-infrastructure  # 외부 모듈
      └── persistence-database # 데이터베이스 모듈

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.1'
    id 'io.spring.dependency-management' version '1.1.5'
}

allprojects {
    apply plugin: 'java'

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }

    dependencies {
        // 필요한 의존성 추가
    }

    tasks.named('test') {
        useJUnitPlatform()
    }
    ...
}

module-domain의 build.gradle

bootJar { enabled = false }
jar { enabled = true }

dependencies {
    implementation project(':module-independent')
    ... // 필요한 의존성 추가
}

module-presentation의 build.gradle

jar { enabled = false }

dependencies {
    implementation project(':module-domain')
    implementation project(':module-independent')
    implementation project(':module-infrastructure:persistence-database')
    ... // 필요한 의존성 추가
}

여기서 module-presentation, module-batch에서는 jar { enabled = false }를 적용하고 이외의 모듈에는 bootJar { enabled = false } jar { enabled = true }를 적용해주었습니다.

  • bootJar : java -jar로 실행될 수 있는 Executable Archive (어플리케이션 실행에 필요한 모든 의존성 포함)

  • jar : 실행되지 않는 Plain Archive (어플리케이션 실행에 필요한 의존성을 제외한 리소스 파일과 빌드된 소스코드의 클래스 파일)

이로 인해 중복되는 공통 코드를 없앨 수 있었고, 빌드 시간을 감소시킬 수 있습니다.

(추후 이로 인해 Test 작성에 문제점이 발생하게 되는데 해결 방법은 나중에 올리도록 하겠습니다.)

3. 헥사고날 아키텍처로 전환

이후 저희 서버 파트는 한 발 더 나아가 헥사고날 아키텍처로 전환하기로 결정을 하였습니다.

헥사고날 아키텍처에 대해 설명하자면, 여러 소프트웨어 환경에 쉽게 연결할 수 있도록, 느슨하게 결합된 어플리케이션 구성 요소를 만드는 것을 목표로 하는 아키텍처입니다.

도메인과 비즈니스 로직을 외부로부터 분리하고, 포트와 어댑터로 외부와 소통하여 포트&어댑터 아키텍처라고도 합니다.

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

Port : 어플리케이션 코어와 외부 세계(Adapter)를 연결하는 역할을 합니다. (Usecase 포함)

Port에는 2가지 유형이 있습니다다.

  1. Input Port : 외부 요청이 어플리케이션 코어로 들어오는 경로(ex : Controller - Service 사이의 인터페이스 - Usecase)

  2. Output Port : 어플리케이션 코어가 외부 세계로 서비스를 제공하는 경로 (ex : Service와 Repository 사이의 인터페이스)

Adapter : 외부 기술, 프레임워크에 의존하는 로직을 담당하며, Port를 통해 어플리케이션 코어와 통신합니다.

Adapter에는 2가지의 유형이 있습니다.

  1. Primary Adapter(Driving Adapter) : 외부 시스템으로부터 들어오는 요청을 Input Port를 통해 어플리케이션 코어로 전달 (ex : Controller)

  2. Secondary Adapter(Driven Adapter) : 어플리케이션 코어에서 Output Port를 통해 외부로 데이터를 전달 (ex : Repository)

근데 얼핏 보기에 기존에 저희가 처음 도입한 개선된 레이어드 아키텍처와 많이 비슷하다는 것을 알 수 있을 것입니다.

개선된 아키텍처에서 클래스 이름 변경해주고 다이어그램만 살짝 이동 시켜주면 바로 헥사고날 아키텍처로 전환할 수 있습니다.

덕분에 생각했던 것 보다 더 빠르게 헥사고날 아키텍처로 전환을 할 수 있게 되었습니다.

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/memory")
public class MemoryController implements MemoryApi {
    private final MemoryFacade memoryFacade;

    @PostMapping
    @Logging(item = "Memory", action = "POST")
    public ApiResponse<MemoryCreateResponse> create(
            @LoginMember Long memberId,
            @Valid @RequestBody MemoryCreateRequest memoryCreateRequest) {
        MemoryCreateResponse response = memoryFacade.create(memberId, memoryCreateRequest);
        return ApiResponse.success(MemorySuccessType.POST_RESULT_SUCCESS, response);
    }
}

Facade

이 포스팅에서는 설명하지 않았으나 저희는 퍼사드 패턴도 적용을 하였습니다.

@RequiredArgsConstructor
@Transactional
@Service
public class MemoryFacade {
    private final CreateMemoryUseCase createMemoryUseCase;
    ...

    @Transactional
    public MemoryCreateResponse create(Long memberId, MemoryCreateRequest request) {
        ...
        Memory newMemory = createMemoryUseCase.save(writer, MemoryMapper.toCommand(request));
        Long memoryId = newMemory.getId();
        ...
        return MemoryCreateResponse.of(month, rank, memoryId);
    }
}

Facade 계층에서 UseCase(Input Port)를 통해 비즈니스 로직과 소통하게 됩니다.

UseCase & Service

public interface CreateMemoryUseCase {
    Memory save(Member member, CreateMemoryCommand command);
}
@Service
@RequiredArgsConstructor
public class MemoryService
        implements CreateMemoryUseCase, ... {
    private final MemoryPersistencePort memoryPersistencePort;
    ...

    @Transactional
    public Memory save(Member writer, CreateMemoryCommand command) {
        ...
        return memoryPersistencePort.save(memory);
    }
}

Usecase로부터 요청을 전달 받은 Service는 PrsistencePort(Output Port)를 통해 Repository와 소통하게 됩니다.

PersistencePort & Repository

public interface MemoryPersistencePort {
    Memory save(Memory memory);
    ...
}
@Repository
@RequiredArgsConstructor
public class MemoryRepository implements MemoryPersistencePort {
    private final MemoryJpaRepository memoryJpaRepository;
    ...

    @Override
    public Memory save(Memory memory) {
        return memoryJpaRepository.save(MemoryEntity.from(memory)).toModel();
    }
    ...
}

Domain & Entity

@Getter
public class Memory {
    private Long id;
    ...

    @Builder
    public Memory(
            Long id,
            ...) {
        this.id = id;
        ...
    }

    public Memory update(Memory updateMemory) {
        ...
    }
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table
public class MemoryEntity {
    @Id
    @Column(name = "memory_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    ...
}

마치며

헥사고날 아키텍처를 적용하면서 아래와 같은 장단점을 경험할 수 있게 되었습니다.

장점

모듈간 결합도가 낮아져 구성 요소를 쉽게 교체할 수 있게 되었습니다.

관심사의 분리로 코드의 이해가 쉬워지고 유지 보수성이 올라갔습니다.

목업 객체를 더 쉽게 만들 수 있게 되어 테스트를 더 안정적으로 할 수 있게 되었습니다.

단점

코드량이 많아졌습니다(…)

디프만에서 프로젝트를 진행하면서 기능이 추가됨에 따라 헥사고날 아키텍처의 장점이 빛을 발하게 되었습니다. 무엇보다도 좋았던 것은 테스트 코드 작성이 더 간편해졌다는 것이었습니다.

기존의 레이어드 아키텍처에서 서비스 계층을 테스트 하려면 H2를 작동 시키거나 Mockito를 활용하여 가짜 Mock 객체를 주입하고 서비스 내부의 Repository 로직들도 구현을 해야 했으나 PersistencePort 인터페이스를 상속받은 FakeRepository를 구현하여 더욱 쉽게 테스트 코드를 작성할 수 있게 되었습니다.

그러나 이전보다는 더 많은 코드량을 요구하게 되었고, 만약 저희가 개선된 레이어드 아키텍처가 아닌 기존의 레이어드 아키텍처에서 전환을 시도했으면 더 많은 시간을 투자해야 했을것입니다.

따라서 개인적으로 헥사고날 아키텍처 사용에 매우 만족하였으나 각자의 상황에 맞게 아키텍처를 도입하는 것이 좋다는 것을 깨닫게 되었습니다.

profile
어쩌다보니 개발하게 된 구황작물

0개의 댓글