728x90
반응형
SMALL
반응형
SMALL

2024.10.25 업데이트


 

데이터베이스와 자바의 객체를 매핑하는 것을 이전 시간에 공부했는데 데이터베이스와 자바의 객체는 한 가지 이질적인 부분이 있다.

데이터베이스는 한 테이블에서 다른 테이블을 참조할 때 외래키를 이용하여 어떤 레코드를 참조하는지만 표현하는 반면, 객체 지향적이란 건 객체가 다른 객체를 참조할 수 있어야 객체 지향적이라고 할 수 있다.

 

객체 지향적인 설계가 복잡하고 어려운 내용인것 같다. 공부하고 공부해도 모자란 부분이 이 부분인 것 같지만 한 가지 확실한 건 다음과 같은 설계가 객체 지향적이라고 볼 순 없을 것 같다.

 

Member

package org.example.domain;

import javax.persistence.*;

@Entity
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;
    private String city;
    private String street;
    private String zipcode;

}

 

Order

package org.example.domain;

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

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    @Column(name = "MEMBER_ID")
    private Long memberId;
    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

}

이렇게 두 개의 엔티티가 있고 쇼핑몰에 대한 데이터베이스를 설계할 때 멤버(유저)는 주문을 할 수 있을 것이고 주문 정보에는 어떤 유저가 주문을 했는지에 대한 정보가 있을 것이다. 그래서 위와 같이 Order Entity에 유저의 PK유저 ID를 필드로 설정했다고 하면 이는 데이터 중심 설계를 했다고 봐야 한다. 데이터베이스가 추구하는 다른 테이블을 참조하는 방식인 외래키를 사용하는 방법.

 

그러나, 이 코드를 객체 관점으로 보게되면 주문 객체에서 유저 객체를 참조하지 않고 유저 객체를 참조할 수 있는 ID만을 가지고 있는 상태이기 때문에 객체 지향적 설계와 거리가 있다고 보는 게 더 합리적이다.

 

실제로 이런 설계를 가진 애플리케이션이라면 애플리케이션에서 주문 정보로부터 유저 정보를 알아내려면 주문 정보를 가져온 후 주문 정보에 담겨있는 유저의 PK를 찾아내서 다시 한 번 유저를 가져와야 한다. 다음 코드를 보자.

package org.example;

import org.example.domain.Member;
import org.example.domain.Order;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Order findOrder = em.find(Order.class, 1L);
            
            Long findOrderMemberId = findOrder.getMemberId();
            
            Member findMember = em.find(Member.class, findOrderMemberId);
            System.out.println("findMember = " + findMember.getName());

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

위 코드에서 엔티티 매니저로부터 주문 정보를 가져온 후 주문 정보로부터 다음과 같은 코드는 사용할 수가 없다.

Member orderMember = findOrder.getMember();

왜냐하면 주문 정보는 유저를 참조하지 않기 때문이다. 이는 객체 지향적인 설계가 아닌 데이터 지향적 설계가 됐기 때문이다.

객체 지향적 설계를 하기 위해서는 이를 변경해야 한다. 그리고 그 시점에서 우린 단방향 연관관계를 배울 수 있다.

 

단방향 연관관계

단방향 연관관계란, 한쪽에서만 다른 쪽을 참조한 상태를 말한다. 예제에서는 주문 정보에서만 유저를 참조하면 된다고 가정해보고 단방향 연관관계를 설정해 보면 다음과 같이 주문 엔티티를 변경할 수 있다.

 

Order - 변경

package org.example.domain;

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

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    
    /*@Column(name = "MEMBER_ID")
    private Long memberId;*/
    
    // 변경한 부분
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    
    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;
    
}

멤버와 주문 정보의 관계는 '일대다'이다. 즉, 멤버 한명이 많은 주문을 할 수 있다는 얘기다. 그러면 주문 엔티티에서는 멤버를 설정할 때 @ManyToOne 어노테이션을 붙여준다. 그리고 한가지 더 애노테이션이 있는데 @JoinColumn이다. ⭐️@JoinColumn 애노테이션이 달려있으면 "Order에서 Member를 가져오기 위해서 조인을 할건데 어떤 값(키)을 조인해야해?"를 작성해주면 된다. 그러니까 쉽게 말해 외래키를 추가하는 애노테이션이라고 보면 된다. 그리고! 통상적으로는 Member의 기본키 컬럼명이 MEMBER_ID라면 Order의 외래키 컬럼명도 MEMBER_ID라고 해주면 가장 아름답지만, 반드시 그 이름이 동일할 필요는 없다. 그러니까, 내가 여기서 @JoinColumn으로 외래키 명을 MID 또는 M_ID라고 해도 아무런 문제도 발생하지 않는다는 의미이다. 결국 조인할 때 Member의 기본키와 Order의 외래키를 가지고 조인하는건 달라지지 않으니까.⭐️

 

그럼 JPA로부터 Order 객체를 가져오면 그 안에는 연관된 멤버 객체가 딸려 오게 된다. (물론 이것도, 뒤에 배울 지연 로딩을 배우면 또 약간 달라지지만 일단은 이렇게 생각해두자)

 

이렇게 변경한다면 주문 정보를 찾아내면 그 주문을 한 유저도 객체 입장에선 바로 참조가 가능하다.

package org.example;

import org.example.domain.Member;
import org.example.domain.Order;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;

public class Main {
    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Order findOrder = em.find(Order.class, 1L);
            
            // 개선된 코드
            Member findMember = findOrder.getMember();
            System.out.println("findMember = " + findMember.getName());

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

저 위에서 주문을 찾고 주문에 담긴 유저의 아이디를 가져와 다시 한 번 유저를 찾는 번거로움 없이 주문을 찾아내면 그 주문을 행한 유저도 바로 가져올 수 있게 코드가 개선되었다. 이렇게 설계가 되어야 객체 지향적 설계에 더 가깝다고 볼 수 있을 것 같다.

 

 

 

⭐️ 양방향 연관관계와 연관관계 주인

이 부분이 너무너무 중요하다. 객체 지향적으로도 중요하고 애플리케이션 관점에서도, 데이터베이스 관리 차원에서도 너무 중요하다.

우선, 위에서 Member, Order 엔티티를 살펴보면 Order에서만 Member를 참조할 수 있고 Member에서는 Order를 참조할 수 없게 되어 있는 '단방향 연관관계'이다. 

 

단방향 연관관계

MemberOrder 대신 좀 더 직관적으로 이해하기 좋게 MemberTeam으로 변경해 보자. 

위 그림은 단방향 연관관계를 표현한 그림이다. TeamMember가 객체로 보이는 부분이 위에 부분인데 TeamMember에 대한 참조가 없고 MemberTeam에 대한 참조가 있다. MemberOrder를 생각해 보면 동일하다.

 

테이블 관점으로 보면 테이블은 사실 방향이란 게 없는 것이다. 무슨 소리냐면 이 상태에서도 멤버는 TEAM_ID라는 외래키를 통해 본인의 팀을 조인으로 알아낼 수 있고 TEAM 입장에서도 본인의 PKMEMBER의 외래키 TEAM_ID를 조인해서 MEMBER를 가져올 수 있다. 즉, 한쪽만 참조 또는 양쪽 둘 다 참조라는 개념이 사실 없는 거다.

 

그러나 객체는 Member라는 객체가 Team을 참조하지만 Team에서는 그 어떤 방법으로도 자기한테 속한 Member를 가져올 방법이 없다. 이런 경우를 단방향 연관관계라고 했고 이제 양방향은 두 객체 모두 서로를 참조하는 경우를 말한다.

 

양방향 연관관계

양방향 연관관계는 이런 상태를 의미한다.

테이블은 그 전과 전혀 다른 게 없다. 정말 동일한 상태이고 객체 간 관계가 이렇게 변한다. 멤버에서도 팀을 참조하고 팀에서도 본인한테 속한 멤버들을 참조한다. 

 

그럼 기존 멤버 엔티티는 이렇게 생겼었다.

package org.example.entity;

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

@Entity
@Table(uniqueConstraints = {
        @UniqueConstraint(name = "UniqueName", columnNames = { "name" })
})
@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;

