환경
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)
}
'스프링 프레임워크 > kotlin' 카테고리의 다른 글
kotlin-springboot spring-kafka로 produce, consume 시작하기 (0) | 2024.08.12 |
---|---|
Java/Spring -> Kotlin/Spring 변환 - 3 (0) | 2024.07.28 |
Java/Spring -> Kotlin/Spring 변환 - 2 (0) | 2024.07.27 |
Java/Spring -> Kotlin/Spring 변환 - 1 (0) | 2024.07.24 |