Post

최적의 모듈화 구조를 찾기 위한 삽질기

모듈화를 위한 긴 여정

이번 Tour Korea 토이 프로젝트를 진행하며, 모듈화와 MSA를 적용하는 소중한 경험을 했습니다. 많은 기술 블로그와 책을 참고하며 모듈화의 첫걸음을 떼었고, 그 과정에서의 시행착오를 공유합니다.

🔍 어떻게 모듈화 할 것인가?

모듈화의 방법은 프로젝트의 특성과 모듈의 크기에 따라 다양합니다. 제가 참고한 기술 블로그와 서적마다 제시하는 방법이 달랐습니다. 이에 저만의 목표를 가지고 모듈화를 진행해보았습니다.

저의 모듈화의 목적은 다음과 같습니다.

  1. 한 번 모듈을 만들면 다른 프로젝트에서도 재활용하고 싶다. 정확히는 도메인 단위, 기능 단위로 재활용.
  2. 코드를 변경했을 때 전파되는 영향도를 최소화하고 싶다.

이를 위해서 다음 기준으로 모듈화를 진행했습니다.

하나의 책임, 독립성, 재활용

🔍 첫 번째 시도

first_module.png

  • 도메인을 Aggregate 즉, 논리적으로 관련된 객체들로 그룹화하여 분리하였습니다. 여기서 도메인은 프로그래밍으로 해결하고자 하는 주제를 의미합니다.
  • 공통적으로 사용되는 Security, Exception 처리, API 응답 클래스, Config 클래스 등은 Core-Service에 위치시켰습니다.

문제점

  1. 다른 모듈에서 공통적으로 사용되는 의존성을 Core-Service에 추가하다 보니 불필요한 의존성을 갖게 되었습니다.
    • Core-Service에 있는 OpenFeign 관련 소스들은 File-Service에서는 사용하지 않음에도 불구하고 의존성을 갖게 됩니다.

first_folder.png

  1. 계층 별로 완벽한 캡슐화를 달성하기 어려웠습니다. 실제로 리팩토링 하는 과정에서 계층의 관계가 모호해지고 있음을 확인하였습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
     public void sendPasswordResetEmail(String email) {
         // 이메일 토큰을 생성하고 저장하는 로직
         var link = UUID.randomUUID().toString();
         emailTokenRepository.save(
                 EmailToken.builder()
                         .email(email)
                         .link(link)
                         .build()
         );
        
         // 이메일 템플릿을 만들고 이를 전송하는 로직 
         var message = javaMailSender.createMimeMessage();
         MimeMessageHelper helper = null;
         try {
             helper = new MimeMessageHelper(message, true);
             helper.setTo(email);
             var context = new Context();
             context.setVariable("link", link);
             var html = templateEngine.process("PasswordEmailForm.html", context);
             helper.setText(html, true);
             javaMailSender.send(message);
         } catch (MessagingException e) {
             throw new RuntimeException(e);
         }
     }
    

다음 코드의 문제는 다음과 같습니다.

  1. 이메일 토큰을 생성하고 저장하는 로직이 이메일을 전송하는 로직과 같은 레이어에 존재한다.
  2. 코드를 읽을 때 세부로직을 하위 레이어에 위임해야 코드가 읽기 쉬워지는데 이를 달성하지 못했다.

🔍 두 번째 시도

첫 번째 시도에서 겪은 문제점을 해결하기 위해 세부 모듈화를 진행하였습니다.

  • Core-Service를 작은 단위인 Global Utils, Component로 세부 모듈화 하였습니다. 이를 통해 필요한 의존성만 재활용 하고자 했습니다.
  • 각각의 도메인안에서도 폴더 구조를 api, bridge, domain으로 세부 모듈화하였습니다. 이를 통해 계층의 역할을 엄격하게 구분하고자 했습니다.

second_module.png

✅ ️Global Utils

  • 모든 프로그램에 공통적으로 사용될 수 있는 코드로 Pure Java로만 구성됩니다.
  • “가능한 사용하지 않는다” 를 기본 원칙으로 합니다.
  • Utils 클래스, 응답 코드

✅ Component

  • Pure Java로 사용할 수는 없지만, 많은 모듈에서 공통적으로 사용될 수 있는 모듈로 구성됩니다.
  • Jwt 모듈, Security 모듈, 예외 처리 모듈

✅ Application

  • global utils, component 모듈을 사용하여 서비스를 구성합니다.
  • UserService, PostService, FileService
  • 서비스 안에서의 의존성 격리와 유지보수성 증가를 위해 Application 모듈도 다시 모듈화하였습니다. 총 세 가지 하위 모듈을 가집니다.

    API 레이어

    • Controller: 사용자의 요청을 받고 응답을 반환하는 역할을 담당합니다.

    Bridge 레이어

    • API와 Service 사이에서 데이터를 가공하거나 변환하는 역할을 담당합니다.

    Domain 레이어

    • Entity, Repository, Service를 정의합니다.

