스프링 프레임워크/kotlin

Java/Spring -> Kotlin/Spring 변환 - 4

blogger903 2024. 8. 7. 22:57
728x90

환경
IDE: intellij
SpringBootVersion: 2.7.18
Gradle: 8.5
Java: 17

해당 포스팅은
인프런 "코틀린 문법부터 실무까지 (자바 to 코틀린 실무)" 을 따라하면서 Java/Spring 프로젝트를 Kotlin/Spring 프로젝트로
점진적인으로 변환하는 내용을 담고 있습니다

 

다루는 내용

  • Controller 변환
  • DummyEntity 클래스 생성
  • DummyDto 클래스 생성
  • let
  • Optional, Stream 대체
  • Repository 변환
  • 범위함수 적용

Controller 변환

Kotlin은 Typescript와 비슷하게 한 파일에 여러 클래스와 enum function 등을 한 파일에 모아 둘수 있습니다

CRUD 컨트롤러를 C,R,U,D 각각을 쪼개려고 합니다

AS-IS

import com.makers.princemaker.dto.CreatePrince
import com.makers.princemaker.dto.EditPrince
import com.makers.princemaker.dto.PrinceDetailDto
import com.makers.princemaker.dto.PrinceDto
import com.makers.princemaker.service.PrinceMakerService
import lombok.RequiredArgsConstructor
import lombok.extern.slf4j.Slf4j
import org.springframework.web.bind.annotation.*
import javax.validation.Valid

/**
 * @author Snow
 */
@RestController
class PrinceMakerController(
    val princeMakerService: PrinceMakerService
) {

    @PostMapping("/create-prince")
    fun createPrince(
        @Valid
        @RequestBody request: CreatePrince.Request?
    ): CreatePrince.Response {
        return princeMakerService.createPrince(request!!)
    }

    @get:GetMapping("/princes")
    val princes: List<PrinceDto>
        get() = princeMakerService.allPrince

    @GetMapping("/prince/{princeId}")
    fun getPrince(
        @PathVariable princeId: String?
    ): PrinceDetailDto {
        return princeMakerService.getPrince(princeId)
    }

    @PutMapping("/prince/{princeId}")
    fun updatePrince(
        @PathVariable princeId: String?,
        @Valid
        @RequestBody request:  EditPrince.Request?
    ): PrinceDetailDto {
        return princeMakerService.editPrince(princeId, request!!)
    }

    @DeleteMapping("/prince/{princeId}")
    fun deletePrince(
        @PathVariable princeId: String?
    ): PrinceDetailDto {
        return princeMakerService.woundPrince(princeId)
    }
}

TO-BE

v1

@RestController
class CreatePrinceController(
    val princeMakerService: PrinceMakerService
) {
    @PostMapping("/create-prince")
    fun createPrince(
        @Valid
        @RequestBody request: CreatePrince.Request?
    ): CreatePrince.Response {
        return princeMakerService.createPrince(request!!)
    }

}

v2 expression body 적용

import com.makers.princemaker.entity.Prince
import com.makers.princemaker.service.PrinceMakerService
import com.makers.princemaker.type.PrinceLevel
import com.makers.princemaker.type.SkillType
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import javax.validation.Valid
import javax.validation.constraints.Min
import javax.validation.constraints.NotNull
import javax.validation.constraints.Size

@RestController
class CreatePrinceController(
    val princeMakerService: PrinceMakerService
) {
    @PostMapping("/create-prince")
    fun createPrince(
        @Valid
        @RequestBody request: CreatePrince.Request?
    ): CreatePrince.Response = request?.let { princeMakerService.createPrince(it) } ?: CreatePrince.Response() 
}

class CreatePrince {

    data class Request(
        @field:NotNull
        val princeLevel:  PrinceLevel? = null,
        @field:NotNull
        val skillType:  SkillType? = null,
        @field:NotNull
        @field:Min(0)
        val experienceYears:   Int? = null,
        @field:NotNull
        @field:Size(min = 3, max = 50, message = "invalid princeId")
        val princeId:   String? = null,
        @field:NotNull
        @field:Size(min = 2, max = 50, message = "invalid name")
        val name:  String? = null,
        @field:NotNull
        val age: @Min(18) Int? = null
    )

