티스토리 뷰

Web/JAVA

[Kotlin] Java에서 Kotlin으로

Aaron 2025. 1. 23. 21:39
반응형

Java to Kotlin

Kotlin version

먼저 코틀린 설정을 위해 아무 .java 파일 우클릭 후 Convert Java File to Kotlin File 기능을 사용하면 OK, Configure Kotlin In the Project로 코틀린 설정이 가능합니다.

JDK 21을 사용할 예정이라서 Kotlin 버전은 2.1.0으로 설정하였습니다.

※ Which versions of Kotlin are compatible with which versions of Java?

IDE의 도움으로 kotlin 설정을 마치면 build.gradle, settings.gradle 파일에 코틀린 설정이 추가됩니다.

Kotlin 설정 및 gradle.kts로 변환

䷿AS-IS) build.gradle

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

group = 'com'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '21'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
    implementation 'org.springframework.boot:spring-boot-starter-mail'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // firebase Cloud Firestore
    implementation 'com.google.firebase:firebase-admin:9.2.0'

    // util
    implementation 'org.jsoup:jsoup:1.17.1'
    implementation 'org.apache.commons:commons-lang3'

    // lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // tool
    compileOnly 'org.springframework.boot:spring-boot-devtools'

    // test
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.projectlombok:lombok'
}

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

䷾ TO-BE) build.gradle.kts

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    val kotlinVersion = "2.1.0"
    kotlin("jvm") version kotlinVersion // Kotlin JVM을 사용하는 프로젝트를 위한 플러그인
    kotlin("plugin.spring") version kotlinVersion
    kotlin("plugin.jpa") version kotlinVersion
    id("org.springframework.boot") version "3.4.0"
    id("io.spring.dependency-management") version "1.1.6"
    id("org.jlleitschuh.gradle.ktlint") version "12.1.0" // Kotlin 코드 스타일을 자동으로 검사하고 포맷팅 하는 도구
}

group = "com"
version = "0.0.1-SNAPSHOT"

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

val springmockk = project.findProperty("springmockk")
val kotestRunner = project.findProperty("kotest.runner.junit5")
val kotestAssertions = project.findProperty("kotest.assertions")
val kotestExtensions = project.findProperty("kotest-extensions")
val jsoup = project.findProperty("jsoup")
val kotlinLogging = project.findProperty("kotlin.logging")

dependencies {
    // web
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
    implementation("org.springframework.boot:spring-boot-starter-mail")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-hateoas")
    implementation("org.jetbrains.kotlin:kotlin-reflect")

    // data
    implementation("org.springframework.boot:spring-boot-starter-data-mongodb")

    // Utility libraries
    implementation("org.jsoup:jsoup:$jsoup")
    implementation("org.apache.commons:commons-lang3")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("io.github.microutils:kotlin-logging-jvm:$kotlinLogging")

    // Spring Boot Devtools
    compileOnly("org.springframework.boot:spring-boot-devtools")

    // Testing libraries
    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
        exclude(group = "org.mockito")
    }
    testImplementation("com.ninja-squad:springmockk:$springmockk")
    testImplementation("io.kotest:kotest-runner-junit5:$kotestRunner")
    testImplementation("io.kotest.extensions:kotest-extensions-spring:$kotestExtensions")
    testImplementation("io.kotest:kotest-assertions-core:$kotestAssertions")
}

