블로그

Gradle Superpower: In Softeer

등록일
2024-10-07 13:29:41
조회수
112

지난 시간에는 Gradle Multi-Project 가 무엇인지, 어떻게 시작하는지 알아봤어요. 오늘은 실제 Softeer 시스템 중 일부를 담당하고 있는 소스코드를 보면서 Use case 몇 가지를 소개할게요.


Table of Content


빠른 빌드


소스코드는 점점 커집니다. 그래서 단일 프로젝트로 유지하다보면 빌드 소요 시간이 점점 더 커지죠. 문제는 이게 한 번에 눈에 띄게 커지는 게 아니라 시나브로 커진다는 것입니다. 너무 느리다는 것을 체감했을 땐 이미 겉잡을 수 없이 프로젝트가 비대해져 있죠. 소스코드를 멀티 프로젝트로 다루면 좋은 점은 빌드 소요 시간이 작다는 것, 그리고 그 시간이 소스코드가 비대해져도 계속 유지된다는 것입니다.


여기 두 소스코드에서 간단한 코드를 작성해보고 그 실행 소요 시간을 비교해볼 것입니다. 하나는 단일 프로젝트로 관리되고 있는 소스코드 A 이고 다른 하나는 멀티 프로젝트로 관리되고 있는 소스코드 B 입니다.


A 는 다음과 같은 소스코드 구조를 갖습니다.


$ tree --filesfirst -L 2
.
├── README.md
├── build.gradle
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
├── config
│   └── checkstyle
├── gradle
│   └── wrapper
└── src
    ├── main
    └── test


A 소스코드에서 src/test/java/ai/softeer/TestTest.java 파일에 다음과 같은 코드를 작성하고 실행해보았습니다.


package ai.softeer;

import org.junit.jupiter.api.Test;

class TestTest {

	@Test
	void test() {
		System.out.println("hi");
	}
}


$ ./gradlew test --info --tests ai.softeer.TestTest

> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test

TestTest > test() STANDARD_OUT
  hi

BUILD SUCCESSFUL in 4s
4 actionable tasks: 3 executed, 1 up-to-date

(일부 출력 내용 생략됨)


4초.


B 는 다음과 같은 소스코드 구조를 갖습니다.


$ tree --filesfirst -L 2
.
├── README.md
├── build.gradle
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
├── buildSrc
│   ├── build.gradle
│   ├── settings.gradle
│   └── src
├── config
│   ├── application-local.yaml
│   └── checkstyle
├── gradle
│   ├── libs.versions.toml
│   └── wrapper
└── s
    ├── ai-chat
    ├── data-on-demand-h2
    ├── datasource
    ├── io
    ├── local-port-forwarding
    ├── micro-frontend-support
    ├── playground
    ├── pure
    ├── security
    └── shared-properties


B 소스코드에서 :s:pure:trying 이라는 새로운 서브 프로젝트를 만들고 거기에도 다음과 같이 코드를 작성하고 실행해보았습니다. s/pure/trying/src/test/java/ai/softeer/pure/trying/TestTest.java


package ai.softeer.pure.trying;

import org.junit.jupiter.api.Test;

public class TestTest {

  @Test
  void test() {
    System.out.println("hi");
  }
}


$ ./gradlew :s:pure:trying:test --info --tests ai.softeer.pure.trying.TestTest

> Task :s:pure:trying:compileTestJava
> Task :s:pure:trying:processTestResources NO-SOURCE
> Task :s:pure:trying:testClasses
> Task :s:pure:trying:test

TestTest > test() STANDARD_OUT
  hi

BUILD SUCCESSFUL in 967ms
11 actionable tasks: 2 executed, 9 up-to-date

(일부 출력 내용 생략됨)


943밀리초.


4초와 943밀리초라는 차이를 보이는데요. 이러한 비교는 제대로된 실험은 아닙니다. A의 경우 4초가 걸린다는 사실에는 큰 의미가 없습니다. 많은 변수가 가려져 있죠. 프로젝트가 더 크다면 얼마든지 더 걸릴 수도 있고 프로젝트가 충분히 작다면 충분히 덜 걸릴 수도 있죠. 프로젝트의 실제 구성에 따라 빌드하는데 필요한 작업도 천차만별일 것입니다. 하지만 중요한 것은 B의 경우 다른 서브 프로젝트가 어떤 볼륨을 갖든, 어떻게 구성되어 있든 상관없이 늘 943밀리초 정도가 걸린다는 것입니다. B에서의 :s:pure:trying 은 불필요한 패키지에 의존하지 않기 때문에 Gradle 은 오직 저 코드만을 위한 최적화된 빌드를 진행합니다.


참고로 B의 :s:pure:trying 서브 프로젝트의 build.gradle 의 전체 내용은 다음과 같습니다.


