JPA(Java Persistence API)

[JPA] Part 5. 객체와 테이블 매핑

cwchoiit 2023. 10. 17. 21:10
728x90
반응형
SMALL
728x90
반응형
SMALL

이제 JPA에서 역시 제일 중요한 부분 중 하나인 객체와 테이블 매핑에 대해서 알아보자. 이 부분은 복잡한 내용은 없고 간단하게 어노테이션을 이용해서 객체를 테이블과 매핑할 수 있다.

 

Member Entity

package org.example.entity;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(uniqueConstraints = {
        @UniqueConstraint(name = "UniqueName", columnNames = { "name" }),
        @UniqueConstraint(name = "UniqueEmail", columnNames = { "email" })
})
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    // ! strategy는 크게 4가지가 있다.
    // ! IDENTITY = DB에게 기본키 생성을 위임
    // ! SEQUENCE = DB에 Sequence 오브젝트를 만들어내서 그 오브젝트에서 다음 값, 또 다음 값을 꺼내서 할당
    // ! TABLE = 키 생성 전용 테이블을 하나 만들어서 데이터베이스 시퀀스를 흉내내는 전략
    // ! AUTO = 위 3개중에 아무거나 하나로 자동 지정
    private Long id;

    //! 컬럼 매핑
    @Column(nullable = false)
    private String email;

    //! 컬럼 매핑
    @Column(nullable = false)
    private String name;

    //! 날짜 타입 매핑
    private LocalDateTime createdAt;

    //! 날짜 타입 매핑
    private LocalDateTime lastModifiedAt;

    //! Enum Type (일반적으로 DB엔 ENUM이 없음)
    //! 기본이 EnumType이 ORDINAL인데 이건 데이터베이스에 ENUM의 순서를 저장하기 때문에 좋지 않음 STRING으로 쓸 것
    @Enumerated(EnumType.STRING)
    private RoleType roleType;

    //! VARCHAR 보다 더 큰 값이 들어가야 할 때
    @Lob
    private String description;

    //! 특정 필드를 컬럼으로 생성하는게 아니라 그냥 객체 안에서 메모리에서만 관리하고자 하는 경우
    @Transient
    private String temp;
}

 

코드를 먼저 보면 위처럼 생겼다. 만약 Member 라는 테이블을 만들고 싶다면 이처럼 Member 클래스를 하나 만들고 @Entity 라는 어노테이션을 붙여주면 JPA는 아 이 클래스가 테이블과 매핑되기를 원하는구나라고 인식한다. 

 

@Column

가장 간단한 @Column 어노테이션부터 살펴보면, 이 어노테이션이 붙으면 필드를 컬럼과 매핑하겠다는 의미이다.

컬럼안에 옵션으로 nullable, unique, length 등 여러 옵션을 줄 수 있고, 이 옵션은 객체와는 상관없이 데이터베이스에만 영향을 주는 것들이다.

 

Timestamp

데이터베이스에 시간 관련 필드를 만들고자 하면 자바에서는 그냥 LocalDateTime 타입을 사용하면 된다.

 

@Enumerated

자바에서 ENUM 클래스를 사용해서 필드를 만들고 싶을 때가 더러 있다. 그러나 DB는 일반적으로 ENUM 타입은 존재하지 않고 그럴 때 이렇게 @Enumerated 라는 어노테이션을 붙이면 ENUM 클래스를 필드로 매핑할 수 있다.

 

여기서 중요한 건 EnumType.STRING 옵션이다. 왜 이게 중요하냐면 기본적으로 EnumType은 ORDINAL이 기본값인데 이것을 사용하면 안된다. 이 값은 데이터베이스에 ENUM의 순서를 값으로 사용한다는 뜻인데 이건 큰 문제가 하나 있다. 

 

다음 코드를 보자. 

package org.example.entity;

public enum RoleType {
    USER, ADMIN
}

나는 두개의 ENUM Data가 있다. 이것을 데이터베이스에 필드로 선언하고 유저를 생성하는 코드를 짜보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        // ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
        EntityManager em = emf.createEntityManager();
        // ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
        // ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            /* CREATE */
            Member member = new Member();
            member.setName("helloC");
            member.setRoleType(RoleType.USER);
            em.persist(member);

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

RoleType.USER로 생성할 멤버의 롤 타입을 지정해서 유저를 생성했다. 데이터베이스에는 어떻게 들어갈까?

보다시피 0이라는 값으로 들어간다. 이번엔 ADMIN으로 생성해보자.

ADMIN은 1로 생성이 된다. 이 0과 1은 ENUM의 순서를 의미한다. 근데 만약, 비즈니스 요구사항이 변경된다면 ?

"GUEST라는 롤도 추가해주세요"

이런 요구사항이 들어와서 이렇게 코드를 수정했다.

package org.example.entity;

public enum RoleType {
    GUEST, USER, ADMIN
}

이제 GUEST를 추가하면 어떤 일이 일어날까?

