728x90
반응형
SMALL
반응형
SMALL

2024.10.25 업데이트


이제 JPA에서 역시 제일 중요한 부분 중 하나인 객체와 테이블 매핑에 대해서 알아보자. 

 

Member

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;
}

 

 

  • @Entity 애노테이션이 붙은 클래스는 JPA가 관리하게 된다. 그 JPA가 관리하는 클래스를 엔티티라고 보통 칭한다.
  • JPA를 사용해서 테이블과 매핑할 클래스에는 이렇게 반드시 @Entity 애노테이션이 붙어야 한다. (JPA가 애플리케이션을 띄울 때, 이 애노테이션이 달린 클래스들을 관리하고 테이블로 만들고 등등의 리플렉션 작업이 일어나기 때문에)
  • 기본 생성자가 필수이다. 접근 제어자는 public 또는 protected 생성자로 가능하다. (이 역시도 JPA가 애플리케이션을 띄울 때, 동적으로 객체를 만들어야 하고 그때 리플렉션을 활용해서 기본 생성자로부터 객체를 만들기 때문에)
  • final 클래스, enum, interface, inner 클래스로는 엔티티를 만드는 것이 불가능하다.
  • 데이터베이스에 저장할 필드에 final 사용은 할 수 없다.

 

어 그럼 이 테이블을 어떻게 만드나요? 

직접 데이터베이스에서 만들면 그게 가장 좋다. 근데, 개발이나 테스트 환경에서는 굳이 그럴 필요 없이 빨리 진행하는 게 더 효율적일 수가 있는데, 그때 사용하는 옵션이 바로 `hibernate.hbm2ddl.auto`이다. 

 

먼저 이 옵션의 속성값들은 뭐가 있는지부터 살펴보자.

옵션 설명
create 기존 테이블 삭제 후 다시 생성 (DROP + CREATE)
create-drop create이랑 비슷한데, 종료 시점에 테이블을 DROP
update 변경분만 반영
validate 엔티티와 테이블이 정상 매핑되었는지만 확인,  정상적이지 않다면 에러를 발생시키고 애플리케이션 종료
none 사용하지 않음

 

이렇게 5가지 옵션을 사용할 수 있고, 개발상에서는 그냥 CREATE, UPDATE, CREATE-DROP을 사용해서 빨리 빨리 테이블을 만들면 더 효율적으로 사용할 수 있다.

 

위 사진과 같이 옵션을 주면 실행할 때, 우선 테이블을 싹 다 삭제한 후, 엔티티 클래스를 테이블로 새로 만들어 준다.

그럼 이 기능을 사용해서 개발할땐 빨리 빨리 진행이 가능해 지겠지. 테이블 만드는 스크립트까지 굳이 작성 안해도 되니까. 

 

그런데, 주의할점은 운영에서는 절대로 그 어떤 옵션도 사용하지말자.

굳이 굳이 사용할려면 가능한게 validate 정도이고, 나머지 옵션은 생각도 하면 안된다. 데이터가 어떻게 보면 회사에서 가장 중요한 정보이자 자산인데, 시스템이 자동화한 작업에 데이터를 믿고 맡길 수 있겠는가? 어떤 변수가 일어날지 알고 말이다.

 

@Column

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

컬럼안에 옵션으로 nullable, unique, length 등 여러 옵션을 줄 수 있고, 이 옵션은 자바와는 상관없이 JPA에만 영향을 주는 것들이다.

이 애노테이션을 확인하는 부분이 JPA에서 리플렉션을 활용할 때이다. 그래서 어떤 제약조건이 있는지 체크해서 데이터베이스에 테이블을 만들거나 검증할 때 사용하는 거라서 자바 객체와는 연관은 없다! 

 

만약, 속성을 추가할 필요가 없으면 이 @Column은 굳이 사용하지 않아도 알아서 데이터베이스에 컬럼으로 잘 추가된다.

 

@Column 속성들