kotlin {
    compilerOptions {
        freeCompilerArgs.addAll("-Xjsr305=strict")
        jvmTarget.set(JvmTarget.JVM_21)
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

버전을 따로 분리해서 관리하고 싶다면 gradle.properties 파일을 활용할 수 있습니다.

kotlin.logging = 3.0.5

jsoup = 1.17.1

springmockk = 4.0.2
kotest.runner.junit5 = 5.9.1
kotest.assertions = 5.9.1
kotest-extensions = 1.3.0

Minimum Gradle version

코틀린 설정을 완료했다면 Gradle도 버전에 맞게 올려줍시다!

Kotlin version 2.1.0으로 설정했으니 Gradle version 8.11로 사용할 계획입니다.

Compatibility Matrix

gradle version 설정은 gradle > wrapper > gradle-wrapper.properties에서 수정할 수 있습니다.

...
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
...

Java to Kotlin

먼저 java에서 적용되던 lombok과의 이별을 해야 할 때입니다.

전반적인 전환 순서는 컴파일 오류가 발생하는 lombok을 없애면서 전환하게 되었는데, 대략적으로 아래 순서로 진행하게 되었던 것 같습니다.

  • DTO class
  • Util class
  • Entity class
  • Repository class
  • Service class
  • Controller class
  • 테스트 코드도 변환 및 보완하면서 정상동작 확인 (Test 파트 참고)

lombok 대신 data class

  • 컴파일러가 equals(), hashCode(), toString(), copy(), componentN() 메서드를 자동 생성
  • 데이터 클래스에 property 를 선언하는 순간 해당 property 는 field, Getter, Setter, 생성자 파라미터역할

䷿ AS-IS)

@Slf4j
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SavePostRequest {
    private String subject;
    private String title;
    private String url;
    private String category;
    private String writer;
    private String date;
    List<String> tags;
    private String createdDt;

    public boolean isLatestDatePost(final String latestPostDate) {
        if (StringUtils.isBlank(this.date) || StringUtils.isBlank(latestPostDate)) {
            return true;
        }

        try {
            final LocalDate latest = LocalDate.parse(latestPostDate, DateUtils.CREATED_FORMATTER);
            final LocalDate date = LocalDate.parse(this.date, DateUtils.CREATED_FORMATTER);
            return date.isAfter(latest);
        } catch (Exception e) {
            log.error("Error parsing the date. date: {}, message: {}", this.date, e.getMessage(), e);
            return false;
        }
    }

    public Post toPost() {
        return Post.builder()
                .subject(this.subject)
                .title(this.title)
                .category(this.category)
                .writer(this.writer)
                .date(this.date)
                .tags(this.tags)
                .url(this.url)
                .shared(false)
                .createdDt(this.createdDt)
                .build();
    }
}

䷾ TO-BE)

private val logger = KotlinLogging.logger {}

data class SavePostRequest(
    val subject: String,
    val title: String,
    val url: String,
    val category: String,
    val writer: String,
    val date: String,
    var tags: List<String>,
    val createdDt: String,
) {

    fun isLatestDatePost(latestPostDate: String): Boolean {
        if (date.isBlank() || latestPostDate.isBlank()) return true

        return try {
            val latest = LocalDate.parse(latestPostDate, DateUtils.CREATED_FORMATTER)
            val parsedDate = LocalDate.parse(date, DateUtils.CREATED_FORMATTER)
            parsedDate.isAfter(latest)
        } catch (e: Exception) {
            logger.error { "Error parsing the date. date: $date, message: ${e.message}" }
            false
        }
    }

    fun toPost(): Post = Post(
        subject = this.subject,
        title = this.title,
        category = this.category,
        writer = this.writer,
        date = this.date,
        tags = this.tags,
        url = this.url,
        shared = false,
        createdDt = this.createdDt,
    )
}

Enum

  • enum도 마찬가지로 property 선언이 field, Getter, Setter, 생성자 파라미터 역할을 하게 됩니다.

䷿ AS-IS)

@AllArgsConstructor
public enum PostSubjects {
    SPRING("Spring"),
    JAVA("Java"),
    ;

    private String value;

    public String value() {
        return value;
    }
}

䷾ TO-BE)

enum class PostSubjects(val value: String) {
    SPRING("Spring"),
    JAVA("Java"),
    ;

    companion object {
        fun from(value: String): PostSubjects {
            return entries.firstOrNull { it.value == value} ?: SPRING
        }
    }
}

Util

  • Util 클래스는 object 타입으로 활용할 수 있습니다.

䷿ AS-IS)

@Slf4j
public class DateUtils {

    public final static DateTimeFormatter ENGLISH_FORMATTER = DateTimeFormatter.ofPattern("[MMMM dd, yyyy][MMMM d, yyyy][MMM dd, yyyy][MMM d, yyyy]", Locale.ENGLISH);
    public final static DateTimeFormatter CREATED_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    public static String getFormattedDate(final String date) {
        try {
            final LocalDate localDate = LocalDate.parse(date, ENGLISH_FORMATTER);
            return localDate.format(CREATED_FORMATTER);
        } catch (DateTimeParseException e) {
            log.error("DateTimeParseException. {}", date);
            return StringUtils.EMPTY;
        }
    }
}

