Spring boot 2.0 과 Jpa2.2 그리고 Hibernate 5.2

오늘은 Spring boot2 기반으로 Jpa2.2 대해서 알아볼 예정이다. 실제 Spring boot 2.0은 기본적으로 하이버네이트 5.2를 지원한다.
그렇다고 해서 Spring boot 2.0 에 대해서 알아볼건 아니고 Spring boot 가 여러가지로 편해서 Spring boot 기반으로 작성할 예정이다. 만약 단독으로 Hibernate 를 사용한다면 여러 설정을 해야하므로 설정 관련은 따로 보는 것으 좋을 듯하다. 주로 JPA 2.2에 대해서 알아보도록 할 것이다. 시작해보자!

기본적으로 Spring boot 2.0Hibernate 5.2.16을 지원하고 있으며 (현재 이글을 쓰고 있을 때 최신버전은 Spring boot 2.0.1) Hibernate 5.2은 JPA2.1을 디펜더시 받고 있으며 2.1을 지원하고 있다. 그렇지만 Hibernate 5.2는 JPA2.2 도 일부 지원하고 있다. 그럼 지원하는 몇가지를 알아보도록 하자.

일단 spring boot 기준으로 설명하니 디펜더시도 Spring boot 기준일 것이다.

Maven Install

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.hibernate.javax.persistence</groupId>
            <artifactId>hibernate-jpa-2.1-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>javax.persistence</groupId>
    <artifactId>javax.persistence-api</artifactId>
    <version>2.2</version>
</dependency>

Spring data jpa 에서 hibernate-jpa-2.1-api 를 제외하고 그대신 javax.persistence-api 2.2 를 작성하면 된다. 그럼 일단 준비는 완료다.

JSR-310

JPA2.2 에서는 JSR-310 스펙인 Date and Time API를 지원한다.

@Converter(autoApply = true)
public class LocalDateTimeConverter implements AttributeConverter<LocalDateTime, Date> {

    @Override
    public Date convertToDatabaseColumn(LocalDateTime date) {
        //blabla
    }

    @Override
    public LocalDateTime convertToEntityAttribute(Date date) {
        //blabla
    }
}

예전에는 LocalDateTime, LocalDate, LocalTime 등 JSR-310을 사용하려면 Converter를 만들어서 위와 같이 변환을 했어야 했다. 하지만 JPA2.2 부터는 그럴 필요 없다. 기본적으로 LocalDate, LocalTime, LocalDateTime, OffsetTime, OffsetDateTime 등을 지원하고 있으니 참고하면 되겠다.

@Entity
public class Person {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private String email;

    private LocalDateTime date;

   //etc
}

그냥 위와 같이 작성해도 정상적으로 동작한다.

@Repeatable

여러 어노테이션에서 @Repeatable 어노테이션이 추가가 되었다.

  • AssociationOverride
  • AttributeOverride
  • NamedQuery
  • NamedStoredProcedureQuery
  • PersistenceUnit
  • PrimaryKeyJoinColumn
  • SequenceGenerator
  • SecondaryTable
  • SqlResultSetMapping
  • TableGenerator
  • JoinColumn
  • MapKeyJoinColumn
  • NamedEntityGraph
  • NamedNativeQuery
  • PersistenceContext

필자가 찾아본 것으로는 대략 위와 같다. 얼추 찾아본거라 더 있을 수 있거나 잘못 작성한 내용도 있을 수 있으니 다시 확인하길 바란다.
기존에는 위의 어노테이션을 여러개 사용했을 경우 아래와 같이 사용했어야 했다.

@Entity
public class Person {

    //...

    @Embedded
    private Phone phone;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name = "number1", column = @Column(name = "telNumber1")),
            @AttributeOverride(name = "number2", column = @Column(name = "telNumber2")),
            @AttributeOverride(name = "number3", column = @Column(name = "telNumber3"))
    })
    private Phone telNumber;
    //...
}

하지만 이제는 그럴 필요 없이 @AttributeOverride 어노테이션만 여러개 작성하면 된다. 좀 더 깔끔해졌다. 물론 위의 코드가 더 낫다고 생각할 수 도 있겠지만 필자는 아래가 더 깔끔한 것 같다.

@Entity
public class Person {

    //...

    @Embedded
    private Phone phone;

    @Embedded
    @AttributeOverride(name = "number1", column = @Column(name = "telNumber1"))
    @AttributeOverride(name = "number2", column = @Column(name = "telNumber2"))
    @AttributeOverride(name = "number3", column = @Column(name = "telNumber3"))
    private Phone telNumber;

