no image
[JPA] 즉시 로딩, 지연 로딩, 영속성 전이, 고아 객체
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.즉시 로딩과 지연 로딩public void printUserAndTeam(String memberId) { Member member = em.find(Member.class, memberId); Team team = member.getTeam(); System.out.println("회원 이름: " + member.getUsername()); System.out.println("소속팀: " + team.getName());}회원과 팀을 함게 출력해야하는 로직에서는 상관 없지만, 단순히 회원만 출력하는 로직에서는 굳이 팀까지 DB에서 조회해서 가져올 필요가 없다.public void print..
2024.07.27
no image
[JPA] 프록시(Proxy)
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.em.find() vs em.getReference()em.find(): 데이터베이스를 통해서 실제 엔티티 객체 조회em.getReference(): 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회import javax.persistence.EntityManager;import javax.persistence.EntityManagerFactory;import javax.persistence.Persistence;public class Example { public static void main(String[] args) { EntityManagerFactory emf = Persist..
2024.07.26
no image
[JPA] 상속관계 매핑
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.상속관계 매핑관계형 데이터베이스는 상속관계라는 개념이 존재하지 않는다.RDB에서는 슈퍼타입 서브타입 관계라는 모델링 기법이 객체 상속과 유사한 개념이다.상속관계 매핑이란 객체의 상속 구조와 DB의 슈퍼타입 서브타입 관계를 매핑하는 것을 뜻한다.상속관계 매핑은 총 2가지 전략이 있다.조인 전략단일 테이블 전략기본적으로 조인 전략을 선택하되, 성능이 우선시된다면 단일 테이블 전략 선택 @Inheritance상속 매핑 전략을 설정하여 상위 클래스와 이를 상속받는 하위 클래스 간의 데이터 저장 방식을 지정할 수 있다. @Inheritance 애노테이션은 상위 클래스에 사용되며, 상속 매핑 전략으로 단일 테이블 전략, 조..
2024.07.25
no image
[JPA] 다양한 연관관계 매핑
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.연관관계 매핑시 고려사항 3가지다중성단방향, 양방향연관관계의 주인 다중성다대일: @ManyToOne일대다: @OneToMany일대일: @OneToOne다대다: @ManyToMany 단방향, 양방향테이블객체외래 키 하나로 양쪽 조인 가능참조용 필드가 있는 쪽으로만 참조 가능방향이라는 개념이 없다.한쪽만 참조하면 단방향 양쪽이 서로 참조하면 양방향  연관 관계의 주인테이블은 외래 키 하나로 두 테이블이 연관관계를 맺음객체 양방향 관계는 A->B, B->A 처럼 참조가 두 방향객체 양방향 관계는 참조가 두 방향이 있음. 둘중 테이블의 외래 키를 관리할 곳을 지정해야 함연관관계의 주인: 외래 키를 관리하는 참조주인의 반..
2024.07.24
no image
[JPA] 연관관계 매핑(단방향, 양방향)을 통한 객체 그래프 탐색
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.객체 지향 프로그래밍(OOP) 패러다임과 관계형 데이터베이스(RDB) 패러다임 간의 불일치는 종종 객체-관계 불일치(O/R Impedance Mismatch)라고 불린다.이는 객체 모델과 관계형 데이터 모델 간의 구조적 차이에서 비롯된다. 이러한 불일치로 인해 양방향 매핑을 구현할 때 다양한 문제와 고려사항이 발생한다. 객체 모델은 상속을 자연스럽게 지원하지만, 관계형 데이터베이스에서는 이를 직접적으로 지원하지 않는다.객체 모델에서는 객체 간의 연관관계를 직접 참조로 표현할 수 있지만, 관계형 데이터베이스에서는 외래 키(Foreign Key)를 사용해 연관관계를 표현해야 한다.객체는 참조 동등성을 사용하지만, 데..
2024.07.17
no image
[JPA] 엔티티 매핑(Entity Mapping)
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.데이터베이스 스키마 자동 생성application.propertiesspring.jpa.hibernate.ddl-auto=createspring.jpa.hibernate.ddl-auto=create-dropspring.jpa.hibernate.ddl-auto=updatespring.jpa.hibernate.ddl-auto=validatespring.jpa.hibernate.ddl-auto=nonecreate설명: 기존 테이블을 삭제한 후 다시 생성한다. (DROP + CREATE)사용 예시: 개발 초기 단계에서 데이터베이스 스키마를 자주 변경할 때 사용된다. 기존 데이터를 모두 삭제하고 테이블을 새로 생성하기 때..
2024.07.16
no image
[JPA] 영속성 컨텍스트(Persistence Context)
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.JPA 구동 방식엔티티 매니저 팩토리 (EntityManagerFactory)정의EntityManagerFactory는 JPA 애플리케이션에서 EntityManager 인스턴스를 생성하기 위한 팩토리이다. 특징비용이 많이 드는 객체: 생성하는 데 많은 리소스를 사용하므로 애플리케이션 전체에서 한 번만 생성하고 공유하는 것이 일반적이다.애플리케이션 전체에서 공유: 여러 스레드에서 동시에 사용될 수 있다.생명 주기: 애플리케이션 시작 시 생성되고, 애플리케이션 종료 시 닫힌다.  엔티티 매니저 (EntityManager)정의EntityManager는 엔티티의 생명 주기(Life Cycle)를 관리하고, 데이터베이스 ..
2024.07.15
no image
[Spring DB] 트랜잭션 전파 활용
이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다. 예제 프로젝트 더보기 Member @Entity @Getter @Setter public class Member { @Id @GeneratedValue private Long id; private String username; public Member() { } public Member(String username) { this.username = username; } } MemberRepository @Slf4j @Repository @RequiredArgsConstructor public class MemberRepository { private final EntityManager em; @Transacti..
2024.04.11

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


즉시 로딩과 지연 로딩

public void printUserAndTeam(String memberId) {
    Member member = em.find(Member.class, memberId);
    Team team = member.getTeam();
    System.out.println("회원 이름: " + member.getUsername());
    System.out.println("소속팀: " + team.getName());
}

회원과 팀을 함게 출력해야하는 로직에서는 상관 없지만, 단순히 회원만 출력하는 로직에서는 굳이 팀까지 DB에서 조회해서 가져올 필요가 없다.

public void printUser(String memberId) {
    Member member = em.find(Member.class, memberId);
    Team team = member.getTeam();
    System.out.println("회원 이름: " + member.getUsername());
}

 

지연 로딩

@ManyToOne(fetch = FetchType.LAZY) 어노테이션은 JPA에서 연관 관계를 정의할 때 사용되며, fetch = FetchType.LAZY 옵션을 통해 지연 로딩(Lazy Loading)을 지정할 수 있다. 

이는 연관된 엔티티를 실제로 사용할 때까지 데이터베이스 조회를 지연시키는 방식이다.

  • @ManyToOne, @OneToOne은 기본(default)이 즉시 로딩
  • @OneToMany, @ManyToMany는 기본(default)이 지연 로딩

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long ID;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne(fetch = FetchType.LAZY) //지연 로딩 설정
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    //getter and setter...
}

 

@Entity
public class Team {
    @Id  @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();

    //getter and setter...
}

 

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member1 = new Member();
member1.setUsername("member1");
member1.setTeam(team);
em.persist(member1);

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

Member m = em.find(Member.class, member1.getID());

System.out.println("m = " + m.getTeam().getClass()); //프록시
System.out.println("=======");
m.getTeam().getName(); //프록시 초기화

 

즉시 로딩

@ManyToOne(fetch = FetchType.EAGER) 어노테이션은 JPA에서 연관된 엔티티를 즉시 로딩하도록 설정하는 방법이다. 

즉, 해당 엔티티를 조회할 때 연관된 엔티티도 함께 로드된다. 이는 기본적으로 사용되는 로딩 방식이기도 하다.

하지만 실무에서는 즉시 로딩을 사용해선 안된다!

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
    
    @ManyToOne(fetch = FetchType.EAGER) //즉시 로딩 설정
    
    @JoinColumn(name = "TEAM_ID")
    private Team team;

}

 

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member1 = new Member();
member1.setUsername("member1");
member1.setTeam(team);
em.persist(member1);

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

Member m = em.find(Member.class, member1.getID());

System.out.println("m = " + m.getTeam().getClass());
System.out.println("=======");
m.getTeam().getName();

 

즉시 로딩의 문제점

  • 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생
  • 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
  • @ManyToOne, @OneToOne은 기본이 즉시 로딩 -> LAZY로 설정을 변경해서 사용
  • @OneToMany, @ManyToMany는 기본이 지연 로딩
  • 가급적 지연 로딩을 사용해야 한다.

 

영속성 전이(CASCADE)

JPA에서 영속성 전이(Cascade)는 엔티티의 상태 변화가 연관된 엔티티에게도 전이되는 것을 의미한다.

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들도 싶을 때 사용한다.

예를 들어, 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장해야할 때 사용한다.

참조하는 곳이 하나일 때 사용해야한다. 여러 곳에 연관관계가 있을 때 사용하면 안 된다.

이를 통해 개발자는 연관된 엔티티의 상태를 관리하기 쉽게 할 수 있다. @ManyToOne, @OneToMany, @OneToOne, @ManyToMany와 같은 관계에서 cascade 속성을 설정할 수 있다.

 

@OneToMany(mappedBy="parent", cascade=CascadeType.PERSIST)

주의

영속성 전이는 연관관계를 매핑하는 것과 아무 관련이 없다.
엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공할 뿐이다.

 

Cascade 유형

  • CascadeType.PERSIST: 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화한다.
  • CascadeType.MERGE: 엔티티를 병합할 때 연관된 엔티티도 함께 병합한다.
  • CascadeType.REMOVE: 엔티티를 삭제할 때 연관된 엔티티도 함께 삭제한다.
  • CascadeType.REFRESH: 엔티티를 새로고침할 때 연관된 엔티티도 함께 새로고침한다.
  • CascadeType.DETACH: 엔티티를 준영속 상태로 만들 때 연관된 엔티티도 함께 준영속 상태로 만든다.
  • CascadeType.ALL: 모든 Cascade 유형을 포함한다.

 

@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) //영속성 전이 설정
    private List<Child> childList = new ArrayList<>();

    public void addChild(Child child) {
        childList.add(child);
        child.setParent(this);
    }

    //getter and setter...
}

 

@Entity
public class Child {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;

    //getter and setter...
}

 

Child child1 = new Child();
Child child2 = new Child();

Parent parent = new Parent();
parent.addChild(child1);
parent.addChild(child2);

//em.persist(parent);
//em.persist(child1);
//em.persist(child2);

em.persist(parent);

 

 

고아 객체

orphanRemoval = true

JPA에서 고아 객체(orphan entity)란 부모 엔티티와의 연관 관계가 제거되어 더 이상 참조되지 않는 자식 엔티티를 의미한다. 이러한 고아 객체는 데이터베이스에서 자동으로 삭제되도록 설정할 수 있다. 이를 위해 JPA에서는 orphanRemoval 속성을 제공한다

고아 객체 제거: 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제

orphanRemoval = true 설정을 통해 고아 객체가 발생할 경우 자동으로 삭제되도록 할 수 있다. 

이 설정은 @OneToMany 및 @OneToOne 관계에서만 사용 가능하다.

 

Parent parent1 = em.find(Parent.class, id);
parent1.getChildren().remove(0);//자식 엔티티를 컬렉션에서 제거
//DELETE FROM CHILD WHERE ID=?

 