    class Response(
        val princeLevel: PrinceLevel? = null,
        val skillType: SkillType? = null,
        val experienceYears: Int? = null,
        val princeId: String? = null,
        val name: String? = null,
        val age: Int? = null,
    ) {

        companion object {

            @JvmStatic
            fun fromEntity(prince: Prince): Response {
                return Response(
                    princeLevel = prince.princeLevel,
                    skillType = prince.skillType,
                    experienceYears = prince.experienceYears,
                    princeId = prince.princeId,
                    name = prince.name,
                    age = prince.age,
                )
            }
        }
    }
}

 

 

DummyEntity 클래스 생성

테스트케이스를 작성해보면 Entity 생성할 일이 굉장히 많고 매번 값을 매핑해주는게 귀찮기 때문에 IDE에 fill property plugin도 존재합니다. 테스트 코드를 읽을때도 장황하면 관심이 분산되기 때문에 dummyEntity 팩토리 메서드를 사용하는것이 좋습니다

 

이렇게 DummyEntity를 생성하는 메서드를 생성합니다

fun dummyPrince(
    id: Long? = 1L,
    princeLevel: PrinceLevel = PrinceLevel.BABY_PRINCE,
    skillType: SkillType = SkillType.WARRIOR,
    status: StatusCode = StatusCode.HEALTHY,
    experienceYears: Int = 23,
    princeId: String = "princeId",
    name: String = "name",
    age: Int = 30,
    createdAt: LocalDateTime? = LocalDateTime.now(),
    updatedAt: LocalDateTime? = LocalDateTime.now(),
) = Prince(
    id = id,
    princeLevel = princeLevel,
    skillType = skillType,
    status = status,
    experienceYears = experienceYears,
    princeId = princeId,
    name = name,
    age = age,
    createdAt = createdAt,
    updatedAt = updatedAt
)

 

테스트를 위해 프로퍼티 값을 변경할때는 네임드 파라미터를 사용해서 명시적으로 필요로 하는 프로퍼티만 바꿀수 있습니다

@Test
fun princeTest() {
        //given
        val juniorPrince = dummyPrince(
            princeLevel = JUNIOR_PRINCE,
            skillType = INTELLECTUAL,
            experienceYears = PrinceMakerConstant.MAX_JUNIOR_EXPERIENCE_YEARS
        )

        every { princeRepository.findByPrinceId(any())
        } returns juniorPrince
        //when
        val prince = princeMakerService.getPrince("princeId")

        //then
        Assertions.assertEquals(JUNIOR_PRINCE, prince.princeLevel)
        Assertions.assertEquals(INTELLECTUAL, prince.skillType)
        Assertions.assertEquals(PrinceMakerConstant.MAX_JUNIOR_EXPERIENCE_YEARS, prince.experienceYears)
    }

 

DummyDto 클래스 생성

애플리케이션 서비스 테스트할때 input 객체에 대한 DummyInput 객체를 생성하는 코드도 적지 않게 사용합니다

그래서 DummyDto를 생성할수 있습니다

fun dummyCreatePrinceRequest(): CreatePrince.Request =
    CreatePrince.Request(
        princeLevel = PrinceLevel.JUNIOR_PRINCE,
        skillType = SkillType.WARRIOR,
        experienceYears = 10,
        princeId = "princeId",
        name = "name",
        age = 38
    )

 

CreatePrince class를 살펴보겠습니다

Request객체는 CreatePrince와 같이 유즈케이스로 묶어서 응집도를 높여서 유즈케이스에 Request, Response를 한군데서 관리할 수 있습니다.

class CreatePrince {