    //...
}

@Repeatable어노테이션을 이해하려면 좀 더 내용을 찾아보기를 권장한다. 그렇게 어려운 내용은 아니니 말이다.

resultStream

EntityManager로 통해서 값을 가져올 경우에 Stream 으로 변환해서 가져올 수 있다. 실제 이 메서드는 TypedQuery 인터페이스의 default 메서드로 List 를 Stream 으로 변환하는 단순한 메서드 이다.

default Stream<X> getResultStream() {
    return getResultList().stream();
}

딱히 어려운 부분은 없는 것 같다.

@Test
void entityManagerStream(@Autowired EntityManager entityManager) {
    entityManager.persist(new Person("wonwoo", "wonwoo@test.com", LocalDateTime.now(),
            new Phone("000", "111", "2222"), new Phone("333", "444", "5555")));
    List<PersonDto> persons = entityManager.createQuery("select p from Person p", Person.class)
            .getResultStream()
            .map(person -> new PersonDto(person.getName(), person.getEmail())).collect(Collectors.toList());
    assertThat(persons).hasSize(1);
    PersonDto personDto = persons.iterator().next();
    assertThat(personDto.getName()).isEqualTo("wonwoo");
    assertThat(personDto.getEmail()).isEqualTo("wonwoo@test.com");
}

위와 같이 map, filter등 변환할 것이나 stream 메서드를 사용하고 싶다면 getResultList() 메서드 말고 getResultStream() 메서드를 사용하면 된다.

CDI Injection

글쎄다. 일단 CDI Injection 스펙은 JPA2.2 에 존재하지만 아직 Hibernate에서는 지원하지 않는 것으로 보인다. 아니면 필자가 잘몰라서..

@Converter(autoApply = true)
public class TypeConverter implements AttributeConverter<Type, String> {

    @Inject
    private PersonService personService;

    @Override
    public String convertToDatabaseColumn(Type attribute) {
        return attribute.getType();
    }

    @Override
    public Type convertToEntityAttribute(String dbData) {
        return new Type(dbData);
    }
}

위와 같이 DI를 받을 수 있다는 내용 같다. 하지만 필자는 동작하지 않는다.ㅠㅠ 만약 되는 것이라면 피드백을..

참고

하다가 몇가지 발견한게 있는데 뭐 중요한 내용은 아니지만 그래도 혹시나 누군가 이런 상황이 온다면 잘 해결하길 바란다. (무책임) 필자는 그냥 테스트한번 해볼려다 당한거니..
Hibernate 5.2의 SessionFactoryEntityManagerFactory를 상속 받고 있다.

public interface SessionFactory extends EntityManagerFactory, HibernateEntityManagerFactory, Referenceable, Serializable, java.io.Closeable {
   // ...
}

기존에는 다음과 같은 형태의 인터페이스 였다.

public interface SessionFactory extends Referenceable, Serializable, java.io.Closeable {
   //...
}

Hibernate 의 Session 또한 EntityManager를 상속 받고 있다. 기존의 Session 인터페이스는 다음과 같다.

public interface Session extends SharedSessionContract, java.io.Closeable {
   //..
}

하지만 현재 Hibernate 5.2에서는 다음과 같다.

public interface Session extends SharedSessionContract, EntityManager, HibernateEntityManager, AutoCloseable {
   //...
}

그것도 모르고 필자가 Hibernate API 를 사용하고 싶어서 문서에 있는 그대로 설정하였으나 제대로 동작하지 않았다. 아마도 그이유는 Spring boot의 자동 설정 때문일 것 같다.

@ConditionalOnMissingBean({ LocalContainerEntityManagerFactoryBean.class,
        EntityManagerFactory.class })
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
        EntityManagerFactoryBuilder factoryBuilder) {
  // ...
}

딱히 필자가 중요한 내용은 아니라서 그냥 알고만 있는 중이다. 나중에 필요하다면 좀 더 자세히 살펴보려고 한다. 혹시나 그 이유와 해결책을 알고 있다면.. 댓글로!
위의 예제 코드들은 여기에 있으니 참고하면 되겠다.

이상으로 오늘은 Hibernate 5.2의 JPA2.2를 알아봤다.
JPA 는 어렵다.

JPA OneToOne?

JPA를 다루다보면 까다로운게 한두군데가 아닌거 같다. 아직 JPA를 손쉽게 다루지 못해서 그러지만.. 하면 할 수록 어렵단 말이야..