참조하는 곳이 하나일 때 사용해야 한다.
특정 엔티티가 개인 소유할 때 사용해야 한다.

@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true) //영속성 전이 설정, 고아객체 삭제 설정
    private List<Child> childList = new ArrayList<>();

    public void addChild(Child child) {
        childList.add(child);
        child.setParent(this);
    }
    
    //getter and setter...
}

 

@Entity
public class Child {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;
    
    //getter and setter...
}

 

Child child1 = new Child();
Child child2 = new Child();

Parent parent = new Parent();
parent.addChild(child1);
parent.addChild(child2);

em.persist(parent);

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

Parent findParent = em.find(Parent.class, parent.getId());
findParent.getChildList().remove(0);

개념적으로 부모를 제거하면 자식은 고아가 된다.
따라서 고아 객체 제거 기능을 활성화 하면, 부모를 제거할 때 자식도 함께 제거된다.
이것은 CascadeType.REMOVE처럼 동작한다.

 

CascadeType.ALL + orphanRemoval=true

두 옵션을 모두 활성화 하면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다.
쉽게 설명하면, 부모 엔티티는 영속성 컨텍스트를 통해 관리되지만, 자식 엔티티는 영속성 컨텍스트가 아닌 부모 엔티티를 통해서 관리되는 것 처럼 보이는 것이다

'Java Category > JPA' 카테고리의 다른 글

[JPA] JPQL 기본 문법  (0) 2024.07.29
[JPA] 값 타입(Value Type)  (0) 2024.07.28
[JPA] 프록시(Proxy)  (0) 2024.07.26
[JPA] 상속관계 매핑  (0) 2024.07.25
[JPA] 다양한 연관관계 매핑  (5) 2024.07.24

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


em.find() vs em.getReference()

  • em.find(): 데이터베이스를 통해서 실제 엔티티 객체 조회
  • em.getReference(): 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회

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

public class Example {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("example-unit");
        EntityManager em = emf.createEntityManager();

        // 엔티티의 참조를 가져오는 예시
        em.getTransaction().begin();

        // 예시로 사용할 엔티티 클래스와 기본 키
        Long primaryKey = 1L;
        MyEntity entity = em.getReference(MyEntity.class, primaryKey);

        // entity를 실제로 사용하면 데이터베이스 조회가 발생
        System.out.println(entity.getName());

        em.getTransaction().commit();
        em.close();
        emf.close();
    }
}

 

 

프록시 특징

  • 실제 클래스를 상속 받아서 만들어짐
  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됨(이론상)
  • 프록시 객체는 실제 객체의 참조(target)를 보관
  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드 호출

 

프록시 객체의 초기화

  1. 프록시 객체의 메서드를 호출하거나 필드에 접근
  2. 영속성 컨텍스트에 초기화 요청
  3. 영속성 컨텍스트는 DB를 조회하여 데이터를 가져옴
  4. 영속성 컨텍스트는 가져온 데이터를 바탕으로 실제 엔티티를 생성
  5. 프록시 객체가 실제 엔티티의 메서드를 호출하거나 필드에 접근

 

프록시 객체를 사용할 때의 주의사항

1.초기화 시점

  • 프록시 객체는 처음 접근할 때 한 번만 초기화된다.
  • 예를 들어, 프록시 객체의 메서드를 호출하거나 필드에 접근할 때 실제 데이터베이스 조회가 발생하여 초기화된다.

2.프록시 객체와 실제 엔티티의 차이

  • 프록시 객체가 초기화되더라도, 이는 프록시 객체가 실제 엔티티로 변하는 것이 아니다.
  • 초기화된 프록시 객체를 통해 실제 엔티티에 접근할 수 있다.

3.타입 체크 주의사항

  • 프록시 객체는 원본 엔티티 클래스를 상속받는다. 따라서, == 연산자로 타입을 비교하면 실패할 수 있다.
  • 대신, instanceof 연산자를 사용하여 프록시 객체인지 확인할 수 있다.
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);

Member member2 = new Member();
member1.setUsername("member2");
em.persist(member2);

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

Member m1 = em.find(Member.class, member1.getID());
Member m2 = em.getReference(Member.class, member2.getID());

System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());

System.out.println("m1 == m2 = " + (m1 == m2));

 

4.영속성 컨텍스트의 역할

  • 영속성 컨텍스트에 이미 찾고자 하는 엔티티가 존재하면, em.getReference()를 호출해도 실제 엔티티가 반환된다.
  • 즉, 프록시 객체가 아닌 실제 엔티티 인스턴스가 반환된다.
Member member = new Member();
member.setUsername("member1");
em.persist(member);

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

Member m1 = em.find(Member.class, member.getID());
System.out.println("m1 = " + m1.getClass());

Member m2 = em.getReference(Member.class, member.getID());
System.out.println("m2 = " + m2.getClass());

System.out.println("m1 == m2 = " + (m1 == m2));

 

참고로 JPA에서 영속성 컨텍스트에서 하나의 트랜잭션안에 묶여있다면 진짜 엔티티든, 프록시든 객체 비교시 동일성을 보장한다.

Member member = new Member();
member.setUsername("member1");
em.persist(member);

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

Member m1 = em.getReference(Member.class, member.getID());
Member m2 = em.find(Member.class, member.getID());

System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());

System.out.println("m1 == m2 = " + (m1 == m2));

 

 

5.준영속 상태에서의 문제

  • 준영속 상태(persistent context의 도움을 받을 수 없는 상태)에서 프록시 객체를 초기화하면 문제가 발생할 수 있다.
  • 이는 LazyInitializationException 등의 예외를 초래할 수 있다.
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);

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

Member m1 = em.getReference(Member.class, member1.getID());
System.out.println("m1 = " + m1.getClass());

em.detach(m1);

m1.getUsername();

 

 

프록시 관련 메서드

PersistenceUnitUtil.isLoaded(Object entity)

프록시 인스턴스의 초기화 여부를 알려주는 메서드이다.

Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);

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

Member m1 = em.getReference(Member.class, member1.getID());

System.out.println("isLoaded = " + emf.getPersistenceUnitUtil().isLoaded(m1));

 

entity.getClass()

프록시 클래스를 확인하는 메서드이다.

Member m1 = em.getReference(Member.class, member1.getID());
System.out.println("m1 = " + m1.getClass());

 

Hibernate.initialize(entity)

프록시 객체를 강제로 초기화하는 메서드이다.

Hibernate.initialize(m1); //강제 초기화

 

참고: JPA 표준은 강제 초기화 없음

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


상속관계 매핑

관계형 데이터베이스는 상속관계라는 개념이 존재하지 않는다.

RDB에서는 슈퍼타입 서브타입 관계라는 모델링 기법이 객체 상속과 유사한 개념이다.

상속관계 매핑이란 객체의 상속 구조와 DB의 슈퍼타입 서브타입 관계를 매핑하는 것을 뜻한다.

상속관계 매핑은 총 2가지 전략이 있다.

  • 조인 전략
  • 단일 테이블 전략

기본적으로 조인 전략을 선택하되, 성능이 우선시된다면 단일 테이블 전략 선택

 

@Inheritance

상속 매핑 전략을 설정하여 상위 클래스와 이를 상속받는 하위 클래스 간의 데이터 저장 방식을 지정할 수 있다. @Inheritance 애노테이션은 상위 클래스에 사용되며, 상속 매핑 전략으로 단일 테이블 전략, 조인 전략, 테이블 퍼 클래스 전략을 제공한다.

strategy: 상속 매핑 전략을 지정하며, InheritanceType.SINGLE_TABLE, InheritanceType.JOINED 중 하나를 선택할 수 있다.

@Inheritance(strategy = InheritanceType.JOINED)
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)

 

@DiscriminatorColumn

상위 클래스와 이를 상속받은 하위 클래스 간의 데이터 구분을 위해 사용된다. 이 애노테이션은 단일 테이블 전략이나 조인 전략에서 사용되며, 특정 컬럼을 기준으로 엔티티 타입을 구분한다.

 

주요 속성

  • name: 테이블에 저장될 컬럼의 이름을 지정한다.
@DiscriminatorColumn(name = "DTYPE")// 기본이 "DTYPE"

 

 

조인 전략

 

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
public class Item {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private int price;

	//getter and setter...
}

 

@Entity
public class Movie extends Item{
    private String director;
    private String actor;
    //getter and setter...
}

 

@Entity
@DiscriminatorValue("MyAlbum")//DTYPE 컬럼에 저장될 이름 지정
public class Album extends Item{
    private String artist;
    //getter and setter...
}

 

@Entity
public class Book extends Item{
    private String Author;
    private String isbn;
    //getter and setter...
}

 

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 {
            Movie movie = new Movie();
            movie.setDirector("aaaa");
            movie.setActor("bbbb");
            movie.setName("바람과 함께 사라지다.");
            movie.setPrice(10000);

            Album album = new Album();
            album.setArtist("Rebugs");
            album.setPrice(2000);
            album.setName("JPA");

            em.persist(movie);
            em.persist(album);

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

 

장점

  • 테이블 정규화
  • 외래 키 참조 무결성 제약조건 활용가능
  • 저장공간 효율화

 

단점

  • 조회시조인을많이사용,성능저하
  • 조회 쿼리가 복잡함
  • 데이터 저장시 INSERT SQL 2번 호출

 

단일 테이블 전략

 

Item 테이블만 변경, 나머지는 모두 그대로

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)//변경된 부분
@DiscriminatorColumn
public class Item {

    @Id @GeneratedValue
    private Long id;

    private String name;
    private int price;

	//getter and setter...
}

 

장점

  • 조인이 필요 없으므로 일반적으로 조회 성능이 빠름
  • 조회 쿼리가 단순함

단점

  • 자식 엔티티가 매핑한 컬럼은 모두 null 허용
  • 단일테이블에모든것을저장하므로테이블이커질수있다.상 황에 따라서 조회 성능이 오히려 느려질 수 있다.

 

@MappedSuperclass

  • 상속관계 매핑X
  • 엔티티X, 테이블과 매핑X
  • 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만 제공
  • 조회, 검색 불가(em.find(BaseEntity) 불가)
  • 직접 생성해서 사용할 일이 없으므로 추상 클래스 권장
  • 테이블과 관계 없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할
  • 주로 등록일, 수정일, 등록자, 수정자 같은 전체 엔티티에서 공통으로 적용하는 정보를 모을 때 사용
  • 참고: @Entity 클래스는 엔티티나 @MappedSuperclass로 지정한 클래스만 상속 가능
@MappedSuperclass
public abstract class BaseEntity {
    private String createBy;
    private LocalDateTime createdDate;
    private String lastModifiedBy;
    private LocalDateTime lastModifiedDate;
    //getter and setter...
}

 

@Entity
public class Member extends BaseEntity{ //상속
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long ID;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
    private Team team;
}

 

@Entity
public class Team extends BaseEntity{ //상속
    @Id  @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}

 

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


연관관계 매핑시 고려사항 3가지

  • 다중성
  • 단방향, 양방향
  • 연관관계의 주인

 

다중성

  • 다대일: @ManyToOne
  • 일대다: @OneToMany
  • 일대일: @OneToOne
  • 다대다: @ManyToMany

 

단방향, 양방향

