본문 바로가기

Server/Spring

[Spring Boot] 멀티 모듈 구조(Multi Module Architecture) 적용기

반응형
SMALL

 

글을 시작하기 전에

최근 멀티 모듈 구조를 도입했다. 아니 정확히 말하면 도입하는 중이다.. 1차적으로 분리와 리팩토링은 완료했으나, 구조를 100%로 이해한 건 아니라서 조금씩 공부하면서 개선점을 찾고 추가 적용해가고 있는 단계이다. 그래서 이 글을 쓸까말까도 좀 고민했다 🤔 잘못된 정보를 전달할 수도 있을까봐ㅠㅠ 하지만 실전과 기록이 최고의 학습법이라고 생각하는 사람으로서, 이런 생소한 개념은 기록으로 좀 남겨야 진짜 내 것이 되겠다!라는 생각도 들었다. 이제까지 설계한 과정을 기록하고, 이후로도 추가 적용할 때마다 해당 블로그 글을 조금씩 수정해나가려 한다. 하지만 또 잘못된 정보를 공유할 순 없기 때문에,, 참고한 레퍼런스들을 틈틈히 남겨둘테니, 한 분이라도 이 글을 참고하며 구조 설계를 도입하려고 하신다면..? 꼭 함께 봐주실 것을 추천드립니다.

 


멀티 모듈을 만나다.

최근 프로젝트의 리팩토링이 시급했다. controller-service-repository 큰 틀 외에 중구난방하게 나눠진 패키지, 패키지 내에 늘어나는 클래스들, 맞춰지지 않은 몇몇 코드 컨벤션까지.. 개발 여유시간이 생겼을 때 같이 서버 개발했던 팀원과 논의해서 패키지 구성도 다시 해보고, 메서드 분리도 1차적으로 한 번 했다. 하지만 일주일만 지나도 클래스를 바로 찾기는 쉽지 않았다. 이대로 기능이 더 추가된다면? 조금이라도 더 복잡해진다면? 유지보수는 정말 힘들어질 수도 있다는 생각이 들었다. 설계의 문제. 아직 크게 복잡하지 않은 상황에서 재설계의 필요성을 느꼈다. 단순 모놀리식 3-layer 구조 밖에 써본 설계 구조는 없었지만, 새로운 설계를 도전해보기로 했다.

 

클린 아키텍처에서 이런 글을 봤다. "좋은 아키텍처는 결합 분리 모드를 선택사항으로 남겨두어서 배포 규모에 따라 가장 적합한 모드를 선택해 사용할 수 있게 만들어 준다." 즉, 모놀리식에서 MSA로, MSA에서 모놀리식으로 유연하게 결합하고 분리할 수 있어야 하는 것이다. 당시 읽을 때는 추상적으로 다가왔지만, 직접 리팩토링의 불편함을 겪고 나니 전보다 가깝게 다가왔다.

 

그럼 어떤 설계가 좋을까? 최근 멀티 모듈 구조를 주변에서 꽤나 들었다. 그래서 좀 찾아봤다.

아래 영상으로 멀티 모듈 구조의 흐름을 접했다. 사실 큰 기업인만큼 MSA 구조 기반이라, 모놀리식을 사용하는 프로젝트라면 필요한 정보를 잘 골라야 한다. 들으면서 아직 내 지식으론 이해할 수 없는 내용도 있었지만, 최대한 이해해 본 내용을 정리해보겠다. (멀티 모듈의 이해를 원한다면 정리본보다 아래 영상을 full로 볼 것을 추천한다.)

 

[INFCON 2022] 실전! 멀티 모듈 프로젝트 구조와 설계 (김대성 스피커님)

