2024. 5. 18. 00:15ㆍ스프링 (Spring)/스프링 팁 (Spring Tip)
All-open
코틀린의 클래스와 메소드들은 기본적으로 final 입니다.
예를 들어 아래와 같은 Parent 클래스가 있다고 하면
class Parent {
fun hello() = "hello world!"
}
이제 Parent 클래스를 상속하지 못합니다. 말 그대로 final 이기 때문에 건드릴 수 없죠
클래스를 open 시켜준다고 한들
open class Parent {
fun hello() = "hello world!"
}
마찬가지입니다.
따라서 아래와 같이 모두 open 키워드를 달아주어야 상속하거나 오버라이드 할 수 있습니다.
open class Parent {
open fun hello() = "hello world!"
}
all-open 플러그인은 사전에 정해둔 어노테이션을 클래스에 붙이기만 하면,
open 키워드를 달아주지 않아도 자동으로 open 되도록 합니다.
예를 들어 다음과 같이 어노테이션을 설정하고
allOpen {
annotation("com.oim.AllOpenAnnotation")
}
해당 어노테이션을 클래스에 붙여주면 됩니다.
@AllOpenAnnotation
class Parent {
fun hello() = "hello world!"
}
더 자세한 이야기는 아래를 방문하면 됩니다.
All-open with JPA
개념이야 쉬우니 금방 끝내고... 이제 중요한 이야기를 해보겠습니다.
우선 깨달음을 얻은 글을 참조로 걸어두겠습니다.
All-open 공식 문서의 Spring support 문단을 보면 아래와 같은 설명이 있습니다.
혹시 보이실까요?
@Entity 가 빠졌습니다... @MappedSuperclass나 @Embeddable 같은 어노테이션도요.
Hibernate의 Lazy Fetch 전략은 초기화되지 않은 프록시(proxy) 객체로 위임(delegate)하는 방법을 씁니다.
자세히 설명하려면 이야기가 길어지니 따로 다른 글에서 다루는 걸로 하고, 예제로 간단히 설명해보겠습니다.
아래처럼 Apple과 AppleRepository 클래스가 있다고 가정해봤을 때,
@Entity
@Table(name = "apples")
class Apple private constructor(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "treeSrl")
var tree: Tree
...
class AppleRepository: JpaRepository<Apple, Long>
아래와 같이 읽으면 쿼리는 아래처럼 1개만 등장합니다.
@Service
class AppleService(private val appleRepository: AppleRepository) {
@Transactional(readOnly = true)
fun findAll() {
const apples: List<Apple> = appleRepository.findAll()
}
}
select
a1_0.treeSrl,
...
from
apples a1_0
apple 변수에는 정상적으로 Apple 객체가 할당됩니다.
하지만 고작 treeSrl만 읽어왔는데 과연 Apple 클래스의 프로퍼티인 tree 객체는 어떻게 만들어졌을까요?
이때 프록시 객체가 등장합니다.
이 친구는 @Id에 해당하는프로퍼티만 실제 값으로 가지고 있습니다.
나머지 프로퍼티들은 실제로 값을 가지고 있지 않죠.
따라서 아래처럼 for 문을 돌렸을 때 srl을 사용해도 쿼리가 만들어지지 않습니다.
const apples: List<Apple> = appleRepository.findAll()
for (apple: Apple in apples) {
println(apple.tree.srl)
}
만일 프록시 방법을 사용하지 않았다면 어떻게 될까요?
아래처럼 findAll() 함수를 호출한 시점부터 apples에 있는 로우(rows) 수 만큼 조회하는 쿼리가 만들어지겠죠.
select
t1_0.srl,
t1_0.name,
t1_0.height
...
from
trees t1_0
where
t1_0.srl=?
-----
select
t1_0.srl,
t1_0.name,
t1_0.height
...
from
trees t1_0
where
t1_0.srl=?
-----
select
t1_0.srl,
t1_0.name,
t1_0.height
...
from
trees t1_0
where
t1_0.srl=?
-----
...
하지만 tree의 다른 프로퍼티를 조회했을 때, 비로소 조회 쿼리가 실행됩니다.
const apples: List<Apple> = appleRepository.findAll()
println(apples.first().tree.name)
select
t1_0.srl,
t1_0.name,
t1_0.height
...
from
trees t1_0
where
t1_0.srl=?
이를 초기화(initialize)한다고 합니다.
해당 객체가 프록시 객체인지, 아니면 초기화된 객체인지 확인하려면 다음 함수를 사용하면 됩니다.
Hibernate.isInitialized()
짧게 한다고 했는데 조금 길어졌네요.
여튼 프록시 객체는 실제 객체를 대리하는 역할을 하기 때문에 상속을 받아야 합니다.
이를 통해 해당 클래스처럼 행동하는 것 뿐만 아니라,
(미초기화된) 프로퍼티에 접근하는 getter() 를 감싸서, 해당 함수를 호출할 때 초기화하도록 합니다.
자 이제 상속이 필요한 걸 알았으니, open 되지 않으면 어떻게 되는 지 알아볼까요.
Fetch Join
OneToOne, ManyToOne, OneToMany 등은 엔티티 간의 관계를 설명해줍니다.
예를 들어 다음과 같이 테이블이 있다고 해봅니다.
나무(tree)는 여러 개의 사과들(apples)을 가질 수 있습니다.
사과 입장에서 바라보면 본인이 속한 나무는 1 그루겠죠. 따라서 (1개 나무 ⇢ 여러 개 사과) ManyToOne 입니다.
반대로 나무 입장에서 바라보면 사과는 여러 개 입니다. 따라서 (여러 개 사과 ⇢ 1개 나무) OneToMany 입니다.
아래와 같이 모든 사과를 조회할 때,
const apples: List<Apple> = appleRepository.findAll()
나무는 쓸 일이 없어 조회하고 싶지 않다면 아래처럼 관계를 만들어주면 됩니다.
@Entity
@Table(name = "apples")
class Apple private constructor(
@Id
val srl: Long = Long.MIN_VALUE,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "treeSrl")
var tree: Tree,
var weightGram: Int,
var color: String
)
이러면 trees 테이블을 조회하는 쿼리가 실행되지 않고 - 실제 영속화된 객체 대신, 프록시 객체가 만들어집니다.
실제로 저 tree의 아이디를 제외한 나머지 프로퍼티를 쓸 일이 있다면, treeSrl로 trees 테이블을 조회해 가져옵니다.
그러나 만일 treeSrl이라는 걸 알려주지 않았다면?
...
@ManyToOne(fetch = FetchType.LAZY)
// @JoinColumn(name = "treeSrl")
var tree: Tree,
...
상식적으로 tree를 못 가져오겠죠.
두 엔티티를 관계시켜주는 컬럼은 treeSrl이고, 해당 컬럼을 가지고 있는 쪽이 mapper입니다.
뭐, 떠도는 글들을 찾아보니 대다수의 사람들이 우리말로 관계의 주인이라고 표현하네요. (더 헷갈리는 듯...)
나무 입장에서, 사과의 어디에 맵핑되는 지 명시해줘야 하는 이유도 위와 같습니다. (mappedBy)
@Entity
@Table(name = "trees")
class Tree private constructor(
@Id
val srl: Long = Long.MIN_VALUE,
@OneToMany(mappedBy = "tree", fetch = FetchType.LAZY)
val apples: MutableList<Apple> = mutableListOf(),
var ageDay: Int,
var height: Int
)
재밌게도 OneToMany인 apples도 프록시 객체를 만들 수 있습니다.
처음에는 빈 리스트로 만들어주고, 사용할 때 초기화 해주면 되기 때문이죠.
이제 OneToOne을 살펴볼까요.
이제 나무는 1개의 사과만을 만들어낸다고 가정해봅니다.
class Apple private constructor(
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "treeSrl")
var tree: Tree,
...
자, 차분히 생각해봅시다.
const apples: List<Apple> = appleRepository.findAll()
와 같은 코드가 있다면, trees 테이블을 조회할 필요가 있을까요?
더 쉽게 질문을 바꿔서 해보자면,
println(apples.first().tree.height)
와 같은 코드를 실행할 때 사전에 trees 테이블을 조회하지 않아도 될까요?
답은 조회하지 않아도 됩니다. treeSrl로 찾으면 되니까요!
반대로 나무 입장에서 보면,
class Tree private constructor(
@OneToOne(mappedBy = "tree", fetch = FetchType.LAZY)
val apple: Apple?,
...
const trees: List<Tree> = treeRepository.findAll()
apple은 초기화하지 않아도 될까요?
사실 apple이 nullable하기 때문에 초기화하지 않는 이상,
tree에 맵핑된 apple이 있는 지 없는 지도 알 방법이 없으니 초기화를 해야됩니다.
결국 나무 수 만큼 apples 테이블을 조회하는 쿼리가 실행되겠죠.
이를 다른 말로 N+1 문제라고 부릅니다. (trees 테이블 조회 1 + 그 수 만큼 apples 테이블 조회 N)
자 이제 프록시 객체가 어떤 경우에 만들어지는 지 알았으니 이제 글을 마무리하면
좋
겠
지
만
이제 All-open로 돌아와서 생각해봅니다.
일전에 말했듯 프록시 클래스는 엔티티 클래스를 상속받아서 생성됩니다.
바꿔말하면, 상속할 수 없는 엔티티 클래스에 대해 프록시 클래스는 생성되지 않습니다.
즉, OneToOne과 ManyToOne에 JoinColumn 등으로 맵핑 컬럼을 명시해봤자 N+1개의 쿼리가 생성되는 것입니다.
실제로 이 상황에서 사과를 조회해보면
SQL AST Tree:
SelectStatement {
FromClause {
StandardTableGroup (a1 : ....Apple(?)) {
primaryTableReference : apples as a1_0
TableGroupJoins {
left join LazyTableGroup (v1 : ....Apple(?).tree) {
...
LazyTableGroup이라고 잘 알아들었음에도 불구하고
DomainResult Graph:
\-EntityResultImpl [....Apple(?)]
...
| \-EntityFetchSelectImpl [....Apple(?).tree]
그래프 상에서는 EntityFetchSelectImpl 로 나오기 때문에 초기화해야되는 대상이 되었습니다.
즉, 쿼리가 N+1개 실행됩니다.
하지만 아래처럼 open 해준다면
allOpen {
annotations(
"jakarta.persistence.Entity",
"jakarta.persistence.MappedSuperclass",
"jakarta.persistence.Embeddable"
)
}
DomainResult Graph:
\-EntityResultImpl [....Apple(?)]
...
| \-EntityDelayedFetchImpl [....Apple(?).tree]
그래프 상에서 EntityDelayedFetchImpl 로 나오기 때문에 프록시 객체가 생성됩니다.
따라서 사과를 조회하는 쿼리 1개만 실행됩니다.
Comment
정말 모든 설명이 끝났네요.
All-open의 필요성을 생각하지 못하고 몇 시간이나 헛짓거리를 해서 바로 기록으로 남겨뒀습니다.
필자는 처음부터 Hibernate JPA의 Fetch 전략을 공부했었어서
구글에 떠도는 별 희한한 짓들을 안해도 되는 걸 알고 있었지만,
만약 그게 아니었다면 어떤 짓을 했을 지... 감도 안 오네요.
아래는 싹 무시하세요.
1. Bytecode Enhancement
2. Proxy or Bytecode Instrumentation (지연 로딩을 강제하는 것처럼 보임)
3. 연관 관계 끊기 (ㅋㅋㅋㅋ...)
4. 포기하고 Fetch Join 하기
(등... 별 이상한게 많았네요.)
마지막으로 이런 질문 감사했습니다.
'스프링 (Spring) > 스프링 팁 (Spring Tip)' 카테고리의 다른 글
테스트 시 발생하는 위험 메시지 무시해보기 (0) | 2024.05.01 |
---|---|
Kotlin 답게 간단하게 로깅해보기 (0) | 2024.04.30 |
Mockito를 이용해 JpaRepository 테스트하기 (0) | 2024.04.30 |
Spring에 Jacoco 적용해보기 (0) | 2024.04.29 |
Spring에서 Gmail 보내기 (1) | 2024.04.29 |