👍 장점

  1. 모듈 세분화를 통해 필요한 의존성만 가져올 수 있으며 이를 통해 의존관계 파악도 용이해졌습니다.
    • file-service의 gradle 파일
      1
      2
      3
      4
      5
      6
      7
      8
      
      dependencies {
          implementation project(':global-utils')
          implementation project(':component-exception')
          implementation project(':component-security')
          implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
          testImplementation 'org.springframework.boot:spring-boot-starter-test'
          implementation 'com.amazonaws:aws-java-sdk-s3:1.12.683'
       }
      
  2. 세부 로직을 하위 레이어에 위임하게 강제함으로써 더 읽기 편한 코드가 된다.
    • 이전에는 emailTokenService의 로직과 mailSenderService의 로직이 한 함수에 존재했지만, 이를 위임함으로써 더 읽기 쉬운 코드가 되었습니다.
    • 물론 이전 구조에서도 해당 로직을 구성할 수 있지만, 이를 강제한다는 점에서 의미가 있습니다.
    1
    2
    3
    4
    5
    6
    7
    8
    
     public void sendPasswordResetEmail(PasswordResetRequestForm form) {
         var link = UUID.randomUUID().toString();
         EmailToken emailToken = emailTokenService.save(
                 EmailToken.of(link)
         );
            
         mailSenderService.sendPasswordResetEmail(emailToken.getEmail(), emailToken.getLink());
     }
    
  3. 레이어 간의 디커플링이 심해 코드의 양이 많아지지만 전파되는 영향도를 최소화할 수 있습니다.
    • 각 레이어는 하위 레이어에만 의존성을 가지기 때문에 domain계층에서 bridge 계층에 정의된 RegisterRequest를 사용할 수 없습니다.
    • bridge에서 인코딩한 패스워드를 DTO클래스에 담아 넘겨주기 때문에 domain 레이어에서 사용자 회원가입에 더 집중할 수 있습니다.
    1
    2
    3
    4
    5
    6
    7
    
     public UserDetail registerUser(RegisterRequest form) {
         UserDTO.RegisterDTO registerDTO = form.toDTO(encodePassword(form.password()));
        
         User user = userService.registerUser(registerDTO);
        
         return UserDetail.toResponse(user);
     }
    

🔍 세 번째 시도

불편한 문제

두 번째 방법을 통해 의존성을 최소화하고 레이어 간의 전파 범위를 최소화할 수 있었습니다. 하지만 다음과 같은 문제도 존재했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// userserivce-bridge
dependencies {
    implementation project(":component-email")
    implementation project(":component-jwt")
    implementation project(":userservice-domain")
    implementation project(":global-utils")
    implementation project(":component-security")
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
    implementation 'org.springframework.boot:spring-boot-starter-mail'
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
}

// userservice-domain
dependencies {
    implementation project(":component-jwt")
    implementation project(":component-exception")
    compileOnly 'org.springframework.boot:spring-boot-starter-security'
    runtimeOnly 'com.mysql:mysql-connector-j'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}
  • 도메인내에서 의존성을 중복해서 가져와야 하는 불편함
  • 불필요하게 작성해야 할 코드의 증가
    • api 레이어에서 bridge 레이어로 request를 넘겨줄 때 새로운 DTO를 만들어 넘겨주어야 한다.(bridge에서는 api로의 의존성이 없기 때문)
  • 도메인별로 유리한 구조가 다르다. 간단한 서비스의 경우 bridge 레이어가가 필요하지 않다.

해결책

  • Application 모듈안에서 세부 모듈화를 하지 않는다. 즉, 모듈화의 범위를 넓힘으로써 불필요한 코드 작성을 줄일 수 있습니다.
  • 공통으로 사용될 모듈은 component 폴더 하위에 위치시키며, 각각의 모듈은 하나의 기능만 담당합니다.
    • OpenFeign, Security, Exception, Email, Jwt

🔍 최종 모듈화 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
├── applications
│   ├── article-service
│   ├── build
│   ├── build.gradle
│   ├── discovery-service
│   ├── file-service
│   ├── gateway-service
│   ├── notification-service
│   └── user-service
├── component
│   ├── component-email
│   ├── component-exception
│   ├── component-jpa
│   ├── component-openfeign
│   ├── component-security
│   └── component-web
├── global-utils
├── gradle
├── settings.gradle
This post is licensed under CC BY 4.0 by the author.

© bear-su. Some rights reserved.