https://www.youtube.com/watch?v=ipDzLJK-7Kc

 

  • CORE, COMMON 모듈은 삭제한다.
    • Core & Common 모듈을 사용하는 의존성 프로젝트는 커넥션 풀을 모두 할당받는다. (Build 호율 저하)
    • 일부 중복을 허용하더라도, Core&Common 모듈이 커질 때의 위험성보다 나은 선택이다.
  • 멀티 모듈 그룹 경계 나누기
    • BOOT (Server)
      • 서버 모듈 (잦은 변화)
      • batch, admin, api
    • INFRA
      • 연동 모듈 (큰 변화)
      • and, vod, photo, billing
    • CLOUD (System)
      • 클라우드(시스템) 모듈 (적은 변화)
      • config, gateway, discovery
      • aws, gcp, azure
    • DATA (Domain)
      • 데이터 모듈 (+도메인)
      • meta, user, chart
  • Servics는 어느 BOOT, DATA 양쪽 모두 책임과 역할에 맞게 각각 구현되어야 한다.
    • BOOT(이벤트 발생) -> DATA(처리)
  • 좋은 설계는 부서 조직이 항상 일정하지 않듯이, 언제든 MSA에서 모놀리식으로, 모놀리식에서 MSA로 넘어갈 수 있게 하는 구조이다. (마지막에 말씀해주셨는데, 클린 아키텍처에서 봤던 내용이 떠올라서 기록해뒀다)
  • SUMMARY
    • [WHY] 왜 멀티 모듈 프로젝트 구조가 중요할까요?
      • 잘못 구성하면 나중에 변경하기 고통스럽다.
      • 프로젝트 초기에 이루어져야 하는 일련의 설계 과정이다.
      • 개발 생산성에 막대한 영향을 미친다.
      • 서비스 장애와 밀접한 관련이 있다.
    • [WHAT] 무엇을 기준으로 멀티 모듈 프로젝트 구조를 나눠야 할까요?
      • 경계 안에서 의미를 가질 수 있는 그룹을 정의하는(나누는) 것이 가장 중요하다. (Bounded Context)
      • 역할, 책임, 협력 관계가 올바른지 다시 한번 생각한다.
      • BOOT(Server), INFRA, DATA(Domain), SYSTEM(Cloud)
    • [HOW] 어떻게 실전 멀티 모듈 프로젝트를 구현해야 할까요?
      • 프로젝트가 커지고 있다면 다시 경게를 나누고 그 기준으로 소스 저장소를 분리한다.
      • INFRA(외부) 라이브러리에는 DATA 관련 구현을 지향한다. (Anticoruption Layer)
      • 서비스 구현은 각자 역할에 맞게 각각 구현될 수 있다. (공통으로 한쪽에 구현되지 않는다.)
      • 시스템 레벨 구현이 실제 서비스 애플리케이션과 밀접하게 연관되지 않도록 격리하거나 전환(Istio)한다.

 


멀티 모듈 구조 설계하기

컨퍼런스 영상과 여러 기술 아티클, 깃허브 자료들을 참고했다. 가장 하단 References에 남겨둘테니 관심있으면 읽어볼 것을 추천한다.

 

여러 자료들을 본 결과, 구조 이름이 있어서 정해진 형식 같은 게 있을 줄 알았는데, 생각보다 경우에 따라서 다양하게 구조화 된 것을 알 수 있었다. 자료를 보면서 현재로서는 이해되지 않는 내용도 있었고, 공통되는 내용도 보였다. 욕심만으로는 처음부터 완벽하게 설계하고 싶었지만, 이제까지의 경험으로 봤을 때 일단 시작하고 조금씩 고쳐나가면서 완성에 가까워지고, 성장할 수 있다고 생각한다. 그러니 일단 하자!

 

아래는 결정한 모듈 구조이다.

 

모듈(Module) 분리

  • project-api
    • 서비스의 API와 비즈니스 로직(Service)을 관리
    • Swagger, Security
    • domain, common, external 의존
    • 가장 클라이언트와 밀접한 파일들로 구성했다. Service 클래스를 Domain 쪽으로도 생각하다가 역할이 한 쪽으로 치우쳐지는 것 같아 api 모듈로 결정했다. 하지만 Repository가 API에서 의존 관계로 드러나는 것은 조금 찝찝하다. Service 파일을 상위, 하위 둘로 나눠 Repository를 좀 더 깊숙히 숨기고자 한다. 상위 Service를 퍼사드 패턴으로 구현할지, 유즈케이스로 구현할지는 좀 더 고민 중이다.
    • Swagger는 Controller 요청/응답 값 기준으로 구성되므로, Security는 우선 JWT 토큰을 클라이언트로부터 받아오니까 api 모듈로 결정했다.
  • project-domain
    • entity, repository, db 관련 infra 관리(postgresql, jpa, querydsl)
    • common 의존
    • db와 밀접한 파일들을 모았다.
  • project-common
    • 공통 파일 관리
    • exception, response code, util 이 모여있다. common 모듈이 커지는 것을 유의하여 좀 더 다른 모듈로 분산할 순 없을 지 계속 고민해볼 것이다. 특히, util 관련은 우발적 중복은 아닌데 common에서 꺼내서 각 클래스에 중복을 허용해줘도 괜찮을 지 고민이다.
  • project-batch
    • scheduler 관리
    • domain, common 의존
    • 스케줄링 기능을 해당 모듈로 분리했다.
  • project-external
    • 외부 API 관리
    • firebase, social login, discord 등 외부 API 호출이 필요한 기능을 모았다. 외부 API 호출 외에 별도의 infra적인 요소는 없어서 모듈 이름은 external로 결정했다.

 


멀티 모듈 분리하기

 

GitHub - Team-Smeme/Smeme-server-renewal: 스밈 서버에도 봄은 온다 🍃