    @Column(nullable = false)
    private String name;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

멤버 입장에서는 팀과 다대일 관계다. 즉, 하나의 팀에 여러 멤버가 속할 수 있다는 뜻이고 그것을 객체로 표현하는 방법이 위와 같다.

@ManyToOne, @JoinColumn(name = "TEAM_ID") 어노테이션으로 Team을 참조한다.

 

그럼, Team 엔티티에서는 어떻게 멤버들을 참조할 수 있을까?

package org.example.entity;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

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

Team에 속한 멤버들을 저장할 ArrayList를 필드로 선언하고 그 필드에 @OneToMany 어노테이션을 추가했다. 팀과 멤버는 팀 입장에서 일대다 관계이기 때문에 OneToMany는 이해하기 어려운 부분은 아니다. 그러나 여기 mappedBy가 문제다.
우선 mappedBy = "team"이라는 건 이 애노테이션이 달린 필드 타입으로 선언한 Member 클래스에서 어떤 필드와 매핑될것이야?를 선언한 것이다. Member 클래스에서 team이라는 필드로 매핑할 것이니까 mappedBy = "team" 이렇게 작성했다. 근데 왜 여기에만 mappedBy 옵션이 있는지 이해하기 힘들다. 이때 '연관관계의 주인' 개념을 알아야 한다.

 

양방향 연관관계에서 연관관계 주인

우선 연관관계의 주인이라는 건 양방향 연관관계일때만 고려하면 된다. 자, 이제 주인이 누구인지를 따져보기 전에 양방향 연관관계에서 '객체 연관관계'와 '테이블 연관관계'를 좀 더 깊게 이해해 보자. 양방향 연관관계에서 객체 연관관계는 2개다.

  • 멤버 > 팀 연관관계 1개 (단방향)
  • 멤버 < 팀 연관관계 1개 (단방향)

즉, 양방향 연관관계에서 객체 연관관계는 그저 단방향 두 개가 존재하는 것이다.

 

그러나 테이블 연관관계는 위에서도 얘기했지만 방향이란 게 사실 없다. 그냥 서로가 서로를 참조할 수 있다. 실제로 팀에서는 테이블에서 멤버 관련 정보는 하나도 없지만 PK와 멤버의 외래키를 가지고 조인해서 속한 멤버를 구할 수 있지 않나. 

  • 멤버 <> 팀 연관관계 1개 (양방향)

그래서 그림으로 살펴보면 다음과 같다. 

그럼 이런 상태에서 만약 멤버가 속한 팀을 변경하고 싶다면? 객체 관점에서는 어떤 객체를 변경해야 할까?

멤버의 팀을 변경해야 하나? 팀의 멤버를 변경해야하나?

뭔가 이상하다. 테이블의 관점에서는 부모 테이블은 아예 자식 테이블에 관련된 정보 자체가 없기 때문에 멤버가 속한 팀을 변경하려면 당연히 그냥 멤버의 특정 레코드의 팀 외래키를 변경하면 된다. 그럼 객체 관점에서는 어떤 걸 변경해야 할까? 테이블의 관점을 따라가면 된다. 

 

즉, 객체 관점에서 두 객체가 양방향으로 연관관계를 가질 때 연관관계의 주인을 지정하면 되는데 그 주인은 테이블의 관점에서 외래키가 있는 곳을 주인으로 정하면 된다. 그럴 때 이 예제에서 연관관계의 주인은 누가 되면 될까? 그렇다. 멤버가 되면 된다. 왜냐? 테이블의 관점에서 멤버가 외래키를 가지고 있기 때문.

 

그리고 테이블 관점에서 멤버가 속한 팀을 변경하려면 멤버의 외래키를 변경하면 된 것처럼 연관관계의 주인만이 값의 변경을 할 수 있다. 그리고 주인이 아닌 쪽은 읽기만 가능하다. 그래서 mappedBy가 달린 필드는 변경이 불가능하고 읽기만 가능하며, 주인은 mappedBy 속성을 사용하지 않는다. 반대로 주인이 아니면 mappedBy 속성으로 주인을 지정해줘야 한다. 

 

이제 다시 엔티티 클래스를 보면 이해가 된다.

package org.example.entity;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@SequenceGenerator(
        name = "TEAM_SEQ_GENERATOR",
        sequenceName = "TEAM_SEQ",
        initialValue = 1,
        allocationSize = 100
)
public class Team {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

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

테이블 관점에서 팀은 멤버 관련 필드는 아예 없다. 그 말은 외래키를 멤버가 가지고 있단 의미이고 외래키를 가지고 있는 쪽이 객체 관점에서는 연관 관계의 주인이 되면 되니까 주인이 아닌 이 Team 엔티티에서 mappedBy 속성으로 연관관계의 주인이 아니라고 알려주고, mappedBy = "team" 이라고 작성해서 이 애노테이션이 달린 필드의 타입 List<Member>Member 객체에 있는 team 필드에 의해 매핑된 데이터이다. 라고 알려주는 것이다.

 

그래서, 객체 관점에서 데이터를 수정 및 변경 작업을 할 때 연관관계의 주인 쪽에서만 변경이 가능하기 때문에 멤버가 속한 팀을 변경하려면 멤버 엔티티에서 team 필드값을 변경하면 되는 것이다. 아무리 팀 엔티티에서 멤버 리스트에 멤버를 추가해 봐야 DB에 추가되지 않는다. 

 

이 점을 이해해야만 왜 팀 엔티티의 멤버 리스트 필드에 멤버를 추가해도 DB에는 추가된 멤버가 반영되지 않는지를 알 수 있고 그렇게 알게 되면 멤버를 만들어서 멤버의 팀 필드에 팀을 추가해야만 DB에 추가된 멤버가 반영된다는 것을 알 수 있다.

 

그래서 아래 코드는 잘못된 코드인데 어디가 잘못된 코드인지 한번 확인해 보자.

package org.example;

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

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 emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            em.persist(member);
            
            team.getMembers().add(member);

            em.flush();
            em.clear();

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

보면, Team 엔티티의 객체 team을 만들었고 team.getMembers().add(member); 라는 코드가 한 줄 보인다.

이 부분이 잘못된 코드이다. 왜냐하면 위에서 말한 이 teammembers라는 리스트는 mappedBy로 읽기 전용 필드이다. 즉, JPA가 데이터베이스에 반영할 때 전혀 관심사항이 없는 데이터라는 의미이다. 실제로 위 코드로 실행해보면 데이터베이스에 해당 멤버의 팀 값은 없다. 아래는 그 결과물이다. 

 

 

따라서, DB에 새로운 멤버를 팀에 추가하고 싶다면 다음과 같은 코드로 변경되어야 한다.

package org.example;

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

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 emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            member.setTeam(team);
            em.persist(member);