䷾ TO-BE)

object DateUtils {
    val ENGLISH_FORMATTER: DateTimeFormatter =
        DateTimeFormatter.ofPattern("[MMMM dd, yyyy][MMMM d, yyyy][MMM dd, yyyy][MMM d, yyyy][d MMMM yyyy][d MMM yyyy", Locale.ENGLISH)

    val CREATED_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")

    fun parseEnglishDateFormat(date: String): String = try {
        LocalDate.parse(date, ENGLISH_FORMATTER).format(CREATED_FORMATTER)
    } catch (e: DateTimeParseException) {
        logger.error(e) { "Failed to parse date: $date" }
        today()
    }

    fun today(): String =
        LocalDate.now().format(CREATED_FORMATTER)
}

Repository

  • Repository 쪽은 기본 문법이 변경된 것 말고는 크게 달라진 부분이 없어 보입니다.

䷿ AS-IS)

public interface PostsRepository extends MongoRepository<Post, String> {

    @Aggregation(pipeline = {
            "{ '$match': { 'category' : ?0 } }",
            "{ '$sort' : { 'date' : -1 } }",
            "{ '$limit' : 1 }"
    })
    List<Post> findByCategoryOrderByDateDescLimitOne(String category);

    Page<Post> findBySubject(String subject, PageRequest pageable);

    Page<Post> findBySubjectAndCategoryIn(String subject, List<String> category, PageRequest pageable);

    List<Post> findBySharedFalse();

    List<Post> findByTitle(String title);
}

䷾ TO-BE)

interface PostsRepository : MongoRepository<Post, String> {
    @Aggregation(
        pipeline = [
            "{ '\$match': { 'category' : ?0 } }",
            "{ '\$sort' : { 'date' : -1 } }",
            "{ '\$limit' : 1 }",
        ],
    )
    fun findByCategoryOrderByDateDescLimitOne(category: String): List<Post>

    fun findBySubject(subject: String, pageable: PageRequest): Page<Post>

    fun findBySubjectAndCategoryIn(subject: String, category: List<String>?, pageable: PageRequest): Page<Post>

    fun findBySharedFalse(): List<Post>

    fun findByTitle(title: String): List<Post>
}

Service

  • kotlin 코드가 확실하게 간결한 것을 확인할 수 있습니다.

䷿ AS-IS)

@Slf4j
@Service
@RequiredArgsConstructor
public class PostsSchedulerService {

    private final PostsRepository postsRepository;

    @Transactional
    public void insertPost(SavePostRequest savePostRequest) {
        try {
            postsRepository.save(savePostRequest.toPost());
            log.info("add new post. {}", savePostRequest.getTitle());
        } catch (Exception e) {
            log.error("SpringBlogsSchedulerService.insertPost exception", e);
        }
    }

    @Transactional(readOnly = true)
    public Post findLatestPost(final String category) {
        final List<Post> latestPost = postsRepository.findByCategoryOrderByDateDescLimitOne(category);
        if (latestPost.isEmpty()) {
            return Post.EMPTY;
        }

        return latestPost.get(0);
    }

    public boolean isNotExistOracleJavaPosts(final String title) {
        final List<Post> posts = postsRepository.findByTitle(title);
        if (posts.isEmpty()) {
            return true;
        }
        return false;
    }
}

䷾ TO-BE)

private val logger = KotlinLogging.logger {}

@Service
class PostsSchedulerService(
    private val postsRepository: PostsRepository,
) {
    @Transactional
    fun insertPost(savePostRequest: SavePostRequest) {
        return try {
            postsRepository.save(savePostRequest.toPost())
            logger.info("add new post. ${savePostRequest.title}")
        } catch (e: java.lang.Exception) {
            logger.error("SpringBlogsSchedulerService.insertPost exception", e)
        }
    }

    @Transactional(readOnly = true)
    fun findLatestPost(category: String): Post =
        postsRepository.findByCategoryOrderByDateDescLimitOne(category).firstOrNull() ?: Post()

    fun isNotExistOracleJavaPosts(title: String): Boolean =
        postsRepository.findByTitle(title).isEmpty()
}