스밈 서버에도 봄은 온다 🍃. Contribute to Team-Smeme/Smeme-server-renewal development by creating an account on GitHub.

github.com

실제 작업하는 프로젝트에서 멀티 모듈 구조로 분리한 repository 이다.

 

(인텔리제이 기준) 모듈 추가는 패키지 추가처럼, 패키지 대신 모듈(module)을 선택하면 된다.

 

Root Module

buildscript {
    repositories {
        mavenCentral()
    }
}

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

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

    group = 'com.smeme'
    version = '0.0.1-SNAPSHOT'
    sourceCompatibility = '17'

    repositories {
        mavenCentral()
    }

    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }

    dependencies {
        // test
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testImplementation 'org.springframework.security:spring-security-test'

        // lombok
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
        testAnnotationProcessor 'org.projectlombok:lombok'
        testCompileOnly 'org.projectlombok:lombok'
    }

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

jar { enabled = true }

bootJar { enabled = false }

root 모듈의 build.gradle 파일이다. 전체적인 모듈에 공통적인 사항을 포함했다. 해당 프로젝트에서는 그 요소를 test와 lombok으로 도출했다. gradle 파일을 직접 수정하게 되면서 jar와 bootJar의 차이를 처음 알게 되었다. jar은 클래스 파일과 dependencies 모두를 묶어서, bootJar는 클래스 파일만 빌드하는 차이라고 한다. root 모듈이므로 jar을 허용해주었다. (이후 하위 모듈은 bootJar 허용)

 

rootProject.name = 'server'
include 'smeem-api'
include 'smeem-domain'
include 'smeem-batch'
include 'smeem-external'
include 'smeem-common'

root 모듈에는 setting.gradle 파일도 존재한다. 모듈을 모아두는 파일이라고 이해하니까 편했다. 모듈을 추가할 때마다 웬만하면 자동으로 추가되긴 하지만, 혹시 모르니 한 번씩 확인은 해주는 것이 좋겠다.

 

Api Module

root-module 이후 하위 모듈은 거의 구조가 비슷하다. 따라서 api-module 하나만 소개해보도록 하겠다. 이후 구성은 비슷하니, 다른 하위 모듈은 위에 첨부한 repository 에서 참고해주길 바란다.

 

dependencies {
    implementation project(':smeem-common')
    implementation project(':smeem-domain')
    implementation project(':smeem-external')

    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // JWT
    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
    implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
    implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

    // Swagger
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

}

ext {
    set('snippetsDir', file("build/generated-snippets"))
}

repositories {
    mavenCentral()
}

api-moudle의 build.gradle 파일이다. 필요한 의존성을 추가해주었다. dependencies 내부에 의존할 모듈도 추가해준다.

 

모듈 내부는 기존 구조에서 api, service 등을 가져와 도메인 별로 패키지를 구성해주었다. 도메인 패키지 내부에 api, service 등 클래스를 배치했다.

 


추가 리팩토링

모듈 분리 후 컨벤션 통일 외에도 추가적인 리팩토링 작업을 통해 확장성을 최대로 끌어내보고자 했다.

 

공통 Response DTO 리팩토링

@Builder(access = PRIVATE)
public record BaseResponse<T>(
        boolean success,
        String message,
        @JsonInclude(value = NON_NULL)
        T data
) {

    public static <T> BaseResponse<T> of(String message, T data) {
        return BaseResponse.<T>builder()
                .success(true)
                .message(message)
                .data(data)
                .build();
    }

    public static BaseResponse<?> of(boolean isSuccess, String message) {
        return BaseResponse.builder()
                .success(isSuccess)
                .message(message)
                .build();
    }
}

클라이언트에게 공통으로 반환하는 응답 DTO 파일이다.

  • 기존 ApiResponse 파일명을 BaseResponse 파일명으로 수정했다. (Swagger의 어노테이션과의 중복 이슈)
  • data의 타입을 Object에서 Generic으로 수정했다. (코드 안정성, 가독성 측면에서 제네릭이 더 효율적임을 판단)
  • @JsonInclude를 추가하여 null일 경우 데이터가 출력되지 않도록 했다. ({success: ..., message: ..., data: null} -> {success: ..., message:...})

성공과 에러 응답으로 DTO를 2개로 분리할 방향으로 고민 중이다. 현재는 중복되는 부분이 꽤 있지만, 이후 성공/에러 응답이 각각 변경될 수도 있기 때문이다. 따라서 우발적 중복이라는 생각이 들었고, 확장성을 고려해서 둘로 분리하는 방향이 더 좋지 않을까 한다.

 

 

Controller, Service DTO 분리