            em.flush();
            em.clear();

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

연관관계의 주인인 memberteam에 적용할 팀을 세팅해줘야 한다. 그래야 JPA는 데이터베이스에 변경사항을 반영할 때 적용해 준다.

 

그럼 여기서 한 가지 의문이 생긴다. 

연관관계의 주인쪽에만 데이터를 넣으면 JPA가 데이터베이스에 쓰기 지연 SQL문을 날려서 실제 데이터베이스에 적용되기 전까진 team.getMembers()를 가져오면 데이터는 없는 것 아닌가?

맞다! 정확하다! 코드로 그것을 증명해보면,

package org.example;

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

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 emf = Persistence.createEntityManagerFactory("hello");

        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        try {
            Team team = new Team();
            team.setName("Team one");
            em.persist(team);

            /* CREATE */
            Member member = new Member();
            member.setName("Member one");
            member.setRoleType(RoleType.GUEST);
            member.setTeam(team);
            em.persist(member);

            Team findTeam = em.find(Team.class, team.getId());
            System.out.println("findTeam.getMembers(): START");
            for (Member teamMember : findTeam.getMembers()) {
                System.out.println("teamMember = " + teamMember.getName());
            }
            System.out.println("findTeam.getMembers(): END");

            em.flush();
            em.clear();

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

위 코드는 연관관계의 주인인 memberteam에만 team값을 세팅한 후, flush()가 발생하기 전 또는 commit()이 이루어지기 전에 엔티티매니저로부터 team을 가져와서 (이 때 가져오는 건 1차 캐시로부터 가져온다. 왜냐하면 persist()commit()이 일어나지 않았기 때문에) 팀이 가지고 있는 모든 멤버를 출력하는 코드이다.

 

결과는 다음과 같다. 아무것도 출력되지 않는다.

 

그러니까 주의할 점은, 연관관계의 주인인 Member 클래스에 팀을 설정하고 DB에 값을 반영하기 전에(다른 말로 같은 트랜잭션 단위가 끝나기 전에, 또 다른 말로 의도적으로 flush()를 호출하기 전에) Teammembers에는 해당 멤버는 없다. 그래서 이 개념을 아주 확실히 알고 있는 상태면 상관이 없지만 그럴거라는 전제하에 개발을 하기보다는 그러지 않을것이라는 전제하에 개발을 진행해서 Member 객체에 팀을 넣어줄 때 Teammembers에도 의도적으로 같이 넣어주는 게 이후 예측하지 못한 버그를 마주하는 것에 있어 안전할 수 있다. 어차피 주인이 아닌쪽에 데이터를 수정해봐야 데이터베이스에는 아무런 영향도 끼치지 않기 때문에 데이터베이스에 문제가 생길 걱정 자체를 할 필요가 없다. 아예 JPA는 그 데이터를 쳐다도 보지 않을테니까.

 

가장 좋은 방식은, 편의 메서드를 만드는 것이다. 단순히 setTeam(team)을 호출하고 반대로 team.getMembers().add(member) 이렇게 복잡하게 두번을 하는게 아니라, changeTeam(team) 이런 메서드를 하나 만들어서 깔끔하게 처리하는 방식으로 말이다.

public void changeTeam(Team team) {
    this.team = team;
    this.team.getMembers().add(this);
}

마무리

진짜 결론은 양방향 연관관계는 없어도 무방하다는 것. 가장 중요한 건 단방향 연관관계만으로 애플리케이션을 충분히 만들 수 있고 단방향 연관관계만 있을 때 가장 깔끔하다. 양방향은 사실 조회를 위해 편의상 만드는 거지 그 외 더 이상 기능이 없다. 그래서, 가장 중요한 건 처음엔 단방향 연관관계로만 잘 설계를 하고 필요하면 (조회를 편하게 하고싶다면) 그 때 양방향을 걸어도 전혀 문제가 없다.

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

이제 H2 Database가 버전이 올라가면서 데이터베이스가 없는 경우 자동으로 데이터베이스를 만들어주지 않는다.

그래서 콘솔에서 다음과 같은 에러를 볼 수 있다.

정말 간단하게 해결할 수 있는데, 우선 데이터베이스를 만들고자하는 경로는 나같은 경우 다음과 같다.

"~/h2/test" 이 경로에 "test"라는 데이터베이스를 만들거면 해당 경로에 이런 파일 하나를 만들어주면 된다.

"test.mv.db"

// ~/h2
touch test.mv.db

 

그러고 다시 Connect를 해보면 정상적으로 데이터베이스 접근할 수 있다. 

 

728x90
반응형
LIST
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
728x90
반응형
SMALL
반응형
SMALL

2024.10.25 업데이트


 

영속성 컨텍스트는 JPA에서 가장 중요한 개념 중 하나이다. 이 PersistenceContext를 이해해야만 JPA를 이해할 수 있다고 봐도 무방하다. 

 

공부하는데 도움을 받은 김영한 강사님의 "자바 ORM 표준 JPA 프로그래밍" 추천합니다.

출처: https://www.inflearn.com/course/ORM-JPA-Basic/dashboard

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런 | 강의

초급자를 위해 준비한 [웹 개발, 백엔드] 강의입니다. JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자

www.inflearn.com

 

엔티티 매니저 팩토리와 엔티티 매니저

우선 영속성 컨텍스트를 알기 전 엔티티 매니저 팩토리와 엔티티 매니저를 그림으로 이해해보자. 

애플리케이션이 실행되면 딱 한번 엔티티 매니저 팩토리가 만들어 지는데, 이 팩토리로부터 엔티티 매니저가 생성된다. 언제? 고객의 요청이 들어오면. 들어와서, 트랜잭션이 필요해다고 개발자가 지정한 지점에.

 

고객의 요청 중 트랜잭션이 필요한 요청 하나에 하나의 엔티티 매니저가 생성된다고 보면 되고 그 엔티티 매니저는 커넥션풀에서 커넥션을 사용한다. 그렇기에 엔티티 매니저가 할 일을 다했으면 반드시 엔티티 매니저를 닫아줘야 한다는 것이다. 사용하지 않는데 커넥션을 계속 잡고 있으면 이후에 들어오는 고객의 요청은 응답할 수 없을테니까.

 

 

영속성 컨텍스트

그럼 영속성 컨텍스트는 무엇이며 어디에 있을까? 영속성 컨텍스트는 엔티티를 영구 저장하는 환경 또는 컨텍스트인데 엔티티 매니저와 1:1 매핑이 된다. 즉, 엔티티 매니저 하나가 생성되면 그 엔티티 매니저가 가지는 영속성 컨텍스트 하나가 생성된다.

 

영속성 컨텍스트로부터 엔티티는 생명 주기를 가지는데, 4단계로 구분된다.

- 비영속 (new/transient): 영속성 컨텍스트와 전혀 관계가 없는 새로운 상태

- 영속 (managed): 영속성 컨텍스트에 관리되는 상태

- 준영속 (detached): 영속성 컨텍스트에 저장되었다가 분리된 상태

- 삭제 (removed): 삭제된 상태 

 

 

말만해서는 이해가 안될것 같으니 코드로 생각해보자.

비영속

// 객체를 생성한 상태 (비영속)
Member member = new Member();
member.setId("member1");
member.setName("member1");

객체를 생성했지만 영속성 컨텍스트에는 전혀 관계가 없는 상태이다. 영속성 컨텍스트에 담는 코드는 없기 때문에.

위에서 영속성 컨텍스트는 곧 엔티티 매니저와 1:1 매핑이 된다고 했는데 엔티티 매니저를 영속성 컨텍스트라고 생각해보자.

그럼 아래와 같은 이미지로 볼 수 있는 것이다.

 

영속

// 객체를 생성한 상태 (비영속)
Member member = new Member();
member.setId("member1");
member.setName("member1");

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

// 객체를 저장한 상태 (영속)
em.persist(member);

영속은 이제 엔티티 매니저에 담는것이라고 보면 된다. 어디서 많이 본 메서드인 persist()가 보인다. 이 메소드를 호출해서 멤버를 담는게 바로 아래와 같은 그림이 되는것이다.

⭐️ 그러니까 결국 persist() 메서드는 데이터베이스에 저장하는게 아니다. 영속성 컨텍스트에 영속시키는 것이다. 이 코드만으로 데이터베이스에 절대 절대 저장되지 않는다.

그럼 이 전 파트에서는 persist() 메서드를 호출하고 CREATE 부분을 끝냈는데 어디서 데이터베이스에 저장되는 걸까?

바로 트랜잭션의 커밋이다. 트랜잭션 커밋을 수행하면 쓰기 지연 SQL문이 실행되어 데이터베이스에 실제로 데이터가 저장된다.

 

못 믿겠다면 실제 코드를 수행해보자.

package org.example;

import org.example.entity.Member;

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("helloD");
            em.persist(member);

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

위 코드처럼 persist() 메서드 호출 후 트랜잭션 커밋을 주석처리 해보자. 실행한 후 실제 데이터베이스에 helloD 라는 이름을 가지는 멤버가 있는지 보자.

없다. 즉, persist() 메서드는 영속 컨텍스트에 저장하는 메서드이지 데이터베이스에 저장하는 메서드가 아니다. 반드시 이 개념을 이해해야 한다. 그리고 또한, 데이터베이스에서 조회할 때도 영속 컨텍스트에 조회한 데이터도 영속된다. 

// 영속 컨텍스트에 영속시킴
Member member = em.find(Member.class, 1L);

이처럼 데이터베이스를 통해 조회를 할 때, 해당 데이터를 조회한 후 영속 컨텍스트에 영속시킨다. 그니까 영속되는 경우는 크게 두가지인거지. 데이터베이스로부터 조회한 경우와 persist() 메서드를 호출해서 영속시킨 경우.

 

준영속, 삭제

// 회원 엔티티를 영속성 컨텍스트에서 분리, 준영속 상태
em.detach(member);

// 객체를 삭제한 상태 (삭제)
em.remove(member);

준영속은 꽤 중요한데 영속된 객체를 비영속으로 변경하는 경우를 말한다.

예를 들어, 데이터베이스를 통해 조회한 객체는 영속된다고 했는데 그 영속된 데이터를 영속 컨텍스트에서 관리할 필요가 없는 경우 준영속으로 변경할 수 있다. 

// 영속 컨텍스트에 영속시킴
Member member = em.find(Member.class, 1L);

// 영속 컨텍스트에서 제외시킴
em.detach(member);

위 코드를 보면 memberdetach() 메서드가 호출된 후 더이상 영속 컨텍스트가 관리하지 않는다.

이를 눈으로 확인해보기 위해선, 꺼낸 데이터를 변경한 후에 커밋하기 전 detach()를 호출해보면 알 수 있다.

// 영속 컨텍스트에 영속시킴
Member member = em.find(Member.class, 1L);

member.setName("BBB");

// 영속 컨텍스트에서 제외시킴
em.detach(member);

// 아무일도 일어나지 않음
tx.commit();

커밋하기 전 영속 컨텍스트에서 제외시켰으므로, setName()을 호출해서 이름을 변경해도 데이터베이스에 적용되지 않는다.

 

준영속으로 변경하는 방법은 크게 세 가지가 있는데, 한 가지는 위처럼 detach() 메서드를 호출하거나 clear() 메서드를 호출하거나 아예 엔티티 매니저를 닫는것이다.

em.detach(member); // 특정 엔티티를 준영속으로 변경
em.clear(); // 영속 컨텍스트를 완전 초기화
em.close(); // 영속 컨텍스트를 종료

 

영속성 컨텍스트의 이점

  • 1차 캐시
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연
  • 변경 감지(Dirty Checking)
  • 지연 로딩(Lazy Loading)

 

 

1차 캐시

1차 캐시란, 영속 컨텍스트에 객체가 영속이 되면 그 객체를 가져올 때 데이터베이스로부터 조회하는 것이 아니라 영속 컨텍스트로부터 가져오는 기술을 말한다. 물론, 이 1차 캐시가 큰 의미가 있진 않은데 그 이유는 위에서도 말했지만 영속 컨텍스트는 엔티티 매니저와 1:1로 매핑된다고 했다. 즉, 엔티티 매니저가 닫히는 순간 1차 캐시도 의미가 없어지기 때문에 비즈니스 로직이 정말 정말 복잡해서 하나의 트랜잭션에서 여러번 같은 객체가 사용되거나 호출될 때만 의미가 있지 그렇지 않고서는 큰 의미는 없다만 그래도 알고 있어야 한다. 

 

// 엔티티 생성한 상태 (비영속)
Member member = new Member();
member.setId("member1");
member.setName("member1");

// 엔티티 영속
em.persist(member);

이렇게 영속 시킨 후 영속 컨텍스트에 1차 캐시에 해당 엔티티(객체)가 담긴다. 그렇게 담긴 이후에 해당 객체를 다시 조회하는 코드를 마주치면 데이터베이스로부터 객체를 조회하지 않고 1차 캐시에서 꺼내올 수 있다. 

// 엔티티 생성한 상태 (비영속)
Member member = new Member();
member.setId("member1");
member.setName("member1");

// 엔티티 영속 -> 1차 캐시에 저장됨 
em.persist(member);

// 1차 캐시에서 조회
Member findMember = em.find(Member.class, "member1");

 

그런데 이제 만약 위 사진처럼 "member1" 이라는 객체만 1차 캐시에 담겨있는 상태에서 "member2"를 조회하고자 한다면, 1차 캐시에 없기 때문에 데이터베이스에서 조회한다. 

Member findMember2 = em.find(Member.class, "member2");

 

동일성 보장

영속 컨텍스트에서 꺼내온 객체는 동일함을 보장한다는 뜻이다. 즉, 아예 같은 인스턴스이고 같은 메모리 주소값을 가지는 것을 보장한다.

Member findMember1 = em.find(Member.class, "member2");
Member findMember2 = em.find(Member.class, "member2");

System.out.println(findMember1 == findMember2) // true

 

 

트랜잭션을 지원하는 쓰기 지연

이는 트랜잭션 안에서 수행되는 모든 데이터베이스에 대한 변경 작업을 수행하기 위한 SQL문을 트랜잭션의 커밋이 호출되기 전까지 데이터베이스에 보내지 않고 모아둔 상태에서 커밋을 호출하는 순간 모아둔 SQL문을 보낸다는 뜻이다.

 

즉, 만약 내가 새로운 멤버를 두 명 만든다고 가정해보자.

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();

// 트랜잭션 시작
transaction.begin();

em.persist(memberA);
em.persist(memberB);
// 여기까지가 영속 컨텍스트에 memberA, memberB를 영속시키고 INSERT SQL문을 쓰기 지연 보관소에 저장한다.
// 허나, 데이터베이스에 보내지는 않는 상태이다.

// 트랜잭션 커밋
// 이 순간에 데이터베이스에 INSERT SQL문을 보낸다.
transaction.commit()

 

그래서 이 코드를 그림으로 살펴보면 다음과 같다.

 

persist() 메서드는 영속 컨텍스트에 객체를 저장. 그리고 저장과 동시에 INSERT SQL문을 쓰기 지연 저장소에 저장한다.

 

이 때까지는 데이터베이스에 해당 객체를 저장하지 않은 상태. 즉, SQL 쿼리도 날라가지 않은 상태.

그리고 트랜잭션 커밋이 호출되는 순간 쓰기 지연 SQL 저장소에 모아둔 쿼리문이 실행된다. 

이 부분에서 persistence.xml 파일에 속성으로 있던 batch_size가 이 내용과 관련이 있다. 한번에 보낼 수 있는 사이즈를 지정하는 속성값.

 

 

변경 감지(Dirty Checking)

업데이트를 할 때 업데이트한 후 뭔가 업데이트를 실행하는 코드가 있어야 할 것 같은데 없었다. 그럼 persist() 메서드라도 호출해야 할까? 그것도 아니다 왜냐하면 이제 우리는 안다. persist() 메서드는 영속 컨텍스트에 영속시키는 것일 뿐인걸. 즉, 이미 영속된 객체를 또 영속시킨다는 코드를 작성할 필요가 없단 말이다. 그럼 어떻게 업데이트한 걸 알까? 

 

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();

transaction.begin();

Member memberA = em.find(Member.class, "memberA");
memberA.setName("Changed");

transaction.commit()

자, 이런 코드가 있고 이게 정말 업데이트의 끝이다. 근데 바꾸고 아무것도 안하고 커밋만 했단 말이지? 어떻게 변경 사항을 적용할까? 

사실 영속 컨텍스트에는 스냅샷 데이터가 존재한다.

 

그래서 객체와 스냅샷을 비교해서 값이 다르면 SQL 문을 수행하게 되는것이다. 

위 그림에서 flush()가 들어오면 엔티티와 스냅샷이 존재하는데 그 값을 비교한다. 비교 후 값이 다르다면 다른 값을 적용하기 위해 UPDATE SQL문을 생성해서 수행하게 되는것이다.

 

플러시란?

플러시란 변경, 수정, 삭제와 같은 데이터베이스의 데이터와 영속 컨텍스트의 데이터가 달라진 경우 데이터베이스에 달라지는 값을 맞춰주는 작업이다. 즉, 쉽게 말해 쓰기 지연 SQL 저장소의 쿼리가 데이터베이스에 날라가는 것이라고 보면 되는데 이 플러시를 호출하는 방법은 크게 세 가지가 있다. 

 

- em.flush(): 직접 호출

- 트랜잭션 커밋: 자동 호출

- JPQL 쿼리 실행: 자동 호출

 

플러시를 한다고해서 영속 컨텍스트에 데이터를 지운다거나 1차 캐시에 데이터를 지우는게 아니고 그냥 쓰기 지연 SQL 저장소의 쿼리가 날라가는 것 뿐이다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

2024.10.24일 업데이트


 

JPA를 진짜 깊게 이해하기 위해 아예 순수 JPA 세팅부터 시작해 보려고 한다. 내가 처음 JPA를 사용했을 때 느꼈던 것보다 훨씬 진짜 훨씬 더 많은 내용이 JPA에 있었는데 지금이라도 깊게 공부하게 되서 다행인거 같다.

 

우선 프로젝트를 Maven 기반으로 시작해보자.

 

pom.xml

현재 정말 아무것도 없는 상태에서 pom.xml 파일 하나만 있는 상태다. 이 파일에서 필요한 두 가지가 있는데 하나는 hibernate, 하나는 h2database다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>hello-jpa</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>18</maven.compiler.source>
        <maven.compiler.target>18</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-entitymanager</artifactId>
            <version>5.6.15.Final</version>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>2.2.224</version>
        </dependency>
    </dependencies>

</project>

딱 두개의 Dependency가 있다. 내려받고 나면 우측 MavenDependencies 섹션에 두 개가 노출된다.

 

persistence.xml

이제 JPA를 사용하기 위해 persistence.xml 파일이 필요하다. 설정 파일이라고 생각하면 되는데 사실 거의 JPA를 사용할 때 스프링과 연동하여 사용하기 때문에 xml 파일로 설정값을 지정하지 않는데 정말 뿌리부터 시작해보기 위해 해보려고한다.

 

persistence.xml 파일은 경로가 중요하다. 이 파일은 반드시 META-INF 폴더 아래에 존재해야 한다. 그래서 내 경로는 다음과 같다.

resources/META-INF/persistence.xml  이 경로에 해당 파일이 존재하면 된다. 이 파일은 다음과 같은 설정 파일이 필요하다.

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2"
             xmlns="http://xmlns.jcp.org/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
    <persistence-unit name="hello">
        <properties>
            <!-- 필수 속성 -->
            <property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
            <property name="javax.persistence.jdbc.user" value="sa"/>
            <property name="javax.persistence.jdbc.password" value=""/>
            <property name="javax.persistence.jdbc.url" value="jdbc:h2:tcp://localhost/~/h2/test"/>
            <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/>
            <!-- 옵션 -->
            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>
            <property name="hibernate.use_sql_comments" value="true"/>
            <property name="hibernate.jdbc.batch_size" value="10"/>
<!--            <property name="hibernate.hbm2ddl.auto" value="create-drop" />-->
        </properties>
    </persistence-unit>
</persistence>

persistence-unit name은 나중에 Persistence에서 EntityManagetFactory를 만들 때 필요한 값이다. 저 값을 통해 어떤 설정값을 가져올지를 지정한다. 나머지 속성값은 어떤 데이터베이스를 사용할지 나는 h2database를 사용할거고, 데이터베이스 접속할 유저정보데이터베이스의 위치정보 그리고 dialect 정보를 지정한다.

 

dialect가 재밌는 옵션인데, 데이터베이스마다 살짝 살짝 다른식으로 표현하는 것을 본적이 있을거다. 예를 들면, MySQL은 VARCHAR Oracle은 VARCHAR2라던가, MySQL은 LIMIT, Oracle은 ROWNUM이라던가. 이런걸 데이터베이스 방언이라고 표현하는데 이 방언을 어떤걸 선택할건지를 지정하는 옵션이라고 보면 된다. 나는 H2를 사용하니까 당연히 H2Dialect를 사용하면 된다.

 

show_sql은 데이터베이스에 날리는 쿼리를 보여줄것인지 여부를 의미한다. format_sqlshow_sqltrue인 경우에 한하여 SQL문을 정렬해서 좀 보기 좋게 보여주겠다는 옵션이고, use_sql_commentsshow_sqltrue인 경우에 한하여 SQL문에 주석 데이터를 추가해주는 옵션이다. 그러니까 이게 어떤 SQL문인지 뭐 그런 comments를 보여주는 옵션. batch_size는 이후에 또 배우겠지만 쓰기 지연 SQL 쿼리를 한번에 날릴 수 있는 사이즈를 지정하는 것이다. 이건 이후에 영속성 컨텍스트를 얘기하면서 다시 얘기하겠다.

 

이렇게 설정해놓으면 필요한 설정 파일은 전부 구성했고 이제 실제 코드를 작성해보자. 

 

Member

package org.example.entity;

import javax.persistence.*;

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

Member라는 간단한 엔티티는 id, name 컬럼을 가진다. 이 클래스의 @Entity 어노테이션을 붙이면 JPA는 해당 클래스를 테이블로 매핑한다. 이렇게 클래스를 엔티티로 구현하고 h2database에 테이블로 만드는 방법은 persistence.xml 파일의 프로퍼티 중 ddl-autocreate으로 설정하거나, 직접 h2database에 들어가서 테이블을 만들면 된다. 

 

아 그리고 한가지 짚고 넘어갈 내용은 이 아래 @Column에 적힌 unique, nullable, length 이런 제약 조건은 데이터베이스 테이블에 영향을 주는거고 애플리케이션에는 아무런 영향이 없다는 것. 그러니까 객체로 만들고 데이터베이스에 저장하지 않으면 객체가 같은 이름을 가지던 길이가 50이 초과되던 상관없단 소리다.

@Column(unique = true, nullable = false, length = 50)

H2Database

우선 H2Database Engine을 다운받아야 한다. 

https://www.h2database.com/html/main.html

 

H2 Database Engine

H2 Database Engine Welcome to H2, the Java SQL database. The main features of H2 are: Very fast, open source, JDBC API Embedded and server modes; in-memory databases Browser based Console application Small footprint: around 2.5 MB jar file size     Supp

www.h2database.com

위 경로에서 Download 받으면 된다.

 

2.2.224 버전을 사용하게 되면 자동으로 데이터베이스를 만들어주지 않는다. 그래서 데이터베이스를 직접 만들어야 하는데, 나같은 경우엔 데이터베이스 경로를 /Users/cw.choiit/h2 이렇게 설정했다. 여기에 데이터베이스명은 'test'라고 할 것이기 때문에 해당 경로에 파일 하나를 추가하면 된다. 'test.mv.db' 이 파일 하나만 추가해주면 H2database를 실행하고 콘솔에서 연결할 수 있다.

H2database를 로컬에서 실행하면 위 경로에 들어가서 콘솔을 이용할 수 있는데 여기서 원하는 경로에 데이터베이스를 연결하고 'Connect' 버튼을 클릭하면 데이터베이스 안으로 들어갈 수 있다.

 

Main

package org.example;

import org.example.entity.Member;

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("helloA");
            em.persist(member);*/

            /* READ */
            /*Member member = em.find(Member.class, 1L);
            System.out.println("member ID = " + member.getId());
            System.out.println("member Name = " + member.getName());*/

            /* UPDATE */
            // ! Update 에서는 persist() 호출하지 않아도 상관없이 그냥 변경할 거 변경하고 트랜잭션을 commit() 해주면 된다.
            // ! 그 이유는 JPA 이 녀석이 데이터베이스의 데이터를 컬렉션처럼 다루기 때문
            /*Member member = em.find(Member.class, 1L);
            member.setName("HelloB");*/

            /* DELETE */
            /*Member member = em.find(Member.class, 1L);
            em.remove(member);*/

            /* Entity 객체를 대상으로 쿼리를 날릴 수 있는 JPQL 이라는 녀석을 사용 / SQL 은 테이블을 대상으로 날리는 것 */
            List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();
            for (Member member : result) {
                System.out.println("member = " + member.getName());
            }

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

위 코드에서 간단한 CRUD를 실행해보았다. 한 줄 한 줄 이해해보자.

 

Persistence 클래스의 createEntityManagerFactory() 메소드를 호출하고 해당 메소드에 'hello' 라는 값을 지정한다. 이 값은 persistence.xml 파일에 name으로 지정한 값이다. 해당 속성값을 불러오겠다는 의미가 된다.

 

이제 팩토리에서 엔티티 매니저를 생성한다. 이 엔티티 매니저는 고객의 요청에 의해 필요한 데이터베이스 작업을 할 때 트랜잭션이라는 단위를 생성해내는데 고객의 요청 하나에 하나의 트랜잭션이 사용된다고 생각하면 된다. 즉, 요청 하나에 엔티티 매니저 하나가 생성된다는 뜻이다. 요청에 의해 필요한 작업이 다 끝나면 엔티티매니저는 종료되어야 한다. 커넥션 풀에 한계가 있기 때문에 잡고 있는 커넥션을 놓아준다고 생각하면 된다.

 

그렇게 엔티티 매니저가 생성되면, 엔티티 매니저로부터 트랜잭션을 가져올 수 있다. 이 트랜잭션을 가져와 시작하게 되면 이 때부터 모든 데이터 베이스의 변경 작업을 진행할 수 있게된다. 어떤 데이터베이스든 모든 데이터베이스의 대한 작업은 트랜잭션이라는 단위 안에서 일어난다.

 

CREATE

멤버를 생성하는 방법은 정말 간단하게 새로운 멤버 객체를 만들고 필요한 데이터를 추가한 후 엔티티매니저의 persist() 메소드에 만든 멤버를 추가해주면 된다. 그럼 끝이다.

 

READ

조회는 엔티티매니저의 find() 메소드를 호출한다. 여기서 원하는 객체 타입과 PK 정보를 던져주면 엔티티매니저가 데이터베이스에서 알맞은 데이터를 찾아낸다. 

 

UPDATE

데이터를 수정하는 방법은 조회한 후 해당 데이터를 변경해주기만 하면 된다. 이러고 persist() 메소드를 호출하지도 않는다. 이를 제대로 이해하려면 영속성 컨텍스트를 알아야 하는데 다음 파트에서 알아 볼 예정이다. 그러니까 쉽게 생각해서 자바에서 컬렉션을 다룰 때 컬렉션의 데이터를 꺼내 데이터를 변경하면 그 데이터를 다시 컬렉션에 추가하지 않는것처럼 똑같은 방식으로 동작한다고 생각해보자.

 

DELETE

엔티티매니저로부터 remove() 메소드를 호출하면 끝.

 

JPQL

그리고 엔티티매니저가 제공하는 기본 메소드 말고도 JPQL을 사용할 수 있는데 JPQLSQL과 유사하나, SQL은 테이블을 대상으로 한 쿼리이고 JPQL은 객체를 대상으로 하는 쿼리이다. 즉 JPQL을 통해 데이터를 객체로 가져온다고 생각하면 된다. 

 

 

트랜잭션 커밋

이렇게 모든 작업이 트랜잭션안에서 이루어지면 트랜잭션의 커밋을 반드시 해줘야한다. 커밋을 하지 않으면 데이터베이스에 변경 작업은 단 하나도 이루어지지 않는다. 그리고 커밋이 행해지기 전 어떤 오류가 있다면 트랜잭션 안에서 작업한 모든 작업을 롤백하는 트랜잭션 롤백이 있다. 다시 한 번 말하지만 모든 데이터베이스는 항상 트랜잭션이라는 단위안에서 데이터베이스에 대한 작업이 이루어진다.

 

 

이렇게 모든 작업을 끝내면 엔티티 매니저를 닫고, 엔티티 매니저 팩토리를 닫는다. 엔티티 매니저는 커넥션을 반납하는 개념이고 엔티티매니저 팩토리는 서비스가 종료될 때 닫으면 된다. 또한 서비스가 띄워질 때 딱 한 번만 생성된다. 

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

DB와 연동된 서비스 로직의 코드를 테스트할 필요가 반드시 생기는데, 이렇게 서비스 로직뿐 아니라 DB와 연동성도 정상적으로 이루어지는지까지 확인하는 테스트를 일반적으로 통합테스트 (Integration Test)라 한다. Spring에서 통합테스트 하는 방법은 굉장히 간단하다.

 

@SpringBootTest, @Transactional 어노테이션을 사용하면 된다.

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
     @Autowired MemberService memberService;
     @Autowired MemberRepository memberRepository;
     
     @Test
     public void 회원가입() throws Exception {
     //Given
     Member member = new Member();
     member.setName("hello");
     
     //When
     Long saveId = memberService.join(member);
     
     //Then
     Member findMember = memberRepository.findById(saveId).get();
     assertEquals(member.getName(), findMember.getName());
 }

모든 DB는 사실 어떤 작업을 하고 그 행위에 대한 '커밋'이 이루어져야 DB에 반영되어 Persist 속성을 가지게 되는데, 이처럼 @Test 라는 어노테이션에 @Transactional 어노테이션이 붙어버리면 커밋을 하지 않고 '롤백'을 한다. 하나의 트랜잭션에서 이루어지는 모든 DB와 관련된 작업이 롤백되는 것이다. 물론 이 @Transactional 어노테이션이 @Test가 아닌 다른 서비스에서 붙으면 자동으로 커밋이 되는데 @Test 어노테이션은 그렇지 않다. @Commit 이라는 어노테이션이 없으면 롤백이 기본이다. 

 

따라서, DB와 연동되는 서비스의 테스트를 간단하게 수행할 수 있다. 또한 이 테스트 코드에서 보다시피 필드 주입을 통해 DI를 수행했다. 

 

근데 수행해보면 알겠지만, @SpringBootTest는 SpringBoot가 띄워지기 때문에 속도 차이도 엄청 난다. 그래서 이렇게 특수한 경우에는 어쩔수 없지만 그렇지 않은 경우 순수 자바코드로 테스트 코드를 짜는게 효율적인 것과 동시에 더 좋은 테스트일 확률이 높다. 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

Spring을 사용하면 중요하게 알아두어야 할 것이 '컴포넌트 스캔'이란 단어다. 이게 무엇인지 공부한 내용을 작성해보고자 한다.

우선 Spring 코드를 보면 이런 어노테이션이 많이 보인다.

@Service
@RequiredArgsConstructor
public class FileStoreImpl implements FileStore {}
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/file")
public class FileController {}

@Service, @RestController, @Controller, @Component, @Repository 등 자주 보이는 어노테이션이 있는데 이게 무얼 의미하는지 알아야한다.

 

컴포넌트 스캔 및 자동 의존관계 설정

Spring이 아닌 기존의 자바 코드를 생각해보면, 어떤 임의의 클래스에서 다른 클래스의 객체를 불러올 때 이러한 방식으로 코드를 작성한다.

public class FileController {
	private final FileService fileService = new FileService();
}

해당 클래스에서 'new'를 사용해서 인스턴스화 하곤했는데, 이러한 객체를 불러오는 것을 스프링은 대신 작업해준다. 더 많은 이점을 가지고. 그것이 컴포넌트 스캔과 자동 의존관계 설정이다.

 

그럼 어떻게 하는지 알아보자.

@Service
public class FileService {

}

위 코드처럼 @Service라는 어노테이션을 특정 클래스에 추가하면 스프링은 스프링이 띄워질 때 이 클래스를 스프링 컨테이너에 유일하게 하나의 객체로 만들어 보관한다. 그래서 이 클래스가 스프링이 관리하는 하나의 컴포넌트가 되고 그 컴포넌트를 스프링이 띄워질 때 모두 찾아내는 걸 컴포넌트 스캔이라고 하는데 왜 컴포넌트일까? @Service 어노테이션 안으로 들어가보면 이와 같다. 

/*
 * Copyright 2002-2022 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.stereotype;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.springframework.core.annotation.AliasFor;

/**
 * Indicates that an annotated class is a "Service", originally defined by Domain-Driven
 * Design (Evans, 2003) as "an operation offered as an interface that stands alone in the
 * model, with no encapsulated state."
 *
 * <p>May also indicate that a class is a "Business Service Facade" (in the Core J2EE
 * patterns sense), or something similar. This annotation is a general-purpose stereotype
 * and individual teams may narrow their semantics and use as appropriate.
 *
 * <p>This annotation serves as a specialization of {@link Component @Component},
 * allowing for implementation classes to be autodetected through classpath scanning.
 *
 * @author Juergen Hoeller
 * @since 2.5
 * @see Component
 * @see Repository
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Service {

	/**
	 * The value may indicate a suggestion for a logical component name,
	 * to be turned into a Spring bean in case of an autodetected component.
	 * @return the suggested component name, if any (or empty String otherwise)
	 */
	@AliasFor(annotation = Component.class)
	String value() default "";

}

실제로 @Service 어노테이션에 들어와보면 @Component 라는 어노테이션이 있다. 이 때문에 @Service는 @Component 어노테이션을 붙인것과 같이 컴포넌트 스캔이 가능한 것.

 

그럼 스프링이 띄워질 때 스프링 컨테이너에 자동으로 컴포넌트들을 다 찾아서 등록해주면 이제 우리는 그 객체를 불러다가 사용만 하면 된다. 그 방식이 다음과 같은 방식이다.

@Controller
public class FileController {
	private final FileService;
    
