자바에서 Entity를 작성하다 코틀린에서 Entity를 작성하려고 하면 자바와 다른 방식으로 인해 Entity를 작성하는 것이 쉽지가 않다.
클래스 작성 키워드, 변수 선언 키워드, 생성자 선언 방식, 디폴트값 정의 등 자바에 존재하지 않거나 다른 방식으로 인해 Entity 클래스 설계시 고려해야할 부분이 많다.
코틀린에서 제공하는 다양한 방식으로 클래스를 설계하며 각 클래스가 Entity의 조건에 맞는지 도메인 객체를 잘 표현하는지 확인해보며 Best Practice Entity 설계 패턴을 찾아나가보도록 하겠다.
우선 Entity의 조건에 대해 알아보겠다.(https://docs.oracle.com/javaee/5/tutorial/doc/bnbqa.html)
- 클래스의 접근 지정자는 public 또는 protected이어야 하며 인자값이 없는 기본생성자를 반드시 필요로 한다.
=> JPA Entity는 기본생성자로 생성 된 이후에 리플렉션을 통해 값을 주입한다. 따라서 기본 생성자를 반드시 필요로함 - 클래스, 메서드, 속성은 final이 선언되어있으면 안된다.
=> 상속 가능한 구조를 만들어야 함
=> 지연로딩시 프록시가 엔티티를 상속함 - Entity는 다른 클래스를 상속할 수 있고, 다른 클래스가 Entity를 상속할 수 있도록 설계되야한다.
- 영속화 객체 변수는 public으로 선언되어서는 안되고 Entity의 상태는 오직 Entity 클래스가 제공하는 메서드를 통해 접근 할 수 있어야한다.
=> 세터 메서드 지양
=> 세터의 무분별한 사용은 의도파악 어려움(객체 생성후 값 주입인지, 객체 속성값 변경인지 알아차리기 어려움)
=> 변경이 필요없는 속성의 세터메서드 제공 지양
=> 행위를 잘 표현하는 메서드명을 통해 엔티티의 변경을 알려주는 것이 좋음
코틀린의 동작 방식
- 변수 선언 키워드 var, val
var 키워드 : getter와 setter를 제공하고 초기화 이후 값 변경 가능
val 키워드 : getter를 제공하고 초기화 이후 값 변경 불가능 - 클래스 키워드 : default, data
default : class 앞에 아무것도 붙지지 않은 기본 방식으로 자바와 다른 점은 앞에 final이 붙는 다는 것이다. 허나 open키워드를 통해 final제거하고 상속 가능 구조로 변경 가능
data class : toString, hashCode, equals, copy메서드를 기본으로 제공해줌, but 주생성자에 포함된 속성만 해당 메서드 적용됨, open키워드와 함께 사용불가 따라서 상속불가 - 디폴트 값에 따른 생성자 : 코틀린의 객체 속성은 생성자를 통해 값을 주입받거나 주입받지 못한다면 디폴트값을 선언해야한다. 주 생성자에 선언된 속성에 대해 조합의 경우로 오버로딩 된 생성자들을 제공하는데 디폴트값이 없다면 생성자를 통해 값을 받아야하므로 모든 생성자에 포함되고 디폴트 값이 있다면 포함되거나 포함되지 않는 경우로 생성자들이 생겨난다.
단, 위 방식은 주 생성자에 속성을 정의한 경우에만 동작하고 클래스의 바디에 속성을 선언한 경우 기본생성자가 생성된다. 따라서 디폴트 값 또한 반드시 선언되어야한다.
(late init var 사용하는 경우 제외)
Entity 설계 첫번째, 변수 선언 키워드 선택 : var과 val
코틀린의 var과 val 키워드는 가변이냐 불변이냐로 나뉠 수 있다.
DB의 값이 매핑된 변수는 당연히 불변으로 사용하는게 바람직하다.
하지만 Entity의 조건 4를 보면 영속화 대상 변수는 public 접근 지정자이면 안되며, Entity 클래스에서 제공하는 메서드로 접근가능하다고 하였다.
이 조건은 아무 곳에서나 변수에 함부로 접근하여 값을 변경하면 안되며 클래스가 제공하는 메서드로만 영속화 변수 값을 변경할 수 있게 해야한다라는 뜻으로 해석된다.
많은 글들에서 세터메서드 사용을 지양해야한다고 한다. 이 조건과 연관이 높은 내용이다.
잠시 세터 메서드 사용을 지양해야하는 이유를 보겠다.
사용자 정보를 변경하기 위해
member.phone = newlyPhone
member.email = newlyEmail
와 같이 사용하기 보다는
member.updatePrivateInfo(newlyPhone, newlyEmail)
와 같이 개인정보 변경이라는 메서드명처럼 그 행위의 의미가 명확하게 드러나는 메서드를 제공하여 사용해야한다는 것이다.
세터를 통해 단순히 값만 변경된다면 어떤 목적인지 파악하기 어렵다.
새로운 entity를 생성하고 값을 주입하는 것인지, 기존 entity의 값을 변경하는 것인지는 entity가 생성되는 부분과 세터메서드를 모두 추적해야만 파악 할 수 있다.
허나 세터가 제거되고 변경의 의미를 명확하게 표현하는 메서드를 사용한다면 우리는 적어도 새로운 entity가 생성되는 것은 아니라는 것을 명확하게 할 수 있다.
다시 본론으로 와서 entity는 어쨋든 값의 변경이 일어 날 수 있다. 세터를 지양하자는 것이지 값의 변경이 일어나지 않는다는 것이 아니다.
따라서 Entity 클래스의 속성은 변경 가능한 var키워드로 선언되어야한다.
Entity 설계 두번째, 클래스 키워드 선택 : data class, class
코틀린에서 매우 유용한 클래스 중 하나로 data class가 있다.
data class는 toString, hashcode, equals, copy와 같은 메서드를 제공해준다.
객체의 동등성 비교에 사용되는 equals와 hashcode, 불변성을 표현하기 위해 원본이 아닌 복사본을 제공하여 원본 값의 변경을 막는데 자주 사용하는 copy와 같은 객체지향적 메서드들이 제공된다는것은 개발을 하는데 있어서 굉장한 편리함을 제공해준다.
entity 조건 2, 3을 보면 entity는 final 키워드가 붙어있으면 안되고 상속 가능한 구조여야한다. data class는 완전 이와 반대이다. open 키워드와 함께 쓰일 수 없으니 final 키워드를 제거하지 못하고 이로인해 상속 가능한 구조로 만들 수도 없다.
조건 1 인자값이 없는 기본생성자를 반드시 필요로 한다라는 부분도 봐보자. data class는 주 생성자에 최소 1개 이상의 속성을 포함해야한다. 주생성자가 인자값이 없는 기본생성자를 가지려면 주생성자의 모든 속성 값이 디폴트 값을 가져야한다. 또한 중요한 것은 주생성자에 포함되지 않은 속성은 toString, hashcode, equals, copy 메서드에 포함되지 않는다.
따라서 data class를 쓴다는 것은 주생성자에 모든 속성을 포함하는 것에 의미가 있는데 이때 기본생성자를 만들기 위해 모든 속성에 default 값을 선언하면 모든 속성 조합의 경우에 대해 생성자 오버로딩이 발생한다.
create나 update와 같이 db에서 값을 알아서 셋팅하거나 jpa가 알아서 값을 만들어주는 속성에 대해서도 생성자가 생겨난다.
생성자는 필요한 속성만 주입받는 생성자가 있는 것이 바람직하다.
디폴트값 또한 entity의 규칙이다. 이러한 디폴트값을 무시할 수 있는 생성자가 생겨나는 것은 바람직하지 않다.
data class는 여러모로 entity 조건에 위배되는 경우가 많다.
따라서 클래스는 기본 키워드인 class에 open 키워드를 붙여 사용하는 것이 Entity에 바람직해 보인다.
참고로 아래와 같이 선언하면 엔티티 클래스와 속성, 메서드에 모두 open 키워드를 붙여준다.
인자값 없는 기본생성자 생성도 noarg 플러그인을 통해 적용할 수 있다.
plugins {
kotlin("plugin.allopen") version "1.9.20"
kotlin("plugin.noarg") version "1.9.20"
}
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
noArg {
annotation("jakarta.persistence.Entity")
}
open 키워드를 하나라도 빠드린다면 원하는 동작이 정상적으로 동작하지 않을 수 있으니 위와 같은 설정으로 공통 적용하자.
data class가 여러 Entity 조건을 만족시키지 못하는 것에 대해서는 알아보았지만 이외에도 치명적인 단점이 하나있다.
연관관계에서 lazy loading을 사용하지 못하는 것이다.
lazy loading 동작 방식은 FetchType이 lazy로 설정된 연관관계 객체를 최초에 진짜 Entity가 아닌 가짜 Entity인 프록시로 생성하여두고 실제 호출이 일어났을 때 DB에서 값을 조회해와 진짜 Entity객체를 제공하는 방식이다.
프록시는 Entity클래스를 상속하기 때문에 lazy loading 대상 Entity는 상속이 가능해야한다. 따라서 data class는 lazy loading기능을 사용할 수 없어 이러한 이유에서도 Entity로 사용하는데 부적합하다.
Entity 설계 세번째, default 값 제어 및 도메인 규칙을 지키는 생성자
코틀린의 생성자는 디폴트값의 선언 유무에 따라 생성자 오버로딩이 결정된다.
위 data class를 설명하며 인자값 없는 기본생성자를 만들기 위해 주 생성자에 선언된 모든 인자값에 디폴트 값을 선언하면 모든 조합의 경우로 생성자가 생겨난다. data class 뿐만 아니라 일반 클래스도 마찬가지이다.
허나 noarg 플러그인을 사용하면 인자값 없는 기본생성자를 만들어주므로 굳이 필요없는 디폴트값을 주지 않아도 된다.
잠깐, 생성자 오버로딩을 막는 이유
class Member(var id: Long = 0, var email: String? = null, var passwd: String? = null)
와 같이 클래스를 설계하면
(), (id), (email), (passwd), (id, email), (id, passwd), (email, passwd), (id, email, passwd)
와 같이 모든 속성에 대해 조합의 경우로 생성자가 생겨난다.
하지만 우리 서비스에서 계정을 생성할 때, email과 passwd 둘다 값이 존재해야하는데
위와 같이 서비스 규칙을 깨는 생성자들이 마구잡이로 생겨난다.
email, passwd를 포함한 생성자가 있기에 제대로된 생성자를 사용하면 문제 없겠지만
저렇게 엉뚱한 생성자를 통해 객체를 생성할 수 있는 가능성을 만들어 놓는 것은 좋지 않은 방식이다.
Entity조건을 만족하는 코틀린 Entity - 첫번째 패턴
@Table(name = "member")
@Entity
class MemberEntity(
id: Long,
email: String,
passwd: String,
role: MutableList<MemberRoleEntity>
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = id
protected set
var email: String = email
protected set
var passwd: String = passwd
protected set
var humanStatus: Int = 0
protected set
var failCnt: Int = 0
protected set
var lastFailTime: LocalDateTime? = null
protected set
@CreationTimestamp
var sysCreateTime: LocalDateTime = LocalDateTime.now()
protected set
@UpdateTimestamp
var sysUpdateTime: LocalDateTime? = null
protected set
@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "mem_id", referencedColumnName = "id")
var roles: MutableList<MemberRoleEntity> = role
protected set
// 로그인 실패 정보 기록
fun updateFailCnt(failCnt: Int) {
this.failCnt = failCnt
this.lastFailTime = LocalDateTime.now()
this.sysUpdateTime = LocalDateTime.now()
if (failCnt == 5) {
this.humanStatus = 1
}
}
// 사용자 권한 추가
fun addRole(role: MemberRoleEntity){
this.roles.add(role)
}
}
복잡해 보일 수 있겠지만 아래와 같은 규칙을 세웠다.
- 모든 프로퍼티는 바디에 선언
=> 무분별한 생성자 오버로딩을 막고 필요한 생성자만 만들도록 하기 위하여 - 객체 생성에 꼭 필요한 속성은 주생성자에 선언
=> 필수값이니 당연히 주생성자에 디폴트값을 선언할 필요가 없음. 따라서 생성자 오버로딩 발생 안함 - var 키워드 사용과 protected set 선언
=> Entity 프로퍼티의 속성은 변경 가능, 허나 무분별한 세터 사용 방지 위하여 세터 접근지정자 protected 선언
Entity의 모든 조건을 만족한다.
도메인 속성이 위배되지 않도록 의미없는 디폴트값 선언도 최대한 막았다.
noargs, allopen 플러그인을 통해 자바로 컴파일 되면 인자값 없는 기본생성자 생성과 상속도 가능한 구조가 되었다.
일반적으로 entity는 repository, service는 최소 패키지 단위로 구분되니 protected를 통해 무분별한 세터 사용을 막을 수 있을 것이다.
그리고 변경 행위의 명확한 의미를 갖는 메서드를 추가함으로써 변경의 의미도 명확히 표현하였다.
필요한 생성자가 있다면 추가적으로 바디에 선언하면 된다.
허나 위 구조에서도 조금은 문제가 될만한 상황이 나타날 수 있다.
만일 우리가 소셜로그인 기능을 개발하고 Entity에 isSocial이라는 속성을 추가하고
소셜 로그인 통해 가입한 회원들은 3rd-party-api를 통해 인증을 거치므로 passwd를 null로 저장하겠다고 했다.
그렇다면 우리는 아래와 같은 생성자를 만들어야한다.
constructor(
id: Long,
email: String,
isSocial:Boolean,
role: MutableList<MemberRoleEntity>
) : this(id, email, null, role) {
this.id = id
this.email = email
this.isSocial = isSocial
this.roles = role
}
그리고 개인정보를 추가로 받는다고 하여 아래와 같은 생성자도 추가되었다.
constructor(
id: Long,
email: String,
isSocial: Boolean,
name: String,
phone: String,
role: MutableList<MemberRoleEntity>
) : this(id, email, null, role) {
this.id = id
this.email = email
this.isSocial = isSocial
this.name = name
this.phone = phone
this.roles = role
}
문제가 무엇이라고 생각하나
주생성자가 선언되어있는 상태에서 보조생성자를 만들다 보니 보조생성자에서 주생성자를 상속받아야 하기에
보조생성자마다 passwd에 null을 넣어주고 있다.
생성자가 추가된다면 생성자마다 이렇게 null을 반복적으로 선언해야한다.
아니면 주생성자를 속성값의 갯수가 가장 적은 생성자로 바꿔야 한다.
그래도 문제는 크다. 만약 이미 많은 생성자가 존재하는데 주생성자를 바꾸면 나머지 모든 생성자에 대해 코드 변경이 일어난다.
Entity조건을 만족하는 코틀린 Entity - 두번째 패턴(추천)
주생성자 없이 바디에 생성자를 만들어 놓을 수도 있다.
@Table(name = "member")
@Entity
class MemberEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = 0
protected set
var email: String? = null
protected set
var passwd: String? = null
protected set
var humanStatus: Int = 0
protected set
var failCnt: Int = 0
protected set
var lastFailTime: LocalDateTime? = null
protected set
@CreationTimestamp
var sysCreateTime: LocalDateTime = LocalDateTime.now()
protected set
@UpdateTimestamp
var sysUpdateTime: LocalDateTime = LocalDateTime.now()
protected set
@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "mem_id", referencedColumnName = "id")
var roles: MutableList<MemberRoleEntity> = mutableListOf()
protected set
// 로그인 실패 정보 기록
fun updateFailCnt(failCnt: Int) {
this.failCnt = failCnt
this.lastFailTime = LocalDateTime.now()
this.sysUpdateTime = LocalDateTime.now()
if (failCnt == 5) {
this.humanStatus = 1
}
}
// 사용자 권한 추가
fun addRole(role: MemberRoleEntity) {
this.roles.add(role)
}
constructor(
id: Long,
email: String,
passwd: String,
role: MutableList<MemberRoleEntity>
) {
this.id = id
this.email = email
this.passwd = passwd
this.roles = role
}
}
첫번째 패턴과 두번째 패턴 모두 자바로 컴파일하면 결과는 같다. 동작 방식, 사용법 모두 같으니 사실상 차이는 없다.
허나 좀전에 말한 것처럼 주생성자를 상속 받는 구조에서 벗어나니 생성자들간에 영향도가 줄어든다.
따라서 위 패턴이 비즈니스 규칙의 변경에 더욱 유연한 구조를 가지고 있고 나는 개인적으로 패턴을 강하게 추천한다.
이렇게 해서 Entity의 조건을 만족하고 정상적으로 동작되며 코틀린의 장점을 최대한 살릴 수 있는 Entity 패턴을 설계해보았다.
내가 설계한 패턴 보다 더 디테일한 부분까지 고려하여 설계할 수도 있고, 더욱 단순하게 설계하여 사용할 수도 있을 것이라고 생각한다.
Entity의 설계 하나로 코드 컨벤션을 정하는게 아니라 사용하는데 있어서도 코드 컨벤션을 정할 수 있으니 이는 회사마다 개발집단마다 충분히 다르게 유연하게 설계하여 다양한 패턴이 나올 수 있을 것이라고 생각한다.
다만, 나는 코틀린과 자바의 차이점으로 인한 안티패턴에 빠지지 않게 하기 위하여 Best Practice를 찾아가 보았다.
코틀린스러운 Entity 설계가 필수 되는 것은 아니지만 위의 내용들을 숙지하여 사용한다면 Entity 설계 뿐만 아니라 사용에 있어서도 도움이 많이 될 것이라고 생각한다.
'Framework & Lib & API > JPA' 카테고리의 다른 글
쿼리dsl, 코틀린 case when sum 구문에서 사용하기 (1) | 2024.03.06 |
---|---|
jpa 연관관계 EAGER와 LAZY(etc, 실제 겪은 문제들) (0) | 2022.06.26 |
jpa 영속성 컨텍스트 개념 (0) | 2022.06.12 |