테이블 객체
외래 키 하나로 양쪽 조인 가능 참조용 필드가 있는 쪽으로만 참조 가능
방향이라는 개념이 없다. 한쪽만 참조하면 단방향
  양쪽이 서로 참조하면 양방향

 

 

연관 관계의 주인

  • 테이블은 외래 키 하나로 두 테이블이 연관관계를 맺음
  • 객체 양방향 관계는 A->B, B->A 처럼 참조가 두 방향
  • 객체 양방향 관계는 참조가 두 방향이 있음. 둘중 테이블의 외래 키를 관리할 곳을 지정해야 함
  • 연관관계의 주인: 외래 키를 관리하는 참조
  • 주인의 반대편: 외래 키에 영향을 주지 않음, 단순 조회만 가능(Read Only)

 

 

다대일 [N : 1]

다대일은 가장 많이 사용하는 연관관계이다.

다대일의 반대는 일대다[1: N]이다.

단방향

 

@Entity
public class Member {
    @Id
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
 private int age;

    
    @Column(name = "TEAM_ID")
    private Long teamId;

    
    @ManyToOne //Member 입장에서 Team과의 관계는 ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
	
    //getter and setter...
}

이렇게 엔티티를 설정하면 단방향 매핑(Member -> Team)이 된다.

 

양방향

외래 키가 있는 쪽이 연관관계의 주인이 되도록 설정해야 한다.

양쪽을 서로 참조하도록 개발 한 것이다.

@Entity
public class Member {
    @Id
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
 private int age;

    
    @Column(name = "TEAM_ID")
    private Long teamId;

    
    @ManyToOne //Member 입장에서 Team과의 관계는 ManyToOne
    @JoinColumn(name = "TEAM_ID") //외래키 지정
    private Team team;
	
    //getter and setter...
}

 

@Entity
public class Team {
private String name;
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "team") //Team 입장에서는 Member와 OneToMany
    List<Member> members = new ArrayList<Member>();
    
    //getter and setter...
}

Member 테이블은 연관관계의 주인이므로 아무 속성 없이 @ManyToOne 애노테이션을 사용한다.

또한, 외래키를 통해 JPA가 JOIN을 하기 때문에 @JoinColumn를 통해 외래키를 지정해주어야 한다.

연관관계 주인이 아닌 Team 테이블은 MappedBy 속성을 사용한다.(@OneToMany(mappedBy = "team"))

 

일대다 [N : 1]

단방향

  • 일대다 단방향은 일대다(1:N)에서 일(1)이 연관관계의 주인
  • 테이블 일대다 관계는 항상 다(N) 쪽에 외래 키가 있음
  • 객체와 테이블의 차이 때문에 반대편 테이블의 외래 키를 관리하는 특이한 구조
  • @JoinColumn을 꼭 사용해야 함. 그렇지 않으면 조인 테이블 방식을 사용함(중간에 테이블을 하나 추가함)

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long ID;

    @Column(name = "USERNAME")
    private String username;

	//getter and setter..
}

 

@Entity
public class Team {
    @Id  @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
	
    //getter and setter...
}

 

public class JpaMain {

    public static void main(String[] args) {

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

        try {
            Member member = new Member();
            member.setUsername("member1");

            em.persist(member);

            Team team = new Team();
            team.setName("teamA");
            team.getMembers().add(member); //UPDATE 쿼리 생성

            em.persist(team);

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

    }
}

일대다 단방향 매핑의 단점

  • 엔티티가 관리하는 외래 키가 다른 테이블에 있음
  • 연관관계 관리를 위해 추가로 UPDATE SQL 실행
  • 일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용하는 것이 좋다.

 

양방향

이런 매핑은 공식적으로 존재하지 않는다.

@JoinColumn(insertable=false, updatable=false)

읽기 전용 필드를 사용해서 양방향 처럼 사용하는 방법

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long ID;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
    private Team team;

}

 

@Entity
public class Team {
    @Id  @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}

양쪽 엔티티를 연관관계의 주인으로 설정하고 한쪽을 읽기 전용으로 설정하는 방식이다.

 

결론은 다대일 양방향을 사용해야한다.

 

일대일 [1 : 1]

일대일 관계는 그 반대도 일대일

주 테이블이나 대상 테이블 중에 외래 키 선택 가능

  • 주 테이블에 외래 키
  • 대상 테이블에 외래 키

외래 키에 데이터베이스 유니크(UNI) 제약조건 추가

 

단방향

다대일(@ManyToOne) 단방향 매핑과 유사

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long ID;

    @Column(name = "USERNAME")
    private String username;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
}

 

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;

    private String name;
}

 

연관관계의 주인이 아닌 테이블 즉, 대상 테이블에 외래키 단방향은 존재하지 않는다.

 

 

양방향

CASE 1

다대일 양방향 매핑 처럼 외래 키가 있는 곳이 연관관계의 주인

반대편은 mappedBy 적용

@Entity
public class Member {
    @Id @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long ID;

    @Column(name = "USERNAME")
    private String username;

    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
}

 

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;

    private String name;
    
    @OneToOne(mappedBy = "locker")
    private Member member;
}
  • 주 객체가 대상 객체의 참조를 가지는 것 처럼
  • 주 테이블에 외래 키를 두고 대상 테이블을 찾음
  • 객체지향 개발자 선호
  • JPA 매핑 편리
  • 장점: 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능
  • 단점: 값이 없으면 외래 키에 null 허용

 

 

CASE 2

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;

    private String name;
    
    @OneToOne(mappedBy = "locker")
    private Member member;
}

 

@Entity
public class Locker {
    @Id @GeneratedValue
    private Long id;

    private String name;
    
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Member member;
}

 

  • 대상 테이블에 외래 키가 존재
  • 전통적인 데이터베이스 개발자 선호
  • 장점: 주 테이블과 대상 테이블을 일대일에서 일대다 관계로 변경할 때 테이블 구조 유지
  • 단점: 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩됨

 

다대다 [N : M]

다대다 매핑은 사용해선 안된다.

따라서 중간 테이블을 두고 @OneToMany, @ManyToOne 를 활용해서 매핑해야 한다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


객체 지향 프로그래밍(OOP) 패러다임과 관계형 데이터베이스(RDB) 패러다임 간의 불일치는 종종 객체-관계 불일치(O/R Impedance Mismatch)라고 불린다.

이는 객체 모델과 관계형 데이터 모델 간의 구조적 차이에서 비롯된다. 이러한 불일치로 인해 양방향 매핑을 구현할 때 다양한 문제와 고려사항이 발생한다.

 

  • 객체 모델은 상속을 자연스럽게 지원하지만, 관계형 데이터베이스에서는 이를 직접적으로 지원하지 않는다.
  • 객체 모델에서는 객체 간의 연관관계를 직접 참조로 표현할 수 있지만, 관계형 데이터베이스에서는 외래 키(Foreign Key)를 사용해 연관관계를 표현해야 한다.
  • 객체는 참조 동등성을 사용하지만, 데이터베이스에서는 기본 키를 사용하여 행의 동등성을 판단한다.
  • 객체 모델에서는 필요한 데이터를 즉시 로딩할 수 있지만, 데이터베이스에서는 조인을 통해 데이터를 가져와야 한다.

 

이러한 이유때문에 객체지향 프로그래밍에서 객체 그래프 탐색을 할 수 없다.

즉, 객체지향다운 프로그래밍을 할 수 없다.

JPA 는 이를 해결해주는 매커니즘을 제공한다.

 

연관관계의 주인

  • 객체의 두 관계 중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래 키를 관리(등록, 수정)
  • 주인이 아닌 쪽은 읽기만 가능
  • 주인은 MappedBy 속성 사용 불가능
  • 주인이 아니면 MappedBy 속성으로 주인 지정
  • MappedBy 속성은 양방향 연관관계일때만 사용한다.

MEMBER 테이블과 TEAM 테이블의 관계를 살펴보면, MEMBER 테이블은 N, TEAM 테이블은 1의 관계이다.

즉 Member 테이블과 TEAM 테이블은 1:N 관계이다.

멤버 한명은 하나의 팀에 소속될 수 있지만, 하나의 팀은 여러 멤버가 소속될 수 있다.

 

여기서 연관관계의 주인은 Member 테이블로 정해야 한다.

  • 외래 키가 있는 곳으로 주인으로 정해야 함.
  • 즉, N의 관계가 있는 곳을 주인으로 정해야 한다.

이와 같은 기준으로 연관관계의 주인을 정해야한다.

 

 

 

단방향 연관관계

JPA에서 단방향 연관관계 매핑은 엔티티 간의 관계를 설정하는 방식 중 하나로, 한쪽 엔티티만 다른 엔티티를 참조하는 구조이다.

@Entity
public class Member {
    @Id
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
 private int age;

    
    @Column(name = "TEAM_ID")
    private Long teamId;

    
    @ManyToOne //Member 입장에서 Team과의 관계는 ManyToOne
    @JoinColumn(name = "TEAM_ID", nullable = false)
    private Team team;
	
    //getter and setter...
}

@ManyToOne 관계는 여러 개의 엔티티가 하나의 엔티티와 관계를 맺는 경우에 사용된다.

MEMBER 테이블이 연관관계의 주인이므로 Member 클래스에 @ManyToOne 애노테이션을 추가했다.

연관관계 주인이 아닌 Team 클래스에는 단방향 연관관계에서는 @ManyToOne 애노테이션을 추가하지 않는다.

 

이렇게 설정하면 member 객체에서 team 객체로 단방향 객체 그래프 탐색을 할 수 있다.

//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);

//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeam(team); //단방향 연관관계 설정, 참조 저장
em.persist(member);

//조회
Member findMember = em.find(Member.class, member.getId());

//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();

// 새로운 팀B

Team teamB = new Team();
teamB.setName("TeamB");
em.persist(teamB);

// 회원1에 새로운 팀B 설정
member.setTeam(teamB); //단방향 연관관계 설정, 참조 저장

 

양방향 연관관계

양방향 매핑은 두 개의 엔티티가 서로를 참조하는 관계를 말한다.

객체 모델에서는 서로를 참조하는 두 객체가 있을 수 있지만, 데이터베이스에서는 이러한 관계를 설정하기 위해 외래 키를 사용해야 한다.

양방향 매핑을 통해 두 엔티티 간의 탐색이 양방향으로 가능하게 된다.

 

양방향 매핑을 통한 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개이다.

객체를 양방향으로 참조하려면 단방향 연관 관계를 2개 만들어야 하기 때문이다.

 

객체 연관관계 = 2개

  • 회원 -> 팀 연관관계 1개(단방향)
  • 팀 -> 회원 연관관계 1개(단방향)

 

테이블 연관관계 = 1개

  • 회원 <-> 팀의 연관관계 1개(양방향)

 

 

양방향 매핑 규칙

  • 객체의 두 관계 중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래 키를 관리(등록, 수정)
  • 주인이 아닌 쪽은 읽기만 가능
  • 주인은 MappedBy 속성 사용 불가능
  • 주인이 아니면 MappedBy 속성으로 주인 지정
  • MappedBy 속성은 양방향 연관관계일때만 사용한다.

 

@Entity
public class Member {
    @Id
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
 private int age;

    
    @Column(name = "TEAM_ID")
    private Long teamId;

    
    @ManyToOne //Member 입장에서 Team과의 관계는 ManyToOne
    @JoinColumn(name = "TEAM_ID") //외래키 지정
    private Team team;
	
    //getter and setter...
}

 