그 중에서 오늘은 JPA에서 OneToOne 관계에 대해서 알아보려한다. JPA의 OneToOne 관계는 정말 까다롭다. OneToOne 관계로 설계할 때는 심히 많은 고민을 해보길 바란다. 정말로 OneToOne 관계를 해야 되나…말이다.

첫 번째 방법

아래의 코드는 OneToOne(양방향)의 첫 번째 방법이다.

@Entity
public class Content {

  @Id
  @GeneratedValue
  private Long id;

  private String title;

  @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, mappedBy = "content")
  @JoinColumn(name = "CONTENT_ID")
  private ContentDetail contentDetail;

  //기타 생략
}
@Entity
public class ContentDetail {

  @Id
  @GeneratedValue
  private Long id;

  private String text;

  @OneToOne(fetch = FetchType.LAZY)
  private Content content;

  //기타 생략
}

우리는 대략 이런 모델링을 했다고 가정하자. Content 엔티티가 부모측이고 ContentDetail가 자식측 이다. 이렇게 작성을 했을 때 스키마는 어떻게 작성 되나 살펴보자.

create table content (
  id bigint generated by default as identity,
  title varchar(255),
  primary key (id)
)

create table content_detail (
  id bigint generated by default as identity,
  text varchar(255),
  content_id bigint,
  primary key (id)
)

alter table content_detail
  add constraint FKkxm1ajimhk153isl1cnun1r4i
foreign key (content_id)
references content

대충 위와 같이 스키마가 나왔다. content에 ContentDetail은 연관관계의 주인이 아니기에 mappedBy로 설정하였다.
그리고 아래와 같이 저장을 해보자.

@Transactional
public void save(){
  ContentDetail contentDetail = new ContentDetail("content");
  Content content = new Content("title", contentDetail);
  contentDetail.setContents(content);
  final Content save = contentRepository.save(content);
  //생략
}

잘들어 간다. content 테이블에 먼저 insert 후에 content_detail 테이블에도 insert를 실행한다. 그러고 나서 해당하는 seq로 조회를 해보자.

public Content findOne(Long id) {
  final Content one = contentRepository.findOne(id);
  //생략
}

그리고나서 한번 실행된 쿼리문을 살펴보자.

select
  content0_.id as id1_0_0_,
  content0_.title as title2_0_0_
from
  content content0_
where
  content0_.id=?

select
  contentdet0_.id as id1_1_0_,
  contentdet0_.content_id as content_3_1_0_,
  contentdet0_.text as text2_1_0_
from
  content_detail contentdet0_
where
  contentdet0_.content_id=?

읭? 뭔가 이상하다. 쿼리가 두번이나 날라갔다. 분명 content에 있는 ContentDetail은 Lazy로딩인데 Lazy로딩이 먹히지 않았다.
왜일까? 책에서는 프록시 때문에 그런다고 했는데 자세히 안나온거 같아서 (필자가 자세히 못봤나?) 찾아봤다.
http://kwonnam.pe.kr/wiki/java/jpa/one-to-one
권남님이 자세히 설명해놨으니 읽어 보면 좋겠다. 간단히 말하자면 null값이 가능한 OneToOne의 경우 프록시 객체로 감쌀 수 없기 때문이다. 만약 null 값에 프록시 객체를 넣는다면 이미 그것은 null이 아닌 프록시 객체를 리턴하는 상태가 되버리기 때문이라고 설명하였다.

두 번째 방법

두 번째 방법은 @PrimaryKeyJoinColumn 를 사용해서 OneToOne을 설정 해보자.


//Content.class @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, mappedBy = "content") @PrimaryKeyJoinColumn private ContentDetail contentDetail; //ContentDetail.class @OneToOne(fetch = FetchType.LAZY) @PrimaryKeyJoinColumn private Content content;

나머지는 생략하고 위와 같이 변경을 해보자. 부모측의 키를 자식측까지 공유하게 설정했다. 생성된 스키마는 다음과 같다.

create table content (
  id bigint not null auto_increment,
  title varchar(255),
  primary key (id)
)

create table content_detail (
  id bigint not null auto_increment,
  text varchar(255),
  primary key (id)
)

다시 한번 테스트를 해보자.

select
  content0_.id as id1_0_0_,
  content0_.title as title2_0_0_
from
  content content0_
where
  content0_.id=?

select
  contentdet0_.id as id1_1_0_,
  contentdet0_.text as text2_1_0_