Controller

  • 아래 코드 기준으로는 @RequiredArgsConstructor 제외하고는 크게 달라지는 점이 없어 보네요.

䷿ AS-IS)

@RestController
@RequestMapping("/posts")
@RequiredArgsConstructor
public class PostsController {

    private final PostsService postsService;

    @GetMapping("/spring")
    public ResponseEntity springScroll(
            @RequestParam(value = "categories", required = false) final List<String> categories,
            @RequestParam(value = "page", required = false, defaultValue = "1") final int page,
            @RequestParam(value = "size", required = false, defaultValue = "10") final int size) {

        final PageRequest pageable = PageRequest.of(page, size,
                Sort.by("createdDt").descending().and(Sort.by("date").descending()));
        final Page<Post> releasePage = postsService.findAllRelease(PostSubjects.SPRING, pageable, categories);
        return BasicResponse.ok(releasePage);
    }
    //...
}

䷾ TO-BE)

@RestController
@RequestMapping("/posts")
class PostsController(
    private val postsService: PostsService
) {
    @GetMapping("/spring")
    fun springScroll(
        @RequestParam(value = "categories", required = false) categories: List<String>?,
        @RequestParam(value = "page", required = false, defaultValue = "1") page: Int,
        @RequestParam(value = "size", required = false, defaultValue = "10") size: Int
    ): ResponseEntity<*> {
        val pageable = PageRequest.of(
            page, size,
            Sort.by("createdDt").descending().and(Sort.by("date").descending())
        )
        val releasePage = postsService.findAllRelease(PostSubjects.SPRING, pageable, categories)
        return BasicResponse.ok(releasePage)
    }
    //...
}

warning

  • Unnecessary non-null assertion (!!) ... 불필요한 non-null assertion 제거
  • Kotlin은 Java로부터 변환될 때 null 허용을 기본으로 하고 있다 보니 ? 키워드를 가급적 모두 제거하고 필요시 추가하기

Test

단위테스트

✅ Fixtures

  • 테스트 픽스처(테스트를 위한 전제 조건)를 반환하는 팩토리함수
createPost(title = "post01")
createPost(subject = "subject01")

...

fun createPost(
    id: String = "",
    subject: String = "",
    title: String = "",
    url: String = "",
    category: String = "",
    writer: String = "",
    date: String = "",
    tags: List<String> = emptyList(),
    shared: Boolean = false,
    createdDt: String = "",
): Post {
    return Post(id, subject, title, url, category, writer, date, tags, shared, createdDt)
}

✅ 테스트 확장함수

  • 확장함수를 사용하여 값을 더 쉽게 표현
  • 확장함수로 검증 코드의 가독성을 향상

👉🏻 StringSpec

Kotest

✅ Kotest

  • 코틀린다운 테스트를 위해 Kotest 라이브러리 활용
  • Kotlin에서 Kotest가 가장 많이 사용
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")

✅ StringSpec

  • 테스트를 단순하고 직관적으로 작성 가능
  • 각 테스트가 독립적으로 실행
  • 주로 독립적인 단위 테스트나, 테스트를 선언적으로 나열하는 데 적합
class SavePostRequestTest : StringSpec({
    "latest post should be return true" {
        val release = SavePostRequest(
            date = "2024-04-30"
        )
        assertSoftly(release) {
            release.isLatestDatePost("2024-04-29").shouldBeTrue()
            release.isLatestDatePost("2024-04-26").shouldBeTrue()
        }
    }

    "not latest post should be return false" {
        val release = SavePostRequest(
            date = "2024-04-30"
        )
        assertSoftly(release) {
            release.isLatestDatePost("2024-04-30").shouldBeFalse()
            release.isLatestDatePost("2024-04-31").shouldBeFalse()
        }
    }
})

✅ BehaviorSpec

  • BDD(Behavior-Driven Development) 스타일로 작성된 테스트
  • 테스트를 행동 단위로 그룹화하며, Given, When, Then의 구조를 따름
  • 계층적으로 테스트를 구성할 수 있어 복잡한 시나리오 테스트에 적합