@Entity
public class Team {
private String name;
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "team") //Team 입장에서는 Member와 OneToMany
    List<Member> members = new ArrayList<Member>();
    
    //getter and setter...
}

Member 테이블은 연관관계의 주인이므로 아무 속성 없이 @ManyToOne 애노테이션을 사용한다.

또한, 외래키를 통해 JPA가 JOIN을 하기 때문에 @JoinColumn를 통해 외래키를 지정해주어야 한다.

연관관계 주인이 아닌 Team 테이블은 MappedBy 속성을 사용한다.(@OneToMany(mappedBy = "team"))

 

 

양방향 매핑시 많이 하는 실수

연관관계의 주인에 값을 입력하지 않음.

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setName("member1");

//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);

TEAM 클래스는 연관관계의 주인이 아니다.

따라서 주인이 아닌 쪽은 읽기만 가능하기 때문에 제대로 입력이 되지 않는다.

따라서 양방향 매핑시 아래의 코드와 같이 연관관계의 주인에 값을 입력해야 한다.

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setName("member1");

member.setTeam(team); //연관관계의 주인에 값 설정
em.persist(member);

 

주의 사항

양쪽 모두 값 설정

순수 객체 상태를 고려해서 양방향 매핑시 주인인 쪽과 주인이 아닌 쪽 모두 값을 설정해주는 것이 좋다.

Team team = new Team();
team.setName("TeamA");
em.persist(team);

Member member = new Member();
member.setName("member1");

team.getMembers().add(member); //역방향(주인이 아닌 방향)만 연관관계 설정
member.setTeam(team); //연관관계의 주인에 값 설정
em.persist(member);

 

양쪽 모두 값을 설정해주는 것을 편하게 해주는 연관 관계 편의 메서드를 이용하면 좋다.

편의 메서드는 두 방향 어느곳에 만들어도 상관없지만, 한 방향에만 만들어야 한다.

즉, 두 클래스 모두 편의 메서드를 만들면 안되고, 둘 중 하나의 클래스에만 있어야 한다.

 

Member 클래스에 편의 메서드를 만들 시

@Entity
public class Member {
    @Id
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
    
    private int age;
    
    @Column(name = "TEAM_ID")
    private Long teamId;

    
    @ManyToOne //Member 입장에서 Team과의 관계는 ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    //편의 메서드 정의
    public void addTeam(Team team){
        this.team = team;
        team.getMembers().add(this);
    }
	
    //getter and setter...
}

 

Team 클래스에 편의 메서드를 만들 시

@Entity
public class Team {
private String name;
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "team")
    List<Member> members = new ArrayList<Member>();
    
    
    //편의 메서드 정의
    public void addMember(Member member){
    	member.setTeam = this;
        this.members.add(member);
    }
    
    //getter and setter...
}

 

무한루프 조심

양방향 매핑시에 무한 루프를 조심해야 한다.

양방향 매핑을 사용할 때 무한 루프가 발생할 수 있는 이유는, 두 엔티티가 서로를 참조할 때 객체를 직렬화하거나 문자열로 변환하는 과정에서 무한 재귀 호출이 발생할 수 있기 때문이다. 이 문제는 특히 toString() 메서드, Lombok, JSON 생성 라이브러리에서 자주 발생한다.

 

  • 양방향 매핑을 사용하는 엔티티 클래스에서 toString() 메서드를 오버라이드할 때, 양쪽 엔티티가 서로를 참조하는 필드를 포함하면 무한 루프가 발생할 수 있다.
  • Lombok을 사용하여 @ToString 어노테이션을 적용하면, 자동으로 toString() 메서드를 생성한다. 양방향 관계 필드를 포함한 toString() 메서드가 생성되면 동일한 문제가 발생할 수 있다.
  • Jackson과 같은 JSON 생성 라이브러리는 객체를 JSON으로 직렬화할 때 양방향 관계를 처리할 수 없다. 양쪽 엔티티가 서로를 참조하고 있으면 무한 루프가 발생할 수 있다.
  • 일반적으로 스프링 애플리케이션에서 엔티티(Entity)를 JSON으로 변환할 때 DTO(Data Transfer Object)를 사용하는 것이 좋은 방법이다. 이는 여러 가지 이유로 권장되며, 특히 양방향 연관 관계와 같은 문제를 해결하고, 보안 및 성능 측면에서도 많은 이점을 제공한다.

 

양방향 매핑 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료되었다.
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐이다.
  • JPQL에서 역방향으로 탐색할 일이 많다.
  • 그렇기 때문에 최대한 단방향 매핑을 이용하고, 양방향은 필요할 때 추가하면 된다.
  • 연관 관계의 주인은 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안되고, 외래 키의 위치를 기준으로 정해야 한다.

'Java Category > JPA' 카테고리의 다른 글

[JPA] 프록시(Proxy)  (0) 2024.07.26
[JPA] 상속관계 매핑  (0) 2024.07.25
[JPA] 다양한 연관관계 매핑  (5) 2024.07.24
[JPA] 엔티티 매핑(Entity Mapping)  (1) 2024.07.16
[JPA] 영속성 컨텍스트(Persistence Context)  (0) 2024.07.15

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


데이터베이스 스키마 자동 생성

application.properties

spring.jpa.hibernate.ddl-auto=create
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.hibernate.ddl-auto=update
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.hibernate.ddl-auto=none

create

  • 설명: 기존 테이블을 삭제한 후 다시 생성한다. (DROP + CREATE)
  • 사용 예시: 개발 초기 단계에서 데이터베이스 스키마를 자주 변경할 때 사용된다. 기존 데이터를 모두 삭제하고 테이블을 새로 생성하기 때문에 주의가 필요하다.

 

create-drop

  • 설명: create와 같지만, 애플리케이션 종료 시점에 테이블을 삭제한다.
  • 사용 예시: 테스트 환경에서 애플리케이션 실행 중에만 테이블을 유지하고 종료 시 데이터를 삭제하고 싶을 때 사용된다

 

update

  • 설명: 변경된 부분만 반영한다. (운영 데이터베이스에는 사용하지 않음)
  • 사용 예시: 개발 또는 테스트 단계에서 엔티티의 변경 사항을 데이터베이스 테이블에 반영할 때 사용된다. 운영 환경에서는 데이터 손실이나 예상치 못한 오류를 방지하기 위해 사용하지 않는 것이 좋다.

 

validate

  • 설명: 엔티티와 테이블이 정상 매핑되었는지 확인한다.
  • 사용 예시: 데이터베이스 스키마가 애플리케이션의 엔티티 클래스와 일치하는지 검증할 때 사용된다. 스키마 변경 없이 검증만 수행하므로 안전하다.

 

none

  • 설명: 아무 작업도 하지 않는다.
  • 사용 예시: 데이터베이스 스키마를 자동으로 변경하거나 검증하지 않고, 수동으로 관리하고 싶을 때 사용된다.

 

운영 서버에서는 절대 create, create-drop, update를 사용하면 안된다.

-개발 초기 단계에는 create 또는 update 사용
-테스트 서버는 update 또는 validate 사용
-스테이징과 운영 서버는 validate 또는 none 사용

 

 

객체와 테이블 매핑

@Entity

@Entity 어노테이션은 해당 클래스가 JPA 엔티티임을 명시한다. 엔티티 클래스는 데이터베이스 테이블에 매핑되며, 각 인스턴스는 테이블의 행을 나타낸다.

JPA를 사용해서 테이블과 매핑할 클래스는 @Entity가 필수이다.

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class MyEntity {
    @Id
    private Long id;

    private String name;

    public MyEntity(){ //기본 생성자
    }
    // getters and setters
}

 

주의 사항

  • 엔티티 클래스는 반드시 기본 키(primary key)를 지정해야 하며, 이를 위해 @Id 어노테이션을 사용한다.
  • 기본 생성자가 필수적이다.(파라미터가 없는 public 또는 protected 생성자)
  • final 클래스, enum, interface, inner 클래스에는 적용할 수 없다.
  • @Entity 애노테이션이 있는 클래스의 저장할 필드에는 final 접근제한자를 사용할 수 없다.

 

@Table

@Table 어노테이션은 엔티티 클래스를 특정 데이터베이스 테이블과 매핑하는 데 사용된다. 주로 테이블 이름이나 스키마를 지정하는 데 사용된다.

@Table 어노테이션을 사용하여 엔티티가 매핑될 데이터베이스 테이블의 이름을 지정할 수 있다.

schema 속성을 사용하여 테이블이 속한 스키마를 지정할 수 있다.

테이블 이름과 클래스 이름이 다를 경우, @Table 어노테이션을 사용하여 명시적으로 매핑해야 한다.

import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.Id;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;

@Entity
@Table(name = "employees", schema = "hr")
public class Employee {
    @Id
    private Long id;

    private String firstName;
    private String lastName;
    private String email;

    // getters and setters
}

이 예제에서 Employee 클래스는 employees 테이블에 매핑되며, 해당 테이블은 hr 스키마에 속해 있다.

  • @Entity: Employee 클래스가 JPA 엔티티임을 명시한다.
  • @Table(name = "employees", schema = "hr"): Employee 엔티티가 hr 스키마의 employees 테이블에 매핑됨을 지정한다.
  • @Id: id 필드를 기본 키로 지정한다.

 

필드와 컬럼 매핑

@Column

Column 어노테이션은 JPA(Java Persistence API)에서 엔티티 클래스의 필드를 데이터베이스 테이블의 컬럼에 매핑하는 데 사용된다. 이 어노테이션을 통해 필드와 컬럼 간의 세부 매핑을 설정할 수 있다.

 

@Column 어노테이션은 주로 필드 이름, 데이터 타입, 크기, 제약 조건 등을 명시적으로 지정할 때 사용된다.

 

  • name: 매핑할 데이터베이스 컬럼의 이름을 지정한다.
  • nullable: 컬럼이 NULL 값을 가질 수 있는지 여부를 지정한다. 기본값은 true이다.
  • unique: 컬럼 값의 고유성을 지정한다. 기본값은 false이다.
  • length: 문자열 컬럼의 길이를 지정한다. 기본값은 255이다.
  • precision: 소수점 숫자 타입의 전체 자릿수를 지정한다.
  • scale: 소수점 이하의 자릿수를 지정한다.
  • insertable : 이 속성이 true라면, 해당 필드에 대해 INSERT 쿼리문이 무시된다.
  • updatable : 이 속성이 true라면, 해당 필드에 대해 UPDATE 쿼리문이 무시된다.
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.Id;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Column;

@Entity
@Table(name = "employees")
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "first_name", nullable = false, length = 50)
    private String firstName;

    @Column(name = "last_name", nullable = false, length = 50)
    private String lastName;

    @Column(name = "email", unique = true, length = 100)
    private String email;

    // getters and setters
}

 

@Temporal

@Temporal 어노테이션은 자바의 날짜/시간 타입인 java.util.Date 또는 java.util.Calendar를 데이터베이스의 특정 타입으로 매핑할 때 사용된다.

JPA는 날짜와 시간을 여러 가지 형식으로 저장할 수 있는데, @Temporal을 사용하여 이를 명시적으로 지정할 수 있다.

 

  • TemporalType.DATE: 날짜 부분만 저장 (yyyy-MM-dd)
  • TemporalType.TIME: 시간 부분만 저장 (HH:mm:ss)
  • TemporalType.TIMESTAMP: 날짜와 시간 모두 저장 (yyyy-MM-dd HH:mm:ss.SSS)
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import java.util.Date;