from
  content_detail contentdet0_
where
  contentdet0_.id=?

그래도 결과는 똑같다. 이 또한 프록시 객체를 리턴하지 못해 즉시로딩을 실시한다.

세 번째 방법

이번에는 @MapsId 를 이용한 방법이다.

//Content.class
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, mappedBy = "content")
private ContentDetail contentDetail;


//ContentDetail.class
@OneToOne(fetch = FetchType.LAZY)
@MapsId
private Content content;

위와 같이 자식측에 @MapsId 를 선언해 주면 된다. 생성된 스키마를 살펴보자.

create table content (
  id bigint not null auto_increment,
  title varchar(255),
  primary key (id)
)

create table content_detail (
  text varchar(255),
  content_id bigint not null,
  primary key (content_id)
)

alter table content_detail
  add constraint FKkxm1ajimhk153isl1cnun1r4i
foreign key (content_id)
references content (id)

자식측에 있는 content_id는 주키이면서 동시에 외래키까지 갖고 있다. 이거 역시 즉시로딩이 실시 된다.

select
  content0_.id as id1_0_0_,
  content0_.title as title2_0_0_
from
  content content0_
where
  content0_.id=?

select
  contentdet0_.content_id as content_2_1_0_,
  contentdet0_.text as text1_1_0_
from
  content_detail contentdet0_
where
  contentdet0_.content_id=?

어쨋든 OneToOne 에서는 지연로딩이 잘 동작 하지 않는다. 물론 동작하게 만들 수는 있다. 여러 조건들이 붙는거 같은데 굳이 해보진 않았다.
조건은 이렇다. null을 허용하지 않아야 하며, PrimaryKeyJoin이 아니여야 하고, 단방향 관계가 되어야 한다.

아무튼 OneToOne 관계를 까다롭다. 대부분의 사람들이 OneToOne의 관계를 설계할 때는 심히 고민을 많이하고 되도록이면 피하는게 좋다고 한다. OneToOne은 @SecondaryTable이나 @Embeddable 등으로 대체 가능 할 수 있으니 그 와 같은 방법을 고려 해보는 것도 나쁘지 않은 생각이다.

오늘은 이렇게 까따로운 OneToOne 관계를 살펴봤다. JPA도 끝이 없구나..

jinq

오늘은 jinq라는 아이에 대해 살펴보자.
jinq는 c#의 linq의 영감을 얻어 linq 스타일을 그대로 따라 만들었다. jinq는 그냥 평범한 자바 코드로 DB의 데이터를 조회 할 수 있다. QueryDsl과 비슷한 역할을 하는 아이이다. QueryDsl 보다 좋은점은 Q 클래스를 같은 클래스를 만들지 않아도 된다는게 가장 좋은 장점이라고 생각한다. 또한 jpa도 지원하며 구현체로는 Hibernate 와 EclipseLink를 지원하고 scala와 jooq라는 프레임워크도 같이 사용할 수 있다고 한다. jooq도 잠깐 봤었는데 그냥 그렇다.

하지만 java8 의 Stream api와 비슷하기 때문에 java8을 써야 한다. 람다를 써야 더 깔끔하고 보기 좋기 때문에 java8을 써서 하자.

오늘은 간단하게 살펴보자. 필자도 공부하는 중이라..

@Entity
@Data
public class Member {

  @Id
  @GeneratedValue
  private Long id;

  private String name;

  private int age;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "TEAM_ID")
  private Team team;
}

@Entity
@Data
@ToString(exclude = "members")
public class Team {

  @Id
  @GeneratedValue
  private Long id;

  private String name;

  @OneToMany(mappedBy = "team")
  private List<Member> members = new ArrayList<>();

}

저런 엔티티가 있다고 가정하자.
그리고 jinq를 사용하기 위한 설정을 해야된다. 설정이라기 보다 디펜더시와 사용하기 위한 setup정도이다.

<dependency>
    <groupId>org.jinq</groupId>
    <artifactId>jinq-jpa</artifactId>
    <version>1.8.13</version>
</dependency>

우리는 jpa를 사용하기 때문에 jinq-jpa를 디펜더시 받으면 된다.

  private EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("default");

  @Test
  public void jinqTest() {
    JinqJPAStreamProvider streams = new JinqJPAStreamProvider(entityManagerFactory);
    EntityManager em = entityManagerFactory.createEntityManager();
   //... 
  }
}