    @Autowired
    public FileController(FileService fileService) {
    	this.fileService = fileService;
    }
}

이 코드를 보면 FileService를 final로 선언하고 초기화 하지 않은 상태에서 생성자에서 FileService를 초기화 해주는데, @Autowired라는 어노테이션이 보인다. 이 어노테이션은 스프링이 자동으로 스프링 컨테이너에서 보관하고 있는 FileService라는 객체를 이 클래스에 주입해준다는 의미다. 이게 자동 의존관계 설정이다.

 

그리고 저렇게 생성자가 딱 하나만 존재하는 경우 @Autowired 어노테이션은 생략할 수 있다. 

아래 코드가 그 예시이고, 이렇게 작성해도 스프링이 FileService를 자동 주입해준다. @Autowired가 생략된 것.

@Controller
public class FileController {
	private final FileService;
    
    public FileController(FileService fileService) {
    	this.fileService = fileService;
    }
}
참고로 @Controller 어노테이션 역시 스프링이 스프링 컨테이너에 객체로 보관해주는 컴포넌트 스캔이 일어난다. 이 @Controller 역시 들어가보면 @Component 라는 어노테이션이 들어있다.

요즘은 @Autowired 보다 더 간단하게 스프링한테 의존성 주입을 맡길 수 있는데 그 코드는 다음과 같다.

@Controller
@RequiredArgsConstructor
public class FileController {
	private final FileService;
    
}

여기서는 생성자도 필요없다. @RequiredArgsConstructor라는 어노테이션을 붙이면 이 어노테이션이 이 클래스가 반드시 가져야하는 인스턴스를 자동으로 주입해준다. 위에보다도 코드가 더 간결해졌다. 이렇게 스프링이 대신 의존관계를 주입해주면 가지는 여러 이점이 있지만 우선 그 중 하나는 유일하게 하나의 객체만을 만들어 사용하는 싱글톤 패턴 방식으로 동작하게 해준다. 

 

그럼 컴포넌트 스캔은 아무곳에 어떤 클래스나 상관없이 @Component라는 어노테이션만 붙으면 가능할까? 그렇지 않다.

어떻게 스프링이 스프링 컨테이너에 객체를 보관하냐면 스프링이 띄워질 때 가장 최초의 시작점인 @SpringBootApplication 어노테이션이 붙은 메인 클래스를 보자.

package com.example.server;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ServerApplication {

	public static void main(String[] args) {
		SpringApplication.run(ServerApplication.class, args);
	}

}

이 클래스를 보면 패키지가 com.example.server로 되어 있다. 스프링은 기본으로 이 패키지 하위에 있는 모든 패키지들만을 찾아 컴포넌트 스캔을 진행한다. 물론 다른 패키지에 있어도 컴포넌트 스캔을 설정하는 방법은 있지만 기본으로 이 메인 클래스의 패키지 하위에 있는 모든 패키지 안에서만 컴포넌트 스캔이 일어난다. 

 

 

 

이렇게 사전에 스프링이 제공해주는 어노테이션인 @Service, @Controller와 같은 어노테이션을 사용해서 스프링 컨테이너에 객체를 등록하는 방법이 있고 직접 자바 코드를 이용해서 등록하는 방법도 있다. 

 

자바 코드로 직접 스프링 빈 등록하기

public class FileService {
	private final FileRepository fileRepository;
    