plugins {
  id 'buildlogic.java-library-conventions'
}


반면 A의 경우는 아주 많은 의존성이 선언되어 있죠.


plugins {
  id 'org.springframework.boot' version '3.0.5'
  id 'io.spring.dependency-management' version '1.1.0'
  id 'checkstyle'
  id 'java'
}

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

  // ...중략...
}

// ...중략...


CheckStyle 구성


조금 다른 이야기이지만 소스코드를 관리할 때 코딩 스타일을 빼놓을 수 없겠죠. 코드베이스를 관통하는 일관된 코딩 스타일은 소스코드 관리에 있어 많은 이점을 가져다 줍니다. 여기에서 일관된 코딩 스타일의 이점을 자세히 설명하지는 않을게요.


CheckStyle 은 Java 코드의 스타일을 강제하는 도구입니다. 다행히도 Gradle 은 CheckStyle 을 이미 잘 알고 있고 plugins 블럭에 한 줄 추가하는 것만으로 사용할 수 있습니다. The Checkstyle Plugin


지난 시간에 함께 구성한 Gradle Multi-Project 를 기반으로 조금 응용해볼까요? 서브 프로젝트에서 반복적으로 CheckStyle 을 사용한다면 buildSrc/src/main/groovy/buildlogic.java-common-conventions.gradle 플러그인을 다음과 같이 수정합니다.


plugins {
  id 'java'
  id 'checkstyle'
}

repositories {
  mavenCentral()
}