    data class Request(
        @field:NotNull
        val princeLevel:  PrinceLevel? = null,
        @field:NotNull
        val skillType:  SkillType? = null,
        @field:NotNull
        @field:Min(0)
        val experienceYears:   Int? = null,
        @field:NotNull
        @field:Size(min = 3, max = 50, message = "invalid princeId")
        val princeId:   String? = null,
        @field:NotNull
        @field:Size(min = 2, max = 50, message = "invalid name")
        val name:  String? = null,
        @field:NotNull
        val age: @Min(18) Int? = null
    )
}

 

data class를 사용하면 equals, hashcode, toString 등을 기본으로 파생됩니다

https://kotlinlang.org/docs/data-classes.html

 

Data classes | Kotlin

 

kotlinlang.org

data class 여기에 copy메서드를 사용할 수 있습니다. 테스트에 필요한 값들을 원하는대로 채워줄수 있습니다.

class PrinceMakerServiceKTest : BehaviorSpec({
    val princeRepository: PrinceRepository = mockk()
    val woundedPrinceRepository: WoundedPrinceRepository = mockk()

    val princeMakerService: PrinceMakerService = PrinceMakerService(princeRepository, woundedPrinceRepository)

    Given("프린스 생성 요청이 들어왔을 때") {
        val request = dummyCreatePrinceRequest()
            .copy(experienceYears = 3, princeLevel = JUNIOR_PRINCE, skillType = INTELLECTUAL)

        val juniorPrince = dummyPrince()

        every { princeRepository.save(any()) } returns juniorPrince

        When("id가 중복되지 않고 정상 요청인 경우") {

            every {
                princeRepository.findByPrinceId(any())
            } returns null

            Then("정상 응답을 합니다") {
                val response = princeMakerService.createPrince(request)
                response.princeLevel shouldBe JUNIOR_PRINCE
                response.skillType shouldBe INTELLECTUAL
                response.experienceYears shouldBe 3
            }
        }

        When("id가 중복되고 정상 요청인 경우") {

            every {
                princeRepository.findByPrinceId(any())
            } returns juniorPrince

            val ex = shouldThrow<PrinceMakerException> {
                princeMakerService.createPrince(request)
            }

            Then("익셉션 발생합니다") {
                ex.princeMakerErrorCode shouldBe DUPLICATED_PRINCE_ID
            }
        }
    }
})

 

let

let은 Kotlin의 범위 함수(scope function) 중 하나입니다. 이 함수는 객체의 컨텍스트 내에서 코드 블록을 실행할 수 있게 해주며, 주로 다음과 같은 상황에서 사용됩니다

  • 널이 아닌 객체에 대해 코드 블록을 실행할 때
  • 지역 변수의 범위를 제한할 때
  • 객체를 변경하지 않고 사용할 때

let의 기본구조

object.let { 
    // 이 블록 내에서 'it'은 object를 가리킴
    // 코드 실행
}

 

let의 주요 특징:

객체를 it이라는 암시적 파라미터로 받습니다 (이름을 명시적으로 지정할 수도 있습니다).
마지막 표현식의 결과를 반환합니다.
null safety call(?.)과 함께 자주 사용되어 널이 아닌 경우에만 코드 블록을 실행합니다.

v3 companion object 대신 확장함수

kotlin에서는 Java의 Static 메서드보다 확장함수라는 좋은 기능이 있기 때문에 더 가독성 좋게 구현할 수 있습니다

 

코드 예시 

import com.makers.princemaker.entity.Prince
import com.makers.princemaker.service.PrinceMakerService
import com.makers.princemaker.type.PrinceLevel
import com.makers.princemaker.type.SkillType
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import javax.validation.Valid
import javax.validation.constraints.Min
import javax.validation.constraints.NotNull
import javax.validation.constraints.Size

@RestController
class CreatePrinceController(
    val princeMakerService: PrinceMakerService
) {
    @PostMapping("/create-prince")
    fun createPrince(
        @Valid
        @RequestBody request: CreatePrince.Request?
    ): CreatePrince.Response = request?.let { princeMakerService.createPrince(it) } ?: CreatePrince.Response()
}



/**
 * @author Snow
 */
class CreatePrince {