    public FileService(FileRepository fileRepository) {
    	this.fileRepository = fileRepository;
    }
}

이러한 FileService 클래스가 있다고 하면, 이 클래스는 생성자로 FileRepository를 받아야한다. 그리고 스프링 컨테이너에 등록하는 어노테이션도 없다. 이제 이 녀석을 자바 코드로 직접 등록해보자. 

 

 

그 방법은 Configuration 클래스를 하나 새로 만들고 @Configuration 어노테이션을 추가해주자. 다음 코드와 같다.

@Configuration
public class SpringConfig {
	
    @Bean
    public FileService fileService() {
    	return new FileService(fileRepository());
    }
    
    @Bean
    public FileRepository fileRepository() {
    	return new FileRepository();
    }
}

이렇게 @Configuration 어노테이션을 추가하면 스프링한테 "스프링을 띄우면 이 파일을 확인해서 내가 @Bean 어노테이션을 붙인 녀석들 모두를 스프링 컨테이너에 등록해줘 !" 라고 하는것이다. 

 

그리고 FileService()는 FileRepository를 생성자로 받아야 하니 FileRepository 또한 스프링 빈으로 등록을 하고 등록된 객체를 불러다가 FileService에 넣어준다. 

 

 

이렇게 자바 코드로 직접 스프링 빈을 등록을 하면 이제 위에서와 같이 의존관계를 위한 의존성 주입이 가능해지는데 의존성 주입(Dependency Injection)을 하는 방법에는 크게 3가지가 있다. 필드 주입, setter 주입, 생성자 주입(위에서 봤던 것).

 

필드 주입, setter 주입은 이제 거의 사용하지 않는다. 아니 아예 사용하지 않을수도 있다. 그래서 어떻게 하는지만 보고 넘어갈 생각이다.

// 필드 주입
public class FileService {
	@Autowired private FileRepository fileRepository;
}
// setter 주입
public class FileService {
	private FileRepository fileRepository;
    