속성 설명 기본값
name 필드와 매핑할 테이블의 컬럼 이름 객체의 필드 이름
insertable 등록 가능 여부  true
updatable 변경 가능 여부 true
nullable (DDL) null 값의 허용 여부를 설정한다. false로 설정하면 DDL 생성시에 not null 제약 조건이 붙는다. true
unique (DDL) @TableuniqueConstraints와 같지만, 한 컬럼에 간단히 유니크 제약조건을 걸 때 사용한다. false
columnDefinition (DDL) 데이터베이스 컬럼 정보를 직접 줄 수 있다.
예) varchar(100) default 'EMPTY'
 
length (DDL) 문자 길이 제약 조건, String 타입에만 허용 255
precision, scale (DDL) BigDecimal 타입에서 사용한다(BigInteger도 가능). precision은 소수점을 포함한 전체 자릿수를, scale은 소수의 자릿수다. 참고로 double, float 타입에는 적용되지 않는다. 아주 큰 숫자나 정밀한 소수를 다루어야 할때만 사용한다. precision = 19, scale=2 
(이렇게 설정하면 전체 자리수가 19자리의 숫자이며 소수점은 2자리까지 표시하겠다는 의미가 된다)

 

Timestamp

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

 

@Enumerated

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

 

여기서 중요한 건 EnumType.STRING 옵션이다. 왜 이게 중요하냐면 기본적으로 EnumTypeORDINAL이 기본값인데 이것을 사용하면 안된다. 이 값은 데이터베이스에 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으로 생성해보자.

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

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

 

이런 요구사항이 들어와서 이렇게 코드를 수정했다. 근데 하필 GUEST를 맨 앞에 추가했다!

package org.example.entity;

public enum RoleType {
    GUEST, USER, ADMIN
}

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

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

 

 

@Lob

VARCHAR(255)보다 큰 매우 큰 문자열이 필요한 경우 @Lob을 사용한다. 대부분은 이 경우에서 다 끝나는데 이 Lob은 두가지 타입이 있다. BLOB, CLOB.

 

매핑하는 필드 타입이 문자면 CLOB 매핑, 나머지는 BLOB 매핑이 된다.

  • BLOB → String, char[], java.sql.CLOB
  • CLOB → byte[], java.sql.BLOB

 

@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:  데이터베이스에게 기본키 생성을 위임 (MySQL의 auto_Increment 같은)

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

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

- AUTO: DB 방언(Dialect)에 따라 자동 지정 (기본값)

 

여기서 중요한건 IDENTITYSEQUENCE다.

 

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();
        }
    }
}

위 코드를 실행해보자. 지금 멤버에 엔티티에 @GeneratedValue 애노테이션을 아예 사용하지 않은 상태다. 즉, 내가 직접 지정한다는 의미이다. 이 상태에서는 절대 절대 트랜잭션 커밋이 일어나기 전까지 SQL문이 보이지 않을 것이다.

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

 

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

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

 

 

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

 

아래 사진은 전략을 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 어노테이션이 있는데 여기서 allocationSize100으로 지정하면 메모리가 미리 시퀀스 100개를 가지게 되는 것과 동일하다.

 

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

두번이냐면, 우선 시퀀스는, 내가 allocationSize를 100으로 설정하면 시퀀스값을 호출할 때 데이터베이스 시퀀스가 100개씩 증가한다. 호출을 아예 한번도 하지 않으면 1인 상태이다. 그러면 애플리케이션에서 최초 호출을 했을 때 1을 받는다. 근데 allocationSize를 100으로 할당했는데 1이 나왔으니, "아 지금 최초 호출도 하지 않은 상태이구나!?"로 애플리케이션이 판단하고 한번 더 호출한다. 그럼 그때 데이터베이스 시퀀스가 100으로 증가하면서 메모리에 1부터 100까지를 미리 올려놓는다. 이후부터는 같은 객체에 대해 시퀀스값을 가져오는건 데이터베이스를 거치지 않고 메모리에 올라와있는 값을 사용하면 되는것이다. 언제까지? 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

+ Recent posts