위와 같이 EntityManagerFactory 를 생성후에 JinqJPAStreamProvider에 생성자로 entityManagerFactory를 넣으면 된다. 그리고 그 다음 부터는 jpa 의 기본 코드이다. EntityManagerFactory 통해 EntityManager를 가져오면 된다.
이제 간단하게 문법을 살펴보자. java8을 사용한다면 좀더 쉽게 느껴질 수 있지만 java8의 Stream api와 lambda 를 잘 모른다면 이해가 되지 않을 수 도 있다. 만약 모른다면 그냥 이런게 있다고만 알면 된다.

final Member member = streams
  .streamAll(em, Member.class)
  .where(m -> m.getName().equals("wonwoo"))
  .getOnlyValue();
System.out.println(member);

딱봐도 Stream api와 많이 닮았다. where 절에 보면 member의 name이 wonwoo와 같은 아이를 찾는 것이다. 실제로 JPQL을 보면 아래와 같다.

SELECT
    A 
FROM
    Member A 
WHERE
    A.name = 'wonwoo' 

getOnlyValue 함수 경우에는 결과 값이 두개 이상이면 에러를 내뱉는다. 만약 두개 이상이 나왔을 때는 첫번째 결과가 나오게 하고 싶다면 아래와 같이 하면된다.

final Member member = streams
  .streamAll(em, Member.class)
  .where(m -> m.getName().equals("wonwoo"))
  .findFirst().get();
System.out.println(member);

findFirst 는 Optional를 리턴한다. 물론 get() 함수를 바로 쓰면 안되는건 다 알고 있을 것이다. orElseThrow나 orElse, orElseGet을 쓰길 바란다. get은 되도록이면 쓰지 않는 것이 좋다. 아니면 isPresent 를 사용해서 먼저 검사를 하는 게 좋다.

final List<Member> members = streams
  .streamAll(em, Member.class)
  .where(m -> m.getAge() < 20)
  .toList();
System.out.println(members);

QueryDsl과는 달리 number 같은 경우에는 우리가 흔히 쓰던 부등호를 사용할 수 있다. 위의 쿼리는 나이가 20보다 작은 Member를 불러워는 쿼리가 된다. 실제 JPQL은 다음과 같다.

SELECT
    A 
FROM
    Member A 
WHERE
    A.age < 20

만약 특정 필드만 출력하고 싶다면 select를 이용하면 된다.

final String member = streams
  .streamAll(em, Member.class)
  .where(m -> m.getName().equals("wonwoo"))
  .select(Member::getName)
  .findFirst().get();
System.out.println(member);

JPQL을 보면 다음과 같다.

SELECT
    A.name 
FROM
    Member A 
WHERE
    A.name = 'wonwoo'

또한 동적 쿼리도 가능하다.

String name = "wonwoo";
int age = 20;

JPAJinqStream<Member> stream = streams
  .streamAll(em, Member.class);
if (name != null)
  stream = stream.where( p -> p.getName().equals(name) );

if (age != 0) {
  stream = stream.where( p -> p.getAge() < age );
}
System.out.println(stream.toList());

QueryDsl과 비슷한 방법으로 해당 필드가 있으면 where 넣고 그렇지 않으면 무시한다. 실제 JPQL은 어떤지 보자.

SELECT
    A 
FROM
    Member A 
WHERE
    A.name = :param0 
    AND A.age < :param1

위와 같이 파라미터를 바인딩 한다.

물론 join도 가능하다.

List<Team> teams =
  streams.streamAll(em, Team.class)
    .joinFetchList(Team::getMembers)
    .toList();
System.out.println(teams);

위는 fetch 조인을 사용한 코드이다. 물론 inner join, outer join 등 있을껀 다 있다. 위의 코드는 JPQL이 어떻게 나오는지 보자.

SELECT
    A 
FROM
    Team A 
JOIN
    FETCH A.members B

조인은 나중에 필자도 공부좀하고 다시 살펴보도록 하자.

List<Team> teams =
  streams.streamAll(em, Team.class)
    .where(i -> i.getId().equals(1L) || i.getId().equals(2L))
    .joinFetchList(Team::getMembers)
    .toList();
System.out.println(teams);

만약 조건절에 and말고 or을 사용하고 싶다면 위와 같이 하면 된다. 흠… 조금 아쉽긴하다.
이렇게 jinq에 대해 살짝 알아봤다.

필자도 공부좀 하고 난뒤에 join도 살펴보고 기타 jinq에서 지원하는 함수들을 살펴보자.