의문점:

  1. 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'))]