그렇다. 0으로 추가된다. 왜냐하면 GUEST를 맨 앞에 두었으니까. 이게 ORDINAL의 가장 큰 문제다. 이를 방지하기 위해서 STRING 타입으로 꼭 변경해야 한다.

 

 

@Lob

VARCHAR(255)보다 큰 매우 큰 문자열이 필요한 경우 @Lob을 사용한다. 

 

@Transient

데이터베이스에 컬럼으로 추가하고 싶진 않지만 객체 메모리 상에서 관리할 데이터가 있을 때 이 어노테이션을 사용하면 데이터베이스에는 컬럼으로 추가되지 않는다.

 

 

@UniqueConstraint

이건 뭐냐면 일반적으로 필드에 unique 속성을 추가하고 싶다면 그냥 @Column(unique = true)라고 추가하면 되는데 이게 주는 문제는 제약의 이름이 마구잡이로 생성된다. 한번 예시를 보자.

@Column(nullable = false, unique = true)
private String name;

이렇게 하고 데이터베이스 테이블을 만들어보면 다음과 같은 SQL문이 출력된다.

보다시피 알 수 없는 이름으로 제약조건이 생성된다. 난 이게 싫다. 로그를 보고 알아 볼 수 있어야 문제가 생겼을 때도 수월하게 해결이 가능하니까. 이를 해결하기 위해서 @UniqueConstraint 어노테이션으로 제약 조건의 이름을 직접 명시한다. 바로 아래처럼.

@Table(uniqueConstraints = {
        @UniqueConstraint(name = "UniqueName", columnNames = { "name" }),
        @UniqueConstraint(name = "UniqueEmail", columnNames = { "email" })
})

저기서 name이 바로 제약조건의 이름이 된다. columnNames는 컬럼명을 의미한다.

이렇게 하고 다시 테이블을 만들게 해보면 다음과 같은 로그가 찍힌다.

 

 

@Id, @GeneratedValue

가장 중요한 부분인 기본키 생성과 전략이다. 우선 모든 테이블은 PK가 필요한데 그 PK를 생성하기 위해 @Id 라는 어노테이션을 붙인다. 그럼 JPA는 아 이 필드가 기본키가 될 녀석이구나라고 인식한다. 그럼 @GeneratedValue는 무엇일까? 기본키를 어떻게 생성할지에 대한 정보이다. 

 

@GeneratedValue 어노테이션은 기본키 생성 전략을 가지는데 그 전략은 크게 4가지가 있다.

 

- IDENTITY:  데이터베이스에게 기본키 생성을 위임

- SEQUENCE: 데이터베이스 시퀀스를 생성하고 그 시퀀스의 값을 기본키로 지정

- TABLE: 기본키를 위한 테이블을 하나 만들고 데이터베이스 시퀀스를 흉내내는 전략

- AUTO: 위 3가지 중 하나를 임의로 지정

 

여기서 중요한건 IDENTITY와 SEQUENCE다.

 

IDENTITY는 데이터베이스에게 완전 위임을 하는 것이기 때문에 데이터베이스에 데이터가 추가가 되기 전까지 INSERT 대상의 객체 기본키를 알 수 없다. 그럼 여기서 의문이 생긴다. 

분명히 엔티티 매니저가 persist()를 호출하면 영속 컨텍스트에 영속시키고 그 영속시킬 때 1차 캐시에 기본키를 저장하는데 INSERT가 되기 전까지 기본키를 알 수 없는 IDENTITY 전략은 어떻게 하지?

그래서! 이 IDENTITY 전략은 persist()를 할 때 영속시키기 전 데이터베이스에 추가한다. 로그로 바로 확인해볼 수 있다. 

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        // ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
        EntityManager em = emf.createEntityManager();
        // ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
        // ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            /* CREATE */
            Member member = new Member();
            member.setName("helloCCC");
            member.setId(1L);
            member.setRoleType(RoleType.GUEST);
            member.setEmail("test33");
            
            System.out.println("BEFORE");
            em.persist(member);
            System.out.println("AFTER");
            
            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

위 코드를 실행해보자. 지금 전략은 아무것도 지정하지 않았다. 즉, 내가 직접 지정한다는 의미이다. 이 상태에서는 절대 절대 트랜잭션 커밋이 일어나기 전까지 SQL문이 보이지 않을 것이다.

로그는 BEFORE, AFTER가 찍히고 난 후 INSERT문이 실행됐다. 당연하다. persist()는 영속 컨텍스트에 영속시킬 뿐 데이터베이스에 뭔가를 작업하는 단계가 아니니까. 

 

근데 여기서 전략을 IDENTITY로 해보면 어떨까? persist()를 호출할 때 INSERT문이 보일것이다.