@Entity
public class Event {
    @Id
    private Long id;

    @Temporal(TemporalType.DATE)
    private Date eventDate;

    @Temporal(TemporalType.TIME)
    private Date eventTime;

    @Temporal(TemporalType.TIMESTAMP)
    private Date eventTimestamp;

    // getters and setters
}

위 예시에서 eventDate는 날짜만 저장되고, eventTime은 시간만 저장되며, eventTimestamp는 날짜와 시간을 모두 저장한다.

 

@Enumerated

@Enumerated 어노테이션은 자바의 열거형 타입(enum)을 데이터베이스에 저장할 때 사용된다. 이 어노테이션을 사용하여 열거형이 데이터베이스에 어떻게 저장될지를 명시할 수 있다.

 

  • EnumType.ORDINAL: 열거형의 순서 값(정수)으로 저장된다. -> 사용 자제
  • EnumType.STRING: 열거형의 이름(문자열)으로 저장된다.
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Enumerated;
import javax.persistence.EnumType;

@Entity
public class User {
    @Id
    private Long id;

    @Enumerated(EnumType.STRING)
    private UserRole role;

    // getters and setters

    public enum UserRole {
        ADMIN,
        USER,
        GUEST
    }
}

위 예시에서 role 필드는 열거형 UserRole을 사용하며, 데이터베이스에는 문자열로 저장된다. 따라서 ADMIN, USER, GUEST 값이 그대로 저장된다.

 

@Lob

@Lob(Large Object) 어노테이션은 대용량 데이터(텍스트 또는 바이너리 데이터)를 데이터베이스에 저장할 때 사용된다. 보통 BLOB(Binary Large Object) 또는 CLOB(Character Large Object) 타입을 사용한다.

 

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

 

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

 

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Lob;

@Entity
public class Article {
    @Id
    private Long id;

    @Lob
    private String content;

    // getters and setters
}

위 예시에서 content 필드는 대용량 텍스트 데이터로 저장된다.

 

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Lob;

@Entity
public class Image {
    @Id
    private Long id;

    @Lob
    private byte[] data;

    // getters and setters
}

위 예시에서 data 필드는 대용량 바이너리 데이터로 저장된다.

 

@Transient

@Transient 어노테이션은 특정 필드를 데이터베이스 테이블에 매핑하지 않도록 지정한다.

이 필드는 JPA에 의해 무시되며, 데이터베이스에 저장되지 않는다.

주로 메모리상에서만 임시로 어떤 값을 보관하고 싶을 때 사용

 

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Transient;

@Entity
public class Product {
    @Id
    private Long id;

    private String name;

    @Transient
    private int tempPrice;

    // getters and setters
}

위 예시에서 tempPrice 필드는 데이터베이스에 저장되지 않는다. 이는 프로그램 내에서만 사용되는 임시 데이터이다.

 

기본 키 매핑

 

시퀀스 전략과 아이덴티티 전략은 데이터베이스에서 기본 키(primary key)를 생성하는 방법으로, 각각의 차이점과 사용 상황을 이해하는 것이 중요하다.

 

Sequence Strategy와 Identity Strategy

Sequence Strategy

시퀀스 전략은 데이터베이스 시퀀스를 이용해 기본 키 값을 생성하는 방법이다. 시퀀스는 데이터베이스 객체로, 연속적인 숫자 값을 생성한다. 주로 Oracle, PostgreSQL 등에서 사용된다.

 

장점

  • 시퀀스 객체는 데이터베이스 내부에서 최적화되어 있어 빠르게 고유 값을 생성할 수 있다.
  • 여러 테이블에서 같은 시퀀스를 공유하여 기본 키를 생성할 수 있다.

 

dentity Strategy

아이덴티티 전략은 기본 키 값을 자동으로 증가시키는 방법이다. 주로 MySQL, SQL Server, DB2 등에서 사용된다. 이 전략은 데이터베이스가 새로운 행이 삽입될 때마다 자동으로 고유 키 값을 생성하도록 한다.

장점

  • 별도의 시퀀스 객체를 생성할 필요 없이, 테이블의 컬럼에 자동 증가 속성을 설정하면 된다.
  • 대부분의 데이터베이스에서 지원하는 기능을 활용하므로 추가 설정이 필요 없다.

 

차이점

시퀀스 객체의 사용

  • 시퀀스 전략은 데이터베이스 시퀀스 객체를 사용한다.
  • 아이덴티티 전략은 테이블의 자동 증가 속성을 사용한다.

 

다양한 데이터베이스 지원

  • 시퀀스 전략은 Oracle, PostgreSQL 등에서 주로 사용된다.
  • 아이덴티티 전략은 MySQL, SQL Server 등에서 주로 사용된다.


성능과 활용도

  • 시퀀스 전략은 성능이 우수하고 여러 테이블에서 공유할 수 있다.
  • 아이덴티티 전략은 설정이 간단하고 데이터베이스의 기본 기능을 사용한다.

 

@Id

@Id 어노테이션은 엔티티 클래스의 필드를 데이터베이스 테이블의 기본 키로 지정한다. 기본 키는 테이블의 각 행을 고유하게 식별하는 데 사용된다.

기본 키를 직접 할당할 때 사용된다.

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class MyEntity {
    @Id
    private Long id;

    // getters and setters
}

위 예시에서 id 필드는 엔티티의 기본 키로 지정되었다.

 

@GeneratedValue

@GeneratedValue 어노테이션은 기본 키의 값을 자동으로 생성하는 전략을 지정한다.

이 어노테이션은 @Id와 함께 사용되며, 다양한 키 생성 전략을 설정할 수 있다.

  • strategy: 기본 키 생성 전략을 지정한다.
  • generator: 시퀀스 생성기나 테이블 생성기의 이름을 지정한다.

 

Identity 전략

GenerationType.AUTO: JPA 구현체가 데이터베이스에 맞는 전략을 자동으로 선택한다.

@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

 

GenerationType.IDENTITY: 데이터베이스의 IDENTITY 열을 사용하여 기본 키 값을 자동으로 생성한다.

  • 주로 MySQL, SQL Server 등에서 사용된다.
  • JPA는 보통 트랜잭션 커밋 시점에 INSERT SQL 실행
  • 하지만 IDENTITY 전략은 em.persist() 시점에 즉시 INSERT SQL 실행 하고 DB에서 식별자를 조회
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

 

Sequence 전략

데이터베이스 시퀀스는 유일한 값을 순서대로 생성하는 특별한 데이터베이스 오브젝트

@SequenceGenerator

@SequenceGenerator는 데이터베이스 시퀀스를 사용하여 기본 키 값을 생성할 때 사용된다. 시퀀스는 주로 Oracle, PostgreSQL과 같은 데이터베이스에서 지원한다.

  • name: JPA에서 사용할 시퀀스 생성기의 이름이다.
  • sequenceName: 데이터베이스 시퀀스의 실제 이름이다.
  • initialValue: 시퀀스의 초기 값이다.
  • allocationSize: 시퀀스 값의 증가 크기이다.
    - 미리 50개의 시퀀스를 DB에서 확보하고 값이 추가될 때 마다 1개씩 메모리에서 가져와 성능 개선을 하기 위함
    - 동시성 이슈가 없다.
데이터베이스 시퀀스 값이 하나씩 증가하도록 설정된 경우 1로 설정

allocationSize
JPA에서 시퀀스를 사용하는 동안 메모리 내에서 미리 할당할 시퀀스 값의 개수를 지정한다. 기본값은 50이다. 즉, 한 번의 데이터베이스 호출로 50개의 시퀀스 값을 미리 할당받아 성능을 향상시킨다.

데이터베이스 시퀀스
데이터베이스 시퀀스는 일반적으로 시퀀스 값이 호출될 때마다 하나씩 증가하도록 설정된다. 예를 들어, INCREMENT BY 1로 설정된다.

JPA는 메모리 내에서 여러 시퀀스 값을 미리 할당받아 사용하지만, 데이터베이스 시퀀스는 여전히 하나씩 증가한다. 이로 인해 두 시스템 간에 불일치가 발생할 수 있다.

그러므로 아래와 같이 설정된 경우 allocationSize 를 1로 설정해야한다.
CREATE SEQUENCE MEMBER_SEQ
    START WITH 1
    INCREMENT BY 1;​


성능을 향상시키기 위해서(allocationSize = 50)는 아래와 같이 설정해야 한다.

CREATE SEQUENCE MEMBER_SEQ
    START WITH 1
    INCREMENT BY 50;

 

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.SequenceGenerator;

@Entity
@SequenceGenerator(
    name = "MEMBER_SEQ_GENERATOR",
    sequenceName = "MEMBER_SEQ",  // 매핑할 데이터베이스 시퀀스 이름
    initialValue = 1, 
    allocationSize = 1
)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
    private Long id;
    
    private String name;

    // getters and setters
}

 

 

GenerationType.SEQUENCE

 

데이터베이스 시퀀스를 사용하여 기본 키 값을 생성한다. 시퀀스 이름은 generator 속성으로 지정

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
private Long id;

 

두 어노테이션의 결합 사용

CREATE SEQUENCE MEMBER_SEQ
    START WITH 1
    INCREMENT BY 50;

 

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.SequenceGenerator;

@Entity
@SequenceGenerator(
    name = "MEMBER_SEQ_GENERATOR",
    sequenceName = "MEMBER_SEQ",  // 매핑할 데이터베이스 시퀀스 이름
    initialValue = 1, 
    allocationSize = 50
)
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "MEMBER_SEQ_GENERATOR")
    private Long id;
    
    private String name;

    // getters and setters
}
  • @SequenceGenerator 어노테이션은 시퀀스 생성기를 정의한다. MEMBER_SEQ_GENERATOR라는 이름으로 생성기를 정의하고, 데이터베이스의 MEMBER_SEQ 시퀀스와 매핑한다.
  • initialValue는 시퀀스의 초기 값이며, allocationSize는 시퀀스가 증가하는 크기이다.
  • @GeneratedValue 어노테이션은 strategy를 GenerationType.SEQUENCE로 설정하고, generator 속성에 MEMBER_SEQ_GENERATOR를 지정하여 @SequenceGenerator에서 정의한 시퀀스 생성기를 사용한다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


JPA 구동 방식

엔티티 매니저 팩토리 (EntityManagerFactory)

정의

  • EntityManagerFactory는 JPA 애플리케이션에서 EntityManager 인스턴스를 생성하기 위한 팩토리이다.

 

특징

  • 비용이 많이 드는 객체: 생성하는 데 많은 리소스를 사용하므로 애플리케이션 전체에서 한 번만 생성하고 공유하는 것이 일반적이다.
  • 애플리케이션 전체에서 공유: 여러 스레드에서 동시에 사용될 수 있다.
  • 생명 주기: 애플리케이션 시작 시 생성되고, 애플리케이션 종료 시 닫힌다.

 

 

엔티티 매니저 (EntityManager)

정의

  • EntityManager는 엔티티의 생명 주기(Life Cycle)를 관리하고, 데이터베이스 작업을 수행하는 객체이다.

 

