의문점:
- JPQL 의 여러 값 projection 을 할 때, [Ljava.lang.Object 로 resultList 가 보이는데, 이걸 사람이 알아볼 수 있게 만드는 방법이 없을까? DTO 를 만들어서 TypedQuery<DTO> 로 해결하는 것으로 보인다.
이 글은 책(김영한, 자바 orm 표준 jpa 프로그래밍, 에이콘, 2019) 의 내용을 정리한 것임을 밝힌다.
값 타입
JPA 의 데이터 타입은 엔티티 타입과 값 타입으로 나뉜다. 엔티티 타입의 객체는 @Entity 로 정의되는 객체로 JPA 에서 식별자와 생명주기를 가져 영속성 컨텍스트에서 관리되지만, 값 타입은 state로서 실별자도 생명주기도 없는 것으로서 기본값 타입(basic value type), 임베디드 타입(embedded type), 컬렉션 값 타입(collection value type) 으로 나뉜다.
임베디드 타입(embedded value type. 복합 값 타입)
사용자가 직접 정의한 값 타입으로, 엔티티 타입이 아니라 값 타입이다. 엔티티 클래스를 더 객체지향에 가깝게 만들 수 있는 수단으로, 엔티티 객체 데이터 타입을 사용자가 정의한 클래스로 정한다.
@Entity
class Member(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@Embedded
val homeAddress: Address
)
@Embeddable
class Address(
val city: String,
val street: String,
val zipcode: String
)
fun main() {
val emf = Persistence.createEntityManagerFactory("jpabook")
val em = emf.createEntityManager()
val tx = em.transaction
try {
tx.begin()
logic(em)
tx.commit()
} catch (e: Exception) {
e.printStackTrace()
tx.rollback()
} finally {
em.close()
}
emf.close()
}
fun logic(em: EntityManager) {
val address = Address("서울", "장안로", "111")
val member = Member(null, "수호", address)
}
위의 코드를 통해 생성되는 DB 내의 Member 엔티티는 homeAddress 가 아니라 city, street, zipcode 필드를 갖는다. 아래의 쿼리에서 확인할 수 있다.
Hibernate: create table Member (id bigint not null, city varchar(255), street varchar(255), zipcode varchar(255), name varchar(255), primary key (id))
참고로 위의 코틀린 코드에서 @Embedded 와 @Embeddable 을 누락하면 아래와 같은 예외가 발생한다.
Exception in thread "main" javax.persistence.PersistenceException: [PersistenceUnit: jpabook] Unable to build Hibernate SessionFactory
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.persistenceException(EntityManagerFactoryBuilderImpl.java:1336)
at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1262)
at org.hibernate.jpa.HibernatePersistenceProvider.createEntityManagerFactory(HibernatePersistenceProvider.java:56)
at javax.persistence.Persistence.createEntityManagerFactory(Persistence.java:79)
at javax.persistence.Persistence.createEntityManagerFactory(Persistence.java:54)
at com.wisdoom.MainKt.main(Main.kt:7)
at com.wisdoom.MainKt.main(Main.kt)
Caused by: org.hibernate.MappingException: Could not determine type for: com.wisdoom.Address, at table: Member, for columns: [org.hibernate.mapping.Column(homeAddress)]
위의 예제에서는 주소가 한 종류만 있다(집 주소). 그런데 회사 주소나, 형제의 주소 등, 동일한 임베디드 타입을 엔티티에 포함하려면 어떻게 해야 할까? 아래 코드와 같이 작성하면 column name 이 겹치는 일 없이 DB 엔티티가 생성된다.
@Entity
class Member(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@Embedded
var homeAddress: Address?, // val 로 설정하는 것이 좋다. 추후 서술
@Embedded
@AttributeOverrides(
AttributeOverride(name = "city", column = Column(name = "HOME2_CITY")),
AttributeOverride(name = "street", column = Column(name = "HOME2_STREET")),
AttributeOverride(name = "zipcode", column = Column(name = "HOME2_ZIPCODE"))
) var home2Address: Address // val 로 설정하는 것이 좋다. 추후 서술
) {
}
Hibernate: create table Member (id bigint not null, HOME2_CITY varchar(255), HOME2_STREET varchar(255), HOME2_ZIPCODE varchar(255), city varchar(255), street varchar(255), zipcode varchar(255), name varchar(255), primary key (id))
참고로 임베디드 타입이 null 이면 매핑한 컬럼 값도 모두 null 이다. 재밌는 점은, Embeddable 클래스 Address 의 프로퍼티는 모두 not nullable 이라는 점이다.
fun main() {
// 생략
}
fun logic(em: EntityManager) {
val address = Address("서울", "장안로", "111")
val address2 = Address("서울", "장안로2", "222")
val member = Member(null, "수호", address, address2)
member.home2Address = null
em.persist(member)
}
아래에 생성된 테이블에서 home2Address 의 매핑된 컬럽이 모두 null 임을 확인할 수 있다.
ID | HOME2_CITY | HOME2_STREET | HOME2_ZIPCODE | CITY | STREET | ZIPCODE | NAME |
---|---|---|---|---|---|---|---|
1 | 서울 | 장안로2 | 222 | null | null | null | 수호 |
임베디드 타입을 사용할 때 주의할 점이 있다. 임베디드 타입은 클래스로서, 그 객체는 참조 변수다. 그러므로 다음과 같은 문제점이 생긴다.
fun logic(em: EntityManager) {
val address = Address("서울", "장안로", "111")
val address2 = Address("서울", "장안로2", "222")
val member = Member(null, "수호", address, address2)
var addressCopy = member.homeAddress
addressCopy?.city = "NY"
addressCopy?.street = "WS"
addressCopy?.zipcode = "xxx"
val member2 = Member(null, "수호", addressCopy, address2)
em.persist(member)
em.persist(member2)
}
아래 테이블과 같이 member2 의 주소만 변경되지 않고 member 의 주소도 변경됐다.
ID | HOME2_CITY | HOME2_STREET | HOME2_ZIPCODE | CITY | STREET | ZIPCODE | NAME |
---|---|---|---|---|---|---|---|
1 | 서울 | 장안로2 | 222 | NY | WS | xxx | 수호 |
2 | 서울 | 장안로2 | 222 | NY | WS | xxx | 수호 |
이걸 막는 방법은 값 타입을 불변 객체로 설정하는 것이며, Java 에서는 setter 를 만들지 않는 방법으로 설정한다. Kotlin 에서는 값 타입의 멤버 변수를 var 가 아닌 val 로 선언하는 방법이 있다.
컬렉션 값 타입(collection value type)
값 타입 컬렉션 대신에 one to many 관계가 많이 쓰인다. 왜냐하면 값 타입 컬렉션은 쿼리가 많이 발생하기 때문이다. 그렇기 때문에 여기서는 정리하지 않기로 한다.
객체지향 쿼리 언어
JPA 가 지원하는 검색 방법으로는 JPQL, Criteria, native SQL, QueryDSL, JDBC 가 있다.
객체지향 쿼리 언어의 기본은 JPQL 으로, Criteria 나 QueryDSL 모두 JPQL 을 편하게 다루기 위한 도구로 자바 코드로 JPQL 문을 생성한다. 그리고 JPQL 은 SQL 문을 생성한다.
JPQL 은 테이블이 아니라 객체를 대상으로 검색하며, SQL 을 추상화해서 데이터베이스 dialect 에 의존하지 않는다.
참고로 navtive query 를 사용하는 경우는 특정 DB dialect 에 의존해야 하는 경우이며, JDBC 를 직접 사용하거나 SQL 매퍼 프레임워크를 사용할 때의 주의 사항으로는 영속성 컨텍스트를 적절한 시점에 강제로 flush 해야 한다는 것이다.
JPQL
아래는 기본적인 JPQL 사용 방법과 출력 결과이다.
fun main() {
// JPQL
val emf = Persistence.createEntityManagerFactory("jpabook")
val em = emf.createEntityManager()
// JPQL 에서 entity 와 attribute 는 대소문자를 구분하지만, JPQL 키워드는 그렇지 않다.
// JPQL 에서는 클래스 명이 아니라 엔티티 명을 사용한다. 즉, 아래 Member 는 엔티티 명이며 이는 @Entity(name="blah") 로 지정할 수 있다.
// 별칭(identification variable 또는 alias)이 필수다. 아래에서는 m 을 무조건 사용해야 한다. AS 는 생략 가능하다.
val jpql = "SELECT m from Member AS m WHERE m.name = '수호'"
val resultList: TypedQuery<Member> = em.createQuery(jpql, Member::class.java)
resultList.resultList.forEach {
println(it)
}
}
Member(id=1, name='수호', homeAddress=Address(city='NY', street='WS', zipcode='xxx'), home2Address=Address(city='서울', street='장안로2', zipcode='222'))
Member(id=2, name='수호', homeAddress=Address(city='NY', street='WS', zipcode='xxx'), home2Address=Address(city='서울', street='장안로2', zipcode='222'))
JPQL 은 SELECT, UPDATE, DELETE 문을 사용할 수 있으며, INSERT 문은 없다 (em.persist() 로 대체).
또한 JPQL 은 위치 기준 parameter binding 뿐만 아니라 이름 기준 parameter binding 기능을 제공한다.
fun main() {
// JPQL
val emf = Persistence.createEntityManagerFactory("jpabook")
val em = emf.createEntityManager()
val userNameParam = "수호"
// parameter binding
val queryWithParameterBinding: TypedQuery<Member> =
em.createQuery("SELECT m FROM Member m WHERE m.name = :username", Member::class.java)
.setParameter("username", userNameParam)
val resultListParameterBinding = queryWithParameterBinding.resultList
// position binding
val queryWithPositionBinding: TypedQuery<Member> =
em.createQuery("SELECT m FROM Member m WHERE m.name = ?1", Member::class.java)
.setParameter(1, userNameParam)
val resultListPositionBinding = queryWithPositionBinding.resultList
resultListParameterBinding.forEach { println(it) }
println("-----------------")
resultListPositionBinding.forEach { println(it) }
}
Member(id=1, name='수호', homeAddress=Address(city='NY', street='WS', zipcode='xxx'), home2Address=Address(city='서울', street='장안로2', zipcode='222'))
Member(id=2, name='수호', homeAddress=Address(city='NY', street='WS', zipcode='xxx'), home2Address=Address(city='서울', street='장안로2', zipcode='222'))
-----------------
Member(id=1, name='수호', homeAddress=Address(city='NY', street='WS', zipcode='xxx'), home2Address=Address(city='서울', street='장안로2', zipcode='222'))
Member(id=2, name='수호', homeAddress=Address(city='NY', street='WS', zipcode='xxx'), home2Address=Address(city='서울', street='장안로2', zipcode='222'))
JPQL 에서 projection 이란 SELECT 절에서 조회할 대상을 지정하는 것을 말한다. “SELECT something” 에서 something 이 엔티티 타입, 임베디드 타입, 스칼라 타입(통계 쿼리도 여기 포함), 그리고 여러 값이 올 수 있다. 임베디드 타입 projection 의 경우 주의 사항이 있다. 임베디드 타입은 조회의 시작점이 될 수 없다는 점이다. 아래 코드를 실행하면 다음과 같은 예외가 발생한다.
fun main() {
// JPQL
// 임베디드 타입 projection 주의 사항
val emf = Persistence.createEntityManagerFactory("jpabook")
val em = emf.createEntityManager()
val query = "SELECT a FROM Address a"
val queryOnAddress = em.createQuery(query, Address::class.java)
}
Exception in thread "main" java.lang.IllegalArgumentException: org.hibernate.hql.internal.ast.QuerySyntaxException: Address is not mapped [SELECT a FROM Address a]
Caused by: org.hibernate.hql.internal.ast.QuerySyntaxException: Address is not mapped [SELECT a FROM Address a]
Caused by: org.hibernate.hql.internal.ast.QuerySyntaxException: Address is not mapped
예외 발생 없이 임베디드 타입 projection 을 행하려면 다음과 같이 코드를 작성하면 된다.
fun main() {
// JPQL
// 임베디드 타입 projection 주의 사항
val emf = Persistence.createEntityManagerFactory("jpabook")
val em = emf.createEntityManager()
val query = "SELECT m.homeAddress FROM Member m"
val addressList = em
.createQuery(query, Address::class.java)
// 아래도 가능
//.createQuery(query)
.resultList
println(addressList)
}
[Address(city='NY', street='WS', zipcode='xxx'), Address(city='NY', street='WS', zipcode='xxx')]
여러 값 projection 을 할 때 주의 사항은 TypedQuery 가 아니라 Query 를 사용해야 한다는 것이다.
fun main() {
// JPQL
// 여러 값 projection 주의 사항
val emf = Persistence.createEntityManagerFactory("jpabook")
val em = emf.createEntityManager()
// TypedQuery 가 아닌 Query 를 쓴다.
val createQuery: Query = em.createQuery("SELECT m.id, m.name FROM Member m")
}
다음은 JPQL 의 페이징 API 다.
fun main() {
// JPQL
// 페이징 API
val emf = Persistence.createEntityManagerFactory("jpabook")
val em = emf.createEntityManager()
val queryStatement = "SELECT m FROM Member m ORDER BY m.id DESC"
val query = em.createQuery(queryStatement, Member::class.java)
query.firstResult = 0
query.maxResults = 2
println(query.resultList)
}
[Member(id=2, name='수호', homeAddress=Address(city='NY', street='WS', zipcode='xxx'), home2Address=Address(city='서울', street='장안로2', zipcode='222')), Member(id=1, name='수호', homeAddress=Address(city='NY', street='WS', zipcode='xxx'), home2Address=Address(city='서울', street='장안로2', zipcode='222'))]