기존 코드에서는 Controller와 Service의 요청/응답 DTO를 공유했다. 하지만 내부 비즈니스 로직이 변경될 때마다 Controller 계층도 영향이 가는 경우가 종종 발생했다. 클라이언트와 직접적으로 소통하는 Controller 측의 DTO 파일은 변경될 일이 거의 없기 때문에 DTO를 계층 별로 분리하기로 결정했다.

 

@Builder(access = PRIVATE)
public record BadgeResponse(
        Long id,
        String name,
        String type,
        String imageUrl
) {

    public static BadgeResponse from(BadgeServiceResponse response) {
        return new BadgeResponse(
                response.id(),
                response.name(),
                response.type(),
                response.imageUrl()
        );
    }
}

Controller 계층에서 사용하는 응답 DTO 중 하나이다. 클라이언트와의 소통 목적으로 사용한다. (변경 가능성 적음)

 

@Builder(access = PRIVATE)
public record BadgeBaseServiceResponse(
        String name,
        BadgeType type,
        String imageUrl
) {

    public static BadgeBaseServiceResponse of(Badge badge, List<MemberBadge> memberBadges) {
        return BadgeBaseServiceResponse.builder()
                .name(badge.getName())
                .type(badge.getType())
                .imageUrl(getImageUrl(badge, memberBadges))
                .build();
    }

    private static String getImageUrl(Badge badge, List<MemberBadge> memberBadges) {
        return hasBadge(badge, memberBadges) ? badge.getBadgeImage().getImageUrl() : badge.getBadgeImage().getGrayImageUrl();
    }

    private static boolean hasBadge(Badge badge, List<MemberBadge> memberBadges) {
        return memberBadges.stream().anyMatch(memberBadge -> memberBadge.getBadge().equals(badge));
    }
}

Service 계층에서 사용하는 응답 DTO이다. 위 Controller 사용 DTO와 대응되는 파일이다. 비교적 비즈니스 코드와 직관적인 필드를 사용하고, 데이터 추가 처리 메서드도 추가했다. (변경될 가능성이 비교적 큼)

 

레이어 구조를 보다 준수하면서, 좀 더 유지보수에 유연한 구조에 가까워졌다.

 

 

(추가 고려 사항) 외부 API 인터페이스 추가

외부 API를 호출하는 코드는 각각 단일 클래스로 존재한다. api 모듈에서 호출할 때, 구현 내용을 최대한 숨기기 위해서 인터페이스를 추가하여 활용할 예정이다. 인터페이스를 추가하게 되면 의존하는 코드에서는 내부 코드를 몰라도 되며, 이후 확장성도 향상될 것이다.

 


글을 마치며

스스로 학습하고, 팀원과 공유 및 논의하여 결정한 사항들이 100% 정확하지는 않을 것이다.

하지만 이론으로만 들었던 유연한 확장성, 설계의 중요성, 계층의 고수준/저수준, 결합분리모드의 선택 사항 등등 한 때는 추상적으로만 다가왔던 개념들이 이번 멀티 모듈 구조 설계를 통해 조금은 구체적으로 이해할 수 있게 되었다. 또한, 직접 리팩토링을 진행하면서 계층이 전보다 뚜렷하게 분리되고, 확장성을 고민해보며 시야가 전보다 한 층 넓어지는 것 같았다. 작년까지 익숙한 개발만 반복하면서 조금씩 처음의 성장을 느꼈던 열정을 잃어가고 있었는데, 이 경험을 통해서 다시 개발의 재미도 찾을 수 있었다 :) 이론적으로만 읽었던 책/강의의 내용을 조금이나마 적용해볼 수 있어서 좋다. 오히려 지금이 완벽하지 않은 것을 알아서 계속 고민할 수 있어서 좋다.

 

덕분에 유용한 자료들을 찾아보고, 팀원과도 의논하면서 유의미한 기술적인 대화를 나눌 수 있는 좋은 경험이었다.

앞으로도 계속 의논하면서 더 좋은 방향성을 찾아 좋은 리팩토링을 하기 위해 노력해보려고 한다. 기록도 활성화하고 말이다! 화이팅~

 


References

https://www.youtube.com/watch?v=ipDzLJK-7Kc

https://ksh-coding.tistory.com/136#2.%20%EB%A9%80%ED%8B%B0%20%EB%AA%A8%EB%93%88%EC%9D%84%20%EC%99%9C%20%EC%82%AC%EC%9A%A9%ED%95%A0%EA%B9%8C%3F-1

https://jaeseo0519.tistory.com/359

https://github.com/Mile-Writings/Mile-Server

https://velog.io/@jonghyun3668/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-%EB%8B%A8%EC%9D%BC-%EB%AA%A8%EB%93%88-%EC%BD%94%EB%93%9C%EC%97%90-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88%EC%9D%84-%EC%A0%81%EC%9A%A9%ED%95%98%EC%97%AC-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B5%AC%EC%A1%B0-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0

반응형
LIST