특징

  • 데이터베이스 연결 관리: 데이터베이스와의 연결을 관리하고, 쿼리 실행, 트랜잭션 관리 등의 작업을 수행한다.
  • 영속성 컨텍스트: 엔티티 인스턴스들을 관리하는 영속성 컨텍스트(Persistence Context)를 제공한다. 영속성 컨텍스트는 엔티티의 상태 변화를 추적하고 데이터베이스와 동기화한다.
  • 스레드 안전하지 않음: EntityManager는 스레드에 안전하지 않으므로 각 트랜잭션 또는 요청마다 새로운 인스턴스를 생성하여 사용해야 한다.
  • 생명 주기: 일반적으로 짧은 생명 주기를 가지며, 각 트랜잭션마다 생성되고 종료된다.

 

주요 메서드

  • persist(Object entity): 엔티티를 영속성 컨텍스트에 저장한다.
  • merge(Object entity): 준영속 상태의 엔티티를 영속성 컨텍스트로 병합한다.
  • remove(Object entity): 엔티티를 영속성 컨텍스트에서 제거한다.
  • find(Class<T> entityClass, Object primaryKey): 기본 키로 엔티티를 조회한다.
  • createQuery(String qlString): JPQL 쿼리를 생성한다.
  • getTransaction(): 현재 트랜잭션을 반환한다.

 

객체와 테이블을 생성하고 매핑하기
create table Member (
	id bigint not null,
	name varchar(255),
	primary key (id)
);​

 

package hellojpa;
import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class Member {
    @Id
    private Long id;
    private String name;
    
    //Getter, Setter …
}

@Entity 어노테이션은 해당 클래스가 JPA 엔티티임을 명시한다.

-클래스 선언부에 @Entity 어노테이션을 추가하여 해당 클래스가 엔티티임을 정의한다.
-엔티티 클래스는 반드시 @Entity 어노테이션을 가져야 하며, public 또는 protected로 선언된 기본 생성자가 있어야 한다.
-@Entity가 붙은 클래스는 반드시 @Id로 표시된 기본 키 필드를 가져야 한다.
-클래스 이름이 기본적으로 데이터베이스 테이블 이름으로 사용되지만, @Table 어노테이션을 사용하여 테이블 이름을 명시적으로 지정할 수 있다.


@Id 어노테이션은 엔티티의 기본 키를 정의하는 데 사용된다. 

-엔티티 클래스의 필드나 메서드에 @Id 어노테이션을 추가하여 해당 필드가 기본 키임을 정의한다.
-모든 JPA 엔티티는 하나 이상의 @Id 어노테이션을 가져야 하며, 이를 통해 기본 키를 지정해야 한다.
-기본 키의 값을 자동으로 생성하기 위해 @GeneratedValue 어노테이션과 함께 사용할 수 있다. @GeneratedValue는 기본 키 생성 전략을 지정하는 데 사용된다.

 

주의 사항

-엔티티 매니저 팩토리는 하나만 생성해서 애플리케이션 전체에서 공유해야 한다.
- 엔티티 매니저는 쓰레드간에 공유를 하면 안된다. (공유를 하게되면 서비스의 장애를 일으킬 수 있다.)
- JPA의 모든 데이터 변경은 트랜잭션 안에서 실행된다.

 

영속성 컨텍스트

-"엔티티를 영구 저장하는 환경" 이라는 뜻

-EntityManager.persist(entity);

-영속성 컨텍스트는 논리적인 개념이다. 엔티티 매니저를 통해서 영속성 컨텍스트에 접근할 수 있다.

 

스프링 프레임워크 같은 컨테이너 환경에서는 엔티티 매니저와 영속성 컨텍스트가 N:1 의 관계이다.

 

엔티티의 생명주기

비영속 (Transient)

정의: 비영속 상태는 엔티티가 아직 영속성 컨텍스트에 의해 관리되지 않는 상태를 말한다.

 

특징

  • 데이터베이스와 전혀 관련이 없다.
  • 엔티티 매니저에 의해 관리되지 않는다.
  • 영속성 컨텍스트에 포함되지 않기 때문에 영속성 컨텍스트의 기능(변경 감지, 쓰기 지연 등)을 사용할 수 없다.
Member member = new Member();
member.setId(1L);
member.setName("Lee");

 

 

영속 (Persistent)

정의: 영속 상태는 엔티티가 영속성 컨텍스트에 의해 관리되는 상태를 말한다.

 

특징

  • 영속성 컨텍스트의 1차 캐시에 저장된다.
  • 트랜잭션을 커밋하거나 flush()를 호출하면 변경 사항이 데이터베이스에 반영된다.
  • 변경 감지(dirty checking) 기능이 적용되어 엔티티의 변경 사항이 자동으로 반영된다.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

Member member = new Member();
member.setId(1L);
member.setName("Lee");
em.persist(member); // 엔티티를 영속성 컨텍스트에 저장

em.getTransaction().commit();

 

 

준영속 (Detached)

정의: 준영속 상태는 엔티티가 한 번 영속 상태였으나 현재는 영속성 컨텍스트에 의해 더 이상 관리되지 않는 상태를 말한다.

 

특징

  • 영속성 컨텍스트가 닫히거나, detach(), clear(), close() 메서드가 호출되면 엔티티가 준영속 상태로 전환된다.
  • 영속성 컨텍스트의 기능(변경 감지, 쓰기 지연 등)을 사용할 수 없다.

 

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

Member member = new Member();
member.setId(1L);
member.setName("Lee");
em.persist(member); // 엔티티를 영속성 컨텍스트에 저장
em.detach(member); // 엔티티를 준영속 상태로 전환

em.getTransaction().commit();
  • em.detach(entity) : 특정 엔티티만 준영속 상태로 전환
  • em.clear() : 영속성 컨텍스트를 완전히 초기화
  • em.close() : 영속성 컨텍스트를 종료

 

삭제 (Removed)

정의: 삭제 상태는 엔티티가 영속성 컨텍스트와 데이터베이스에서 삭제될 예정인 상태를 말한다.

 

특징

  • remove() 메서드를 호출하면 엔티티가 삭제 상태로 전환된다.
  • 트랜잭션을 커밋하면 데이터베이스에서 해당 엔티티가 삭제된다.
  • 삭제된 엔티티는 더 이상 영속성 컨텍스트에서 관리되지 않는다.
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

Member member = em.find(Member.class, 1L);
em.remove(member); // 엔티티를 삭제 상태로 전환

em.getTransaction().commit(); // 트랜잭션을 커밋하여 데이터베이스에서 삭제

 

주요 메서드

  • persist(Object entity): 엔티티를 영속성 컨텍스트에 저장한다.
  • remove(Object entity): 엔티티를 영속성 컨텍스트에서 제거한다.
  • find(Class<T> entityClass, Object primaryKey): 기본 키를 통해 엔티티를 조회한다.
  • merge(Object entity): 준영속 상태의 엔티티를 영속성 컨텍스트로 병합한다.
  • flush(): 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다.
  • clear(): 영속성 컨텍스트를 초기화하여 모든 엔티티를 준영속 상태로 만든다.

 

 

영속성 컨텍스트의 이점

엔티티 조회, 1차 캐시

    • 영속성 컨텍스트는 엔티티를 1차 캐시에 저장하여 관리한다.
    • 같은 엔티티를 반복 조회할 때, 데이터베이스를 다시 조회하지 않고 1차 캐시에서 가져온다. 이를 통해 성능을 최적화할 수 있다.

 

 

  • 만약 조회시 1차 캐시에 존재하지 않는다면, DB에서 데이터를 가져오고 자동으로 영속 컨텍스트에 저장한다.

 

영속 엔티티의 동일성 보장

  • 영속성 컨텍스트는 같은 트랜잭션 내에서 같은 식별자를 가진 엔티티에 대해 동일한 객체를 반환한다. 이는 엔티티의 동일성을 보장하여 일관된 데이터를 제공한다.

 

쓰기 지연(Write-behind)

  • 영속성 컨텍스트는 엔티티의 변경 내용을 즉시 데이터베이스에 반영하지 않고, 트랜잭션을 커밋하거나 flush를 호출할 때까지 지연시킨다.
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
//엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
transaction.begin(); // [트랜잭션] 시작

em.persist(memberA);
em.persist(memberB);
//여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.

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

 

변경 감지(Dirty Checking)

  • 영속성 컨텍스트는 엔티티의 상태 변화를 감지하고, 트랜잭션이 커밋될 때 변경된 내용을 자동으로 데이터베이스에 반영한다.
  • 이는 엔티티를 수정하고 flush 또는 commit 시 자동으로 UPDATE 쿼리를 생성하여 데이터베이스에 적용한다.
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin(); // [트랜잭션] 시작

// 영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");

// 영속 엔티티 데이터 수정
memberA.setUsername("hi");
memberA.setAge(10);

transaction.commit(); // [트랜잭션] 커밋

  • 최초로 1차 캐시에 저장될때, 스냅샷을 미리 생성하고 나중에 현재 스냅샷과 최초 스냅샷을 비교하여 변경점이 있으면 UPDATE SQL을 생성한다.

 

플러시

  • 변경 감지
  • 수정된 엔티티 쓰기 지연 SQL 저장소에 등록
  • 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송(등록, 수정, 삭제 쿼리)
  • 플러시(Flush)는 JPA에서 영속성 컨텍스트(Persistence Context)에 있는 변경 사항을 데이터베이스에 동기화하는 작업이다.

 

특징

  • 영속성 컨텍스트에서 관리되는 엔티티의 변경 사항(INSERT, UPDATE, DELETE)을 SQL 문으로 데이터베이스에 보낸다.
  • 트랜잭션이 커밋되기 전에 자동으로 발생하지만, 명시적으로 flush() 메서드를 호출하여 중간에 실행할 수도 있다.
  • 일시적 동기화: 플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하지만 트랜잭션을 종료하지는 않는다. 즉, 트랜잭션은 여전히 열려 있다.

 

플러시의 동작 시점

  • 트랜잭션 커밋: 트랜잭션을 커밋할 때 자동으로 플러시가 발생하여 변경 사항이 데이터베이스에 반영된다.
  • JPQL 쿼리 실행: JPQL 쿼리를 실행할 때 플러시가 발생하여 변경 사항이 쿼리 결과에 반영되도록 한다.
  • 명시적 호출: EntityManager의 flush() 메서드를 호출하여 명시적으로 플러시를 수행할 수 있다.

 

플러시 모드 옵션

em.setFlushMode(FlushModeType.COMMIT)
  • FlushModeType.AUTO : 커밋이나 쿼리(JPQL)를 실행할 때 플러시 (기본값)
  • FlushModeType.COMMIT : 커밋할 때만 플러시(JPQL을 작성할 때도 플러시 되지 않음)

 

주의 사항

  • 플러시는 영속성 컨텍스트를 비우지 않는다.
  • 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화한다.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


예제 프로젝트

더보기

Member

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String username;
    public Member() {
    }
    public Member(String username) {
        this.username = username;
    }
}

 

MemberRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {
    private final EntityManager em;
    @Transactional
    public void save(Member member) {
        log.info("member 저장");
        em.persist(member);
    }
    public Optional<Member> find(String username) {
        return em.createQuery("select m from Member m where m.username=:username", Member.class)
                .setParameter("username", username)
                .getResultList().stream().findAny();
    }
}

 

Log

@Entity
@Getter
@Setter
public class Log {
    @Id
    @GeneratedValue
    private Long id;
    private String message;
    public Log() {
    }
    public Log(String message) {
        this.message = message;
    }
}

 