	@Autowired
    public void setFileRepository(FileRepository fileRepository) {
    	this.fileRepository = fileRepository;
    }
}

필드 주입과 setter 주입은 이렇게 생겨먹었다. 아 물론 필드 주입같은 경우 테스트 코드에서는 종종 쓰이곤한다. 왜냐하면 테스트는 가장 끝단에 있는거니까 그냥 편하게 쓸 수 있는게 가장 좋은거라고 생각하자.

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

회사에서 새로운 프로젝트를 진행할 때 이번엔 Maven말고 Gradle을 사용해보고 싶어서 로컬에서는 아무 문제가 없었는데 물리서버에 코드를 옮기고 빌드할 때 이러한 에러가 발생했다.

java.lang.UnsupportedClassVersionError:  
org/springframework/boot/gradle/plugin/SpringBootPlugin 
has been compiled by a more recent version of the Java Runtime (class file version 61.0),
this version of the Java Runtime only recognizes class file versions up to 52.0

이 에러 내용만을 읽어봤을 땐 현재 사용하는 JRE가 클래스 파일의 버전 52까지만 수용할 수 있는듯하고 이 경로 org/springframework/boot/gradle/plugin/SpringBootPlugin의 클래스 파일의 버전은 61이라 버전 미스매치가 된 것 같다.

 

해결하는 방법은 그냥 현재 물리서버에서 사용중인 JRE 버전이 로컬에서 사용한 JRE 버전보다 낮고 허용가능한 범위를 벗어났으니 JRE 버전을 높이면 된다. 아래는 클래스 파일의 버전과 호환 가능한 JRE 버전이다.

49 = Java 5
50 = Java 6
51 = Java 7
52 = Java 8
53 = Java 9
54 = Java 10
55 = Java 11
56 = Java 12
57 = Java 13
58 = Java 14
59 = Java 15
60 = Java 16
61 = Java 17
62 = Java 18
63 = Java 19
64 = Java 20
65 = Java 21

 

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

UserService의 API 중 Create 부분을 만들어보자.

 

 

VO

우선, vo 패키지를 하나 추가하고 그 패키지 안에서 CreateUser.java 파일을 생성한다.

이 클래스는 User를 생성하고자 하는 사용자의 요청을 받을 때 Payload를 담는 클래스다. 

package com.example.tistoryuserservice.vo;

import lombok.Data;

@Data
public class CreateUser {
    private String email;
    private String name;
    private String password;
}

위처럼 작성하면 되는데 좀 더 완성도를 높여보자. 각 필드 별 Validation을 걸 수 있다. 예를 들면 최소한의 길이, 최대 길이, NotNull 조건 등 여러 유효성 검사를 필드에 걸어놓을 수 있는데 이를 위해 dependency 하나를 추가해야 한다.

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>3.1.3</version>
</dependency>

위 의존성을 내려받고 아래와 같이 코드를 수정해 보자.

 

package com.example.tistoryuserservice.vo;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class CreateUser {
    @NotNull(message = "Email must be required")
    @Size(min = 2, message = "Email should be more than two characters")
    @Email
    private String email;
    
    @NotNull
    @Size(min = 2, message = "Name should be more than two characters")
    private String name;
    
    @NotNull
    @Size(min = 8, message = "Password should be more than 8 characters")
    private String password;
}

@NotNull, @Size, @Email과 같은 어노테이션은 방금 내려받은 dependency에 의해 사용할 수 있다. 이런 제약조건을 걸어놓으면 payload로 받은 데이터를 이 클래스에 담으려고 할 때 조건에 해당하지 않으면 담지 못한다. 이와 같이 유효성 검사를 간단하게 적용할 수 있다.

 

 

DTO

이제 DTO를 만들 차례다. 즉, 외부 요청에 의해 전달된 새로운 유저를 만들 데이터를 DB에 저장하기 전 DB에 들어갈 알맞은 형식의 데이터가 필요한데 그때 사용되는 클래스라고 보면 된다.

package com.example.tistoryuserservice.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;

import java.util.Date;

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(value = JsonInclude.Include.NON_NULL)
public class CreateUserDto {
    private String email;
    private String name;
    private String password;
    private String userId;
    private Date createdAt;

