23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
안녕하세요, T멤버십서비스팀 Backend 개발자 이연주입니다.
현재 에이닷에서 T멤버십 혜택 및 이벤트 정보를 접할 수 있는데요.
T멤버십서비스팀에서 backend 개발자는 고객이 사용하는 Application뿐만 아니라 에이닷과 같은 내부서비스에 전반적으로 T멤버십 데이터를 제공하고 처리하는 업무를 하고 있습니다.
현재 포인트 시스템 마이그레이션 프로젝트에 참여하여 외부 시스템에 포인트 데이터를 전달하고 처리하는 컴포넌트를 맡았습니다.
해당 컴포넌트는 기존에 Java, Spring boot, Oracle, Mybatis로 개발되어 신규 컴포넌트를 생성하여 Kotlin, Spring boot, Jpa, Querydsl 환경에서 개발하고 있습니다.
이번 글에서는 신규 컴포넌트(프로젝트)에서 환경세팅 및 개발하면서 트러블 슈팅하면서 공부한 내용을 공유하고자 합니다.
신규 프로젝트에 QueryDsl 관련 Config를 만들어 JpaQueryFactory를 주입해서 사용하고자 했습니다.
JPAQueryFactory는 Spring Data JPA에서 제공하는 쿼리 빌더로, JPQL을 사용하여 쿼리를 작성하고 실행할 수 있습니다.
Repository 클래스에서 JPAQueryFactory를 사용하려면, 주 생성자의 매개변수로 JPAQueryFactory를 전달해야 합니다.
@Configuration
class QuerydslConfiguration(
@PersistenceContext
private val entityManager: EntityManager
) {
@Bean
fun jpaQueryFactory(): JPAQueryFactory = JPAQueryFactory(entityManager)
}이 때 Repository 클래스에서 JPAQueryFactory를 사용하고자 할 때 이슈가 발생했습니다.
이번 글에서는 이에 대한 이유와 해결 방법을 살펴볼 예정입니다.
Repository를 open class로 선언했을 때, JPAQueryFactory를 필드 또는 JPAQueryFactory필드를 사용하는 메서드에 open 키워드가 없으면, 필드에 bean이 주입된 상태로 사용하지 못해서 NullPointerException이 발생!
@Repository
class PersonRepository(
private val jpaQueryFactory: JPAQueryFactory
) {
fun getAll(): List<Person> {
return jpaQueryFactory.selectFrom(QPerson.person)
.orderBy(QPerson.person.name.asc())
.limit(100).fetch()
}
}디버깅을 통해 init{}으로 디버깅을 시도해보니 init 블럭 안에서는 jpaQueryFactory bean이 정상적으로 주입되는 것을 확인할 수 있었습니다.
하지만 service에서 repository의 getAll 메서드를 호출했을 때, jpaQueryFactory가 null이 되었습니다.
init : PersonRepository with org.springframework.data.jpa.repository.support.SimpleJpaRepository@429f7919
---------------------- jPAQueryFactory Bean Created ------------------- com.querydsl.jpa.impl.JPAQueryFactory@f4a3a8d
getAll : null
2024-03-03 22:52:16.394 ERROR 66112 --- [nio-8001-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException: Cannot invoke "com.example.domain.test.repository.PersonJpaRepository.findAll()" because "this.jpaQueryFactory" is null] with root cause다음 이슈는 build.gradel.kts에서 spring 관련 plugin을 추가하여 해결되었습니다.
plugins {
...
val kotlinVersion = "1.9.22"
kotlin("plugin.spring") version kotlinVersion
...
}만약에 spring 관련 plugin 이 없었다면?
궁금했었습니다. Java와 다른 Kotlin 접근제한자 개념과 Kotlin open 키워드로 2가지 방법으로 해결할 수 있었습니다.
open class이면서 jpaQueryFactory 를 호출하는 getAll 메서드가 open 일 경우, 다음 코드에서는 getAll() 메서드를 open으로 선언하여 상속, 메소드 오버라이딩을 할 수 있도록 하였습니다.
이렇게 하면 getAll() 메서드를 호출할 때 jpaQueryFactory가 null이 되는 문제를 해결할 수 있었습니다.
@Repository
open class PersonRepository(
private val jpaQueryFactory: JPAQueryFactory
) {
open fun getAll(): List<Person> {
return jpaQueryFactory.selectFrom(QPerson.person)
.orderBy(QPerson.person.name.asc())
.limit(100).fetch()
}
}open class이면서 jpaQueryFactory 필드가 open 일 경우다음 코드에서는 jpaQueryFactory를 open val로 선언하여 상속, 메소드 오버라이딩을 할 수 있도록 하였습니다.
이렇게 하면 getAll() 메서드를 호출할 때 jpaQueryFactory가 null이 되는 문제를 해결할 수 있었습니다.
@Repository
open class PersonRepository(
open val jpaQueryFactory: JPAQueryFactory
) {
fun getAll(): List<Person> {
return jpaQueryFactory.selectFrom(QPerson.person)
.orderBy(QPerson.person.name.asc())
.limit(100).fetch()
}
}이슈가 발생하는 bad 코드와 두 해결방식의 good 코드를 모두 Java로 디컴파일 해보았습니다.
bad 코드를 디컴파일한 코드를 보면 PersonRepository에서 getAll(),getJpaQueryFactory() 메서드가 final이 되어 있는 것을 확인할 수 있습니다. getAll() 메서드를 호출하면 jpaQueryFactory 필드가 null이기 때문에 NullPointerException이 발생합니다.
@Repository
open class PersonRepository(
private val jpaQueryFactory: JPAQueryFactory
) {
fun getAll(): List<Person> {
return jpaQueryFactory.selectFrom(QPerson.person)
.orderBy(QPerson.person.name.asc())
.limit(100).fetch()
}
}public class PersonRepository {
private final JPAQueryFactory jpaQueryFactory;
@NotNull
public final List getAll() {
List var10000 = ((JPAQuery)((JPAQuery)this.jpaQueryFactory.selectFrom((EntityPath)QPerson.person).orderBy(QPerson.person.name.asc())).limit(100L)).fetch();
Intrinsics.checkNotNullExpressionValue(var10000, "jpaQueryFactory.selectFr… .limit(100).fetch()");
return var10000;
}
@NotNull
public final JPAQueryFactory getJpaQueryFactory() {
return this.jpaQueryFactory;
}
public PersonRepository(@NotNull JPAQueryFactory jpaQueryFactory) {
Intrinsics.checkNotNullParameter(jpaQueryFactory, "jpaQueryFactory");
super();
this.jpaQueryFactory = jpaQueryFactory;
}
}첫번째 good 코드를 디컴파일해보았습니다. getAll이 open 키워드로 final 메서드가 아닌 기본 메서드입니다. getAll() 호출 시 jpaQueryFactory 필드에 접근할 수 있어 정상적으로 동작하게 됩니다.
@Repository
open class PersonRepository(
private val jpaQueryFactory: JPAQueryFactory
) {
open fun getAll(): List<Person> {
return jpaQueryFactory.selectFrom(QPerson.person)
.orderBy(QPerson.person.name.asc())
.limit(100).fetch()
}
}@Repository
@Metadata(
mv = {1, 9, 0},
k = 1,
d1 = {"메타정보입니다"},
d2 = {...}
)
public class PersonRepository {
private final JPAQueryFactory jpaQueryFactory;
@NotNull
public List getAll() {
List var10000 = ((JPAQuery)((JPAQuery)this.jpaQueryFactory.selectFrom((EntityPath)QPerson.person).orderBy(QPerson.person.name.asc())).limit(100L)).fetch();
Intrinsics.checkNotNullExpressionValue(var10000, "jpaQueryFactory.selectFr… .limit(100).fetch()");
return var10000;
}
public PersonRepository(@NotNull JPAQueryFactory jpaQueryFactory) {
Intrinsics.checkNotNullParameter(jpaQueryFactory, "jpaQueryFactory");
super();
this.jpaQueryFactory = jpaQueryFactory;
}
}두번째 good 코드를 디컴파일한 결과입니다. jpaQueryFactory를 가져오는 getJpaQueryFactory이 open 키워드로 final 메서드가 아닌 기본 메서드입니다. 덕분에 getAll() 이 final 이지만 런타임에 jpaQueryFactory를 사용할 수 있습니다.
@Repository
open class PersonRepository(
open val jpaQueryFactory: JPAQueryFactory
) {
fun getAll(): List<Person> {
return jpaQueryFactory.selectFrom(QPerson.person)
.orderBy(QPerson.person.name.asc())
.limit(100).fetch()
}
}@Repository
@Metadata(
mv = {1, 9, 0},
k = 1,
d1 = {"메타정보입니다"},
d2 = {...}
)
public class PersonRepository {
@NotNull
private final JPAQueryFactory jpaQueryFactory;
@NotNull
public final List getAll() {
List var10000 = ((JPAQuery)((JPAQuery)this.getJpaQueryFactory().selectFrom((EntityPath)QPerson.person).orderBy(QPerson.person.name.asc())).limit(100L)).fetch();
Intrinsics.checkNotNullExpressionValue(var10000, "jpaQueryFactory.selectFr… .limit(100).fetch()");
return var10000;
}
@NotNull
public JPAQueryFactory getJpaQueryFactory() {
return this.jpaQueryFactory;
}
public PersonRepository(@NotNull JPAQueryFactory jpaQueryFactory) {
Intrinsics.checkNotNullParameter(jpaQueryFactory, "jpaQueryFactory");
super();
this.jpaQueryFactory = jpaQueryFactory;
}
}두 해결방법 모두 jpaQueryFactory가 null이 되는 문제를 해결할 수 있습니다.
첫 번째 방법은 getAll() 메서드를 open으로 선언하여 상속, 메소드 오버라이딩을 할 수 있도록 하였습니다.
두 번째 방법은 jpaQueryFactory 변수를 open으로 선언하여 상속, 메소드 오버라이딩을 할 수 있도록 하였습니다.
상속 필요 여부에 따라 두 가지 방법 중 어떤 것을 선택하면 좋을 것 같습니다.
Bad 코드에서 원인은 코틀린의 default가 final 이기 때문입니다. Kotlin에서 클래스, 메서드, 프로퍼티는 default 로 final이며 상속이 불가능합니다.
Q. 그렇다면 왜 상속이 가능해야할까요?
CGLIB proxy 방식 때문입니다.
spring boot에서 기본적으로 CGLIB proxy를 사용하고 있습니다.
spring에서 프록시를 사용하여 AOP(Aspect-Oriented Programming) 기능을 구현하거나 트랜잭션 관리를 위해 프록시를 생성합니다. CGLIB는 spring 3.2에 spring-core에 re-packing되었습니다. spring 4에서 Objensis 라이브러리를 포함하면서 CGLIB proxy 관련 여러 개선이 있었습니다.
많은 스프링 기반 라이브러리는 클래스를 상속하여 기능을 확장하거나 프록시를 생성하여 런타임에 동작을 제어합니다. CGLIB는 실행시점에 reflection(proxy할 타겟을 오버라이딩)을 통하여 프록시를 생성합니다.
즉, final 등으로 인한 상속된 객체에 override가 불가능한 경우에 proxy에서 aspect를 적용할 수 없습니다. CGLIB프록시가 제대로 작동하지 않게 됩니다. 코틀린에서는 기본적으로 클래스가 상속이 가능한(open인) 상태여야만 프록시를 생성할 수 있습니다. 따라서 코틀린으로 작성된 클래스가 이러한 라이브러리와 호환되려면 open 키워드를 사용하여 상속이 가능한 클래스로 선언되어야 합니다.
allopen이라는 플러그인을 통해, @Repository 애노테이션이 붙은 클래스를 자동으로 open 클래스로 만들어 줄 수 있습니다. 다음과 같이 build.gradle.kts에 플러그인을 추가합니다.
plugins {
...
val kotlinVersion = "1.9.22"
kotlin("plugin.allopen") version kotlinVersion
...
}
allOpen {
annotation("org.springframework.stereotype.Repository")
}위와 같이 설정하면, @Repository 애노테이션이 붙은 클래스는 자동으로 open 클래스로 컴파일됩니다.
allopen 플러그인을 사용하면 개발자가 직접 클래스와 메서드에 open 키워드를 작성하지 않아도 된답니다.
그렇지만 all-open 플러그인을 사용하신다면 클래스나 메소드에 모두 open 키워드가 붙기 때문에 고민하지 않으셔도 됩니다!
그렇다면 spring 플러그인 추가하면 왜 해결이 되었을까요?
이 plugin은 spring 관련 annotation(@Controller, @Service, ...)이 달린 클래스를 all-open 플러그인처럼 open 키워드를 추가하고 있습니다.
plugins {
id("org.jetbrains.kotlin.plugin.spring") version "2.0.0-Beta4"
}buildscript {
repositories {
maven {
url = uri("https://plugins.gradle.org/m2/")
}
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-allopen:2.0.0-Beta4")
}
}
apply(plugin = "org.jetbrains.kotlin.plugin.spring")PersonRepository 클래스에 @Repository 을 사용하고 있었으니 open이 되었겠죠!
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.