LogRepository

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {
    private final EntityManager em;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(Log logMessage) {
        log.info("log 저장");
        em.persist(logMessage);
        if (logMessage.getMessage().contains("로그예외")) {
            log.info("log 저장시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }

    public Optional<Log> find(String message) {

        /*
        getResultList 메소드를 호출하여 쿼리의 실행 결과를 리스트로 가져온다.
        이후 Java 8의 스트림 API를 사용하여 해당 리스트를 스트림으로 변환하고, findAny 메소드를 통해 결과 리스트 중 임의의 하나를 선택한다.
        findAny는 Optional<Log> 타입을 반환
         */
        return em.createQuery("select l from Log l where l.message = :message", Log.class)
                .setParameter("message", message)
                .getResultList().stream().findAny();
    }
}

 

MemberService

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
    private final MemberRepository memberRepository;
    private final LogRepository logRepository;

    public void joinV1(String username) {
        Member member = new Member(username);
        Log logMessage = new Log(username);
        log.info("== memberRepository 호출 시작 ==");
        memberRepository.save(member);

        log.info("== memberRepository 호출 종료 ==");
        log.info("== logRepository 호출 시작 ==");
        logRepository.save(logMessage); 
        log.info("== logRepository 호출 종료 ==");
    }

    public void joinV2(String username) {
        Member member = new Member(username);
        Log logMessage = new Log(username);
        log.info("== memberRepository 호출 시작 ==");
        memberRepository.save(member);
        log.info("== memberRepository 호출 종료 ==");
        log.info("== logRepository 호출 시작 ==");

        try {
            logRepository.save(logMessage);
        } catch (RuntimeException e) {
            log.info("log 저장에 실패했습니다. logMessage={}", logMessage.getMessage());
            log.info("정상 흐름 변환");
        }
        log.info("== logRepository 호출 종료 ==");
    }
}

 

@Slf4j
@SpringBootTest
class MemberServiceTest {
    @Autowired
    MemberService memberService;
    @Autowired
    MemberRepository memberRepository;
    @Autowired
    LogRepository logRepository;

    /**
     * MemberService    @Transactional:OFF
     * MemberRepository @Transactional:ON
     * LogRepository    @Transactional:ON
     */
    @Test
    void outerTxOff_success() {
        //given
        String username = "outerTxOff_success";

        //when
        memberService.joinV1(username);

        //then: 모든 데이터가 정상 저장된다.
        assertTrue(memberRepository.find(username).isPresent());
        assertTrue(logRepository.find(username).isPresent());
    }
}

 

JPA를 통한 모든 데이터 변경(등록, 수정, 삭제)에는 트랜잭션이 필요하다. (조회는 트랜잭션 없이 가능하다.)
현재 서비스 계층에 트랜잭션이 없기 때문에 리포지토리에 트랜잭션이 있다.

 

서비스 계층에 트랜잭션이 없을 때

커밋

  • 서비스 계층에 트랜잭션이 없다.
  • 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
  • 회원, 로그 리포지토리 둘다 커밋에 성공한다.
/**
 * MemberService    @Transactional:OFF
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON
 */
@Test
void outerTxOff_success() {
    //given
    String username = "outerTxOff_success";

    //when
    memberService.joinV1(username);

    //then: 모든 데이터가 정상 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isPresent());
}

  1. MemberService 에서 MemberRepository 를 호출한다. MemberRepository 에는 @Transactional 애노테이션이 있으므로 트랜잭션 AOP가 작동한다. 여기서 트랜잭션 매니저를 통해 트랜잭션을 시작한다. 이렇게 시작한 트랜잭션을 트랜잭션B라 하자.
    -그림에서는 생략했지만, 트랜잭션 매니저에 트랜잭션을 요청하면 데이터소스를 통해 커넥션 con1 을 획득 하고, 해당 커넥션을 수동 커밋 모드로 변경해서 트랜잭션을 시작한다.
    -그리고 트랜잭션 동기화 매니저를 통해 트랜잭션을 시작한 커넥션을 보관한다.
    -트랜잭션 매니저의 호출 결과로 status 를 반환한다. 여기서는 신규 트랜잭션 여부가 참이 된다.
  2. MemberRepository 는 JPA를 통해 회원을 저장하는데, 이때 JPA는 트랜잭션이 시작된 con1 을 사용 해서 회원을 저장한다.
  3. MemberRepository 가 정상 응답을 반환했기 때문에 트랜잭션 AOP는 트랜잭션 매니저에 커밋을 요청 한다.
  4. 트랜잭션 매니저는 con1 을 통해 물리 트랜잭션을 커밋한다.
    -물론 이 시점에 앞서 설명한 신규 트랜잭션 여부, rollbackOnly 여부를 모두 체크한다.

 

이렇게 해서 MemberRepository 와 관련된 모든 데이터는 정상 커밋되고, 트랜잭션B는 완전히 종료된다.

이후에 LogRepository 를 통해 트랜잭션C를 시작하고, 정상 커밋한다.
결과적으로 둘다 커밋되었으므로 Member, Log 모두 안전하게 저장된다.

 

@Transactional과 REQUIRED
트랜잭션 전파의 기본 값은 REQUIRED 이다. 따라서 다음 둘은 같다.
-@Transactional(propagation = Propagation.REQUIRED)
-@Transactional

REQUIRED 는 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고, 기존 트랜잭션이 있으면 참여한다.

 

 

롤백

  • 서비스 계층에 트랜잭션이 없다.
  • 회원, 로그 리포지토리가 각각 트랜잭션을 가지고 있다.
  • 회원 리포지토리는 정상 동작하지만 로그 리포지토리에서 예외가 발생한다.
/**
 * MemberService    @Transactional:OFF
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON Exception
 */
@Test
void outerTxOff_fail() {
    //given
    String username = "로그예외_outerTxOff_fail";

    //when
    assertThatThrownBy(() -> memberService.joinV1(username))
            .isInstanceOf(RuntimeException.class);

    //then: 완전히 롤백되지 않고, member 데이터가 남아서 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}
  • 사용자 이름에 로그예외 라는 단어가 포함되어 있으면 LogRepository 에서 런타임 예외가 발생한다. 
  • 트랜잭션 AOP는 해당 런타임 예외를 확인하고 롤백 처리한다.

 

MemberService 에서 MemberRepository 를 호출하는 부분은 앞서 설명한 내용과 같다. 트랜잭션이 정상 커밋되고, 회원 데이터도 DB에 정상 반영된다.

MemberService 에서 LogRepository 를 호출하는데, 로그예외 라는 이름을 전달한다. 이 과정에서 새로운 트랜잭션 C가 만들어진다.

 

LogRepository 응답 로직

  1. LogRepository 는 트랜잭션C와 관련된 con2 를 사용한다.
  2. 로그예외 라는 이름을 전달해서 LogRepository 에 런타임 예외가 발생한다.
  3. LogRepository 는 해당 예외를 밖으로 던진다. 이 경우 트랜잭션 AOP가 예외를 받게된다.
  4. 런타임 예외가 발생해서 트랜잭션 AOP는 트랜잭션 매니저에 롤백을 호출한다.
  5. 트랜잭션 매니저는 신규 트랜잭션이므로 물리 롤백을 호출한다.

 

참고
트랜잭션 AOP도 결국 내부에서는 트랜잭션 매니저를 사용하게 된다.

 

단일 트랜잭션

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:OFF
 * LogRepository    @Transactional:OFF
 */
@Test
void singleTx() {
    //given
    String username = "singleTx";

    //when
    memberService.joinV1(username);

    //then: 모든 데이터가 정상 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isPresent());
}

이렇게 하면 MemberService 를 시작할 때 부터 종료할 때 까지의 모든 로직을 하나의 트랜잭션으로 묶을 수 있다.
물론 MemberService 가 MemberRepository , LogRepository 를 호출하므로 이 로직들은 같은 트랜잭션을 사용한다.

 

MemberService 만 트랜잭션을 처리하기 때문에 앞서 배운 논리 트랜잭션, 물리 트랜잭션, 외부 트랜잭션, 내부 트랜잭션, rollbackOnly , 신규 트랜잭션, 트랜잭션 전파와 같은 복잡한 것을 고민할 필요가 없다. 아주 단순하고 깔끔하게 트랜잭션을 묶을 수 있다.

 

@Transactional 이 MemberService 에만 붙어있기 때문에 여기에만 트랜잭션 AOP가 적용된다.

MemberRepository, LogRepository 는 트랜잭션 AOP가 적용되지 않는다.

 

MemberService 의 시작부터 끝까지, 관련 로직은 해당 트랜잭션이 생성한 커넥션을 사용하게 된다.

MemberService 가 호출하는 MemberRepository , LogRepository 도 같은 커넥션을 사용하면서 자연스럽게 트랜잭션 범위에 포함된다.

 

참고
같은 쓰레드를 사용하면 트랜잭션 동기화 매니저는 같은 커넥션을 반환한다.

 

 

트랜잭션 전파

각각 트랜잭션이 필요한 상황

  • 클라이언트 A는 MemberService 부터 MemberRepository, LogRepository 를 모두 하나의 트랜잭션으로 묶고 싶다.
  • 클라이언트 B는 MemberRepository 만 호출하고 여기에만 트랜잭션을 사용하고 싶다.
  • 클라이언트 C는 LogRepository 만 호출하고 여기에만 트랜잭션을 사용하고 싶다.

 

클라이언트 A만 생각하면 MemberService 에 트랜잭션 코드를 남기고, MemberRepository , LogRepository 의 트랜잭션 코드를 제거하면 깔끔하게 하나의 트랜잭션을 적용할 수 있다.

 

하지만 이렇게 되면 클라이언트 B, C가 호출하는 MemberRepository, LogRepository 에는 트랜잭션을 적용할 수 없다.

 

트랜잭션 전파 없이 이런 문제를 해결하려면 아마도 트랜잭션이 있는 메서드와 트랜잭션이 없는 메서드를 각각 만들어야 할 것이다.

 

더 복잡하게 다음과 같은 상황이 발생할 수도 있다.

클라이언트 Z가 호출하는 OrderService 에서도 트랜잭션을 시작할 수 있어야 하고, 클라이언트A가 호출하는 MemberService 에서도 트랜잭션을 시작할 수 있어야 한다.

이런 문제를 해결하기 위해 트랜잭션 전파가 필요한 것이다.

 

 

전파 커밋

@Transactional 이 적용되어 있으면 기본으로 REQUIRED 라는 전파 옵션을 사용한다.

이 옵션은 기존 트랜잭션이 없으면 트랜잭션을 생성하고, 기존 트랜잭션이 있으면 기존 트랜잭션에 참여한다. 참여한다는 뜻은 해당 트랜잭션을 그대로 따른다는 뜻이고, 동시에 같은 동기화 커넥션을 사용한다는 뜻이다.

 

이렇게 둘 이상의 트랜잭션이 하나의 물리 트랜잭션에 묶이게 되면 둘을 구분하기 위해 논리 트랜잭션과 물리 트랜잭션으로 구분한다.

  • 이 경우 외부에 있는 신규 트랜잭션만 실제 물리 트랜잭션을 시작하고 커밋한다.
  • 내부에 있는 트랜잭션은 물리 트랜잭션 시작하거나 커밋하지 않는다.
  • 모든 논리 트랜잭션을 커밋해야 물리 트랜잭션도 커밋된다. 하나라도 롤백되면 물리 트랜잭션은 롤백된다.

 

 

모든 논리 트랜잭션이 정상 커밋되는 경우

회원 리포지토리와 로그 리포지토리를 하나의 트랜잭션으로 묶는 가장 간단한 방법은 이 둘을 호출하는 회원 서비스에만 트랜잭션을 사용하는 것이다.

 /**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON
 */
@Test
void outerTxOn_success() {
    //given
    String username = "outerTxOn_success";

    //when
    memberService.joinV1(username);

    //then: 모든 데이터가 정상 저장된다.
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isPresent());
}

 

 

  • 클라이언트A(여기서는 테스트 코드)가 MemberService 를 호출하면서 트랜잭션 AOP가 호출된다.
    -여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작한다.
  • MemberRepository 를 호출하면서 트랜잭션 AOP가 호출된다.
    -이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • MemberRepository 의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    -트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
  • LogRepository 를 호출하면서 트랜잭션 AOP가 호출된다.
    -이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • LogRepository 의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    -트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋(물리 커밋)을 호출하지 않는다.
  • MemberService 의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    -트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이므로 물리 커밋을 호출한다.

 

예외가 발생해서 전체가 롤백되는 경우

로그 리포지토리에서 예외가 발생해서 전체 트랜잭션이 롤백되는 경우

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON Exception
 */
@Test
void outerTxOn_fail() {
    //given
    String username = "로그예외_outerTxOn_fail";

    //when
    assertThatThrownBy(() -> memberService.joinV1(username))
            .isInstanceOf(RuntimeException.class);

    //then: 모든 데이터가 롤백된다.
    assertTrue(memberRepository.find(username).isEmpty());
    assertTrue(logRepository.find(username).isEmpty());
}

 

  • 클라이언트A가 MemberService를 호출하면서 트랜잭션 AOP가 호출된다.
    -여기서 신규 트랜잭션이 생성되고, 물리 트랜잭션도 시작한다.
  • MemberRepository를 호출하면서 트랜잭션 AOP가 호출된다.
    -이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • MemberRepository의 로직 호출이 끝나고 정상 응답하면 트랜잭션 AOP가 호출된다.
    -트랜잭션 AOP는 정상 응답이므로 트랜잭션 매니저에 커밋을 요청한다. 이 경우 신규 트랜잭션이 아니므로 실제 커밋을 호출하지 않는다.
  • LogRepository를 호출하면서 트랜잭션 AOP가 호출된다.
    -이미 트랜잭션이 있으므로 기존 트랜잭션에 참여한다.
  • LogRepository 로직에서 런타임 예외가 발생한다. 예외를 던지면 트랜잭션 AOP가 해당 예외를 받게 된다.
    -트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청한다. 이 경우 신규 트랜잭션이 아니므로 물리 롤백을 호출하지는 않는다. 대신에 rollbackOnly를 설정한다.
    -LogRepository가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.
  • MemberService에서도 런타임 예외를 받게 되는데, 여기 로직에서는 해당 런타임 예외를 처리하지 않고 밖으로 던진다.
    -트랜잭션 AOP는 런타임 예외가 발생했으므로 트랜잭션 매니저에 롤백을 요청한다. 이 경우 신규 트랜잭션이므로 물리 롤백을 호출한다.
    -참고로 이 경우 어차피 롤백이 되었기 때문에, rollbackOnly 설정은 참고하지 않는다.
    -MemberService가 예외를 던졌기 때문에 트랜잭션 AOP도 해당 예외를 그대로 밖으로 던진다.
  • 클라이언트A는 LogRepository부터 넘어온 런타임 예외를 받게 된다.

 

회원과 회원 이력 로그를 처리하는 부분을 하나의 트랜잭션으로 묶은 덕분에 문제가 발생했을 때 회원과 회원 이력 로그가 모두 함께 롤백된다. 따라서 데이터 정합성에 문제가 발생하지 않는다.

 

 

예외가 발생해서 일부 커밋, 일부 롤백

회원 이력 로그를 DB에 남기는 작업에 가끔 문제가 발생해서 회원 가입 자체가 안되는 경우가 가끔 발생하게 되었다.

그래서 사용자들이 회원 가입에 실패해서 이탈하는 문제가 발생하기 시작했다.

회원 이력 로그의 경우 여러가지 방법으로 추후에 복구가 가능할 것으로 보인다.

 

그래서 비즈니스 요구사항이 변경되었다.
회원 가입을 시도한 로그를 남기는데 실패하더라도 회원 가입은 유지되어야 한다.

 

  • 단순하게 생각해보면 LogRepository 에서 예외가 발생하면 그것을 MemberService 에서 예외를 잡아서 처리하면 될 것 같다.
  • 이렇게 하면 MemberService 에서 정상 흐름으로 바꿀 수 있기 때문에 MemberService 의 트랜잭션 AOP에서 커밋을 수행할 수 있다.
  • 하지만 이렇게 하면 안된다.

 

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional:ON Exception
 */
@Test
void recoverException_fail() {
    //given
    String username = "로그예외_recoverException_fail";

    //when
    assertThatThrownBy(() -> memberService.joinV2(username))
            .isInstanceOf(UnexpectedRollbackException.class);

    //then: 모든 데이터가 롤백된다.
    assertTrue(memberRepository.find(username).isEmpty());
    assertTrue(logRepository.find(username).isEmpty());
}

여기서 memberService.joinV2()를 호출하는 부분을 주의해야 한다.

joinV2()에는 예외를 잡아서 정상 흐름으로 변환하는 로직이 추가되어 있다.

 try {
     logRepository.save(logMessage);
} catch (RuntimeException e) {
	log.info("log 저장에 실패했습니다. logMessage={}", logMessage); 
    log.info("정상 흐름 변환");
}

 

내부 트랜잭션에서 rollbackOnly 를 설정하기 때문에 결과적으로 정상 흐름 처리를 해서 외부 트랜잭션에서 커밋을 호출해도 물리 트랜잭션은 롤백된다.
그리고 UnexpectedRollbackException 이 던져진다.

 

  • LogRepository 에서 예외가 발생한다. 예외를 던지면 LogRepository 의 트랜잭션 AOP가 해당 예외를 받는다.
  • 신규 트랜잭션이 아니므로 물리 트랜잭션을 롤백하지는 않고, 트랜잭션 동기화 매니저에 rollbackOnly 를 표시한다.
  • 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
  • 예외가 MemberService 에 던져지고, MemberService 는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
  • 정상 흐름이 되었으므로 MemberService 의 트랜잭션 AOP는 커밋을 호출한다.
  • 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly 를 체크한다.
  • rollbackOnly 가 체크 되어 있으므로 물리 트랜잭션을 롤백한다.
  • 트랜잭션 매니저는 UnexpectedRollbackException 예외를 던진다.
  • 트랜잭션 AOP도 전달받은 UnexpectedRollbackException 을 클라이언트에 던진다.

 

정리
-논리 트랜잭션 중 하나라도 롤백되면 전체 트랜잭션은 롤백된다.
-내부 트랜잭션이 롤백 되었는데, 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 발생한다.
-rollbackOnly 상황에서 커밋이 발생하면 UnexpectedRollbackException 예외가 발생한다.

 

REQUIRES_NEW

/**
 * MemberService    @Transactional:ON
 * MemberRepository @Transactional:ON
 * LogRepository    @Transactional(REQUIRES_NEW) Exception
 */
@Test
void recoverException_success() {
    //given
    String username = "로그예외_recoverException_success";

    //when
    memberService.joinV2(username);

    //then: member 저장, log 롤백
    assertTrue(memberRepository.find(username).isPresent());
    assertTrue(logRepository.find(username).isEmpty());
}

  • MemberRepository 는 REQUIRED 옵션을 사용한다. 따라서 기존 트랜잭션에 참여한다.
  • LogRepository 의 트랜잭션 옵션에 REQUIRES_NEW 를 사용했다.
  • REQUIRES_NEW 는 항상 새로운 트랜잭션을 만든다. 따라서 해당 트랜잭션 안에서는 DB 커넥션도 별도로 사용하게 된다.

 

  • REQUIRES_NEW 를 사용하게 되면 물리 트랜잭션 자체가 완전히 분리되어 버린다.
  • 그리고 REQUIRES_NEW 는 신규 트랜잭션이므로 rollbackOnly 표시가 되지 않는다. 그냥 해당 트랜잭션이 물리 롤백되고 끝난다.

 

  • LogRepository에서 예외가 발생한다. 예외를 던지면 LogRepository의 트랜잭션 AOP가 해당 예외를 받는다.
  • REQUIRES_NEW를 사용한 신규 트랜잭션이므로 물리 트랜잭션을 롤백한다. 물리 트랜잭션을 롤백했으므로 rollbackOnly를 표시하지 않는다. 여기서 REQUIRES_NEW를 사용한 물리 트랜잭션은 롤백되고 완전히 끝이 난다.
  • 이후 트랜잭션 AOP는 전달 받은 예외를 밖으로 던진다.
  • 예외가 MemberService에 던져지고, MemberService는 해당 예외를 복구한다. 그리고 정상적으로 리턴한다.
  • 정상 흐름이 되었으므로 MemberService의 트랜잭션 AOP는 커밋을 호출한다.
  • 커밋을 호출할 때 신규 트랜잭션이므로 실제 물리 트랜잭션을 커밋해야 한다. 이때 rollbackOnly를 체크한다.
  • rollbackOnly가 없으므로 물리 트랜잭션을 커밋한다.
  • 이후 정상 흐름이 반환된다.

 

결과적으로 회원 데이터는 저장되고, 로그 데이터만 롤백 되는 것을 확인할 수 있다.

정리

-논리 트랜잭션은 하나라도 롤백되면 관련된 물리 트랜잭션은 롤백되어 버린다.
-이 문제를 해결하려면 REQUIRES_NEW를 사용해서 트랜잭션을 분리해야 한다.
-예제를 단순화 하기 위해 MemberService가 MemberRepository, LogRepository만 호출하지만 실제로는 더 많은 리포지토리들을 호출하고 그 중에 LogRepository만 트랜잭션을 분리한다고 생각해보면 이해하는데 도움이 될 것이다.

 

주의

REQUIRES_NEW를 사용하면 하나의 HTTP 요청에 동시에 2개의 데이터베이스 커넥션을 사용하게 된다. 따라서 성능이 중요한 곳에서는 이런 부분을 주의해서 사용해야 한다.
REQUIRES_NEW를 사용하지 않고 문제를 해결할 수 있는 단순한 방법이 있다면, 그 방법을 선택하는 것이 더 좋다.

 

아래와 같이 REQUIRES_NEW 를 사용하지 않고 구조를 변경하는 것도 좋은 방법이다.

이렇게 하면 HTTP 요청에 동시에 2개의 커넥션을 사용하지는 않는다. 순차적으로 사용하고 반환하게 된다.
물론 구조상 REQUIRES_NEW 를 사용하는 것이 더 깔끔한 경우도 있으므로 각각의 장단점을 이해하고 적절하게 선택 해서 사용하면 된다.

'Java Category > Spring' 카테고리의 다른 글

[Spring DB] 트랜잭션 전파  (1) 2024.04.10
[Spring DB] 스프링 트랜잭션의 이해  (0) 2024.04.06
[Spring DB] MyBatis  (0) 2024.04.03
[Spring DB] 데이터 접근 계층 테스트  (0) 2024.04.02
[Spring DB] SimpleJdbcInsert  (0) 2024.04.01