    private String encryptedPassword;
}

 

dto 패키지를 추가한 후 CreateUserDto라는 클래스로 만들고 위와 같이 작성했다. CreateUser 클래스에는 없는 userId, createdAt, encryptedPassword 필드는 DB에 넣기 전 서비스 클래스에서 추가될 내용이고 나머지는 CreateUser 클래스에서 받아올 거다.

 

CrudRepository

이제 CrudRepository를 사용해서 기본적인 CRUD API를 제공하는 JPA의 도움을 받을 것이다.

repository라는 패키지를 하나 만들고 그 안에 UserRepository 인터페이스를 생성하자.

package com.example.tistoryuserservice.repository;

import com.example.tistoryuserservice.entity.User;
import org.springframework.data.repository.CrudRepository;

public interface UserRepository extends CrudRepository<User, Long> {}

이렇게 인터페이스를 만들면 User Entity에 대한 기본적인 CRUD 메서드를 가져다가 사용할 수 있다. 그 방법은 이후에 서비스 클래스를 구현하면서 볼 수 있다.

 

Service

UserService를 구현해 볼 차례다. 인터페이스를 만들고 필요한 메서드들을 정의한 뒤 그 인터페이스를 구현한 서비스 클래스를 만들어보자. 우선 service라는 패키지에 UserService 인터페이스를 만들자.

package com.example.tistoryuserservice.service;

import com.example.tistoryuserservice.dto.CreateUserDto;

public interface UserService {
    CreateUserDto createUser(CreateUserDto createUserDto);
}

 

 

그리고 이 인터페이스를 상속받는 서비스 클래스를 만든다. 우선은 메서드들을 정의한 인터페이스 먼저 만들자.

service라는 패키지안에 UserService 인터페이스를 만들어준다.

package com.example.tistoryuserservice.service;

import com.example.tistoryuserservice.dto.CreateUserDto;

public interface UserService {
    CreateUserDto createUser(CreateUserDto createUserDto);
}

이 인터페이스를 구현하는 UserServiceImpl 클래스를 만들어준다.

package com.example.tistoryuserservice.service;

import com.example.tistoryuserservice.dto.CreateUserDto;
import com.example.tistoryuserservice.entity.User;
import com.example.tistoryuserservice.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    @Override
    public CreateUserDto createUser(CreateUserDto createUserDto) {
        createUserDto.setUserId(UUID.randomUUID().toString());

        ObjectMapper mapper = new ObjectMapper();
        User user = mapper.convertValue(createUserDto, User.class);
        user.setEncryptedPassword("encrypted_password");

        userRepository.save(user);
        return createUserDto;
    }
}

이 서비스 클래스에서 createUser()를 구현하고 있다. 여기서는 DTO에는 없는 userId와 encryptedPassword를 직접 추가해 준다. encryptedPassword를 만들어 내는 것을 구현하지 않았기 때문에 일단은 텍스트로 써넣는다. 이건 추후에 구현 예정이다.

 

DTO 데이터를 가지고 실제 데이터베이스에 들어갈 User라는 Entity로 타입 변환을 해준다. 그리고 그렇게 변환한 객체를 UserRepository를 주입받아서 save() 메서드를 호출한다. CrudRepository가 제공하는 save() 메서드에 어떠한 문제도 발생하지 않는다면 정상적으로 DTO 데이터를 다시 리턴한다.

 

이제 이 서비스 클래스를 호출할 Controller를 구현해야 한다. 실제로 유저가 사용할 API를 받아줄 수 있는.

 

Controller

controller 패키지 안에 UserController.java 파일을 만들자. 

package com.example.tistoryuserservice.controller;

import com.example.tistoryuserservice.dto.CreateUserDto;
import com.example.tistoryuserservice.service.UserService;
import com.example.tistoryuserservice.vo.CreateUser;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/user-service")
public class UserController {

