최적의 모듈화 구조를 찾기 위한 삽질기
모듈화를 위한 긴 여정
이번 Tour Korea 토이 프로젝트를 진행하며, 모듈화와 MSA를 적용하는 소중한 경험을 했습니다. 많은 기술 블로그와 책을 참고하며 모듈화의 첫걸음을 떼었고, 그 과정에서의 시행착오를 공유합니다.
🔍 어떻게 모듈화 할 것인가?
모듈화의 방법은 프로젝트의 특성과 모듈의 크기에 따라 다양합니다. 제가 참고한 기술 블로그와 서적마다 제시하는 방법이 달랐습니다. 이에 저만의 목표를 가지고 모듈화를 진행해보았습니다.
저의 모듈화의 목적은 다음과 같습니다.
- 한 번 모듈을 만들면 다른 프로젝트에서도 재활용하고 싶다. 정확히는 도메인 단위, 기능 단위로 재활용.
- 코드를 변경했을 때 전파되는 영향도를 최소화하고 싶다.
이를 위해서 다음 기준으로 모듈화를 진행했습니다.
하나의 책임, 독립성, 재활용
🔍 첫 번째 시도
- 도메인을 Aggregate 즉, 논리적으로 관련된 객체들로 그룹화하여 분리하였습니다. 여기서 도메인은
프로그래밍으로 해결하고자 하는 주제
를 의미합니다. - 공통적으로 사용되는
Security
,Exception 처리
,API 응답 클래스
,Config 클래스
등은Core-Service
에 위치시켰습니다.
문제점
- 다른 모듈에서 공통적으로 사용되는 의존성을
Core-Service
에 추가하다 보니 불필요한 의존성을 갖게 되었습니다.- Core-Service에 있는
OpenFeign
관련 소스들은File-Service
에서는 사용하지 않음에도 불구하고 의존성을 갖게 됩니다.
- Core-Service에 있는
계층 별로 완벽한 캡슐화를 달성하기 어려웠습니다. 실제로 리팩토링 하는 과정에서 계층의 관계가 모호해지고 있음을 확인하였습니다.
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); } }
다음 코드의 문제는 다음과 같습니다.
- 이메일 토큰을 생성하고 저장하는 로직이 이메일을 전송하는 로직과 같은 레이어에 존재한다.
- 코드를 읽을 때 세부로직을 하위 레이어에 위임해야 코드가 읽기 쉬워지는데 이를 달성하지 못했다.
🔍 두 번째 시도
첫 번째 시도에서 겪은 문제점을 해결하기 위해 세부 모듈화를 진행하였습니다.
Core-Service
를 작은 단위인Global Utils
,Component
로 세부 모듈화 하였습니다. 이를 통해 필요한 의존성만 재활용 하고자 했습니다.- 각각의 도메인안에서도 폴더 구조를
api
,bridge
,domain
으로 세부 모듈화하였습니다. 이를 통해 계층의 역할을 엄격하게 구분하고자 했습니다.
✅ ️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를 정의합니다.
👍 장점
- 모듈 세분화를 통해 필요한 의존성만 가져올 수 있으며 이를 통해 의존관계 파악도 용이해졌습니다.
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' }
- 세부 로직을 하위 레이어에 위임하게 강제함으로써 더 읽기 편한 코드가 된다.
- 이전에는 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()); }
- 레이어 간의 디커플링이 심해 코드의 양이 많아지지만 전파되는 영향도를 최소화할 수 있습니다.
- 각 레이어는 하위 레이어에만 의존성을 가지기 때문에 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.