https://kchabin.notion.site/Kotlin-Spring-blog-029501d268794d04a2aa1f046f461caa?pvs=4
※ Notion에 개발 과정과 트러블슈팅 과정 등을 적어두고 티스토리로 정리해서 옮기고 있습니다.
OpenLab 스터디 진행해주신 개발자분께 멘토링을 요청했고, Kotlin을 주력 언어로 해서 한번 블로그를 만들어보라고 조언해주셨다. 마침 스터디 후 Kotlin을 활용한 스프링부트 개발이 너무 재밌어서 다시 하기로 마음 먹었다.
<Do it! 점프 투 스프링부트 3> 도서의 베타테스터로 활동하면서 책의 실습을 3장까지 따라했었는데, 실습에서는 STS 환경에서 Java와 JPA를 사용해서 개발한다. 코드만 kotlin으로 바꿔서 개발하고 있다.
URL Mapping
@GetMapping("/")
fun index() : Unit {
println("Hello World!")
}
- 클라이언트의 페이지 요청 발생
- 가장 먼저 컨트롤러에 등록된 URL 매핑을 찾음
- 발견 시 연결된 메서드를 실행
- 'index()'가 '/' 경로로 들어오는 GET 요청을 처리할 때 콘솔에 "Hello World!"가 출력된다.
URL 매핑 : URL과 컨트롤러의 메서드를 일대일로 연결
MainController 작성
‘/kch’ 라는 url에 kch 메서드를 매핑함.
- 도메인명과 포트는 적지 않음 → 서버 설정에 따라 변하기 때문
- Internal Server Error, status = 500 에러 발생
- 원래 URL과 매핑된 메서드는 결과값을 리턴해야 하는데 아무 값도 리턴하지 않아 이와 같은 오류가 발생
- 콘솔엔 kch가 뜸
@ResponseBody 추가하기
화면에 리턴하는 문자열이 출력된다.
package org.kchabin.myblog
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ResponseBody
@Controller
class MainController {
@GetMapping("/kch")
@ResponseBody
fun kch():String {
return "kch"
}
}
@ResponseBody
를 생략하면 'kch'라는 문자열을 리턴하는 대신에 해당하는 이름의 템플릿 파일을 찾는다.
JPA
ORM : SQL을 사용하지 않고 데이터베이스를 관리할 수 있는 도구.
- DB 테이블 → 자바 클래스(여기선 kt)
- DBMS의 종류에 관계 없이 일관된 자바 코드를 사용할 수 있음.
- 블로그 게시물 본문, 게시물 댓글
Java Persistence API
- 인터페이스 모음.
- Hibernate -> JPA 구현한 실제 클래스. 자바의 ORM framework
먼저 H2 DB부터 연결하자.
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:~/local/kch
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
h2 db를 사용했다. 책 후반부에 aws에 띄우는 내용이 나오는데, 후반부 실습하기 전에 MySQL이나 PostgreSQL로 변경할 예정이다.
Kotlin과 Lombok
Lombok을 Kotlin에서는 못 쓴다는 충격적인 글을 발견했다.
코틀린 컴파일러 -> .class 파일 생성 -> Java 컴파일러 -> .class 생성 -> 애너테이션 프로세싱 -> .class 파일 생성
애너테이션 프로세싱이 java 컴파일을 할 때 일어나기 때문에 코틀린에서는 롬복을 사용할 수 없다.
코틀린 롬복 플러그인이 있다고는 하는데 공식 문서를 보면 쓰지 않는 게 좋아보인다. https://kotlinlang.org/docs/lombok.html#maven
The Lombok compiler plugin is Experimental. It may be dropped or changed at any time. Use it only for evaluation purposes. We would appreciate your feedback on it in YouTrack.
The Lombok compiler plugin cannot replace Lombok, but it helps Lombok work in mixed Java/Kotlin modules. Thus, you still need to configure Lombok as usual when using this plugin. Learn more about how to configure the Lombok compiler plugin.
- Lombok compiler plugin은 Lombok을 대체하지는 못하고, 혼합된 Java/Kotlin 모듈에서 롬복이 잘 작동하도록 돕는 역할이라고 한다.
일단 Lombok을 쓰지 못하게 되었으니 실제 회사에서는 Kotlin을 spring 프로젝트에 어떻게 적용했는지 찾아봤다.
- kotlin은 롬복 라이브러리를 사용하지 않아도 된다.
- 클래스 접근 범위 = public, 속성 접근 범위 = private
- getter, setter를 내부적으로 자동 생성
- data class : 데이터를 전달하기 위한 용도로 사용됨.
- DTO(Data Transfer Object)를 데이터 클래스로 정의함. -> openapi codegen 생성 결과로 data class가 나오는데, 이걸 dto 대신으로 쓸 수 있다.
- toString(), equals() , hashCode() 자동 생성
- 데이터 분해와 대입(Destructing Declarations) 기능 제공
- 생성자의 파라미터 순서와 상관없이 생성자 사용 가능
- 롬복의 Builder가 갖는 장점을 갖추고 있음.
Destructing Declarations
배열이나 객체의 값을 손쉽게 개별 변수에 분해하여 할당하는 기능
- 간단한 데이터 클래스나 컬렉션에서 주로 사용됨
- 구조 분해를 통해 한 줄로 여러 변수를 초기화 할 수 있음.
배열 구조 분해
val (a, b, c) = listOf(1, 2, 3)
println(a) //1
println(b) //2
println(c) //3
데이터 클래스 구조 분해
데이터클래스의 인스턴스에서 프로퍼티를 추출한다.
data class Person(val name: String, val age: Int)
val person = Person("Gildong", 30)
//person의 프로퍼티를 각각의 변수에 할당
val (name, age) = person
println(name) // Gildong
println(age) // 30
맵 구조 분해
val map = mapOf("key1" to "value1", "key2" to "value2")
for((key, value) in map) {
println("$key: $value")
}
// key1: value1
// key2: value2
파라미터로 구조 분해
함수의 파라미터로도 구조 분해를 사용할 수 있다.
data class Point(val x: Int, val y: Int)
fun printPoint(point: Point) {
val (x, y) = point
println("x: $x, y: $y")
}
val point = Point(10, 20)
printPoint(point) //x: 10, y: 20
롬복의 빌더 패턴
- 복잡한 객체 생성을 빌더 패턴으로 명확하고 간단하게
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Person {
private String name;
private int age;
public static void main(String[] args) {
Person person = Person.builder()
.name("John Doe")
.age(30)
.build();
System.out.println(person.getName()); // John Doe
System.out.println(person.getAge()); // 30
}
}
특징 | Kotlin Destructuring | Lombok @Builder |
사용 목적 | 객체나 컬렉션의 프로퍼티를 쉽게 추출 | 복잡한 객체 생성 |
주 사용처 | 데이터 클래스, 리스트, 맵 | 많은 인자를 가진 생성자, 불변 객체 생성 |
코드 간결화 | 변수 초기화를 한 줄로 처리 | 객체 생성 시 명확한 인자 설정 |
문법 및 사용법 | 데이터 클래스와 컬렉션의 기본 기능 | 어노테이션을 통해 빌더 패턴 자동 생성 |
주요 장점 | 코드 가독성 향상, 간편한 변수 할당 | 생성자 인자가 많은 경우 객체 생성의 가독성 향상 |
추가 의존성 | 없음 | Lombok 라이브러리 필요 |
Kotlin으로 JPA 사용하기
Lombok만 문제가 아니라 JPA도 사용하려면 설정을 잘 해야한다.
plugins {
kotlin("plugin.spring") version "1.9.24"
kotlin("plugin.jpa") version "1.9.24"
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.Embeddable")
annotation("jakarta.persistence.MappedSuperclass")
}
- 코틀린으로 JPA를 사용하기 위해서 kotlin-spring, kotlin-jpa 플러그인이 필요하다.
하이버네이트 → 엔티티 클래스는 기본 생성자를 가져야 한다.
- 기본 생성자 직접 정의
- 모든 필드에 default 파라미터를 정의해서 기본생성자가 생성되도록 함.
→ 불편한 보일러 플레이트 코드
kotlin-noarg 플러그인
noArg {
annotation("jakarta.persistence.Entity")
}
코틀린에서 JPA를 위한 클래스들의 기본 생성자를 모두 처리해주는 플러그인 → noArg 설정
kotlin-spring 플러그인
코틀린은 기본적으로 불변(immutable)의 특징을 갖고 있다. final 클래스이기 때문에 빈으로 등록된 클래스가 상속 가능한(open) 상태여야 하는 스프링 부트에서는 open class로 바꿔줘야 한다.
kotlin-spring 플러그인에 포함된 all-open 플러그인이 자동으로 @Component, @Service, @Repository, @Configuration, @Controller, @RestController, @SpringBootApplication 등이 사용된 클래스를 open class로 만들어주지만, @Entity, @Embeddable, @MappedSuperclass 처럼 JPA에서 사용하는 애너테이션은 all-open 플러그인을 사용하도록 명시해야 한다.
Entity 클래스와 final
• The entity class must not be final. No methods or persistent instance variables of the entity class may be final.
• Technically Hibernate can persist final classes or classes with final persistent state accessor (getter/setter) methods. However, it is generally not a good idea as doing so will stop Hibernate from being able to generate proxies for lazy-loading the entity.
- 엔티티 클래스는 final이면 안된다.
- 기술적으로 final인것도 영속화는 할 수 있지만 lazy-loading은 불가능할 것.
Hibernate의 프록시 사용
지연로딩을 통한 성능 최적화
💡 Fetch Type
JPA가 하나의 Entity를 조회할 때, 연관관계에 있는 객체들을 어떻게 가져올 것이냐를 나타내는 설정값.
- @xxToOne : EAGER
- @xxToMany : LAZY
지연 로딩 엔티티의 연관된 데이터를 실제로 필요할 때까지 DB에서 불러오지 않는 것. 필요한 시점에 연관된 객체의 데이터를 불러옴.
즉시 로딩 데이터를 조회할 때, 연관된 모든 객체의 데이터까지 한 번에 불러옴. -> JPA N+1 문제
- Member 하나를 조회하는 쿼리를 날렸는데 연관된 객체가 1000개 → 쿼리 하나에 1000개가 추가됨
프록시 객체 사용 : 실제 엔티티 클래스를 상속받거나 구현, 데이터가 실제로 필요할 때 DB에서 값을 로드함.
지연 로딩 불가능
지연 로딩은 실제 객체를 참조하기 전까진 Proxy 객체를 참조하는데, 이 객체는 엔티티 클래스를 상속받아서 생성된다. data class는 상속이 불가능하도록 설계되어 있기 때문에 Proxy 객체를 생성할 수 없다.
data class로 Entity를 정의할 수 없을까?
JPA에서 엔티티 객체를 올바르게 비교하기 위해서는 equals()나 hasCode() 메서드를 재정의해야 한다. 자동 생성되는 메서드는 클래스의 생성자에 정의된 값으로 비교하는데, 엔티티는 PK 값만 같으면 객체로 인식되어야 한다고 함.
override fun equals(other: Any?): Boolean {
if(this === other) return true
if (javaClass != other?.javaClass) return false
val otherUser = other as? User ?: return false
return loginId == otherUser.loginId
}
//loginID가 동일한 경우 같은 해시코드를 반환한다.
override fun hashCode(): Int {
return loginId.hashCode()
}
just 참고용
- javaClass 체크 : 엄격하게 동일한 클래스인지 구분하는 방식. 부모클래스와 자식 클래스간의 타입 혼동 방지
- is 연산자는 상속 관계에서도 동등성을 인정하고 싶을 때 사용한다.
그래서 JPA 설정을 해주고, Post.kt를 아래와 같이 작성했다.
@Entity
class Post(
@Column(length = 200)
var title: String,
@Column(columnDefinition = "TEXT")
var content: String,
@Column
var createDate: LocalDateTime,
@OneToMany(mappedBy = "post", cascade = [CascadeType.REMOVE])
var commentList: MutableList<Comment> = mutableListOf(),
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
) {
//JPA를 위한 기본 생성자
constructor() : this("", "",LocalDateTime.now())
//PostService를 위한 생성자
constructor(title: String, content: String) : this(title=title, content=content, createDate=LocalDateTime.now())
}
나중에 PostService를 만드는 과정에서 기본 생성자 외에 다른 생성자가 필요했다. 생각해보면 당연한게 기본 생성자말고도 다른 생성자들이 존재할 수 있는 게 원래 기본 개념아니었나.. 개발하다가 이런거 까먹지 말자!
jpa와 lombok을 사용하는 것에서 조금 당황하긴 헀지만 그래도 새로운 걸 배우게 돼서 재밌다
https://wslog.dev/kotlin-jpa#5411f597905541569ab83c6923862d58