    private final UserService userService;

    @PostMapping("/users")
    public CreateUserDto createUser(@RequestBody CreateUser createUser) {
        ObjectMapper mapper = new ObjectMapper();
        CreateUserDto createUserDto = mapper.convertValue(createUser, CreateUserDto.class);

        return userService.createUser(createUserDto);
    }
}

별건 없다. PostMapping으로 유저를 생성하는 API를 선언하고, @RequestBody 어노테이션을 사용하여 Request의 Body에서 데이터를 받아 CreateUser 클래스로 변환시킨다. 이걸 스프링이 알아서 다 해주는 것이다. 매우 편하다.

 

받아온 데이터를 서비스가 받아줄 수 있는 DTO 타입의 데이터로 변환해 주기 위해 ObjectMapper를 이용한다.

그리고 서비스를 호출해 실제 유저를 생성하고 DB에 저장한 뒤 서비스가 돌려주는 데이터인 CreateUserDto 데이터를 컨트롤러도 리턴한다. 테스트해보자!

 

Create User

Gateway를 통해 UserService를 호출한다. Gateway로부터 요청을 UserService는 전달받을 거고 요청에 대한 처리를 해준 후 응답한 결과다.

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

Hibernate는 자바 기반의 오픈소스 프레임워크로 객체 지향된 도메인 모델과 RDB 사이의 연결을 제공한다. Hibernate가 자바 애플리케이션에서 데이터베이스 관련 프로그래밍을 개발자들이 Java Objects를 통해 할 수 있도록 도와준다. 복잡한 SQL 쿼리 대신. 

 

Hibernate의 핵심 특징은 다음과 같다. 

 

1. Object-Relational Mapping (ORM): Hibernate의 핵심 목적은 객체 지향적 언어와 RDB 사이의 갭에 연결 다리를 놓는것이다. 그리고 그를 위해 양방향으로 자바 오브젝트들과 데이터베이스 테이블들 사이를 매핑해준다. 

 

2. Persistence: Hibernate는 RDB에 또는 RDB로부터 자바 오브젝트들을 저장하거나 읽어오는 메커니즘을 제공한다. Hibernate는 low-level SQL을 사용해 상호작용하는 방식을 추상화하여 개발자들이 데이터베이스와 직접적으로 상호작용하는게 아닌 자바 오브젝트들을 가지고 작업할 수 있게 도와준다.

 

3. Hibernate Configuration: Hibernate를 사용하기 위해, Java-based configuration file(application.yml or application.properties)을 통해 설정이 필요한데 이런 설정 파일은 데이터베이스 접속 정보를 포함한 여러 설정 정보들을 명세할 수 있다. 

 

4. Hibernate Mapping: Hibernate는 metadata(XML or Annotation)를 사용해서 자바 클래스들과 데이터베이스 테이블들에 대한 매핑을 정의한다. 이런 매핑들은 자바 오브젝트들의 필드나 속성들이 어떻게 데이터베이스의 컬럼과 상응하는지를 구체화한다. 
(예: @Id, @Column(nullable = false, length =50, unique = true))

 

5. Session and Session Factory: Hibernate는 세션 기반의 모델에서 동작한다. 하나의 세션은 하나의 단일 데이터베이스 커넥션에 상응하고 작업한다. 세션 팩토리는 세션을 생성하고 관리한다. 꽤나 무겁고 Thread-safe objects 형태이며 일반적으로 애플리케이션이 시작될 때 한 번 만들어진다.

 

6. HQL(Hibernate Query Language): Hibernate는 본인만의 언어인 HQL을 제공한다. SQL과 유사하나 자바 오브젝트로 동작한다. HQL은 개발자들이 데이터베이스에 대한 쿼리를 객체 지향형 문법으로 사용할 수 있게 해준다. 

 

7. Caching: Hibernate는 캐싱 메커니즘을 지원하는데, 이것이 애플리케이션의 퍼포먼스를 향상시킨다. 메모리에서 오브젝트들의 캐시를 할 수 있으며 이는 곧 많은 수의 데이터베이스 쿼리들을 절감시켜준다.

 

8. Lazy Loading: Hibernate는 개발자들로 하여금 lazy loading 구성을 할 수 있게 해주는데 그 데이터들이 실제로 액세스될 때만 오브젝트들이 데이터베이스로부터 로드된다. 이는 퍼포먼스 최적화에 도움을 준다.

 

9. Transactions: Hibernate는 데이터베이스 트랜잭션을 지원하고 다양한 트랜잭션 관리 시스템을 통합할 수 있게 해준다. 

 

Hibernate는 흔히 Java enterprise application에서 사용되고 효율적인 방식으로 데이터베이스 상호작용을 관리한다. 반복적인 SQL 코드들을 줄여주고, 데이터베이스 스키마의 변화를 단순화시킨다. 

 

 

Hibernate와 JPA의 차이

Hibernate과 JPA는 연관성이 있으나 가장 큰 차이점은 JPA는 명세 또는 API라는 점이다. 즉, 표준 인터페이스(또는 어노테이션)를 정의한다. 반면 Hibernate는 유명하며 완성된 ORM 프레임워크이다. 그리고 이 프레임워크는 JPA 명세를 구현했다. JPA의 구현체가 Hibernate이라고 생각하면 되지만 JPA 명세를 넘어서서 추가적인 특징들도 가지고 있는게 Hibernate이다.

 

요약하자면, JPA는 표준화된 API로 여러 ORM 프레임워크들이 구현할 수 있다. 이 JPA가 공통된 규칙들의 셋과 어노테이션을 제공한다. Hibernate는 JPA의 구체적인 구현체다. 

728x90
반응형
LIST

+ Recent posts