    data class Request(
        @field:NotNull
        val princeLevel:  PrinceLevel? = null,
        @field:NotNull
        val skillType:  SkillType? = null,
        @field:NotNull
        @field:Min(0)
        val experienceYears:   Int? = null,
        @field:NotNull
        @field:Size(min = 3, max = 50, message = "invalid princeId")
        val princeId:   String? = null,
        @field:NotNull
        @field:Size(min = 2, max = 50, message = "invalid name")
        val name:  String? = null,
        @field:NotNull
        val age: @Min(18) Int? = null
    )

    class Response(
        val princeLevel: PrinceLevel? = null,
        val skillType: SkillType? = null,
        val experienceYears: Int? = null,
        val princeId: String? = null,
        val name: String? = null,
        val age: Int? = null,
    ) 
}

fun Prince.toCreatePrinceResponse() = CreatePrince.Response(
        princeLevel = this.princeLevel,
        skillType = this.skillType,
        experienceYears = this.experienceYears,
        princeId = this.princeId,
        name = this.name,
        age = this.age,
)

 

Optional, Stream 대체

JDK8 이후부터는 Optional, Stream을 통해 효과적이고 깔끔하게 Nullable한 데이터를 안전하게 처리했습니다

 

Optional

  • (꼭 있어야 하는 데이터)가 없을 경우
    • optionalData.orElseThrow(() -> new Exception("No data"))
  • 데이터가 없는 경우 대체값 지정
    • optionalData.orElseGet("Default Data")
  • 기타 다양한 기능 제공
    • 데이터가 있는 경우만 동작, 데이터 존재 여부 확인

코틀린에서 Optional 대체합니다

java kotlin
optionalData.orElseThrow(() -> throw new IllegalArgumentsException()) optionalData ?: throw IllegalArgumentsException()
optionalData.orElseGet("defaultValue") optionalData ?: "defaultValue"
optionalData.ifPresent(data -> data.doSth()) optionalData?.doSth()

 

굳이 Optional로 감쌀 필요가 없습니다

 

Stream

배열 또는 컬렉션 데이터를 처리하는 공통된 기능 제공

  java kotlin
데이터 필터링 collectionData.stream().filter(name -> filterName() > 3).collect(Collections.toList()); collectionData.filter{ it.length > 3 }
데이터 매핑 변환 collectionData.stream().map(name -> name.toUpperCase()).collect(Collections.toList()); collectionData.map{ it.uppercase }
데이터 정렬 collectionData.stream().sorted().collect(Collectors.toList()); collectionData.sorted()

 

위 기능을 보통 같이 쓰이고 Java예시는 다음과 같습니다

import java.util.Arrays;
import java.util.List;
import java.util.Scanner;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eva");

        List<String> filteredAndSortedNames = names.stream()
                .filter(name -> name.length() > 3)  // filter: 길이가 3보다 큰 이름만 선택
                .sorted()  // sorted: 알파벳 순으로 정렬
                .collect(Collectors.toList());  // collect: 결과를 List로 수집

        System.out.println(filteredAndSortedNames);
    }
}

 

Stream 기능을 Kotlin에서는 이렇게 씁니다

class JavaStreamToKotlinTest : ShouldSpec({
    context("Java Stream to Kotlin conversion") {
        should("filter names longer than 3 characters and sort them") {
            val list = listOf("Alice", "Bob", "Charlie", "David", "Eva")
            val result = list.filter { it.length > 3 }.sorted()
            result shouldBe listOf("Alice", "Charlie", "David")
        }
    }
})

 

Repository 변환

변환전 java 코드 

import com.makers.princemaker.code.StatusCode;
import com.makers.princemaker.entity.Prince;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface PrinceRepository extends JpaRepository<Prince, Long> {

    Optional<Prince> findByPrinceId(String princeId);

    List<Prince> findByStatusEquals(StatusCode status);
}

 

변환후 Kotlin 코드

import com.makers.princemaker.code.StatusCode
import com.makers.princemaker.entity.Prince
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface PrinceRepository : JpaRepository<Prince, Long> {
    fun findByPrinceId(princeId: String): Prince?
    fun findByStatusEquals(status: StatusCode): List<Prince>
}

 