dependencies {
  testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'

  testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

java {
  toolchain {
    languageVersion = JavaLanguageVersion.of 17
  }
}

checkstyle {
  toolVersion = '10.17.0'
}

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


그 다음 적용하고 싶은 서브 프로젝트의 build.gradle 에서 다음과 같이 적용하면 됩니다.


plugins {
  id 'buildlogic.java-common-conventions'
}


그런데 문제는 CheckStyle 구성을 이렇게만 하고 끝낼 수 없다는 것입니다. 기본 구성인 상태에서 CheckStyle Tasks (checkstyleMain, checkstyleTest) 를 실행하면 어마어마한 오류 메시지를 뿜을 것이기 때문입니다. 팀의 컨벤션에 따라 적절히 CheckStyle 룰을 구성해야 하는데요. 보너스로 현재 Softeer 가 사용하고 있는 CheckStyle 룰 구성을 공유합니다.


config/checkstyle/checkstyle.xml

<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
    "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
    "https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
  <module name="SuppressWithPlainTextCommentFilter"/>
  <module name="NewlineAtEndOfFile"/>
  <module name="FileTabCharacter"/>
  <module name="LineLength">
    <property name="max" value="120"/>
  </module>
  <module name="TreeWalker">
    <module name="Indentation">
      <property name="arrayInitIndent" value="8"/>
    </module>
    <module name="UnusedLocalVariable"/>
    <module name="UnusedImports"/>
    <module name="WhitespaceAround"/>
    <module name="NoWhitespaceBefore"/>
    <module name="TrailingComment"/>
    <module name="LeftCurly"/>
    <module name="RightCurly"/>
    <module name="OperatorWrap"/>
    <module name="AvoidStarImport"/>
    <module name="CustomImportOrder">
      <property name="customImportOrderRules" value="THIRD_PARTY_PACKAGE, STANDARD_JAVA_PACKAGE, STATIC"/>
      <property name="sortImportsInGroupAlphabetically" value="true"/>
    </module>
    <module name="JavadocStyle">
      <property name="scope" value="public"/>
      <property name="checkHtml" value="true"/>
      <property name="endOfSentenceFormat" value=".*"/>
    </module>
    <module name="JavadocMissingWhitespaceAfterAsterisk">
      <property name="violateExecutionOnNonTightHtml" value="true"/>
    </module>
    <module name="SingleLineJavadoc">
      <property name="violateExecutionOnNonTightHtml" value="true"/>
    </module>
    <module name="EmptyLineSeparator">
      <property name="allowNoEmptyLineBetweenFields" value="true"/>
    </module>
  </module>
</module>


만일 이러한 구성 중 일부 내용이 수정되어야 한다면 CheckStyle 을 사용하는 모든 프로젝트에 일괄적으로 적용이 되어야 할 텐데요. Gradle Multi Project 로 관리 중인 Monorepo 라면 편리하게 그러한 수정을 적용할 수 있겠죠.


보너스의 보너스로 Editorconfig 구성도 공유합니다.


.editorconfig

root = true

[s/**/src/{main,test}/java/**/*.java]
indent_style = space
indent_size = tab
tab_width = 4
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
max_line_length = 120
ij_continuation_indent_size = 8
ij_smart_tabs = true
ij_formatter_enabled = true
ij_formatter_tags_enabled = true
ij_formatter_off_tag = @formatter:off
ij_formatter_on_tag = @formatter:on
ij_java_block_brace_style = end_of_line
ij_java_class_brace_style = end_of_line
ij_java_lambda_brace_style = end_of_line
ij_java_method_brace_style = end_of_line
ij_java_blank_lines_after_class_header = 1
ij_java_method_parameters_wrap = on_every_item
ij_java_method_parameters_new_line_after_left_paren = true
ij_java_call_parameters_wrap = on_every_item
ij_java_call_parameters_new_line_after_left_paren = true
ij_java_method_call_chain_wrap = on_every_item
ij_java_wrap_first_method_in_call_chain = true
ij_java_ternary_operation_wrap = on_every_item
ij_java_ternary_operation_signs_on_next_line = true
ij_java_class_count_to_use_import_on_demand = 2147483647
ij_java_imports_layout = *, |, javax.**, java.**, |, $*


이 구성은 IntelliJ IDEA 사용을 전제합니다. IDEA 는 기본적으로 프로젝트에서 .editorconfig 파일을 발견하면 그 내용에 따라 에디터 설정을 구성합니다. 보너스로 드린 CheckStyle 구성에 이 Editorconfig 구성까지 해 놓으면 IDEA 에서 자동 포매팅 액션(Reformat Code)을 실행하는 것만으로 CheckStyle 구성이 요구하는 스타일을 만족시킬 수 있습니다. (이건 JetBrains 가 해줄 만한데 말이죠...)


정말 멋지죠? 더 멋진 방법이 있다면 알려주세요...


Custom Task: MergeProps


Gradle Task 를 직접 만들 생각을 해보신 적이 있나요?


저는 Spring Boot App 개발자로서 Gradle Multi Project 소스코드를 사용하고 많은 도메인 분리를 하면서 application*.yaml 파일도 따라서 관심사 분리를 해 두었습니다. 특정 구성 프로젝트에 한정적인 변수가 있다면 그 변수를 해당 서브 프로젝트 소속으로 해두는 것이죠. application-remote-db.yaml 처럼요.


그런데 어쩌다가 그러한 파일의 내용 중 일부를 덮어쓰고 싶을 때는 난감했습니다. 작업한지 오래된 코드를 나중에 보면 어디에 뭐가 있었나 기억이 안 나더라고요. 그래서 모든 서브 프로젝트 경로를 순회하면서 거기에 있는 application*.yaml 파일을 전부 찾아서 어딘가에 하나의 파일로 병합해주면 참 좋겠다고 생각했습니다.


그래서 mergeProps 라는 Gradle Task 를 직접 만들었습니다.


buildSrc/src/main/groovy/MergeProps.groovy


import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.tasks.TaskAction
import org.yaml.snakeyaml.DumperOptions
import org.yaml.snakeyaml.Yaml

/**
 * 서브 프로젝트에 흩어진 application*.yaml 파일을 모두 병합하여
 * config/application-local.yaml 파일을 만듭니다. (내용은 모두 주석처리)
 * 그렇게 하는 이유는 어떤 속성을 쉽게 덮어 쓰기 위함입니다.
 */
class MergeProps extends DefaultTask {

  @TaskAction
  void merge() {
    final Yaml yaml = new Yaml(new DumperOptions().with {
      setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK)
      it
    })

    Closure<Map> merge

    merge = { Map acc, Map.Entry entry ->
      final String k = entry.key
      final v = entry.value
      acc[k] = v instanceof Map && acc[k] instanceof Map
          ? v.entrySet().inject(acc[k], merge)
          : v
      acc
    }

    final String merged = yaml.dump project.subprojects
        .collectMany { Project subproject ->
          [
              'src/main/resources',
              'src/main/resources/config',
              'src/test/resources',
              'src/test/resources/config',
          ]
              .collect {
                subproject.fileTree(it) {
                  include 'application.yaml', 'application-*.yaml'
                }
              }
              .collectMany { it.files.toList() }
              .each { println "Merging $it\t[$subproject.name]" }
        }
        .collect { yaml.<Map> load it.text }
        .collectMany { it.entrySet() }
        .inject([:], merge as Closure<Map>)

    final File target = project.file('config/application-local.yaml')

    target.text = '''###
# 이 파일은 서브 프로젝트에 흩어진 Application Properties (application*.yaml) 를 한 군데에 모은 것입니다.
# 로컬에서 어떤 속성을 쉽게 덮어 쓰기 위함입니다.
#
# How to use:
# 덮어 쓰고자하는 줄의 주석을 풀고 원하는 값을 설정하세요.
# (기본으로 들어가 있는 값에는 어떤 의미도 없습니다.)
###

#''' + merged.split("\r?\n").join('\n#') + '\n'

    println "\nMerging Props Succeeded. > $target"
  }
}