보다시피 BEFORE와 AFTER 사이에 INSERT문이 실행됐다. 즉, 영속 컨텍스트에 객체를 담기 위해선 기본키가 필요하고 IDENTITY 전략은 데이터베이스에 추가되기 전까지 기본키 값을 알 수 없기 때문에 내부 메커니즘이 IDENTITY일 경우 persist()를 호출하면 데이터베이스에 항상 SQL문을 날리는 것이다. 이 말은 INSERT 문에 한하여 쓰기 지연 SQL문은 IDENTITY에서 없다는 뜻이다. 

 

 

반면, SEQUENCE는 데이터베이스의 시퀀스를 만들고 그 시퀀스를 가지고 기본키를 적용하는 전략이다. 그럼 생각해보자. 역시나 영속 컨텍스트에 객체를 보관하기 위해 기본키가 필요하고 전략이 SEQUENCE라면 persist()가 호출될 때 데이터베이스의 시퀀스를 조회하지 않을까? 맞다.

 

아래 사진은 전략을 SEQUENCE로 변경 후 새로운 멤버를 추가했을 때 로그 출력 결과다.

persist()가 호출될 때 시퀀스로부터 다음값을 먼저 불러온다. 왜냐하면 그래야 persist(member)가 호출될 때 이 멤버의 기본키를 알 수 있기 때문이다.

 

그럼 이 SEQUENCE 전략에선 이런 생각을 할 수 있다.

그럼 시퀀스를 가져오는 것 한 번, 데이터베이스에 INSERT 쿼리를 날리는 것 한 번해서 쓸데없이 두번 왔다 갔다 해야 하잖아? 성능에 좋지 않겠다.

어느 정도 합리적이다. 그러나 이에 대한 성능 최적화를 위해 allocationSize라는 속성이 있다.

@Entity
@SequenceGenerator(
        name = "MEMBER_SEQ_GENERATOR",
        sequenceName = "MEMBER_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
    private Long id;
}

이렇게 시퀀스를 생성할 때 이름도 지정하고 기본값들을 지정할 수 있는 @SequenceGenerator 어노테이션이 있는데 여기서 allocationSize를 100으로 지정하면 메모리가 미리 시퀀스 100개를 가지게 되는 것과 동일하다.

그러면 영속 컨텍스트에 객체를 보관하기 위해 시퀀스 값을 가져오는 횟수는 100개가 될 때까지 딱 2번이다.

두번이냐면 100개씩 시퀀스를 가져오는 범위가 다음과 같다. 1, 2-101, 102 - 201, … 

initialValue 1인데 처음에는 애플리케이션 입장에선 데이터베이스에 저장된 시퀀스 현재값이 뭔지 모르기 때문에 현재값 뭐야?!라는 1 그리고 가져왔더니 1이네? 한번 persist() 호출되면 그때 100 가져와야지! 해서 이후에 persist() 호출 시에 100 가져오는 1번해서 2번을 100개의 시퀀스동안 가져온다. 다음부턴 이제 102번째에 100 가져오고 하는것.

 

그래서 멤버가 100명이 될 때까지 시퀀스를 땡겨오지 않는다. 이것도 역시 로그로 직접 확인해보자.

package org.example;

import org.example.entity.Member;
import org.example.entity.RoleType;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        // ! EntityManagerFactory 이 녀석은 서비스가 띄워지면 딱 한개만 생성되어야 한다.
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");

        // ! EntityManager 이 녀석은 어떤 디비에 작업을 할 때마다 하나씩 만들어지고 작업이 끝나면 버려져야 한다.
        EntityManager em = emf.createEntityManager();
        // ! 모든 데이터베이스에 대한 변경 작업은 트랜잭션 안에서 일어나야 한다. (조회는 꼭 트랜잭션 안에서가 아니더라도 상관없다)
        // ! 하나의 트랜잭션에서 원하는 작업을 끝내고 그 트랜잭션안에서 커밋을 해줘야 변경이 적용된다.
        EntityTransaction tx = em.getTransaction();

        // ! Transaction 시작
        tx.begin();

        try {
            /* CREATE */
            System.out.println("BEFORE");
            Member member = new Member();
            member.setName("helloCCC");
            member.setRoleType(RoleType.GUEST);
            member.setEmail("test33");

            Member member2 = new Member();
            member2.setName("helloBBB");
            member2.setRoleType(RoleType.GUEST);
            member2.setEmail("test22");

            Member member1 = new Member();
            member1.setName("helloAAA");
            member1.setRoleType(RoleType.GUEST);
            member1.setEmail("test11");

            em.persist(member);
            em.persist(member2);
            em.persist(member1);

            System.out.println("AFTER");

            tx.commit();
        } catch (Exception e) {
            System.out.println(e.getMessage());
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

자, 위 사진에서 볼 수 있듯 시퀀스의 값은 두 번만 호출했다. 이제 100개의 시퀀스가 채워질 때 까지 더 이상 시퀀스를 데이터베이스로부터 가져오지 않는다. 이렇게 성능 최적화도 가능하다.

 

728x90
반응형
LIST