문제 상황
현재 진행중인 프로젝트는 멀티모듈로 레이어드 아키텍처를 구성하여 진행중입니다. 도메인 계층의 코드를 순수한 자바 코드로 관리하고, db에 접근하는 엔티티와 비즈니스 로직을 위한 자바 객체를 분리함으로서 역할을 나누기 위해서입니다. 자세한 설계는 아래와 같습니다.
.
├── build.gradle.kts
├── api // Kotlin
│ ├── build.gradle.kts
├── build.gradle.kts
├── domain // Java
│ └── build.gradle
├── settings.gradle.kts
└── storage // Kotlin
├── core-mongo
└── core-mysql
├── build.gradle.kts
└── src
Plain Text
복사
domain 모듈에서는 @Getter 등 간결한 코드와 편리를 위해 Lombok 라이브러리를 사용 중입니다. 하지만 storage 모듈의 AccountEntity.kt 에서, 엔티티를 자바 객체로 변환하는 코드에서 Unresolved reference: builder 라는 에러가 발생합니다.
/**
:storage:core-mysql
AccountEntity(.kt)
*/
@Entity
@Table(name="account")
class AccountEntity(
) : BaseEntity() {
fun to(): Account {
return Account.builder().build()
}
}
Kotlin
복사
/**
:domain
Account(.java)
*/
@Getter
public class Account {
private final Long id;
private final String email;
private final String password;
private final String riotName;
private final String riotTag;
private final String riotId;
private final Boolean isAgreeToPersonalInfo;
private final Boolean isAgreeToEmail;
private final Boolean isCertificatedEmail;
@Builder
public Account(Long id, String email, String password, String riotName, String riotTag, String riotId, Boolean isAgreeToPersonalInfo, Boolean isAgreeToEmail, Boolean isCertificatedEmail) {
this.id = id;
this.email = email;
this.password = password;
this.riotName = riotName;
this.riotTag = riotTag;
this.riotId = riotId;
this.isAgreeToPersonalInfo = isAgreeToPersonalInfo;
this.isAgreeToEmail = isAgreeToEmail;
this.isCertificatedEmail = isCertificatedEmail;
}
}
Java
복사
Lombok의 문제
@Entity
@Table(name="account")
class AccountEntity(
) : BaseEntity() {
fun to(): Account {
return Account(id, email, password, riotName, riotTag, riotId, isAgreeToPersonalInfo, isAgreeToEmail, isCertificatedEMail)
}
}
Java
복사
하지만 위와 같은 방식으로, Builder 를 사용하지 않고 객체를 생성하게 되면 문제가 발생하지 않습니다. 이는 Lombok 라이브러리의 특성 때문입니다. 자바와 코틀린을 함께 사용할 때는, 코틀린 쪽에서 자바의 Lombok을 인식하지 못합니다. Lombok 은 자바 코드(.java)가 자바 컴파일러를 통해 자바 바이트 코드(.class)로 컴파일 될 때, 소스 코드에 적용된 어노테이션을 분석하고, 어노테이션이 있는 필드/메서드를 기반으로 코드를 생성합니다. 필드 → 생성자 순으로 자동으로 코드를 생성한 후, 컴파일 시점에 원본 소스 코드에 주입됩니다. 자바 바이트 코드(.class)에는 Lombok 어노테이션이 존재하지 않게 됩니다.
dependencies {
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
}
Plain Text
복사
gradle에서 Lombok 라이브러리가 compileOnly 인 이유입니다. Lombok 라이브러리는 자바 코드에서 자바 바이트 코드로 컴파일 될 때만 사용됩니다.
하지만 자바와 코틀린이 함께 사용된 프로젝트를 빌드할 때는 아래 순서로 빌드가 진행됩니다.
1. .kt -> .class 컴파일
2. .java -> .class 컴파일(Lombok)
3. 런타임 실행
Plain Text
복사
때문에 당연히 Lombok 에서 주입되지 않은 코드를 확인할 수 없으므로 문제가 발생하게 됩니다.
해결 방안 1. Lombok을 사용하지 않기
장단점이 뚜렷한 방법입니다.
장점: 문제가 발생할 가능성이 Zero
단점: 단순 타이핑 시간이 많아짐
Plain Text
복사
변환된 코드만 빠르게 보고 넘어가겠습니다.
/**
:domain
Account(.java)
*/
public class Account {
private String id;
private String email;
private String password;
private String riotName;
private String riotTag;
private String riotId;
private Account(String id, String email, String password, String riotName, String riotTag, String riotId) {
this.id = id;
this.email = email;
this.password = password;
this.riotName = riotName;
this.riotTag = riotTag;
this.riotId = riotId;
}
public static class Builder {
private String id;
private String email;
private String password;
private String riotName;
private String riotTag;
private String riotId;
public Builder id(String id) {
this.id = id;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder password(String password) {
this.password = password;
return this;
}
public Builder riotName(String riotName) {
this.riotName = riotName;
return this;
}
public Builder riotTag(String riotTag) {
this.riotTag = riotTag;
return this;
}
public Builder riotId(String riotId) {
this.riotId = riotId;
return this;
}
public Account build() {
return new Account(id, email, password, riotName, riotTag, riotId);
}
}
// Getters...
}
Java
복사
어차피 도메인 모듈은 자바 코드로만 작성하는 것이 목표입니다. Kotlin으로 도메인 객체를 작성하게 되면, @Setter 를 열어두어야 하는데, 개인적으로 필요하지 않은 메서드를 열어두어야 하는 것이 좋아보이지 않기 때문입니다.
해결 방안 2. 컴파일 순서 조정하기
> Task :api:checkKotlinGradlePluginConfigurationErrors
> Task :domain:checkKotlinGradlePluginConfigurationErrors
> Task :domain:kaptGenerateStubsKotlin UP-TO-DATE
> Task :domain:kaptKotlin SKIPPED
> Task :domain:compileKotlin NO-SOURCE
> Task :domain:compileJava UP-TO-DATE
> Task :domain:processResources UP-TO-DATE
> Task :domain:classes UP-TO-DATE
> Task :domain:jar UP-TO-DATE
> Task :api:kaptGenerateStubsKotlin UP-TO-DATE
> Task :api:kaptKotlin SKIPPED
> Task :api:compileKotlin UP-TO-DATE
> Task :api:compileJava NO-SOURCE
> Task :api:processResources UP-TO-DATE
> Task :api:classes UP-TO-DATE
> Task :api:kaptGenerateStubsTestKotlin UP-TO-DATE
> Task :api:kaptTestKotlin SKIPPED
> Task :api:compileTestKotlin UP-TO-DATE
> Task :api:compileTestJava NO-SOURCE
> Task :api:processTestResources NO-SOURCE
> Task :api:testClasses UP-TO-DATE
> Task :storage:core-mysql:checkKotlinGradlePluginConfigurationErrors
> Task :storage:core-mysql:kaptGenerateStubsKotlin UP-TO-DATE
> Task :storage:core-mysql:kaptKotlin SKIPPED
> Task :storage:core-mysql:compileKotlin UP-TO-DATE
> Task :storage:core-mysql:compileJava NO-SOURCE
> Task :storage:core-mysql:processResources UP-TO-DATE
> Task :storage:core-mysql:classes UP-TO-DATE
> Task :storage:core-mysql:jar UP-TO-DATE
Plain Text
복사
setting.gradle.kts 파일과 각 모듈 속의 build.gradle.kts 파일을 잘 조정하면 빌드 순서를 조정할 수 있습니다.
/**
* api module의 build.gradle.kts
*/
dependencies {
implementation(project(":domain"))
runtimeOnly(project(":storage:core-mysql"))
implementation("org.springframework.boot:spring-boot-starter-web")
}
Groovy
복사
/**
* setting.gradle.kts
*/
include (
"domain",
"api",
"mail",
"common",
"storage:core-mysql"
)
Groovy
복사
⇒ 이렇게 되면 api 모듈의 @SpringBootApplication 실행 시, 별다른 코드 변경 없이 잘 실행됩니다.
결론 및 후속 문제
하지만 api 모듈의 테스트 코드를 실행할 때 컴파일 에러가 발생합니다.
> Task :api:checkKotlinGradlePluginConfigurationErrors
> Task :domain:checkKotlinGradlePluginConfigurationErrors
> Task :domain:kaptGenerateStubsKotlin UP-TO-DATE
> Task :domain:kaptKotlin SKIPPED
> Task :domain:compileKotlin NO-SOURCE
> Task :domain:compileJava UP-TO-DATE
> Task :domain:processResources UP-TO-DATE
> Task :domain:classes UP-TO-DATE
> Task :domain:jar UP-TO-DATE
> Task :api:kaptGenerateStubsKotlin UP-TO-DATE
> Task :api:kaptKotlin SKIPPED
> Task :api:compileKotlin UP-TO-DATE
> Task :api:compileJava NO-SOURCE
> Task :api:processResources UP-TO-DATE
> Task :api:classes UP-TO-DATE
> Task :api:kaptGenerateStubsTestKotlin UP-TO-DATE
> Task :api:kaptTestKotlin SKIPPED
> Task :api:compileTestKotlin UP-TO-DATE
> Task :api:compileTestJava NO-SOURCE
> Task :api:processTestResources NO-SOURCE
> Task :api:testClasses UP-TO-DATE
> Task :storage:core-mysql:checkKotlinGradlePluginConfigurationErrors
> Task :storage:core-mysql:kaptGenerateStubsKotlin UP-TO-DATE
> Task :storage:core-mysql:kaptKotlin SKIPPED
> Task :storage:core-mysql:processResources UP-TO-DATE
> Task :storage:core-mysql:compileKotlin FAILED
e: file:/co/storage/core-mysql/src/main/kotlin/boaz/lol/co/entity/account/AccountEntity.kt:38:24 Unresolved reference: builder
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':storage:core-mysql:compileKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details
Groovy
복사
:storage:core-mysql 모듈에서 compileKotlin 작업을 수행할 때 에러가 발생하네요.
아쉬운 결론이지만, 도메인 모듈을 코틀린으로 작성하는 것으로 변경하였습니다. Kotlin에서 data class 를 선언해서 도메인 객체에 대한 비즈니스 코드를 작성할 때 setter 가 열리게 되는 것은 아쉬운 부분이지만, 코드에 기여하는 개발 팀원들이 setter의 활용을 지양하는 이유를 이미 알고 있고, DeLombok을 진행해서 자바 코드를 작성하기에 너무 많은 개발 비용과 시간이 투자되기 때문입니다.
두 언어의 장단점을 각각 살리기 위해 굳이 혼용하기보다, 장점(코틀린의 경우 빠른 빌드 속도와 적은 코드량)을 극대화 하는 게 낫겠다는 결론을 내렸습니다.
References
아래 글에서 모듈 설계에 대한 내용을 더 찾아볼 수 있습니다.