그리고 루트 프로젝트의 build.gradle 에 다음과 같은 내용을 추가합니다.


build.gradle

/*
 * 이 Task 는 서브 프로젝트에 흩어져 있는 application*.yaml 을 모두 병합하여
 * config/application-local.yaml 을 만드는 작업입니다.
 *
 * 이 작업을 실행하는 것 자체는 애플리케이션에 아무 영향이 없습니다.
 * 실행 후 생긴 config/application-local.yaml 을 통해 자세한 내용을 확인할 수 있습니다.
 *
 * > ./gradlew mergeProps
 */
tasks.register('mergeProps', MergeProps) {
  group = 'custom'
}


이렇게 하고 mergeProps 작업을 실행하면


$ ./gradlew mergeProps

> Task :mergeProps
Merging .../s/data-on-demand-h2/src/main/resources/application-dod-h2.yaml [data-on-demand-h2]
Merging .../s/local-port-forwarding/src/main/resources/application-local-pf.yaml  [local-port-forwarding]
Merging .../s/shared-properties/src/main/resources/application-prd.yaml   [shared-properties]
Merging .../s/shared-properties/src/main/resources/application-ref-dev.yaml [shared-properties]
Merging .../s/shared-properties/src/main/resources/application-dev.yaml   [shared-properties]
Merging .../s/shared-properties/src/main/resources/application-ref-prd.yaml [shared-properties]
Merging .../s/shared-properties/src/main/resources/application.yaml [shared-properties]
Merging .../s/ai-chat/candi/app/src/main/resources/config/application-with-router.yaml   [app]
Merging .../s/ai-chat/candi/app/src/main/resources/config/application.yaml [app]
Merging .../s/datasource/r/primary-local/src/main/resources/application-p-db-local.yaml   [primary-local]
Merging .../s/datasource/r/primary-remote/src/main/resources/application-p-db-remote.yaml  [primary-remote]
Merging .../s/security/r/provider/ci/src/main/resources/application-sec-r-ci.yaml  [ci]
Merging .../s/security/r/provider/developer/src/main/resources/application-sec-r-dev.yaml  [developer]
Merging .../s/security/r/provider/manager/src/main/resources/application-sec-r-man.yaml   [manager]

Merging Props Succeeded. > .../config/application-local.yaml

BUILD SUCCESSFUL in 613ms
9 actionable tasks: 1 executed, 8 up-to-date


그 결과로 모든 서브 프로젝트에 흩어져 있는 application*.yaml 파일이 모두 병합된 새로운 YAML 파일이 config/application-local.yaml 과 같은 경로에 만들어집니다.


그렇게 하기 위해서 buildSrc 프로젝트에 관련된 라이브러리 의존성을 추가한 것도 잊지 마세요.


buildSrc/build.gradle

plugins {
  id 'groovy-gradle-plugin'
}

repositories {
  gradlePluginPortal()
}

dependencies {
  implementation 'org.yaml:snakeyaml:2.2'
}


이렇게 해두면 나중에 어떤 속성을 덮어 쓰고 싶을 때 매우 유용합니다. mergeProps Task 를 통해 만들어진 config/application-local.yaml 파일에서 빠르게 속성을 찾고 값을 입력한 후 local 프로파일을 활성화하여 실행하면 되니까요. (참고 Profiles :: Spring Boot)


Conclusion


사실 이러한 Monorepo 를 구성하는 방법보다 중요한 것은 그래서 어떻게 프로젝트를 분리할 것인지를 정하는 것입니다. 저는 도메인 주도 설계 실천의 일환으로 이러한 작업을 진행했던 것입니다. 그래서 이러한 방법을 아는 것보다 이렇게 해야겠다는 결론이 나오게된 동기가 더 중요하다고 생각하는데요. 도메인 분리에 있어 제가 가장 중요하게 생각한 기준 중 하나는 새로운 개발자가 들어왔을 때 파악하기 쉽도록 하는 것이었습니다. 한 프로젝트에 너무나 많은 의존성이 있으면 도대체 뭐가 뭘 위한 의존성인지, 어떻게 사용되고 있는 것인지 알기가 너무 어렵거든요. 물론 빌드 소요 시간처럼 퍼포먼스 측면에서도 많은 득을 보고요.


이 두 편의 블로그를 통해, 제가 갈아 넣은 약 3개월의 산물을 공유하여 저와 비슷한 고민을 하고 있는 사람에게 조금이라도 도움이 되길 바랍니다.


아직도 갈 길이 멀었지만 저처럼 게으른 개발자를 위해 오늘도 부지런히 DDD를 하겠습니다. 긴 글 읽어주셔서 정말 고맙습니다. ㄷㄷㄷ;


최신 블로그