Repository 변환시 트러블슈팅

Type mismatch.
Required: String
Found: String?

 

 

외부 요청(클라이언트 요청)의 경우 request 객체에서 nonnull 애노테이션을 달아두었기 때문에 여기서부터는 무조건 존재합니다

여기서 let을 사용하는건 오히려 코드가 verbose해집니다

 

변환후 코드

?.let 를 사용해서 깔끔하고 명시적인 인상을 줍니다

?.let은 다음 범위함수에서 다루겠습니다

 

범위함수 적용

이게 뭔지 확인후 쓰면서 익히는게 좋았습니다

응답값/수신객체 접근 this로 접근 it으로 접근
this apply also
마지막 값 with let

 

Apply 예시

 

typescript로 코드 작성시 저렇게 값을 매핑해주는 경우가 있습니다. 그런데 저렇게 엔티티가 오픈되면 유지보수할때 잘못 사용할 수 있는 여지가 농후하고, 유지보수하는 담당자도 짜증납니다

apply를 사용하면 필드 수정도 자유롭고 범위가 한정됐기 때문에 유지보수 담당자도 편안해집니다

 

Apply 적용전

@Transactional
fun editPrince(
    princeId: String?, request: EditPrince.Request
): PrinceDetailDto {
    val prince = princeRepository.findByPrinceId(princeId!!)
        ?: throw PrinceMakerException(PrinceMakerErrorCode.NO_SUCH_PRINCE)
    prince.princeLevel = request.princeLevel
    prince.skillType = request.skillType
    prince.experienceYears = request.experienceYears
    prince.name = request.name
    prince.age = request.age
    return PrinceDetailDto.fromEntity(prince)
}

 

Apply 적용후

@Transactional
fun editPrince(
    princeId: String?, request: EditPrince.Request
): PrinceDetailDto {
    val prince = princeRepository.findByPrinceId(princeId!!)
        ?: throw PrinceMakerException(PrinceMakerErrorCode.NO_SUCH_PRINCE)
    prince.apply {
        princeLevel = request.princeLevel
        skillType = request.skillType
        experienceYears = request.experienceYears
        name = request.name
        age = request.age
    }
    return PrinceDetailDto.fromEntity(prince)
}

 

Also 예시

 

Also 적용전

@Transactional
fun createPrince(request: CreatePrince.Request): CreatePrince.Response {
    validateCreatePrinceRequest(request)
    val prince = Prince(
        null,
        request.princeLevel!!,
        request.skillType!!, StatusCode.HEALTHY,
        request.experienceYears!!,
        request.princeId!!,
        request.name!!,
        request.age!!,
        null,
        null
    )
    princeRepository.save(prince)
    return prince.toCreatePrinceResponse()
}

 

also 적용후

@Transactional
fun createPrince(request: CreatePrince.Request): CreatePrince.Response {
    validateCreatePrinceRequest(request)
    return Prince(
        null,
        request.princeLevel!!,
        request.skillType!!, StatusCode.HEALTHY,
        request.experienceYears!!,
        request.princeId!!,
        request.name!!,
        request.age!!,
        null,
        null
    ).also {
        princeRepository.save(it)
    }.toCreatePrinceResponse()
}

 

With 활용

영역만 잡아주는데 가깝습니다

@Transactional
fun createPrince(request: CreatePrince.Request): CreatePrince.Response {
    validateCreatePrinceRequest(request)
    val prince = Prince(
        null,
        request.princeLevel!!,
        request.skillType!!, StatusCode.HEALTHY,
        request.experienceYears!!,
        request.princeId!!,
        request.name!!,
        request.age!!,
        null,
        null
    )
    princeRepository.save(prince)
    return prince.toCreatePrinceResponse()
}

 

let 활용

// without let
val prince = findBy(request.princeId)
if ( prince != null ) {
  PrinceDetailDto.fromEntity(prince)
}

// with let
findBy(request.princeId)?.let {
  // it은 non null입니다 
  PrinceDetailDto.fromEntity(it)
}