class PostsSchedulerServiceTest : BehaviorSpec({
    val postsRepository = mockk<PostsRepository>()

    val postsSchedulerService = PostsSchedulerService(postsRepository)

    Given("특정 카테고리의 게시물이 존재하는 경우") {
        val post = listOf(createPost(date = "2024-12-25"))

        every { postsRepository.findByCategoryOrderByDateDescLimitOne(any()) } returns post

        When("가장 마지막으로 수집된 게시을 조회하여") {
            Then("게시일을 확인할 수 있다.") {
                val lastPost = postsSchedulerService.findLastPost(JavaBlogsSubject.INSIDE.value)
                lastPost.date shouldBe "2024-12-25"
            }
        }
    }

    Given("특정 카테고리의 게시물이 존재하지 않는 경우") {
        every { postsRepository.findByCategoryOrderByDateDescLimitOne(any()) } returns emptyList()

        When("기본 엔티티를 리턴하여") {
            Then("기본 게시일을 확인할 수 있다.") {
                val lastPost = postsSchedulerService.findLastPost(JavaBlogsSubject.INSIDE.value)
                lastPost.date shouldBe ""
            }
        }
    }
})

✅ ExpectSpec

  • 예상(expectation) 기반의 테스트
  • 테스트 계층을 구성할 수 있고, 명확한 테스트 시나리오와 결과를 나타내는 데 유리
  • 기대치를 중심으로 context와 expect 블록을 사용해 테스트를 구성
@RepositoryTest
class RepositoryTest(
    private val repository: Repository
) : ExpectSpec({
    context("A 조회") {
        val now = LocalDateTime.now()
        val codes = repository.saveAll(
            listOf(
                code(EMAIL, createdDateTime = now),
                code(EMAIL, createdDateTime = now.plusSeconds(1L)),
                code(EMAIL, createdDateTime = now.plusSeconds(2L))
            )
        )

        expect("가장 최근에 생성된 A를 조회한다") {
            val actual = repository.findFirstByA(EMAIL)
            actual shouldBe codes.last()
        }
    }
})

예제를 만들기 위해 약간의 억지로 테스트코드가 생성되었지만 참고하는데 도움이 되었으면 좋겠습니다.

ktlint

Kotlin 코드 스타일을 자동으로 검사하고 포맷팅 하는 플러그인

ktlint를 제공하는 여러 도구 중 JLLeitschuh/ktlint-gradle 이 주로 사용되는 것 같습니다.

id("org.jlleitschuh.gradle.ktlint") version "12.1.1"

Standard rules을 enable 처리할 수 있는데 pinterest에서 제공하는 ktlint 문서를 참고할 수 있습니다.

  • 커스텀한 규칙은 .editorconfig 파일에서 설정할 수 있습니다.

.editorconfig

# Standard rules
# https://pinterest.github.io/ktlint/latest/rules/standard/
root = true

[*]
insert_final_newline = true

[*.{kt,kts}]
# A multiline expression should start on a new line
ktlint_standard_string-template-indent=disabled
ktlint_standard_multiline-expression-wrapping=disabled

# Newline expected before closing parenthesis
# Newline expected before expression body
# First line of body expression fits on same line as function signature
# ...
ktlint_standard_function-signature=disabled

# Parameter should start on a newline
ktlint_standard_parameter-list-wrapping=disabled

# Exceeded max line length (140)
ktlint_standard_max-line-length=disabled

Apply IDE

해당 프로젝트에만 적용

$ ./gradlew ktlintApplyToIdea

IntelliJ를 사용하는 모든 프로젝트에 적용

$ ./gradlew ktlintApplyToIdeaGlobally

수동으로 ktlint를 이용하여 컨벤션 체크

  • 또는 Tasks → verification → ktlintCheck
$ ./gradlew clean ktlintCheck

Git hook을 통해 ktlint 설정

  • 커밋 전에 ktlintCheck 테스트 실행
  • 등록한 hook을 삭제하고 싶다면, hook 경로(.git/hooks)에서 pre-commit 삭제
$ mkdir .git/hooks
$ ./gradlew addKtlintCheckGitPreCommitHook

Reference

자바 프로젝트 3개 코틀린 점진적 전환기(feat. lombok 됩니다.)

반응형
댓글
최근에 올라온 글
최근에 달린 댓글
링크
Total
Today
Yesterday