일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 26 | 27 | 28 |
29 | 30 | 31 |
- CI/CD
- 오블완
- 파이썬
- 액세스토큰
- 프로그래머스
- AWS
- githubactions
- 백준
- 스프링부트
- 스프링
- 국제화
- Spring
- 트랜잭션
- 티스토리챌린지
- yaml-resource-bundle
- 재갱신
- docker
- 메시지
- 리프레시토큰
- 스프링시큐리티
- springsecurity
- java
- springdataredis
- JIRA
- springsecurityoauth2client
- 도커
- 소셜로그인
- 토이프로젝트
- 데이터베이스
- oauth2
- Today
- Total
땃쥐네
[토이프로젝트] 게시판 시스템(board-system) 11. 멀티모듈과 테스트 픽스쳐 중복문제 본문
이번 글에서는 지난 글들에서 계속 발생했던 테스트 픽스쳐 중복 문제를 해결해보도록 하겠습니다.
1. 문제점: 픽스쳐의 중복
제 프로젝트 구조는 현재 멀티모듈로 구성되어 있습니다.
domain-core 를 application-member, domain-member 가 의존하고 있고, 향후 domain-auth 모듈과 같은 모듈이 추가된다면 이들 역시 domain-core를 의존할 것입니다.
package com.ttasjwi.board.system.member.domain.model
class Email(
val value: String
) {
companion object {
/**
* 기본 Email 객체 복원
*/
fun restore(value: String): Email {
return Email(value)
}
}
package com.ttasjwi.board.system.member.domain.model.fixture
import com.ttasjwi.board.system.member.domain.model.Email
fun emailFixture(
value: String = "test@gmail.com"
): Email {
return Email(value)
}
도메인 모듈에는 Email 클래스가 위치해있고, 이 Email 에 대한 생성 로직은 현재 생성자가 public 으로 열려 있긴 합니다만
테스트코드에서는 테스트 Fixture 함수를 통해서만 할 수 있도록 코드를 작성하고 싶습니다.
Email 인스턴스를 생성자를 직접 호출해서 이곳저곳에서 하게 하는 것은 유지보수 면에서 좋지 못 하기 때문입니다.
생성자 그 자체는 이름을 가질 수 없기 때문에 생성자가, 이곳저곳에서 호출되게 된다면 어떤 이유로 어디서 됐는지 추적하기 힘들어지는 문제가 있습니다. 그리고 인스턴스를 생성하는 데에도, 생성의 절차가 생성 사유에 따라 제각각 달라지는 경우가 있는데 생성자 하나만으로 인스턴스 생성을 모두 일임하는 것은 생성자의 책임이 과해지는 점도 있습니다.
실제 Email 을 생성하는 비즈니스 코드에서는 이메일 포맷 검증 등의 작업을 거쳐서 이메일을 생성하도록 하고, 외부 모듈에서는 Email 클래스를 통해 바로 이메일 인스턴스를 함부로 생성할 수 없게 할거에요. 이메일이 구체적으로 어떤 과정을 거쳐서 생성되는 지에 대한 내용은 사용하는 측에서 알 필요가 없게 하기 위해서에요.
그러면서 테스트 코드에서는 Email 인스턴스를 편리하게 바로 생성할 수 있게 하려고 해요.
@Test
@DisplayName("Email이면서 값이 같으면 동등하다")
fun emailAndSameValue() {
val email1 = emailFixture("test@gmail.com")
val email2 = emailFixture("test@gmail.com")
val email3 = emailFixture("test@gmail.com")
assertThat(email1).isEqualTo(email2)
assertThat(email1).isEqualTo(email3)
assertThat(email1.hashCode()).isEqualTo(email2.hashCode())
assertThat(email1.hashCode()).isEqualTo(email3.hashCode())
}
가령 예를 들어 Email 그 자체를 테스트를 한다면 Email 의 구체적인 생성코드를 바로 호출하지 못 하게 하고,
이렇게 emailFixture를 호출해서 간단하게 테스트용 인스턴스를 생성하게 하는거죠.
그런데 이렇게 domain-core 모듈 내에서 Email 도메인 자체의 테스트를 하려면 결국 email 에 대한 Fixture 코드를 domain-core의 테스트 소스셋 안에 작성해야합니다. 같은 모듈에 있어야 그 테스트 픽스쳐를 쓸 수 있기 떄문이죠.
이번엔 domain-member 모듈의 Member 클래스를 보겠습니다.
class Member
internal constructor(
id: MemberId? = null,
email: Email,
password: EncodedPassword,
username: Username,
nickname: Nickname,
role: Role,
val registeredAt: ZonedDateTime,
) : DomainEntity<MemberId>(id) {
Member 클래스는 email 을 의존하고 있습니다. 그런데 Email 은 domain-core 모듈에 작성해뒀죠.
fun memberFixtureRegistered(
id: Long = 1L,
email: String = "test@gmail.com",
password: String = "1111",
username: String = "test",
nickname: String = "테스트유저",
role: Role = Role.USER,
registeredAt: ZonedDateTime = timeFixture()
): Member {
return Member(
id = memberIdFixture(id),
email = emailFixture(email),
password = encodedPasswordFixture(password),
username = usernameFixture(username),
nickname = nicknameFixture(nickname),
role = role,
registeredAt = registeredAt,
)
}
Member 역시 편리한 테스트를 위해 Fixture 함수를 작성하고 싶은데
테스트 Member 생성에 필요한 Email 의 픽스쳐 함수가 필요합니다.
하지만 fixture 함수는 domain-core 모듈의 test 소스셋에 위치해 있고 이걸 domain-member의 test 소스셋에서 사용할 순 없습니다.
결국 당장의 문제를 해결할 수 있는 방법은 domain-core 에 위치한 EmailFixture 코드를 복사/붙여넣기 하여 domain-member 모듈에서 재사용할 방법밖에 없습니다.
그리고 앞으로 domain-core를 의존하는 모든 모듈에서 email 관련 테스트 픽스쳐가 필요할 때 그곳에서도 복사 붙여넣기를 해야합니다.
email 에 관한 Fixture 코드를 한 곳에 두고 이걸 재사용할 방법은 없을까 고민이 들었습니다.
2. 픽스쳐 전용 모듈 작성?
rootProject.name = "board-system"
include(
// 생략
"board-system-domain:domain-core",
"board-system-domain:domain-core-fixture",
"board-system-domain:domain-member",
당장 먼저 든 생각은 테스트용 픽스쳐를 위한 모듈을 새로 생성하는 방법이였습니다.
테스트 픽스쳐에 관련된 코드를 별도의 외부 모듈로 빼내서 쓰는 방법이였죠.
domain-core 모듈의 픽스쳐를 모아둔 모듈, domain-core-fixture를 만들고 domain-member에서 testImplementation 을 하는 방법을 생각해봤습니다.
dependencies {
implementation(project(":board-system-domain:domain-core"))
}
domain-core-fixture 모듈의 build.gradle.kts 에는 이렇게 domain-core 에 대한 의존성을 둡니다.
그리고 기존 domain-core 의 test 소스셋에 위치한 픽스쳐 코드와 픽스쳐에 대한 테스트 코드를 가져왔습니다.
domain-core-fixture 에 대해 테스트를 돌려보면 잘 돌아갑니다.
dependencies {
implementation(project(":board-system-domain:domain-core"))
testImplementation(project(":board-system-domain:domain-core-fixture"))
}
그리고 domain-member 에서, domain-core-fixture에 대해 testImplementation 의존성 등록을 하고
domain-member 에 위치해있던, domain-core의 픽스쳐 코드를 제거한뒤 테스트를 돌려보면
잘 돌아갑니다! domain-member에서 domain-core의 픽스쳐에 대한 코드를 다시 작성하지 않아도 되는거죠.
하지만 이 방식도 한계가 있습니다.
domain-core 의 코드는 domain-core-fixture 를 의존성으로 등록할 수 없기 때문에
domain-core-fixture 와 domain-core 양쪽에 픽스쳐 코드를 중복해서 등록해야합니다.
domain-core 자기 자신이, 외부에 둔 픽스쳐 코드를 재사용하지 못 하는 문제가 생기죠.
어라? 왜 재사용 못 하느냐 하실 수 있는데
domain-core-fixture 가 이미 domain-core를 implementation 의존을 하고 있기 때문입니다.
domain-core 에서 domain-core-fixture 의 main 소스셋을 testImplementation 의존하게 되면 순환참조가 발생하기 때문에 빌드가 되지 않습니다.
package com.ttasjwi.board.system.member.domain.model
class Email(
val value: String
) {
package com.ttasjwi.board.system.member.domain.model
class Email
internal constructor(
val value: String
) {
그리고 도메인 클래스의 생성자를 public으로만 둘 수 밖에 없는 문제가 생깁니다.
만약 여기서 Email 의 생성자의 접근제어자를 외부 모듈에서 호출하지 못 하도록 하기 위해
생성자를 internal 키워드로 막아두면 어떻게 될까요?
domain-core 의 테스트 모듈 내에서 위치한 fixture 코드는 컴파일에 문제가 없겠지만
domain-core-fixture에서 fixture를 작성하는 부분에서는 문제가 생깁니다. 접근제어자가 public 으로 열려있지 않아서, 생성자를 호출할 수 없기 때문입니다.
현재 상황을 요약해보면 domain-core-fixture 모듈과 같이, 픽스쳐 코드를 외부 모듈로 분리하는 방법은
1. domain-core 외부의 모듈에서는 참조해서 사용할 수 있어서 재사용성을 늘릴 수 있지만
2. 정작 domain-core 모듈에서는 순환참조 문제 때문에 참조할 수 없어서 동일한 코드를 domain-core 내부에 복사/붙여넣기 해야하는 문제가 있습니다.
3. 또, domain-core 내부에서만 사용할 수 있게 internal 키워드를 작성한 코드들은 domain-core-fixture에서 사용할 수 없습니다.
이 문제를 모두 해결해줄 방법이 없을까요?
3. java-test-fixtures
이런 문제에 대해 고민하면서 검색을 해봤는데 Toss 의 기술 블로그에서 저와 비슷한 고민을 해결하신 글을 보게 됐습니다.
java-test-fixtures gradle 플러그인을 사용하는 방식인데요. 제 문제를 전부 해결해준 좋은 방법이여서 이 방법을 사용해보겠습니다.
enum class Plugins(
val id: String,
val version: String = "",
) {
KOTLIN_JVM(id = "org.jetbrains.kotlin.jvm", version = "1.9.25"),
KOTLIN_SPRING(id = "org.jetbrains.kotlin.plugin.spring", version = "1.9.25"),
SPRING_BOOT(id = "org.springframework.boot", version = "3.3.4"),
SPRING_DEPENDENCY_MANAGEMENT(id = "io.spring.dependency-management", version = "1.1.6"),
JAVA_TEST_FIXTURES(id = "java-test-fixtures", version = ""),
}
java-test-fixtures 플러그인을 선언해두고
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
// 루트 프로젝트
plugins {
id(Plugins.KOTLIN_JVM.id) version Plugins.KOTLIN_JVM.version
id(Plugins.KOTLIN_SPRING.id) version Plugins.KOTLIN_SPRING.version apply false
id(Plugins.SPRING_BOOT.id) version Plugins.SPRING_BOOT.version apply false
id(Plugins.SPRING_DEPENDENCY_MANAGEMENT.id) version Plugins.SPRING_DEPENDENCY_MANAGEMENT.version
// 추가
id(Plugins.JAVA_TEST_FIXTURES.id)
}
java {
sourceCompatibility = JavaVersion.valueOf("VERSION_${ProjectProperties.JAVA_VERSION}")
}
// 루트 프로젝트 + 서브 프로젝트 전체
allprojects {
group = ProjectProperties.GROUP_NAME
version = ProjectProperties.VERSION
repositories {
mavenCentral()
}
}
// 서브프로젝트에 적용
subprojects {
apply { plugin(Plugins.KOTLIN_JVM.id) }
apply { plugin(Plugins.KOTLIN_SPRING.id) }
apply { plugin(Plugins.SPRING_BOOT.id) }
apply { plugin(Plugins.SPRING_DEPENDENCY_MANAGEMENT.id) }
// 추가
apply { plugin(Plugins.JAVA_TEST_FIXTURES.id) }
루트의 build.gradle.kts 에서 플러그인 적용 코드를 작성하고 gradle 을 다시 reload 하면
이제 testFixtures 소스셋을 gradle에 통합하여 관리할 수 있게 됐습니다.
gradle은 이제부터 testFixtures 디렉토리가 있으면 이 디렉토리를, 모듈의 테스트 픽스쳐 소스셋으로 간주하게 됩니다.
이 상태에서 모듈 내에서는 main, test, testFixtures 소스셋을 관리할 수 있으며
모듈 내의 소스셋 간 의존성의 방향은 위와 같습니다
main 은 의존성 방향의 끝에 위치합니다.
testFixtures는 main 을 의존합니다.
test 는 main, testFixtures 를 의존합니다.
또, 이들은 같은 모듈에 위치해있으므로 의존하는 소스셋에 위치한 internal 접근제어자가 적용된 함수/클래스 등을 사용할 수 있습니다.
이번엔 의존성 설정 방법을 따져보겠습니다.
모듈 a가 모듈 b를 의존하는 상황을 보겠습니다.
implementation(project(":b")) 를 사용하면
a의 main 소스셋 및 test 소스셋은 모듈 b의 main 소스셋을 사용할 수 있습니다.
a의 test 소스셋은 내부의 main 소스셋을 의존하므로 main의 의존성인 b의 main 소스셋을 암시적으로 의존하기 때문입니다. 그래서 a의 test 소스셋이 b의 main 소스셋 코드를 사용할 수 있습니다.
testImplementation(project(":b")) 를 사용하면 a의 test 소스셋만 모듈 b의 main 소스셋을 사용할 수 있습니다.
이 경우 a의 main 소스셋은 b의 main 소스셋을 사용하지 못 합니다.
testImplementation(testFixtures(project(":b")) 을 사용하면
a의 test 소스셋이 b의 main, testFixtures 소스셋을 사용할 수 있습니다
a의 test소스셋이 b의 testFixtures를 의존할 경우
b의 testFixtures 코드가 b main 소스셋을 내부 의존하므로 의존성에 끝에 있는main도 암시적으로 의존하기 떄문입니다.
testFixturesImplementation(project(":b"))) 를 사용하면,
a의 testFixtures 소스셋이 b의 main 을 사용할 수 있고,
a의 test 소스셋 역시 b의 main 소스셋을 사용할 수 있습니다.
a의 test는 내부적으로 testFixtures 를 의존하고 있는데 여기서 암시적 의존이 적용되어 의존성 끝에 있는 main도 사용할 수 있게 되는 겁니다.
testFixturesImplementation(testFixtures(project(":b"))) 를 사용하면
a의 testFixtures 소스셋은 b의 main, testFixtures 소스셋을 사용할 수 있고
a의 test 소스셋 역시 b의 main, testFixtures 소스셋을 사용할 수 있습니다.
이것 역시 위에서 설명한 암시적 의존 원리가 적용되서 그렇습니다.
이 원리를 적용하면
1. A 모듈이 B모듈을 의존하면서 B의 테스트 픽스쳐를 쓰지 않는다면 implementation 의존성으로 충분합니다.
2. 하지만 A 모듈의 테스트 픽스쳐가 B 모듈의 테스트 픽스쳐를 의존하거나, A 모듈의 테스트 코드에서 B 모듈의 테스트 픽스쳐 역시 쓰고 싶다면 testFixturesImplementation(testFixtures(project(":b"))) 의존성도 추가해야합니다.
3. 개발자가 작성한 인터페이스의 픽스쳐는 같은 모듈의 testFixtures에 작성합니다.
그리고 이렇게 할 경우 아래와 같은 이점이 생깁니다.
1. 테스트 픽스쳐를 모듈 내부의 소스셋에 위치시킴으로서 테스트 코드에서는 내부 테스트 Fixture 코드를 쓸 수 있습니다.
2. 내부 모듈에 위치해있으므로 픽스쳐에서는 internal 한 메서드/함수에 접근할 수 있습니다.
3. 픽스쳐를 외부모듈에서 재사용할 수 있으므로 픽스쳐를 더이상 중복코드를 작성할 필요가 없습니다.
4. 외부 모듈에서 테스트용도로 사용할 때는 픽스쳐 함수만 사용하게 하고, 내부모듈에서만 사용하는 기능은 internal 로 막음으로서 모듈 내에서만 불필요하게 외부로 노출시키지 않아도 되는 로직은 캡슐화시킬 수 있습니다.
4. 픽스쳐 소스셋 적용해보기
예시로, member 모듈에서 현재 시간을 빠르게 만들 수 있도록 만들었던 timeFixture 함수가 있는데 이 함수는 여러 모듈의 테스트 소스셋에서 많이 사용될 것 같아요. 이대로 두면 중복코드가 발생할 것 같습니다.
subprojects {
apply { plugin(Plugins.KOTLIN_JVM.id) }
apply { plugin(Plugins.KOTLIN_SPRING.id) }
apply { plugin(Plugins.SPRING_BOOT.id) }
apply { plugin(Plugins.SPRING_DEPENDENCY_MANAGEMENT.id) }
apply { plugin(Plugins.JAVA_TEST_FIXTURES.id) }
dependencies {
val sharedModuleNames = listOf("board-system-core", "board-system-logging")
if(project.name !in sharedModuleNames) {
implementation(project(":board-system-core"))
implementation(project(":board-system-logging"))
// 추가
testFixturesImplementation(testFixtures(project(":board-system-core")))
}
루트의 build.gradle.kts 에서 모든 모듈에 대해서 board-system-core 에 대한 테스트 픽스쳐 의존성을 추가하고
기존 domain-member 에 작성했던 timeFixture 코드를 옮깁니다.
이때, 픽스쳐 자체도 테스트 대상이므로 픽스쳐에 대한 테스트 코드는 test 소스셋에서 작성합니다.
core 모듈쪽으로 testFixture 코드를 옮기고 domain-member 테스트를 실행하면 여전히 테스트가 잘 동작합니다.
> Task :board-system-container:testFixturesJar
> Task :board-system-container:compileTestKotlin FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':board-system-container:compileTestKotlin'.
> Could not resolve all files for configuration ':board-system-container:detachedConfiguration2'.
> Failed to transform board-system-container-0.0.1-plain.jar to match attributes {artifactType=classpath-entry-snapshot, org.gradle.libraryelements=jar, org.gradle.usage=java-runtime}.
> Execution failed for ClasspathEntrySnapshotTransform: /home/runner/work/board-system/board-system/board-system-container/build/libs/board-system-container-0.0.1-plain.jar.
> Check failed.
하지만 gradle 로 전체 프로젝트를 빌드하면 실패가 발생하는데요.
java-text-fixtures 플러그인을 추가하고나니, plain jar를 작업에 필요로 하는데 디렉터리에서 없어서 문제가 생긴 것 같습니다.
tasks.getByName("bootJar") {
enabled = true
}
tasks.getByName("jar") {
enabled = false
}
container 모듈은 기존에 jar 빌드를 false로 해뒀었는데 이것이 플러그인 추가로 인해 문제가 된 듯 합니다.
tasks.getByName("bootJar") {
enabled = true
}
jar 빌드를 false로 해뒀던 것을 지워서 다시 jar 빌드가 되게 합니다.
그런데 이렇게 하면 container 모듈의 build/libs 아래에 jar 파일이 3개 생성되게 됩니다.
FROM amazoncorretto:21-alpine3.20-jdk
ARG JAR_FILE=board-system-container/build/libs/*.jar
ARG PROFILES
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["sh", "-c", "java -DSpring.profiles.active=${PROFILES} -Dspring.config.location=classpath:/,file:/app/config/board-system/ -jar app.jar"]
그런데 이렇게 하면 Dockerfile에서 문제가 될 여지가 생깁니다. 모든 jar 파일이 가져와지겠죠.
저희가 필요한건 bootJar로 빌드된 jar파일 하나뿐입니다.
저는 여기서 board-system-container-0.0.1.jar 가 필요합니다.
그런데 0.0.1 부분은 버전정보인데 아무리 생각해도 향후 불필요할 것 같더라구요.
// 루트 프로젝트 + 서브 프로젝트 전체
allprojects {
group = ProjectProperties.GROUP_NAME
repositories {
mavenCentral()
}
}
그래서 루트의 build.gradle.kts 에서 version 정보를 제거하고
다시 build 해보니 board-system-container.jar 형태로 빌드되네요.
FROM amazoncorretto:21-alpine3.20-jdk
ARG JAR_FILE=board-system-container/build/libs/board-system-container.jar
ARG PROFILES
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["sh", "-c", "java -DSpring.profiles.active=${PROFILES} -Dspring.config.location=classpath:/,file:/app/config/board-system/ -jar app.jar"]
Dockerfile에서는 board-system-container.jar 로 구체적인 jar 파일을 지정해줬습니다.
빌드 문제가 해결됐습니다.
그 외에도 기존에 중복 작성하거나, 선언된 모듈과 다른 모듈에서 구현된 Fixture들을 선언 모듈의 testFixtures 소스셋으로 이동하는 작업도 진행했습니다. 모두 성공적으로 빌드됐습니다.
그 이외에, 인터페이스를 작성해두고 테스트 픽스쳐를 작성해두지 않은 것들도 많이 있는데 이들에 대해서는 이후 구현과정에서 하나씩 만들어가도록 하겠습니다.
5. 접근제어자 수정
package com.ttasjwi.board.system.member.domain.model
class Email
internal constructor(
val value: String
) {
companion object {
앞서 testFixture 들을 다른 모듈에 구현해야했기 때문에 도메인 모델들의 생성자 접근제어자를 public으로 열어뒀었는데, 이제 접근제어자를 internal 로 변경해보겠습니다.
이제 이메일 인스턴스의 생성자는 restore 메서드 또는 testFixtures 메서드의 내부 구현에서만 호출되고, 그 외의 어떤 곳에서도 호출될 수 없습니다.
물론 이후 domain-core 모듈 내에 이메일 생성 로직을 만들텐데 그 과정에서도 내부적으로 생성자가 또 호출되도록 하긴 할겁니다. 하지만 외부에서는 생성자의 접근 자체가 금지된 것은 동일합니다. domain-core 모듈이 아닌 곳에서 Email 에 대한 생성을 특수한 목적의 함수, 메서드를 통해서만 할 수 있습니다.
이렇게 테스트 픽스쳐 중복 문제가 해결됐습니다.
이번 글 이후부터는 테스트 픽스쳐는 testFixtures 소스셋에 두고 작성하면서, 구현되지 않은 기능들을 하나 둘 씩 구현해보겠습니다.
리포지토리 : https://github.com/ttasjwi/board-system
PR: https://github.com/ttasjwi/board-system/pull/28
'Project' 카테고리의 다른 글
[토이프로젝트] 게시판 시스템(board-system) 13. 데이터베이스 접근기술 적용 (0) | 2024.11.04 |
---|---|
[토이프로젝트] 게시판 시스템(board-system) 12. 이메일/사용자 아이디/닉네임 사용가능 여부 확인 API 구현 (0) | 2024.10.31 |
[토이프로젝트] 게시판 시스템(board-system) 10. 애플리케이션 내부 아키텍처 설계 (0) | 2024.10.28 |
[토이프로젝트] 게시판 시스템(board-system) 9. API 예외 메시지 처리 (0) | 2024.10.21 |
[토이프로젝트] 게시판 시스템(board-system) 8. 메시지,국제화 / API 응답 규격 (0) | 2024.10.16 |