티스토리 뷰

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) 기반의 테스트
  • 테스트 계층을 구성할 수 있고, 명확한 테스트 시나리오와 결과를 나타내는 데 유리
  • 기대치를 중심으로 contextexpect 블록을 사용해 테스트를 구성
@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 rulesenable 처리할 수 있는데 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를 이용하여 컨벤션 체크

  • 또는 TasksverificationktlintCheck
$ ./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