728x90

예외 처리

JPA표준 예외들은 javax.persistence.PersistenceException의 자식 클래스다. 

그리고 이 예외 클래스는 RuntimeException의 자식이다.

따라서 JPA예외는 모두 언체크 예외다.

JPA 표준 예외는 크게 2가지로 나눌 수 있다.

  • 트랜잭션 롤백을 표시하는 예외
  • 트랜젝션 롤백을 표시하지 않는 예외

트랜젝션 롤백을 표시하는 예외는 심각한 예외이므로 복구해선 안된다.

출처 : 자바 ORM 표준 JPA 프로그래밍

 

스프링 프레임워크의 JPA 예외 변환

서비스 계층에서 데이터 접근 계층의 구현 기술에 직접 의존하는 것은 좋은 설계가 아니듯, 예외 또한 마찬가지이다.

스프링 프레임워크는 이런 문제를 해결하려고 데이터 접근 계층에 대한 예외를 추상화해서 개발자에게 제공한다.

출처 : 자바 ORM 표준 JPA 프로그래밍

스프링 프레임워크에 JPA 예외 변환기 적용

JPA 예외를 스프링 프레임워크가 제공하는 추상화 된  예외로 변경하려면 PersistenceExceptionTranslationPostProcesser를 스프링 빈으로 등록하면 된다.

이것은 @Repository 어노테이션을 사용한 곳에 예외 변환 AOP를 적용해서 JPA예외를 스프링 프레임워크가 추상화한 예외로 변환해준다.

 

@Repository
public class NoResultExceptionTestRepository
{
    @PersistenceContext
    EntityManager em;
    
    public Member findMember()
    {
        // 조회된 데이터가 없음
        return em.createQuery("select m from Member m", Member.class)
            .getSingleResult();
    }
}

이 메소드는 조회된 결과가 없으면 javax.persistence.NoResultException이 발생한다.

이 예외가 메소드를 빠져나갈 때 PersistenceExceptionTranslationPostProcessor에서 등록한 AOP 인터셉터가 동작해서 해당 예외를 org.springframework.dao.EmptyResultDataAccessException예외로 변환해서 반환한다.

@Repository
public class NoResultExceptionTestRepository
{
    @PersistenceContext
    EntityManager em;
    
    public Member findMember() throws javax.persistence.NoResultException
    {
        // 조회된 데이터가 없음
        return em.createQuery("select m from Member m", Member.class)
            .getSingleResult();
    }
}

만약 예외를 변환하지 않고 그대로 반환하고 싶으면 위 코드처럼 throws 절에 명시하면 된다.

 

트랜잭션 롤백 시 주의사항

트랜잭션을 롤백하는 것은 DB의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구해주지는 않는다.

따라서 트랜잭션이 롤백된 영속성 컨텍스트를 그대로 사용하는 것은 위험하다.

새로운 영속성 컨텍스트를 생성해서 사용하거나 EntityManager.clear()를 호출해서 영속성 컨텍스트를 초기화한 다음에 사용해야 한다.

 

기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 AOP 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함꼐 종료하므로 문제가 발생하지 않는다.

문제는 OSIV처럼 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용해서 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때 발생한다.

이때는 트랜잭션을 롤백해서 영속성 컨텍스트에 이상이 발생해도 다른 트랜잭션에서 해당 영속성 컨텍스트를 그대로 사용하는 문제가 있다.

스프링 프레임워크는 영속성 컨텍스트의 범위를 트랜잭션의 범위보다 넓게 설정하면 잘못된 영속성 컨텍스트를 사용하는 문제를 예방한다.

 

엔티티 비교

영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하기 위한 1차 캐시가 있다.

영속성 컨텍스트를 더 정확히 이해하기 위해서는 1차 캐시의 가장 큰 장점인 애플리케이션 수준의 반복 가능한 읽기를 이해해야한다.

Member member1 = em.find(Member.class, "1L");
Member member2 = em.find(Member.class, "2L");

assertTrue(member1 == member2); // 둘은 같은 인스턴스다.

같은 영속성 컨텍스트에서 엔티티를 조회하면 위 코드와 같이 항상 같은 엔티티 인스턴스를 반환한다.

 

영속성 컨텍스트가 같을 때 엔티티 비교

 

출처 : 자바 ORM 표준 JPA 프로그래밍

@Transactional
public class MemberServiceTest
{
    @Autowired
    MemberService memberService;
    
    @Autowired
    MemberRepository memberRepository;
    
    @Test
    pulbic void 회원가입() throws Exception
    {
        //Given
        Member member = new Member("kim");
        
        //When
        Long saveId = memberService.join(member);
        
        //Then 
        Member findMember = memberRepository.fineOne(saveId);
        assertTrue(member == findMember); // 참조값 비교
    }
}

위 코드의 테스트는 트랜잭션 안에서 시작하므로 테스트의 범위와 트랜잭션의 범위가 위 그림과 같다.

따라서 테스트 전체에서 같은 영속성 컨텍스트에 접근한다.

 

위 코드와 같이 테스트 클래스의 @Transactional이 선언되어 있으면 트랜잭션을 먼저 시작하고 테스트 메소드를 실행한다.

그러므로 회원가입 메서드에서 사용된 코드는 항상 같은 트랜잭션과 같은 영속성 컨텍스트에 접근한다.

 

출처 : 자바 ORM 표준 JPA 프로그래밍

저장한 회원과 회원 리포지토리에서 찾아온 엔티티가 완전히 같은 인스턴스이다.

이것은 같은 트랜잭션 범위에 있으므로 같은 영속성 컨텍스트를 사용하기 때문이다.

따라서 영속성 컨텍스트가 같으면 엔티티를 비교할 때 다음 3가지 조건을 모두 만족한다.

  • 동일성(identical): == 비교가 같다.
  • 동등성(equinalent): equals() 비교가 같다.
  • DB 동등성 : @Id인 DB 식별자가 같다.

영속성 컨텍스트가 다를 때 엔티티 비교

// @Transactional
public class MemberServiceTest
{
    @Autowired
    MemberService memberService;
    
    @Autowired
    MemberRepository memberRepository;
    
    @Test
    pulbic void 회원가입() throws Exception
    {
        //Given
        Member member = new Member("kim");
        
        //When
        Long saveId = memberService.join(member);
        
        //Then 
        Member findMember = memberRepository.fineOne(saveId);
        assertTrue(member == findMember); // 참조값 비교
    }
}

위 코드는 실패한다.

  1. 테스트 코드에서 memberService.join(member)를 호출해서 회원가입을 시도하면 서비스 계층에서 트랜잭션이 시작되고, 영속성 컨텍스트1이 만들어진다.
  2. memberRepository 에서 em.persist()를 호출해서 member 엔티티를 영속화한다.
  3. 서비스 계층이 끝날 때 트랜잭션이 커밋되면서 영속성 컨텍스트가 플러시된다. 이때 트랜잭션과 영속성 컨텍스트가 종료된다.
  4. 테스트 코드에서 memberRepository.findOne(saveId)를 호출해서 저장한 엔티티를 조회하면 리포지토리 계층에서 새로운 트랜잭션이 시작되면서 새로운 영속성 컨텍스트2가 생성된다.
  5. 저장된 회원을 조회하지만 새로 생성된 영속성 컨텍스트 2에는 찾는 회원이 존재하지 않는다.
  6. 따라서 DB에서 찾아온다.

member와 findMember는 각각 다른 영속성 컨텍스트에서 관리되었기 때문에 둘은 다른 인스턴스다.

하지만 member와 findMember는 인스턴스는 다르지만 같은 DB 로우를 가르키고 있어서 사실상 같은 엔티티이다.

  • 동일성(identical): == 비교가 실패한다.
  • 동등성(equinalent): equals() 비교가 만족한다. 단 equals를 구현해야한다.
  • DB 동등성: @Id인 DB 식별자가 같다.

지금처럼 영속성 컨텍스트가 달라지면 동일성 비교는 실패하기 때문에 다른 방법을 사용해야 한다.

 

프록시 심화 주제

프록시는 원본 엔티티를 상속받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 원본엔티티인지 구분하지 않고 사용할 수 있다.

 

영속성 컨텍스트와 프록시

영속성 컨텍스트는 자신이 관리하는 영속 엔티티의 동일성(identical)을 보장한다.

@Test
public void 영속성컨텍스트와_프록시()
{
    Member newMember = new Member("member1", "회원1");
    em.persist(newMember);
    em.flush();
    em.clear();
    
    Member refMember = em.getReference(Member.class, "member1");
    Member findMember = em.find(Member.class, "member1");
    
    Assert.assertTrue(refMember == findMember); //성공
}

refMember는 프록시고 findMember는 원본 엔티티이므로 둘은 서로 다른 인스턴스로 생각할 수 있지만 이렇게 되면 영속성 컨텍스트가 영속 엔티티의 동일성을 보장하지 못하는 문제가 발생한다.

때문에 영속성 콘텍스트는 프록시로 조회된 엔티티에 대해서 같은 엔티티를 찾는 요청이 오면 원본 엔티티가 아닌 처음 조회된 프록시를 반환한다.

따라서 프록시로 조회해도 영속성 컨텍스트는 영속 엔티티의 동일성을 보장한다.

 

@Test
public void 영속성컨텍스트와_프록시()
{
    Member newMember = new Member("member1", "회원1");
    em.persist(newMember);
    em.flush();
    em.clear();
    
    Member findMember = em.find(Member.class, "member1");
    Member refMember = em.getReference(Member.class, "member1");
    
    Assert.assertTrue(refMember == findMember); //성공
}

반대로 원본 엔티티를 먼저 조회하고 나서 프록시를 조회했다.

원본 엔티티가 조회되었는데 프록시를 반환할 필요가 없다.

따러서 em.getReference()를 호출해도 프록시가 아닌 원본을 반환한다.

 

프록시 타입 비교

@Test
public void 프록시_타입비교() {
    Member newMember = new Member("member1", "회원1");
    em.persist(newMember);
    em.flush();
    em.clear();

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

    Assert.assertFalse(Member.class == refMember.getClass()); // false
    Assert.assertTrue(refMember instanceof Member); // true
}

프록시는 원본 엔티티를 상속 받아서 만들어지므로 프록시로 조회한 엔티티의 타입을 == 비교하면 안되고 대신에  instanceof를 사용해야 한다.

==비교는 부모 클래스와 자식 클래스를 == 비교한 것이 되서 false이다.

 

프록시 동등성 비교

엔티티의 동등성을 비교하려면 비즈니스 키를 사용해서 equals()메소드를 오버라이딩하고 비교하면 된다.

그러나 IDE나 외부 라이브러리를 사용해서 구현한 equals() 메소드로 엔티티를 비교할 때, 비교 대상이 원본 엔티티면 문제가 없지만 프록시면 문제가 발생할 수 있다.

@Entity
public class Member {

    @Id
    private String id;
    private String name;

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

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null) return false;

        if (this.getClass() != obj.getClass()) return false; //1

        Member member = (Member) obj;

        if (name != null ? !name.equals(member.name) : member.name != null) //2
            return false;

        return true;
    }

    @Override
    public int hashCode() {
        return name != null ? name.hashCode() : 0;
    }
}

회원 엔티티는 name필드를 비즈니스 키로 사용해서 equals() 메서드를 오버라이딩했다.

@Test
public void 프록시와_동등성비교()
{
    Member saveMember = new Member("member1", "회원1");
    em.persists(saveMember);
    em.flush();
    em.clear();
    
    Member newMember = new Member("member1", "회원1");
    Member refMember = em.getReference(Member.class, "member1");
    
    Assert.assertTrue( newMember.equals(refMember)); 
}

 

 

출처 : 자바 ORM 표준 JPA 프로그래밍

새로 생성한 회원 newMember와 프록시로 조회한 회원 refMember의 name 속성은 둘 다 회원1로 같으므로 동등성 비교를 하면 성공할 것 같다.

하지만 실행해보면 false가 나오면서 테스트가 실패한다.

 

this.getClass() != obj.getClass()) return false;

여기서 타입을 동일성(==) 비교한다.

앞서 이야기한 대로 프록시는 원본을 상속받은 자식 타입이므로 프록시의 타입을 비교할 때는 == 비교가 아닌 instanceof를 사용해야 한다. 

 

출처 : 자바 ORM 표준 JPA 프로그래밍

Member member = (Member) obj; // member는 프록시다.

if (name != null ? !name.equals(member.name):member.name != null)
    return false;

equals() 메서드를 구현할 때는 일반적으로 멤버변수를 직접 비교하는데, 프록시의 경우는 문제가 된다.

프록시는 실제 데이터를 가지고 있지 않기 때문이다.

따라서 프록시의 멤버변수에 직접 접근하면 아무 값도 조회할 수 없다.

따라서 equals()는 false를 반환한다.

 

Member member = (Member) obj;

if(name != null ? !name.equals(member.getName()) : member.getName() != null)
    return false;

다음은 수정한 코드다.

@Override
public boolean equals(Object obj) {
    if (this == obj) return true;
    if (!(obj instanceof Member)) return false;

    Member member = (Member) obj;
    if (name != null ? !name.equals(member.getName()) : member.getName() != null)
        return false;

    return true;
}

 

정리

  • 프록시의 타입 비교는 ==비교 대신에 instanceof를 사용해야 한다.
  • 프록시의 멤버변수에 직접 접근하면 안 되고 대신에 접근자 메소드를 사용해야 한다.

상속관계와 프록시

 

출처 : 자바 ORM 표준 JPA 프로그래밍

@Test
public void 부모타입으로_프록시조회() {

    // 테스트 데이터 준비
    Book saveBook = new Book();
    saveBook.setName("jpaBook");
    saveBook.setAuthor("kim");
    em.persist(saveBook);

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

    // 테스트 시작
    Item proxyItem = em.getReference(Item.class, saveBook.getId());
    System.out.println("proxyItem = " + proxyItem.getClass());

    if (proxyItem instanceof Book) {
        System.out.println("proxyItem instanceof Book");
        Book book = (Book) proxyItem;
        System.out.println("책 저자 = " + book.getAuthor());
    }

    // 결과 검증
    Assert.assertFalse(proxyItem.getClass() == Book.class);
    Assert.assertFalse(proxyItem instanceof Book);
    Assert.assertTrue(proxyItem instanceof Item);
}

위 코드는 Item을 조회해서 Book 타입이면 저자 이름을 출력한다.

 

코드 분석

먼저 em.getReference() 메소드를 사용해서 Item엔티티를 프록시로 조회했다.

그리고 instanceof 연산을 사용해서 proxyItem이 Book 클래스 타입인지 확인한다.

Book 타입이면 다운캐스팅해서 Book 타입으로 변경하고 저자 이름을 출력한다.

 

그런데 출력 결과를 보면 기대와는 다르게 저자가 출력되지 않은 것을 알 수 있다.

 

출처 : 자바 ORM 표준 JPA 프로그래밍

코드에서는 em.getReference() 메서드를 사용해서 Item 엔티티를 프록시로 조회했다.

이때 실제 조회된 엔티티는 Book 이므로 Book 타입을 기반으로 원본 엔티티 인스턴스가 생성된다.

그런데 em.getReference() 메서드에서 Item엔티티를 대상으로 조회했으므로 프록시인 proxyItem은 Item타입을 기반으로 만들어진다.

이 프록시 클래스는 원본 엔티티로 Book 엔티티를 참조한다.

 

프록시인 proxyItem은 Item의 프록시 타입이고 이 타입은 Book타입과 관계가 없기 때문이다.

캐스팅 또한 되지 않는다.

 

해결 방법

 

1. JPQL로 대상 직접 조회

가장 간단한 방법은 다음과 같이 처음부터 자식타입을 직접 조회해서 필요한 연산을 하면 된다.

물론 이 방법을 사용하면 다형성을 활용할 수 없다.

Book jpqlBook = em.createQuery(
    "select b from Book b where b.id=:bookId", Book.class)
    .setParameter("bookId", item.getId())
    .getSingleResult();

 

2. 프록시 벗기기

하이버네이트가 제공하는 기능을 사용하면 프록시에서 원본 엔티티를 가져올 수 있다.

...
Item item = orderItem.getItem();
Item unProxyItem = unProxy(item);

if (unProxyItem instanceof Book) {
    System.out.println("proxyItem instanceof Book");
    Book book = (Book) unProxyItem;
    System.out.println("책 저자 = " + book.getAuthor());
}

Assert.assertTrue(item != unProxyItem);

// 하이버네이트가 제공하는 프록시에서 원본 엔티티를 찾는 기능을 사용하는 메소드
public static <T> T unProxy(Object entity) {
    if (entity instanceof HibernateProxy) {
        entity = ((HibernateProxy) entity)
            .getHibernateLazyInitializer()
            .getImplementation();
    }
    return (T) entity;
}

영속성 컨텍스트는 한 번 프록시로 노출한 엔티티는 계쏙 프록시로 노출한다.

그래야 영속성 컨텍스트가 영속 엔티티의 동일성을 보장할 수 있고, 클라이언트는 조회한 엔티티가 프록시인지 아닌지 구분하지 않고 사용할 수 있다.

하지만 이 방법은 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다.

 

3. 기능을 위한 별도의 인터페이스 제공

출처 : 자바 ORM 표준 JPA 프로그래밍

public interface TitleView
{
    String getTitle();
}

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item implements TitleView {

    @Id @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    // Getter, Setter
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {

    private String author;
    private String isbn;

    @Override
    public String getTitle() {
        return "[제목:" + getName() + " 저자:" + author + "]";
    }
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {

    private String director;
    private String actor;

    // Getter, Setter

    @Override
    public String getTitle() {
        return "[제목:" + getName() + " 감독:" + director + " 배우:" + actor + "]";
    }
}

위 코드에서 TitleView라는 공통 인터페이스를 만들고 자식 클래스들은 인터페이스의 getTitle() 메소드를 각각 구현했다.

@Entity
public class OrderItem {

    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "ITEM_ID")
    private Item item;

    ...

    public void printItem() {
        System.out.println("TITLE=" + item.getTitle());
    }
}

이제 위 코드처럼 Item의 getTitle() 을 호출하면 된다.

 

이처럼 인터페이스를 제공하고 각각의 클래스가 자신에 맞는 기능을 구현하는 것은 다형성을 활용하는 좋은 방법이다.

이후에 다양한 상품 타입이 추가되어도 Item을 사용하는 OrderItem의 코드는 수정하지 않아도 된다.

그리고 이 방법은 클라이언트 입장에서 대상 객체가 프록시인지 아닌지를 고민하지 않아도 되는 장점이 있다.

 

4. 비지터 패턴 사용

 

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림을 보면 비지터 패턴은 Visitor와 Visitor를 받아들이는 대상 클래스로 구성된다.

여기서는 Item이 accept(visitor) 메서드를 사용해서 Visitor를 받아들인다.

그리고 Item은 단순히 Visitor를 받아들이기만 하고 실제 로직은 Visitor가 처리한다.

 

Visitor 인터페이스

public interface Visitor
{
    void visit(Book book);
    void visit(Album album);
    void visit(Movie movie);
}

Visitor에는 visit()라는 메소드를 정의하고 모든 대상 클래스를 받아들이도록 작성하면 된다.

여기서는 Book, Album, Movie를 대상 클래스로 사용한다.

 

비지터 구현

public class PrintVisitor implements Visitor {

    @Override
    public void visit(Book book) {
        // 넘어오는 book은 Proxy가 아닌 원본 엔티티다.
        System.out.println("book.class = " + book.getClass());
        System.out.println("[PrintVisitor] [제목:" + book.getName() + " 저자:" + book.getAuthor() + "]");
    }

    @Override
    public void visit(Album album) { ... }

    @Override
    public void visit(Movie movie) { ... }
}

public class TitleVisitor implements Visitor {

    private String title;

    public String getTitle() {
        return title;
    }

    @Override
    public void visit(Book book) {
        title = "[제목:" + book.getName() + " 저자:" + book.getAuthor() + "]";
    }

    @Override
    public void visit(Album album) { ... }

    @Override
    public void visit(Movie movie) { ... }
}

 

위 코드는 Visitor의 구현 클래스로 대상 클래스의 내용을 출력해주는 PrintVisitor와 대상 클래스의 제목을 보관하는 TitleVisitor를 작성했다.

 

비지터 대상 클래스

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;

    private String name;

    ...

    public abstract void accept(Visitor visitor);
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {

    private String author;
    private String isbn;
    // Getter, Setter
    public String getAuthor() {
        return author;
    }

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {

    private String director;
    private String actor;
    // Getter, Setter

    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item {

    ...
    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

위 코드에 있는 각각의 자식 클래스들은 부모에 정의한 accept(visitor)메서드를 구현했는데, 구현 내용은 단순히  파라미터로 넘어온 Visitor의 visit(this) 메서드를 호출하면서 자신(this)을 파라미터로 넘기는 것이 전부다.

 

@Test
public void 상속관계와_프록시_VisitorPattern() {
    ...
    OrderItem orderItem = em.find(OrderItem.class, orderItemId);
    Item item = orderItem.getItem();

    // PrintVisitor
    item.accept(new PrintVisitor());
}

실제로는 위 코드처럼 사용한다.

 

출처 : 자바 ORM 표준 JPA 프로그래밍

먼저 item.accept() 메서드를 호출하면서 파라미터로 PrintVisitor를 넘겨주었다.

item은 프록시이므로 먼저 프록시(ProxyItem)가 accept() 메서드를 받고 원본 엔티티(book)의 accept()를 실행한다.

원본 엔티티

다음 코드를 실행해서 자신(this)을 visitor에 파라미터로 넘겨준다.

visitor가 PrintVisitor 타입이므로 PrintVisitor.visit(this) 메서드가 실행되는데 이때 this가 Book타입이므로 visit(Book book) 메서드가 실행된다.

 

비지터 패턴 정리

 

장점

  • 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다.
  • instanceof와 타입캐스팅 없이 코드를 구현할 수 있다.
  • 알고리즘과 객체 구조를 분리해서 구조를 수정하지 않고 새로운 동작을 추가할 수 있다.

단점

  • 너무 복잡하고 더블 디스패치를 사용해야하기 때문에 이해하기 어렵다.
  • 객체 구조가 변경되면 모든 Visitor를 수정해야 한다.

 

성능 최적화

 

N + 1 문제

JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 것이 N + 1 문제다

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();

    ...
}

 

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

    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    private Member member;

    ...
}

 

회원과 주문정보는 1:N, N:1 양방향 연관관계다.

그리고 회원이 참조하는 주문정보인 Member.orders를 즉시로딩으로 설정했다.

 

즉시 로딩과 N+1

특정 회원 하나를 em.find() 메서드로 조회하면 즉시 로딩으로 설정한 주문 정보도 함께 조회한다.

 

여기서 함께 조회하는 방법이 중요한데, SQL을 두 번 실행하는 것이 아니라 조인을 사용해서 한번의 SQl로 회원과 주문정보를 함께 조회한다.

여기까지만 보면 즉시 로딩이 상당히 좋아보인다.

문제는 JPQL을 사용할 때 발생한다.

 

JPQL을 실행하면 JPA는 이것을 분석해서 SQL을 생성한다.

이때는 즉시 로딩과 지연 로딩에 대해서 전혀 신경 쓰지 않고 JPQL만 사용해서 SQL을 생성한다.

따라서 다음과 같은 SQL이 실행된다.

SELECT * FROM MEMBER

SQL의 실행 결과로 먼저 회원 엔티티를 애플리케이션에 로딩한다.

그런데 회원 엔티티와 연관된 주문 컬렉션이 즉시 로딩으로 설정되어 있으므로 JPA는 주문 컬렉션을 즉시 로딩하려고 다음 SQL 을 추가로 실행한다.

SELECT * FROM ORDERS WHERE MEMBER_ID = ?

조회된 회원이 하나면 이렇게 총 2번의 SQL을 실행하지만 조회된 회원이 5명이면 

SELECT * FROM MEMBER // 1번 실행으로 회원 5명 조회
SELECT * FROM ORDERS WHERE MEMBER_ID=1 // 회원과 관련된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=2 // 회원과 관련된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=3 // 회원과 관련된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=4 // 회원과 관련된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=5 // 회원과 관련된 주문

 

이처럼 처음 실행한 SQL의 결과 수만큼 추가로 SQL을 실행하는 것을 N + 1 문제라고 한다.

 

지연 로딩과 N + 1

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<Order>();

    ...
}

지연 로딩으로 설정하면 JPQL에서는 N + 1 문제가 발생하지 않는다.

List<Member> members = 
    em.createQuery("select m from Member m", Member.class)
    .getResultList();

지연 로딩이므로 DB에서 회원만 조회된다.

따라서 다음 SQL만 실행되고 연관된 주문 컬렉션은 지연 로딩된다.

SELECT * FROM MEMBER

 

이후에 비즈니스 로직에서 주문 컬렉션을 실제 사용할 때 지연 로딩이 발생한다.

문제는 다음처럼 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때 발생한다.

for(Member member : members)
{
    System.out.println("member = " + member.getOrders().size());
}

주문 컬렉션을 초기화하는 수만큼 다음 SQL이 실행될 수 있다.

회원이 5명이면 회원에 따른 주문도 5번 조회된다.

SELECT * FROM ORDERS WHERE MEMBER_ID=1 // 회원과 관련된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=2 // 회원과 관련된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=3 // 회원과 관련된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=4 // 회원과 관련된 주문
SELECT * FROM ORDERS WHERE MEMBER_ID=5 // 회원과 관련된 주문

결국 이것도 N + 1 문제다.

N + 1 문제는 즉시 로딩과 지연 로딩일 때 모두 발생할 수 있다.

 

해결 방법

 

1. 페치 조인 사용

N + 1 문제를 해결하는 가장 일반적인 방법은 페치 조인을 사용하는 것이다.

페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함꼐 조회하므로 N + 1 문제가 발생하지 않는다.

 

페치 조인 JPQL

select m from Member m join fetch m.orders

실행된 SQL

SELECT M.*, O.* FROM MEMBER M
INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID

 

2. 하이버네이트 @BatchSize

하이버네이트가 제공하는 org.hibernate.annotations.BatchSize 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN절을 사용해서 조회한다.

만약 조회한 회원이 10명인데 size=5로 지정하면 2번의 SQL만 추가로 실행한다.

@Entity
public class Member
{
    ...
    @org.hibernate.annotations.BatchSize(size = 5)
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
    ...
}

즉시 로딩으로 설정하면 조회 시점에 10건의 데이터를 모두 조회해야 하므로 다음 SQL이 두번 실행된다.

SELECT * FROM ORDERS
WHERE MEMBER_ID IN (
    ?, ?, ?, ?, ?
)

 

 

3. 하이버네이트 @Fetch(FetchMode.SUBSELECT)

하이버네이트가 제공하는 org.hibernate.annotations.Fetch 어노테이션에 FetchMode를 SUBSELECT로 사용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용해서 N + 1 문제를 해결한다.

@Entity
public class Member
{
    ...
    @org.hibernate.annotations.Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<Order>();
    ...
}

 

 

N+1 정리

즉시 로딩과 지연 로딩 중 추천하는 방법은 즉시 로딩은 사용하지 말고 지연 로딩만 사용하는 것이다.

즉시 로딩 전략은 그럴듯해 보이지만 N+1 문제는 물론이고 비즈니스 로직에 따라 필요하지 않은 엔티티를 로딩해야 하는 상황이 자주 발생한다.

그리고 즉시 로딩의 가장 큰 문제는 성능 최적화가 어렵다는 점이다.

엔티티를 조회하다보면 즉시 로딩이 연속으로 발생해서 전혀 예상하지 못한 SQL이 실행될 수 있다.

따라서 모두 지연 로딩으로 설정하고 성능 최적화가 꼭 필요한 곳에는 JPQL 페치 조인을 사용하자.

JPA의 글로벌 페치 전략 기본값은 다음과 같다:

  • @OneToOne, @ManyToOne: 기본 페치 전략은 즉시 로딩
  • @OneToMany, @ManyToMany: 기본 페치 전략은 지연 로딩

 

읽기 전용 쿼리의 성능 최적화

엔티티가 영속성 컨텍스트에 관리되면 1차 캐시부터 변경 감지까지 얻을 수 있는 혜택이 많다.

하지만 영속성 컨텍스트는 변경 감지를 위해 스냅샷 인스턴스를 보관하므로 더 많은 메모리를 사용하는 단점이 있다.

이때는  있다.

읽기 전용으로 엔티티를 조회하면 메모리 사용량을 최적화할 수 있다.

 

  • 스칼라 타입으로 조회
    • 가장 확실한 방법은 다음처럼 엔티티가 아닌 스칼라 타입을 모든 필드를 조회하는 것이다.
  • 읽기 전용 쿼리 힌트 사용
    • 하이버네이트 전용 힌트인 org.hibernate.readOnly 를 사용하면 엔티티를 읽기 전용으로 조회할 수 있다.
    • 읽기 전용이므로 영속성 컨텍스트는 스냅샷을 보관하지 않는다. 
    • 따라서 메모리 사용량을 최적화할 수 있다.
    • 단 스냅샷이 없으므로 엔티티를 수정해도 DB에 반영되지 않는다.
  • 읽기 전용 트랜젝션 사용
    • 스프링 프레임워크를 사용하면 트랜잭션을 읽기 전용 모드로 설정할 수 있다.
    • @Transactional(readonly = true)를 하면 강제로 플러시를 호출하지 않는 한 플러시가 일어나지 않는다.
  • 트랜잭션 밖에서 읽기
    • 트랜잭션 밖에서 읽는 다는 것은 트랜잭션 없이 엔티티를 조회하는 것이다.
    • JPA에선 수정 시 트랜잭션은 필수이기 때문에 조회가 목적일 때만 사용해야 한다.

배치 처리

수백만건의 데이터를 배치 처리해야 하는 상황이라 가정.

일반적인 방식으로 엔티티를 계속 조회하면 영속성 컨텍스트에 아주 많은 엔티티가 쌓이면서 메모리 부족 오류가 발생한다.

따라서 이런 배치 처리는 적절한 단위로 영속성 컨텍스트 초기화를 해야한다.

 

JPA 등록 배치

수천에서 수만 건 이상의 엔티티를 한 번에 등록할 때 주의할 점은 영속성 컨텍스트에 엔티티가 계속 쌓이지 않도록 일정 단위마다 영속성 컨텍스트의 엔티티를 DB에 플러시하고 영속성 컨텍스트를 초기화해야 한다.

만약 이런 작업을 하지 않으면 영속성 컨텍스트에 너무 많은 엔티티가 저장되면서 메모리 부족 오류가 발생할 수 있다.

EntityManager em = entityManagerFactory.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();

for (int i = 0; i < 100000; i++) {
    Product product = new Product("item" + i, 10000);
    em.persist(product);

    // 100건마다 플러시와 영속성 컨텍스트 초기화
    if (i % 100 == 0) {
        em.flush();
        em.clear();
    }
}

tx.commit();
em.close();

 

 

JPA 페이징 배치 처리

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

tx.begin();

int pageSize = 100;
for (int i = 0; i < 10; i++) {

    List<Product> resultList = em.createQuery("select p from Product p", Product.class)
        .setFirstResult(i * pageSize)
        .setMaxResults(pageSize)
        .getResultList();

    // 비즈니스 로직 실행
    for (Product product : resultList) {
        product.setPrice(product.getPrice() + 100);
    }

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

tx.commit();
em.close();

한번에 100건씩 페이징 쿼리로 조회하면서 상품의 가격을 100원 씩 증가한다.

그리고 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화한다.

 

 

하이버네이트 scroll사용

EntityTransaction tx = em.getTransaction();
Session session = em.unwrap(Session.class);
tx.begin();
ScrollableResults scroll = session.createQuery(
    "select p from Product p")
    .setCacheMode(CacheMode.IGNORE) // 2차 캐시 기능을 끈다.
    .scroll(ScrollMode.FORWARD_ONLY);

int count = 0;

while (scroll.next()) {
    Product p = (Product) scroll.get(0);
    p.setPrice(p.getPrice() + 100);

    count++;
    if (count % 100 == 0) {
        session.flush(); // 플러시
        session.clear(); // 영속성 컨텍스트 초기화
    }
}

tx.commit();
session.close();

scroll은 하이버네이트 전용 기능이므로 먼저 em.unwrap() 메서도를 사용해서 하이버네이트 세션을 구한다.

해서 하이버네이트 세션을 구한다.

다음으로 쿼리를 조회하면서 scroll() 메서드를 호출하면 엔티티 하나씩 조회할 수 있다.

 

하이버네이트 무상태 세션 사용

 

하이버네이트는 무상태 세션이라는 특별한 기능을 제공한다.

이름 그대로 무상태 세션은 영속성 컨텍스트를 만들지 않고 심지어 2차 캐시도 사용하지 않는다.

무상태 세션은 영속성 컨텍스트가 없다.

그리고 엔티티를 수정하려면 무상태 세션이 제공하는 update() 메서드를 직접 호출해야 한다.

SessionFactory sessionFactory = 
    entityManagerFactory.unwrap(SessionFactory.class);
StatelessSession session = sessionFactory.openStatelessSession();
Transaction tx = session.beginTransaction();
ScrollableResults scroll = session.createQuery(
    "select p from Product p").scroll();

while (scroll.next()) {
    Product p = (Product) scroll.get(0);
    p.setPrice(p.getPrice() + 100);
    session.update(p); // 직접 update를 호출해야 한다.
}

tx.commit();
session.close();

 

SQL 쿼리 힌트 사용

JPA는 DB SQL 힌트 기능을 제공하지 ㅇ낳는다.

SQL 힌트를 사용하려면 하이버네이트를 직접 사용해야 한다

SQL 힌트는 하이버네이트 쿼리가 제공하는 addQueryHint() 메서드를 사용한다.

Session session = em.unwrap(Session.class);

List<Member> list = session.createQuery("select m from Member m")
    .addQueryHint("FULL (MEMBER)") // SQL 힌트 추가
    .list();

실행된 SQL

select
    /*+ FULL (MEMBER) */ m.id, m.name
from
    Member m

현재 하이버네이트 버전에는 오라클 방언에만 힌트가 적용되어 있다.

다른 DB에서 SQL 힌트를 사용하려면 각 방언에서 org.hibernate.dialect.Dialect에 있는 다음 메서드를 오버라이딩해서 기능을 구현해야 한다.

 

트랜잭션을 지원하는 쓰기 지연과 성능 최적화

 

트랜잭션을 지원하는 쓰기 지연과 JDBC 배치

네트워크 호출 한 번은 단순한 메서드를 수만 번 호출하는 것보다 더 큰 비용이 든다.

JDBC가 제공하는 SQL 배치 기능을 사용하면 SQL을 모아서 DB에 한 번에 보낼 수 있다.

 

그래서 보통은 수백 수천 건 이상의 데이터를 변경하는 특수한 상황에 SQL 배치 기능을 사용한다.

JPA는 플러시 기능이 있으므로 SQL 배치 기능을 효과적으로 사용할 수 있다.

 

트랜잭션을 지원하는 쓰기 지연과 애플리케이션 확장성

트랜잭션을 지원하는 쓰기 지연과 변경 감지 기능 덕분에 성능과 개발의 편의성이라는 두 마리 토끼를 모두 잡을 수 있었다.

하지만 진짜 장점은 DB 테이블 로우에 락이 걸리는 시간을 최소화한다는 점이다.

이 기능은 트랜잭션을 커밋해서 영속성 컨텍스트를 플러시하기 전까지는 DB에 데이터를 등록, 수정, 삭제하지 않는다.

따라서 커밋 직전까지 DB 로우에 락을 걸지 않는다.

 

728x90

'JPA' 카테고리의 다른 글

JPA TIL - 09 값 타입  (0) 2024.05.15
JPA TIL - 07 고급 매핑  (0) 2024.05.08
JPA TIL - 06 다양한 연관관계 매핑  (0) 2024.04.16
JPA TIL - 05 연관관계 매핑 기초  (0) 2024.04.09
JPA TIL - 04 엔티티 매핑  (0) 2024.04.02
728x90

JPA의 데이터 타입을 가장 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있다.

엔티티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말한다.

 

1. 값 타입

@Entity
public class Member
{
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    private int age;
    ...
}

위 코드 Member에서 String, int 가 값 타입이다.

Member 엔티티는 id라는 식별자 값도 가지고 생명주기도 있지만 값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존한다.

따라서 회원 엔티티 인스턴스를 제거하면 name, age 값도 제거된다. 그리고 값 타입은 공유하면 안된다.

예를 들어 다른 회원 엔티티의 이름을 변경한다고 해서 나의 이름까지 변경되는 것은 있어선 안된다.

 

2. 임베디드 타입(복합 값 타입)

새로운 값 타입을 직접 정의해서 사용할 수 있는데, JPA에서는 이것을 임베디드 타입이라고 한다.

직접 정의한 임베디드 타입도 int, String처럼 값 타입이라는 것이다.

@Entity
public class Member
{
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    // 근무 기간
    @Temporal(TemporalType.DATE) java.util.Date startDate;
    @Temporal(TemporalType.DATE) java.util.Date endDate;
    
    // 집 주소 표현
    private String city;
    private String street;
    private String zipcode;
    ...
}

위 코드는 평범한 회원 엔티티다.

 

"회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편번호를 가진다."

이런 설명은 단순히 정보를 나열한 것이다.

그리고 근무 시작일과 우편번호는 서로 아무 관련이 없다.

 

"회원 엔티티는 이름, 근무 기간, 집 주소를 가진다."

라는 표현이 더 명확하다.

 

회원이 상세한 데이터를 그대로 가지고 있는 것은 객체지향적이지 않으며 응집력만 떨어뜨린다.

대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해질 것이다.

@Entity
public class Member
{
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    
    @Embedded Period workPeriod; // 근무 기간
    @Embedded Address homeAddress; // 집 주소
    ...
}

회원 엔티티가 더욱 의미 있고 응집력 있게 변했다.

@Embeddable
public class Period
{
    @Temporal(TemporalType.DATE) java.util.Date startDate;
    @Temporal(TemporalType.DATE) java.util.Date endDate;
    ..
    
    public boolean isWork(Date date)
    {
         ...
    }
}

startDate, endDate 를 합해서 Period 클래스를 만들었다.

@Embeddable
public class Address
{
    @Column(name = "city") // 매핑할 컬럼 정의 기능
    private String city;
    private String street;
    private String zipcode;
    ...
}

city, street, zipcode를 합해서 Address 클래스를 만들었다.

출처 : 자바 ORM 표준 JPA 프로그래밍

새로 정의한 값 타입들은 재사용할 수 있고 응집도도 높다.

또한 Period클래스의 isWork() 메서드처럼 해당 값 타입만 사용하는 의미 있는 메소드도 만들 수 있다.

 

임베디드 타입을 사용하려면 

@Embeddable : 값 타입을 정의하는 곳에 표시

@Embedded : 값 타입을 사용하는 곳에 표시

이 2가지 어노테이션이 필요하다.

 

또한 임베디드 타입은 기본 생성하자 필수이다.

 

더보기

01. 임베디드 타입과 테이블 매핑

출처 : 자바 ORM 표준 JPA 프로그래밍

임베디드 타입은 엔티티의 값일 뿐이다.

따라서 값이 속한 엔티티의 테이블에 매핑한다.

 

임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능하다.

잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.

 

테이블 하나에 여러 클래스를 매핑하는 것은 상상하기도 싫을 것이다..

이런 지루한 반복 작업은 JPA에게 맡기고 더 세밀한 객체지향 모델을 설계하는 데에 집중해야한다.

 

02. 임베디드 타입과 연관관계

임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있다.

출처 : 자바 ORM 표준 JPA 프로그래밍
@Entity
public class Member
{
    @Embedded Address address; // 임베디드 타입 포함
    @Embedded PhoneNumber phoneNumber; // 임베디드 타입 포함
    ...
}

@Embeddable
public class Address
{
    String street;
    String city;
    String state;
    @Embedded Zipcode zipcode; // 임베디드 타입 포함
}

@Embeddable
public class Zipcode
{
    String zip;
    String plusFour;
}

@Embeddable
public class PhoneNumber
{
    String areaCode;
    String localNumber;
    @ManyToOne PhoneServiceProvider provider; // 엔티티 참조
    ...
}

@Entity
public class PhoneServiceProvider
{
    @Id
    String name;
    ...
}

값 타입인 Address 가 값 타입인 Zipcode를 포함하고, 값 타입인 PhoneNumber가 엔티티 타입인 PhoneServiceProvider를 참조한다.

 

03. @AttrivuteOverride : 속성 재정의

임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 된다.

@Entity
public class Member
{
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    
    @Embedded Address homeAddress;
    @Embedded Address companyAddress;
}

만약 회원에게 주소가 하나 더 필요하면 어떻게 해야할까?

위 코드에서 문제는 테이블에 매핑하는 컬럼 명이 중복되는 것이다.

이때는 @AttributeOverrides를 사용해서 매핑정보를 재정의해야 한다.

 

@Entity
public class Member
{
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    
    @Embedded Address homeAddress;
    
    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "city", column = @Column(name = "COMPANY_CITY")),
        @AttributeOverride(name = "street", column = @Column(name = "COMPANY_STREET")),
        @AttributeOverride(name = "zipcode", column = @Column(name = "COMPANY_ZIPCODE"))
    }
    Address companyAddress;
}

이렇게 엔티티를 정의하면

CREATE TABLE MEMBER
(
    COMPANY_CITY varchar(255),
    COMPANY_STREET varchar(255),    
    COMPANY_ZIPCODE varchar(255),    
    city varchar(255),
    street varchar(255),
    zipcode varchar(255),
    ...
)

이렇게 생성된다.

 

04. 임베디드 타입과 null

임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null이 된다.

 

3. 값 타입과 불변 객체

값 타입은 복잡한 객체 세상을 조금이라도 단순화하려고 만든 개념이다.

따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 한다.

 

더보기

01. 값 타입 공유 참조

임베디드 타입 같은 값 타입을 여러 엔티티에서 공유하면 위험하다.

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림의 상황은

memver1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

address.setCity("NewCity"); // 회원1의 address 값을 공유해서 사용
member2.setHomeAddress(address);

이렇게 표현할 수 있다.

 

회원2에 새로운 주소를 할당하려고 회원1의 주소를 그대로 참조해서 사용했다.

이 코드를 실행하면 회원1의 주소도 "NewCity"로 변경된다.

영속성 컨텍스트에서는 회원1과 회원2 둘 다 city 속성이 변경된 것으로 판단해서 회원1, 회원2 각각 UPDATE SQL을 실행한다.

이런 부작용을 막으려면 값을 복사해서 사용하면 된다.

 

02. 값 타입 복사

값 타입의 실제 인스턴스인 값을 공유하는 것은 위험하다.

대신에 값(인스턴스)을 복사해서 사용해야 한다.

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림의 상황은

member1.setHomeAddress(new Address("OldCity"));
Address address = member1.getHomeAddress();

// 회원1의 address 값을 복사해서 새로운 newAddress 값을 생성
Address newAddress = address.clone();

newAddress.setCity("NewCity");
member2.setHomeAddress(newAddress);

이렇게 표현할 수 있다.

 

회원2에 새로운 주소를 할당하기 위해 clone() 메소드를 만들었는데, 이 메소드는 자신을 복사해서 반환하도록 구현했다.

그러면 영속성 컨텍스트는 회원2의 주소만 변경된 것으로 판단해서 회원2에 대해서만 UPDATE SQL을 실행한다.

 

이처럼 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있다.

문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이라는 것이다.

 

자바는 기본 타입에 값을 대입하면 값을 복사해서 전달한다.

 

int a = 10;
int b = a; // 기본 타입은 항상 값을 복사한다.
b = 4;

이 코드의 최종 결과는 a = 10, b = 4 다.

int b = a에서 a의 값 10을 복사해서 b에 넘겨준다.

따라서 a, b는 완전히 독립된 값을 가지고 부작용도 없다.

 

문제는 Address같은 객체 타입이다.

자바는 객체에 값을 대입하면 항상 참조값을 전달한다.

Address a = new Address("Old");
Address b = a; // 객체 타입은 항상 참조 값을 전달한다.
b.setCity("New");

Address b = a 에서 a가 참조하는 인스턴스의 참조 값을 전달하기 때문에 

b에서 city를 바꿔도 a의 city도 같이 바뀌게 된다.

 

Address a = new Address("Old");
Address b = a.clone(); // 항상 복사해서 넘겨야 한다.
// Address b = a; // 이렇게 참조만 넘기면 부작용이 발생할 수 있다.
b.setCity("New");

이때도 값 타입 복사를 해주면 된다.

 

객체의 공유 참조는 피할 수 없다.

따라서 근본적인 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 된다.

 

03. 불변 객체

값 타입은 부작용 걱정 없이 사용할 수 있어야 한다.

부작용이 일어나면 값 타입이라 할 수 없다.

객체를 불변하게 만들면 값을 수정할 수 없으므로 부작용을 원천 차단할 수 있다.

따라서 값 타입은 될 수 있으면 불변 객체로 설계해야 한다.

@Embeddable
public class Address
{
    private String city;
    
    protected Address() {} // JPA에서 기본 생성자는 필수다.
    
    // 생성자로 초기 값을 설정한다.
    public Address(String city) {this.city = city}
    
    // 접근자(Getter)는 노출한다.
    public String getCity()
    {
        return city;
    }
    
    // 수정자(Setter)는 만들지 않는다.
}

위의 Address 는 이제 불변 객체이다.

Address address = member1.getHomeAddress();
// 회원1의 주소 값을 조회해서 새로운 주소값을 생성
Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);

만약 값을 수정해야 하면 위 코드처럼 새로운 객체를 생성해서 사용해야 한다.

 

4. 값 타입의 비교

int a = 10;
int b = 10;

Address a = new Address("서울시", "종로구", "1번지");
Address b = new Address("서울시", "종로구", "1번지");

int a의 숫자 10과 int b의 숫자 10은 같다고 표현한다.

Address a 와 Address b는 같다고 표현한다.

 

자바가 제공하는 객체 비교는 2가지가 있다.

  • 동일성 비교 : 인스턴스 참조 값을 비교, == 사용
  • 동등성 비교 : 인스턴스의 값을 비교, equals()사용

Address값 타입을 a == b로 동일성 비교하면 둘은 서로 다른 인스턴스이므로 결과는 거짓이다.

값 타입은 비록 인스턴스가 달라도 그 안에 값이 같으면 같은 것으로 봐야한다.

따라서 값 타입을 비교할 때는 a.equals(b)를 사용해서 동등성 비교를 해야한다.

equals()메소드는 재정의 해야한다.

 

5. 값 타입 컬렉션

값 타입을 하나 이상 저장하려면 컬렉션에 보관하고 @ElementCollection, @CollectionTable 어노테이션을 사용하면 된다.

@Entity
public class Member
{
    @Id
    @GeneratedValue
    private Long id;
    
    @Embedded
    private Address HomeAddress;
    
    @ElementCollection
    @CollectionTable(name = "FAVORITE_FOODS",
        joinColumns = @JoinColumn(name = "MEMBER_ID"))
    @Column(name="FOOD_NAME")
    private Set<String> favoriteFoods = new HashSet<String>();
    
    @ElementCollection
    @CollectionTable(name = "ADDRESS", joinColumns
        = @JoinColumn(name = "MEMBER_ID"))
    private List<Address> addressHistory = new ArrayList<Address>();
    ...
}

Member 엔티티를 보면  값 타입 컬렉션을 사용하는 favoriteFoods, addressHistory에 @ElementCollection을 지정했다.

 

favoriteFoods는 기본 값 타입인 String을 컬렉션으로 가진다.

이것을 DB 테이블로 매핑해야 하는데 관계형 DB의 테이블을 컬럼안에 컬렉션을 포함할 수 없다.

출처 : 자바 ORM 표준 JPA 프로그래밍
출처 : 자바 ORM 표준 JPA 프로그래밍

따라서 위 그림처럼 별도의 테이블을 매핑해야 한다.

그리고 favoriteFoods처럼 값으로 사용되는 컬럼이 하나면 @Column을 사용해서 컬럼명을 지정할 수 있다.

 

addressHistory는 임베디드 타입인 Address를 컬렉션으로 가진다.

이것도 마찬가지로 별도의 테이블을 사용해야 한다.

테이블 매핑정보는 @AttributeOverride를 사용해서 재정의할 수 있다.

 

더보기

01. 값 타입 컬렉션 사용

Member member = new Member();

// 임베디드 값 타입
member.setHomeAddress(new Address("통영", "몽돌해수욕장", "660-123"));

// 기본값 타입 컬렉션
member.getFavoriteFoods().add("짬뽕");
member.getFavoriteFoods().add("짜장");
member.getFavoriteFoods().add("탕수육");

// 임베디드 값 타입 컬렉션
member.getAddressHistory().add(new Address("서울", "강남", "123-123"));
member.getAddressHistory().add(new Address("서울", "강북", "000-000"));

em.persist(member);

위 코드는 등록하는 코드다.

마지막에 member엔티티만 영속화 했다.

JPA는 이때 member의 엔티티 값 타입도 함께 저장한다.

  • member : INSERT SQL 1번
  • member.homeAddress : 컬렉션이 아닌 임베디드 값 타입이므로 회원테이블을 저장하는 SQL에 포함된다.
  • member.favoriteFoods : INSERT SQL 3번
  • member.addressHistory : INSERT SQL 2번

따라서 em.persist(member) 한 번 호출로 총 6번의 INSERT SQL을 실행한다.

 

 

// SQL : SELECT ID, CITY, STREET, ZIPCODE FROM MEMBER WHERE ID = 1
Member member = em.find(Member.class, 1L); // 1. member

// 2. member.homeAddress
Address homeAddress = member.getHomeAddress();

// 3. member.favoriteFoods
Set<String> favoriteFoods = member.getFavoriteFoods(); // LAZY

// SQL : SELECT MEMBER_ID, FOOD_NAME FROM FAVORITE_FOODS WHERE MEMBER_ID = 1
for (String favoriteFood : favoriteFoods)
{
    System.out.println("favoriteFood = " + favoriteFood);
}

// 4. member.addressHistory
List<Address> addressHistory = member.getAddressHistory(); // LAZY

// SQL : SELECT MEMBER_ID , CITY, STREET, ZIPCODE FROM ADDRESS WHERE MEMBER_ID = 1
addressHistory.get(0);

지연로딩으로 설정

 

  1. member : 회원만 조회한다. 이때 임베디드 값 타입인 homeAddress도 함께 조회한다. SELET SQL을 1번 호출한다.
  2. member.homeAddress : 1번에서 회원을 조회할 때 같이 조회해둔다.
  3. member.favoriteFoods : LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.
  4. member.addressHistory : LAZY로 설정해서 실제 컬렉션을 사용할 때 SELECT SQL을 1번 호출한다.
Member member = em.find(Member.class, 1L);

// 1. 임베디드 값 타입 수정
member.sethomeAddress(new Address("새로운도시", "신도시1", "123456"));

// 2. 기본값 타입 컬렉션 수정
Set<String> favoriteFoods = member.getFavoriteFoods();
favoriteFoods.remove("탕수육");
favoriteFoods.add("치킨");

// 3. 임베디드 값 타입 컬렉션 수정
List<Address> addressHistory = member.getAddressHistory();
addressHistory.remove(new Address("서울", "기존 주소", "123-123"));
addressHistory.add(new Address("새로운도시", "새로운 주소", "123-456"));
  • 임베디드값 타입 수정 : homeAddress 임베디드 값 타입은 MEMBER 테이블과 매핑했으므로 MEMBER 테이블만 UPDATE한다. 사실 Member 엔티티를 수정하는 것과 같다.
  • 기본값 타입 컬렉션 수정 : 탕수육을 치킨으로 변경하려면 탕수육을 제거하고 치킨을 추가해야 한다. 자바의 String타입을 수정 불가하다.
  • 임베디드 값 타입 컬렉션 수정 : 값 타입은 불변해야 한다. 따라서 컬렉션에서 기존 주소를 삭제하고 새로운 주소를 등록했다. 참고로 값 타입은 equals, hashcode를 꼭 구현해야 한다.

 

02. 값 타입 컬렉션 제약사항

엔티티는 식별자가 있어서 엔티티 값을 변경해도 식별자로 DB에 젖아된 원본 데이터를 쉽게 찾아서 변경 가능하다.

하지만 값 타입은 식별자가 없고 단순한 값들의 모음이므로 값을 변경해버리면 DB에 저장된 원본 데이터를 찾기 어렵다.

 

특정 엔티티 하나에 소속된 값 타입은 값이 변경되어도 자신의 소속된 엔티티를 DB에서 찾고 값을 변경하면 된다.

문제는 값 타입 컬렉션이다. 

값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관되어서 DB에 있는 원본 데이터를 찾기 어렵다.

 

이런 문제로 인해 JPA 구현체들은 값 타입 컬렉션에 변경사항이 발생하면 값테입 컬렉션이 매핑된 테이블의 연관된 모든 데이터를 삭제하고, 현제 값 타입 컬렉션 객체제 있는 모든 값을 DB에 다시 저장한다.

 

따라서 실무에서는 값 타입 컬렉션이 매핑된 테이블에 데이터가 많다면 값 타입 컬렉션 대신에 일대다 관계를 고려해야 한다.

 

지금까지 나온 문제를 해결하려면 값 타입 컬렉션을 사용하는 대신에 새로운 엔티티를 만들어서 일대다 관계로 설정하면 된다.

여기에 추가로 영속성 전이(Cascade) + 고아 객체 제거(ORPHAN REMOVE) 기능을 적용하면 값 타입 컬렉션처럼 사용할 수 있다.

728x90

'JPA' 카테고리의 다른 글

JPA TIL - 15 고급 주제와 성능 최적화  (0) 2024.07.10
JPA TIL - 07 고급 매핑  (0) 2024.05.08
JPA TIL - 06 다양한 연관관계 매핑  (0) 2024.04.16
JPA TIL - 05 연관관계 매핑 기초  (0) 2024.04.09
JPA TIL - 04 엔티티 매핑  (0) 2024.04.02
728x90

1. 상속 관계 매핑

관계형 DB에는 객체지향 언어에서 다루는 상속이라는 개념이 없다.

출처 : 자바 ORM 표준 JPA 프로그래밍

 대신에 위 그림과 같이 슈퍼타입 서브타입 관계라는 모델링 기법이 객체의 상속 개념과 가장 유사하다.

ORM에서 이야기 하는 상속 관계 매핑은 객체의 상속 구조와 DB의 슈퍼타입 서브타입 관계를 매핑하는 것이다.

 

슈퍼타입 서브타입 논리 모델을 실제 물리 모델인 테이블로 구현할 때는 3가지 방법을 선택할 수 있다.

 

더보기

1.1 각각의 테이블로 변환

출처 : 자바 ORM 표준 JPA 프로그래밍

각각을 모두 테이블로 만들고 조회할 때 조인을 사용한다.

JPA에서는 조인 전략이라고 한다.

 

엔티티 각각을 모두 테이블로 만들고 자식 테이블이 부모 테이블의 기본 키를 받아서 기본 키 + 외래 키로 사용하는 전략이다.

따라서 조회할 때 조인을 자주 사용한다.

이 전략을 사용할 때 주의할 점이 있는데 객체는 타입으로 구분할 수 있지만 테이블은 타입의 개념이 없다.

따라서 타입을 구분하는 DTYPE 컬럼을 구분 컬럼으로 사용한다.

 

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item
{
    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;
    
    private String name;
    private int price;
    ...
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item
{
    private String artist;
    ...
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item
{
    private String director;
    private String actor;
    ...
}

 

위 코드를 분석해보자

 

01. @Inheritance(strategy = InheritanceType.JOINED)

상속 매핑은 부모 클래스에 @Inheritance를 사용해야 한다.

그래고 매핑 전략을 지정해야 하는데 여기서는 조인 적략을 사용하므로 InheritanceType.JOINED를 사용했다.

 

02. @DiscriminatorColumn(name = "DTYPE") 

부모 클래스에 구분 컬럼을 지정한다.

이 컬럼으로 저장된 자식 테이블을 구분할 수 있다.

기본값이 DTYPE이므로 @DiscriminatorColumn으로 줄여서 사용해도 된다.

 

03. @DiscriminatorValue("M")

엔티티를 저장할 때 구분 컬럼에 입력할 값을 지정한다.

만약 영화 엔티티를 저장하면 구분 컬럼인 DTYPE에 값 M이 저장된다.

 

기본 값으로 자식 테이블은 부모 테이블의 ID 컬럼명을 그대로 사용하는데, 만약 자식 테이블의 기본 키 컬럼명을 변경하고 싶으면 @PrimaryKeyJoinColumn을 사용하면 된다.

 

@Entity
@DiscriminatorValue("B")
@PrimaryKeyJoinColumn(name = "BOOK_ID") // ID 재정의
public class Book extends Item
{
    private String author;
    private String isbn;
    ...
}

BOOK 테이블의 ITEM_ID 기본 키 컬럼명을 BOOK_ID로 변경했다.

 

장점

  • 테이블이 정규화된다.
  • 외래 키 참조 무결성 제약조건을 활용할 수 있다.
  • 저장공간을 효율적으로 사용한다.

단점

  • 조회할 때 조인이 많이 사용되므로 성능이 저하될 수 있다.
  • 조회 쿼리가 복잡하다.
  • 데이터를 등록할 INSERT SQL을 두 번 실행한다.

특징

  • JPA 표준 명세는 구분 컬럼을 사용하도록 하지만 하이버네이트를 포함한 몇몇 구현체는 구분컬럼(@DiscriminatorColumn)없이도 동작한다.

 

 

02. 통합 테이블로 변환

테이블을 하나만 사용해서 통합한다.

JPA에서는 단일 테이블 전략이라고 한다.

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림과 같이 이름 그대로 테이블을 하나만 사용한다.

구분 컬럼(DTYPE)으로 어떤 자식 데이터가 저장되었는지 구분한다.

조회할 때 조인을 사용하지 않으므로 일반적으로 가장 빠르다.

 

이 전략을 사용할 때 주의점은 자식 엔티티가 매핑한 컬럼은 모두 null을 허용해야 한다.

예를 들어 Book 엔티티를 저장하면 ITEM 테이블의 AUTHOR, ISBN 컬럼만 사용하고 다른 엔티티와 매핑 된 ARTIST. DIRECTOR, ACTOR 컬럼은 사용하지 않으므로 null이 입력되기 때문이다.

 

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item
{
    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;
    
    private String name;
    private int price;
    ...
}

@Entity
@DiscriminatorValue("A")
public class Album extends Item { ... }


@Entity
@DiscriminatorValue("M")
public class Movie extends Item { ... }


@Entity
@DiscriminatorValue("B")
public class Book extends Item { ... }

 

InheritanceType.SINGLE_TABLE 로 지정하면 단일 테이블 전략을 사용한다.

테이블 하나에 모든 것을 통합하므로 구분 컬럼을 필수로 사용해야 한다.

단일 테이블 전략의 장단점은 하나의 테이블을 사용하는 특징과 관련 있다.

 

장점

  • 조인이 필요 없으므로 일반적으로 조회 성능이 빠르다.
  • 조회 쿼리가 단순하다.

단점

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

특징

  • 구분 컬럼을 꼭 사용해야 한다. 따라서 @DiscriminatorColumn을 꼭 설정해야 한다.
  • @DiscriminatorValue를 지정하지 않으면 기본으로 엔티티 이름을 사용한다.

03. 서브타입 테이블로 변환

서브 타입마다 하나의 테이블을 만든다.

JPA에서는 구현 클래스마다 테이블 전략이라 한다.

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림과 같이 자식 엔티티마다 테이블을 만든다.

그리고 자식 테이블 각각에 필요한 컬럼이 모두 있다.

 

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item
{
    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long id;
    
    private String name;
    private int price;
    ...
}

@Entity
public class Album extends Item { ... }

@Entity
public class Movie extends Item { ... }

@Entity
public class Book extends Item { ... }

InheritanceType.TABLE_PER_CLASS를 선택하면 구현 클래스마다 테이블 전략을 사용한다.

이 전략은 자식 엔티티마다 테이블을 만든다.

일반적으로 추천하지 않는 전략이다.

 

장점

  • 서브 타입을 구분해서 처리할 때 효과적이다.
  • not null 제약조건을 사용할 수 있다.

단점

  • 여러 자식 테이블을 함께 조회할 때 성능이 느리다(SQL에 UNION을 사용해야 한다.)
  • 자식 테이블을 통합해서 쿼리하기 어렵다.

특징

  • 구분 컬럼을 사용하지 않는다.

 

이 전략은 DB 설계자와 ORM 전문가 둘 다 추천하지 않는 전략이다.

조인이나 단일 테이블 전략을 고려하자.

2. @MappedSuperclass

더보기

지금까지 학습한 상속 관계 매핑은 부모 클래스와 자식 클래스를 모두 DB 테이블과 매핑했다.

부모 클래스는 테이블과 매핑하지 않고 부모 클래스를 상속받는 자식 클래스에게 매핑 정보만 제공하고 싶으면 @MappedSuperclass를 사용하면 된다.

 

@MappedSuperclass는 비유를 하자면 추상 클래스와 비슷한데 @Entity는 실제 테이블과 매핑되지만 @MappedSuperclass는 실제 테이블과는 매핑되지 않는다.

이것은 단순히 정보를 상속할 목적으로만 사용된다.

출처 : 자바 ORM 표준 JPA 프로그래밍
출처 : 자바 ORM 표준 JPA 프로그래밍

회원(Member)과 판매자(Seller)는 서로 관계가 없는 테이블과 엔티티이다.

@MappedSuperclass
public abstract class BaseEntity
{
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    ...
}

@Entity
public class Member extends BaseEntity
{
    // ID 상속
    // NAME 상속
    private String email;
    ...
}

@Entity
public class Seller extends BaseEntity
{
    // ID 상속
    // NAME 상속
    private String shopName;
    ...
}

테이블은 그대로 두고 객체 모델의 id, name 두 공통 속성을 부모 클래스로 모으고 객체 상속 관계로 만든 코드이다

 

BaseEntity에는 객체들이 주로 사용하는 공통 매핑 정보를 정의했다.

그리고 자식 엔티티들은 상속을 통해 BaseEntity의 매핑 정보를 물려받았다.

여기서 BaseEntity는 테이블과 매핑할 필요가 없고 자식 엔티티에게 공통으로 사용되는 매핑 정보만 제공하면 된다.

따라서 @MappedSuperclass를 사용했다.

 

부모로부터 물려받은 매핑 정보를 재정의하려면 @AttributedOverrides나 @AttributeOverride를 사용하고, 연관관계를 재정의하려면  @AssociationOverrides나 @AssociationOverride를 사용한다.

 

@Entity
@AttributeOverrides({
        @AttributeOverride(name = "id", column = @Column(name = "MEMBER_ID")),
        @AttributeOverride(name = "name", column = @Column(name = "MEMBER_NAME"))
})
public class Member extends BaseEntity { ... }

 

정리

  • 테이블과 매핑되지 않고 자식 클래스에 엔티티에 매핑 정보를 상속하기 위해 사용한다.
  • @MappedSuperclass로 지정한 클래스는 엔티티가 아니므로 em.find()나 JPQL에서 사용할 수 없다.
  • 이 클래스를 직접 생성해서 사용할 일은 거의 없으므로 추상 클래스로 만드는 것을 권장한다.
  • @MappedSuperclass를 사용하면 등록일자, 수정일자, 등록자, 수정자 같은 여러 엔티티에서 공통으로 사용하는 속성을 효과적으로 관리할 수 있다.

3. 복합 키와 식별 관계 매핑

더보기

01. 식별관계 vs 비식별 관계

DB 테이블 사이에 관계는 외래 키가 기본 키에 포함되는지 여부에 따라 식별 관계와 비식별 관계로 구분한다.

 

식별 관계

식별 관계는 부모 테이블의 기본 키를 내려받아서 자식 테이블의 기본 키 + 외래 키로 사용하는 관계다.

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림을 보면 PARENT 테이블의 기본 키 PARENT_ID를 받아서 CHILD 테이블의 기본 키(PK) + 외래 키(FK)로 사용한다.

 

비식별 관계

비식별 관계는 부모 테이블의 기본 키를 받아서 자식의 테이블의 외래 키로만 사용하는 관계이다.

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림을 보면 PARENT 테이블의 기본 키 PARENT_ID를 받아서 CHILD 테이블의 외래 키(FK)로만 사용한다.

 

비식별 관계는 외래 키에 NULL을 허용하는지에 따라 필수적 비식별 관계와 선택적 비식별 관계로 나눈다.

  • 필수적 비식별 관계(Mandatory) : 외래 키에 NULL을 허용하지 않는다. 연관관계를 필수적으로 맺어야 한다.
  • 선택적 비식별 관계(Optional) : 외래 키에 NULL을 허용한다. 연관관계를 맺을지 말지 선택할 수 있다.

DB 테이블을 설계할 때 식별 관계나 비식별 관계 중 하나를 선택해야 한다.

최근에는 비식별 관계를 주로 사용하고 꼭 필요한 곳에만 식별 관계를 사용하는 추세다.

JPA는 식별관계와 비식별 관계를 모두 지원한다.

 

식별 관계와 비식별 관계를 어떻게 매핑하는지 알아보자.

 

02. 복합키 : 비식별 관계 매핑

@Entity

public class Hello
{

    @Id
    private String id;
}

기본 키를 구성하는 컬림이 하나면 위 코드처럼 단순하게 매핑한다.

@Entity

public class Hello
{

    @Id
    private String id1;
    
    @Id
    private String id2; // 실행 시점에 매핑 예외 발생
}

둘 이상의 컬럼으로 구성된 복합 기본 키는 위 코드처럼 매핑하면 될 것 같지만 막상 해보면 매핑 오류가 발생한다.

JPA에서 식별자를 둘 이상 사용하려면 별도의 식별자 클래스를 만들어야한다.

 

JPA는 영속성 컨텍스트에 엔티티를 보관할 때 엔티티의 식별자를 키로 사용한다.

그리고 식별자를 구분하기 위해 equals와 hashCode를 사용해서 동등성 비교를 한다.

그런데 식별자 필드가 하나일 때는 보통 자바의 기본 타입을 사용하므로 문제가 없지만, 식별자 필드가 2개 이상이면 별도의 식별자 클래스를 만들고 그곳에 equals와 hashCode를 구현해야 한다.

 

JPA는 복합 키를 지원하기 위해 @IdClass와 @EmbeddedId 2가지 방법을 제공하는데 @IdClass는 관계형 DB에 가까운 방법이고 @EmbeddedId는 좀 더 객체지향에 가까운 방법이다.

 

 

@IdClass

자바 ORM 표준 JPA 프로그래밍

PARENT 테이블을 보면 기본 키를 PARENT_ID1, PARENT_ID2로 묶은 복합 키로 구성했다.

따라서 복합 키를 매핑하기 위해 식별자 클래스를 별도로 만들어야 한다.

 

부모 클래스

@Entity
@IdClass(ParentId.class)
public class Parent
{
    @Id
    @Column(name = "PARENT_ID1")
    private String id1; // ParentID.id1 과 연결
    
    @Id
    @Column(name = "PARENT_ID2")
    private String id2; // ParentID.id2 와 연결
    
    private String name;
    ...
}

 

식별자 클래스

public class ParentId implements Serializable
{
    private String id1; // Parent.id1 매핑
    private String id2; // Parent.id2 매핑
    
    public ParentId() {}
    
    public ParentId(String id1, String id2)
    {
        thid.id1 = id1;
        this.id2 = id2;
    }
    
    @Override
    public boolean equals(Object o) { ... }
    
    @Override
    public int hashCode() { ... }
}

PARENT 테이블을 매핑한 코드이다.

각각의 기본 키 컬럼을 @Id로 매핑했다.

그리고 @IdClass를 사용해서 ParentId 클래스를 식별자 클래스로 지정했다.

 

@Entity
public class Child
{
    @Id
    private String id;
    
    @ManyToOne
    @JoinColumns({
            @JoinColumn(name = "PARENT_ID1",
                referencedColumnName = "PARENT_ID1"),
            @JoinColumn(name = "PARENT_ID2",
                referencedColumnName = "PARENT_ID2")
    }
    private Parent parent;
}

부모 테이블의 기본 키 컬럼이 복합 키 이므로 자식 테이블의 외래 키도 복합 키다.

따라서 외래 키 매핑 시 여러 컬럼을 매핑해야 하므로 @JoinColumns 어노테이션을 사용하고 각각의 외래 키 커럼을 @JoinColumn으로 매핑한다.

 

참고로 위 코드처럼 JoinColumn의 name 속성과 referencedColumnName 속성의 값이 같으면 referencedColumnName은 생략해도 된다.

 

 

@EmbeddedId

@IdClass가 DB에 맞춘 방법이라면 @EmbeddedId는 좀 더 객체지향적인 방법이다.

@Entity
public class Parent
{
    @EmbeddedId
    private ParentId id;
    
    private String name;
    ...
}

Parent 엔티티에서 식별자 클래스를 직접 사용하고 @EmbeddedId 어노테이션을 적어주면 된다.

@Embeddable
public class ParentId implements Serializable
{
    @Column(name = "PARENT_ID1")
    private String id1;
    @Column(name = "PARENT_ID2")
    private String id2;
    
    // equals and hashCode 구현
    ...
}

@IdClass와는 다르게 @EmbeddedId를 적용한 식별자 클래스는 식별자 클래스에 기본 키를 직접 매핑한다.

@EmbeddedId를 적용한 식별자 클래스는 다음 조건을 만족해야한다.

  • @Embeddable 어노테이션을 붙여주어야 한다.
  • Serializable 인터페이스를 구현해야 한다.
  • equals, hashCode를 구현해야 한다.
  • 기본생성자가 있어야 한다.
  • 식별자 클래스는 public이어야한다.
Parent parent = new Parent();
ParentId parentId = new ParentId("myId1", "myId2");
parent.setId(parentId);
parent.setName("parentName");
em.persist(parent);

저장하는 코드이다.

식별자 클래스 parentId를 직접 생성해서 사용한다.

ParentId parentId = new ParentId("myId1", "myId2");
Parent parent = em.find(Parent.class, parentId);

조회 코드도 식별자 클래스 parentId를 직접 사용한다.

 

 

복합 키와 equals(), hashCode()

복합 키는 equals()와 hashCode()를 필수로 구현해야 한다.

ParentID id1 = new parentId();
id1.setId1("myId1");
id1.setId2("myId2");

ParentID id2 = new parentId();
id2.setId1("myId1");
id2.setId2("myId2");

id1.equals(id2) // ??

이것은 순수한 자바 코드이다.

id1과 id2 인스턴스 둘 다 myId1, myId2라는 같은 값을 가지고 있지만 인스턴스는 다르다.

그래서 오버라이딩을 하지 않으면 마지막 줄은 거짓이다.

그 이유는 기본 equals()는 인스턴스 참조 값 비교인 ==비교(동일성 비교)를 하기 때문이다.

 

영속성 컨텍스트는 엔티티의 식별자를 키로 사용해서 엔티티를 관리한다.

기르고 식별자를 비교할 때 equals() 와 hashCode()를 사용한다.

따라서 식별자 객체의 동등성이 지겨지지 않으면 예상과 다른 엔티티가 조회되거나 엔티티를 찾을수 없는 등 심각한 문제가 발생한다.

따라서 복합 키는 equals()와 hashCode()를 필수로 구현해야 한다.

 

03. 복합 키 : 식별 관계 매핑

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림은 부모, 자식, 손자까지 계속 기본 키를 전달하는 식별 관계다.

식별 관계에서 자식 테이블은 부모 테이블의 기본 키를 포함해서 복합 키를 구성해야 하므로 @IdClass나 @EmbeddedId를 사용해서 식별자를 매핑해야 한다.

 

@IdClass와 식별 관계

 

부모 엔티티

// 부모
@Entity
public class Parent
{
    @Id
    @Column(name = "PARENT_ID")
    private String id;
    private String name;
    ...
}

 

자식 엔티티

// 자식
@Entity
@IdClass(ChildId.class)
public class Child
{
    @Id
    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    public Parent parent;
    
    @Id
    @Column(name = "CHILD_ID")
    private String childId;
    
    private String name;
    ...
}

// 자식 ID
public class ChildId implements Serializable
{
    private String parent; // Child.parent 매핑
    private String childId; // Child.childId 매핑
    
    // equals, hashCode
    ...
}

 

손자 엔티티

// 손자
@Entity
@IdClass(GrandChildId.class)
public class GrandChild
{
    @Id
    @ManyToOne
    @JoinColumn({
            @JoinColumn(name = "PARENT_ID"),
            @JoinColumn(name = "CHILD_ID")
    }
    private Child child;
    
    @Id
    @Column(name = "GRANDCHILD_ID")
    private String id;
    
    private String name;
    ...
}

// 손자 ID
public class GrandChildId implements Serializable
{
    private ChildId child; // GrandChild.child 매핑
    private String id; // GrandChild.id 매핑
    
    // equals, hashCode
    ...
}

 

식별 관계는 기본 키와 외래 키를 같이 매핑해야 한다.

@Id
@ManyToOne
@JoinColumn(name = "PARENT_ID")
public Parent parent;

따라서 식별자 매핑인 @Id와 연관관계 매핑인 @ManyToOne을 같이 사용하면 된다.

Child 엔티티의 parent 필드를 보면 @Id로 기본 키를 매핑하면서 @ManyToOne과 @JoinColumn으로 외래 키를 같이 매핑한다.

 

 

@EmbeddedId와 식별 관계

@EmbeddedId로 식별 관계를 구성할 때는 @MapsId를 사용해야 한다.

 

부모 엔티티

// 부모
@Entity
public class Parent
{
    @Id
    @Column(name = "PARENT_ID")
    private String id;
    
    private String name;
    ...
}

 

자식 엔티티

// 자식
@Entity
public class Child
{
    @EmbeddedId
    private ChildId id;
    
    @MapsId("parentId") // ChildId.parentId 매핑
    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    public Parent parent;
    
    private String name;
    ...
}

//자식 ID
@Embeddable
public class ChildId implements Serializable
{
    private String parentId; // @MapsId("parentId")로 매핑
    
    @Column(name = "CHILD_ID")
    private String id;
    
    // equals, hashCode
    ...
}

 

손자 엔티티

// 손자
@Entity
public class GrandChild
{
    @EmbeddedId
    private GrandChildId id;
    
    @MapsId("childId") // GrandChildId.childId 매핑
    @ManyToOne
    @JoinColumns({
            @JoinColumn(name = "PARENT_ID"),
            @JoinColumn(name = "CHILD_ID")
    })
    private Child child;
    
    private String name;
    ...
}

// 손자 ID
@Embeddable
public class GrandChildId implements Serializable
{
    private ChildId childId; // @MapsId("childId")로 매핑
    
    @Column(name = "GRANDCHILD_ID")
    private String id;
    
    // equals, hashCode
    ...
}

 

@EmbeddedID는 식별 관계로 사용할 연관관계의 속성에 @MapsId를 사용하면 된다.

@MapsId("parentId")
@ManyToOne
@JoinColumn(name = "PARENT_ID")
public Parent parent;

@IdClass와 다른 점은 @Id 대신에 @MapsId를 사용한 점이다.

@MapsId는 외래 키와 매핑한 연관관계를 기본 키에도 매핑하겠다는 뜻이다.

@MapsId의 속성 값은 @EmbeddedId를 사용한 식별자 클래스의 기본 키 필드를 지정하면 된다.

 

04. 비식별 관계로 구현

출처 : 자바 ORM 표준 JPA 프로그래밍

 

부모 엔티티

@Entity
public class Parent
{
    @Id
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    private String name;
    ...
}
@Entity
public class Child
{
    @Id
    @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;
    
    @ManyToOne
    @JoinColumn(name = "PARENT_ID")
    private Parent parent;
    ...
}
@Entity
public class GrandChild
{
    @Id
    @GeneratedValue
    @Column(name = "GRANDCHILD_ID")
    private Long id;
    private String name;
    
    @ManyToOne
    @JoinColumn(name = "CHILD_ID")
    private Child child;
    ...
}

 

식별 관계의 복합 키를 사용한 코드와 비교하면 매핑도 쉽고 코드도 단순하다.

그리고 복합 키가 없으므로 복합 키 클래스를 만들지 않아도 된다.

 

05. 일대일 식별 관계

 

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림에서 일대일 식별 관계는 자식 테이블의 기본 키 값으로 부모 테이블의 기본 키 값만 사용한다.

그래서 부모 테이블의 기본 키가 복합 키가 아니면 자식 테이블의 기본 키는 복합 키로 구성하지 않아도 된다.

// 부모
@Entity
public class Board
{
    @Id
    @GeneratedValue
    @Column(name = "BOARD_ID")
    private Long id;
    
    private String title;
    
    @OneToOne(mappedBy = "board")
    private BoardDetail boardDetail;
    ...
 }
 
 // 자식
 @Entity
 public class BoardDetail
 {
     @Id
     private Long boardId;
     
     @MapsId // BoardDetail.boardId 매핑
     @OneToOne
     @JoinColumn(name = "BOARD_ID")
     private Board board;
     
     private String content;
     ...
}

 

BoardDetail처럼 식별자가 단순히 컬럼 하나면 @MapsId를 사용하고 속성 값은 비워두면 된다.

이때 @MapsId는 @Id를 사용해서 식별자로 지정한 BoardDetail.boardId와 매핑된다.

public void save()
{
    Board board = new Board();
    board.setTitle("제목");
    em.persist(board);
    
    BoardDetail boardDetail = new BoardDetail();
    boardDetail.setContent("내용");
    boardDetail.setBaord(board);
    em.persist(boardDetail);
}

저장하는 코드.

 

06. 식별, 비식별 관계의 장단점

 

DB 설계 관점에서 보면 다음과 같은 이유로 식별 관계보다는 비식별 관계를 선호한다.

  • 식별 관계는 부모 테이블의 기본 키를 자식 테이블로 전파하면서 자식 테이블의 기본 키 컬럼이 점점 늘어난다. 때문에 조인할 때 SQL이 복잡해지고 기본 키 인텍스가 불필요하게 커질 수 있다.
  • 식별 관계는 2개 이상의 컬럼을 합해서 복합 기본 키를 만들어야 하는 경우가 많다.
  • 식별 관계를 사용할 때 기본 키로 비즈니스 의미가 있는 자연 키 컬럼을 조합하는 경우가 많다. 반면에 비식별 관계의 기본 키는 비즈니스와 전혀 관계없는 대리 키를 주로 사용한다. 비즈니스 요구사항은 시간이 지마넹 따라 언젠가는 변한다. 식별 관계으 ㅣ자연 키 컬럼들이 자식에 손자까지 전파되면 변경하기 힘들다.
  • 식별 관계는 부모 테이블의 기본 키를 자식 테이블의 키본 키로 사용하므로 비식별 관계보다 테이블 구조가 유연하지 못하다.

객체 관계 매핑의 관점에서 보면 다음과 같은 이유로 비식별 관계를 선호한다.

  • 일대일 관계를 제외하고 식별 관계는 2개 이상의 컬럼을 묶은 복합 기본 키를 사용한다. JPA에서 복합 키는 별도의 복합 키 클래스를 만들어서 사용해야 한다. 따라서 컬럼이 하나인 기본 키를 매핑하는 것보다 많은 노력이 필요하다.
  • 비식별 관계의 기본 키는 주로 대리 키를 사용하는데 JPA는 @GenerateValue처럼 대리 키를 생성하기 위한 편리한 방법을 제공한다.

물론 식별 관계가 가지는 장점도 있다.

기본 키 인덱스를 활용하기 좋고, 상위 테이블들의 기본 키 컬럼을 자식, 손자 테이블들이 가지고 있으므로 특정 상황에 조인 없이 하위 테이블만으로 검색을 완료할 수 있다.

4. 조인 테이블

더보기

DB 테이블의 연관관계를 설계하는 방법은 크게 2가지다.

  • 조인 컬럼 사용(외래 키)
  • 조인 테이블 사용(테이블 사용)

조인 컬럼 사용

테이블 간에 관계는 주로 조인 컬럼이라 부르는 외래 키 컬럼을 사용해서 관리한다.

출처 : 자바 ORM 표준 JPA 프로그래밍

회원과 사물함이 있다.

각각 테이블에 데이터를 등록했다가 회원이 원할 때 사물함을 선택할수 있다는 가정.

회원이 사물함을 사용하기 전까지는 아직 둘 사이에 관계가 없으므로 MEMBER 테이블의 LOCKER_ID 외래 키에 null을 입력해두어야 한다.

이렇게 외래 키에 null을 허용하는 관계를 선택적 비식별 관계라 한다.

출처 : 자바 ORM 표준 JPA 프로그래밍

선택적 비식별 관계는 외래 키에 null을 허용하므로 회원과 사물함을 조인할 때 외부 조인을 사용해야 한다.

실수로 내부 조인을 사용하면 사물함과 관계가 없는 회원은 조회되지 않는다.

그리고 회원과 사물함이 아주 가끔 관계를 맺는다면 외래 키 값 대부분이 null로 저장되는 단점이 있다.

 

조인 테이블 사용

출처 : 자바 ORM 표준 JPA 프로그래밍

이 방법은 조인 테이블이라는 별도의 테이블을 사용해서 연관관계를 관리한다.

위의 조인 컬럼과 비교해보면 조인 컬럼을 사용하는 방법은 단순히 외래 키 컬럼만 추가해서 연관관계를 맺지만

조인 테이블을 사용하는 방법은 연관관계를 관리하는 조인 테이블을 추가하고 여기서 두 테이블의 외래 키를 가지고 연관관계를 관리한다.

따라서 MEMBER와 LOCKER에는 연관관계를 관리하기 위한 외래 키 컬럼이 없다.

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림을 보면 회원과 사물함 데이터를 각각 등록했다가 회원이 원할 때 사물함을 선택하면 MEMBER_LOCKER 테이블에만 값을 추가하면 된다.

 

조인 테이블의 가장 큰 단점은 테이블을 하나 추가해야 한다는 점이다.

따라서 관리해야 하는 테이블이 늘어나고 회원과 사물함 두 테이블을 조인하려면 MEMBER_LOCKER 테이블까지 추가로 조인해야 한다.

따라서 기본은 조인 컬럼을 사용하고 필요하다고 판단되면 조인 테이블을 사용하는게 바람직하다.

 

01. 일대일 조인 테이블

 

출처 : 자바 ORM 표준 JPA 프로그래밍

일대일 관계를 만들려면 조인 테이블의 외래 키 컬럼 각각에 총 2개의 유니크 제약조건을 걸어야 한다.

(PARENT_ID는 기본 키이므로 유니크 제약조건이 걸려있다.)

// 부모
@Entity
public class Parent
{
    @Id
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    private String name;
    
    @OneToOne
    @JoinTable(name = "PARENT_CHILD",
            joinColumns = @JoinColumn(name = "PARENT_ID"),
            inverseJoinColumns = @JoinColumn(name = "CHILD_ID")
    )
    private Chile child;
    ...
}

// 자식
@Entity
public class Child
{
    @Id
    @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;
    ...
}

부모 엔티티를 보면 @JoinColumn 대신에 @JoinTable을 사용했다.

@JoinTable의 속성은 다음과 같다.

  • name : 매핑할 조인 테이블 이름
  • joinColumns : 현재 엔티티를참조하는 외래 키
  • inverseJoinColumns : 반대방향 엔티티를 참조하는 외래 키
public class Child
{
    ...
    @OneToOne(mappedBy = "child")
    private Parent parent;
}

양방향으로 매핑하려면 위 코드를 추가하면 된다

 

02. 일대다 조인 테이블

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림은 일대다 관계이다.

일대다 관계를 만들려면 조인 테이블의 컬럼 중 다(N)와 관련된 컬럼인 CHILD_ID에 유니크 제약조건을 걸어야 한다.

(CHILD_ID는 기본 키 이므로 유니크 제약조건이 걸려있다.)

 

// 부모
@Entity
public class Parent
{
    @Id
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    private String name;
    
    @OneToMany
    @JoinTable(name = "PARENT_CHILD",
            joinColumns = @JoinColumn(name = "PARENT_ID"),
            inverseJoinColumns = @JoinColumn(name = "CHILD_ID")
    )
    private List<Child> child = new ArrayList<Child>();
    ...
}

// 자식
@Entity
public class Child
{
    @Id
    @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;
    ...
}

 

03. 다대일 조인 테이블

다대일은 일대다에서 방향만 반대이다.

 

04. 다대다 조인 테이블

출처 : 자바 ORM 표준 JPA 프로그래밍

위 그림은 다대다 관계다.

다대다 관계를 만들려면 조인 테이블의 두 컬럼을 합해서 하나의 복합 유니크 제약조건을 걸어야한다.

(PARENT_ID, CHILD_ID는 기본 키이므로 유니크 제약조건이 걸려 있다.)

// 부모
@Entity
public class Parent
{
    @Id
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;
    private String name;
    
    @ManyToMany
    @JoinTable(name = "PARENT_CHILD",
            joinColumns = @JoinColumn(name = "PARENT_ID"),
            inverseJoinColumns = @JoinColumn(name = "CHILD_ID")
    )
    private List<Child> child = new ArrayList<Child>();
    ...
}

// 자식
@Entity class Child
{
    @Id
    @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;
    ...
}

 

5. 엔티티 하나에 여러 테이블 매핑

더보기

잘 사용하지는 않지만 @SecondaryTable을 사용하면 한 엔티티에 여러 테이블을 잘 매핑할 수 있다.

출처 : 자바 ORM 표준 JPA 프로그래밍
@Entity
@Table(name = "BOARD")
@SecondaryTable(name = "BOARD_DETAIL",
    pkJoinColumns = @PrimaryKeyJoinColumn(name = "BOARD_DETAIL_ID"))
public class Board
{
    @Id
    @GeneratedValue
    @Column(name = "BOARD_ID")
    private Long id;
    
    private String title;
    
    @Column(table = "BOARD_DETAIL")
    private String content;
    ...
}

Board 엔티티는 @Table을 사용해서 BOARD 테입르과 매핑했다.

그리고 @SecondaryTable을 사용해서 BOARD_DETAIL 테이블을 추가로 매핑했다.

 

@SecondaryTable 속성

  • @SecondaryTable.name :  매핑할 다른 테이블의 이름, 예제에서는 테이블명을 BOARD_DETAIL로 지정했다.
  • @SecondaryTable.pkJoinColumns : 매핑할 다른 테이블의 기본 키 컬럼 속성, 예제에서는 기본 키 컬럼 명을 BOARD_DETAIL_ID로 지정했다. 

content 필드는 @Column(table = "BOARD_DETAIL")을 사용해서 BOARD_DETAIL 테이블의 컬럼에 매핑했다.

title 필드처럼 테이블을 지정하지 않으면 기본 테이블인 BOARD에 매핑된다.

 

더 많은 테이블을 매핑하려면 @SecondaryTables를 사용하면 된다.

@SecondaryTables({
    @SecondaryTable(name = "BOARD_DETAIL"),
    @SecondaryTable(name = "BOARD_FILE")
})

 

@SecondaryTable을 사용해서 두 테이블을 하나의 엔티티에 매핑하는 방법보다는 테이블당 엔티티를 각각 만들어서 일대일 매핑하는 것을 권장한다.

728x90

'JPA' 카테고리의 다른 글

JPA TIL - 15 고급 주제와 성능 최적화  (0) 2024.07.10
JPA TIL - 09 값 타입  (0) 2024.05.15
JPA TIL - 06 다양한 연관관계 매핑  (0) 2024.04.16
JPA TIL - 05 연관관계 매핑 기초  (0) 2024.04.09
JPA TIL - 04 엔티티 매핑  (0) 2024.04.02
728x90

엔티티의 연관관계를 매핑할 때는 다음 3가지를 고려해야 한다.

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

1. 다대일

다대일 관계의 반대 방향은 항상 일대다 관계고, 일대다 관계의 반대 방향은 항상 다대일 관계다.

DB 테이블의 일대다 관계에서 외래키는 항상 다쪽에 있다.

예를 들어 회원(N)과 팀(1)이 있으면 항상 회원 쪽이 연관관계의 주인이다.

 

01. 다대일 단방향[N:1]

더보기
자바 ORM 표준 JPA 프로그래밍

 

회원 엔티티

@Entity
public class Member
{
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    //Getter, Setter ...
    
}

 

팀 엔티티

@Entity
public class Team
{
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    // Getter, Setter ...
    
}

회원은 Memger.team으로 팀 엔티티를 참조할 수 있지만 반대로 팀에는 회원을 참조하는 필드가 없다.

따라서 회원과 팀은 다대일 단방향 연관관계이다.

 

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

@JoinColumn(name = "TEAM_ID")를 사용해서 Member.team 필드를 TEAM_ID 외래 키와 매핑했다.

따라서 Member.team 필드로 회원 테이블의 TEAM_ID 외래키를 관리한다.

 

02. 다대일 양방향[N:1, 1:N]

 

더보기
자바 ORM 표준 JPA 프로그래밍

위 사진에서 실선은 연관관계의 주인이고, 점선은 연관관계의 주인이 아니다.

 

회원 엔티티

@Entity
public class Member
{
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
    
    public void setTeam(Team team)
    {
        this.team = team;
        
        // 무한루프에 빠지지 않도록 체크
        if(!team.getMembers().contains(this)
        {
            team.getMembers().add(this);
        }
    }
}

 

팀 엔티티

@Entity
public class Team
{
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();
    
    public void addMember(Member member)
    {
        this.members.add(member);
        // 무한루프에 빠지지 않도록 체크
        if(member.getTeam() != this)
        {
            member.setTeam(this);
        }
    }
}

 

  • 양방향 외래 키가 있는 쪽이 연관관계의 주인이다.

일대다와 다대일 연관관계는 항상 다(N)에 외래 키가 있다.

여기서는 MEMBER 테이블이 외래 키를 가지고 있으므로 Member.team이 연관관계의 주인이다.

JPA는 외래 키를 관리할 때 연관관계의 주인만 사용한다.

주인이 아닌 Team.members는 조회를 위한 JPQL이나 객체 그래프를 탐색할 때 사용한다.

 

  • 양방향 연관관계는 항상 서로를 참조해야 한다

양방향 연관관계는 항상 서로 참조해야 한다.

어느 한 쪽만 참조하면 양방향 연관관계가 성립하지 않는다.

항상 서로 참조하게 하려면 연관관계 편의 메소드를 작성하는 것이 좋은데 회원의 setTeam(), 팀의 addMember() 메소드가 이런 편의 메소드들이다.

편의 메소드는 한 곳에만 작성하거나 양쪽 다 작성할 수 있는데, 양쪽에 다 작성하면 무한루프에 빠지므로 주의해야 한다.


2. 일대다

일대다 관계는 다대일 관계의 반대 방향이다.

일대다 관계는 엔티티를 하나 이상 참조할 수 있으므로 자바 컬렉션인 Collection, List, Set, Map중에 하나를 사용해야 한다.

 

01. 일대다 단방향[1:N]

더보기

하나의 팀은 여러 회원을 참조할 수 있는데 이런 관계를 일대다 관계라고 한다.

팀은 회원들을 참조하지만 반대로 회원은 팀을 참조하지 않으면 둘의 관계는 단방향이다.

자바 ORM 표준 JPA 프로그래밍

일대다 단방향 관계는 약간 특이하다.

위 그림을 보면 팀 엔티티의 Team.members로 회원 테이블의 TEAM_ID 외래 키를 관리한다.

보통 자신이 매핑한 테이블의 외래 키를 관리하는데, 이 매핑은 반대쪽 테이블에 있는 외래 키를 관리한다.

어쩔 수 없는게, 일대다 관계에서 외래 키는 항상 다쪽 테이블에 있다.

따라서 반대편 테이블의 외래 키를 관리하는 특이한 모습이 나타난다.

 

팀 엔티티

@Entity
public class Team
{
    @Id 
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    @OneToMany
    @JoinColumn(name = "TEAM_ID") // MEMBER 테이블의 TEAM_ID(FK)
    private List<Member> members = new ArrayList<Member>();
    
    // Getter, Setter ...
}

 

회원 엔티티

@Entity
public class Member
{
    @Id 
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    // Getter, Setter ...
}

일대다 단방향 관계를 매핑할 때는 @JoinColumn을 명시해야 한다.

그렇지 않으면 연결 테이블을 중간에 두고 연관관계를 관리하는 조인 테이블(Join Table) 전략을 기본으로 사용해서 매핑한다.

 

  • 일대다 단방향 매핑의 단점

일대다 단방향 매핑의 단점은 매핑한 객체가 관리하는 외래 키가 다른 테이블에 있다는 점이다.

본인 테이블에 외래 키가 있으면 엔티티의 저장과 연관관계 처리를 INSERT SQL 한번으로 끝낼 수 있지만, 다른 테이블에 외래 키가 있으면 연관관계 처리를 위한 UPDATE SQL을 추가로 실행해야 한다.

public void testSave()
{
    Member member1 = new Member("member1");
    Member member2 = new Member("member2");    
    
    Team team1 = new Team("team1");
    team1.getMembers().add(member1);
    team1.getMembers().add(member2);    
    
    em.persist(member1); // INSERT-member1
    em.persist(member2); // INSERT-member2    
    em.persist(team1); // INSERT-team1
    				   // UPDATE-member1.fk,
    				   // UPDATE-member2.fk,                       
    transaction.commit();
}

위 코드를 실행한 결과 SQL은 다음과 같다.

insert into Member (MEMBER_ID, username) values (null, ?)
insert into Member (MEMBER_ID, username) values (null, ?)
insert into Team (TEAM_ID, name) values (null, ?)
update Member set TEAM_ID=? where MEMBER_ID=?
update Member set TEAM_ID=? where MEMBER_ID=?

 

Member엔티티는 Team엔티티를 모른다.

그리고 연관관계에 대한 정보는 Team엔티티의 members가 관리한다.

따라서 Member 엔티티를 저장할 때는 MEMBER테이블의 TEAM_ID 외래 키에 아무 값도 저장되지 않는다.

대신 Team엔티티를 저장할 때 Team.members의 참조 값을 확인해서 회원 테이블에 있는 TEAM_ID 외래 키를 업데이트한다.

 

결국 일대다 단방향  매핑보다는 다대일 양방향 매핑을 사용하는게 맞다.

일대다 단방향 매핑을 사용하면 엔티티를 매핑한 테이블이 아닌 다른 테이블의 외래 키를 관리해야 한다.

이것은 성능 문제 뿐만 아니라 관리도 부담스럽게 된다.

반면에 다대일 양방향 매핑은 관리해야 하는 외래 키가 본인 테이블에 있어서 위와 같은 문제가 발생하지 않는다.

02. 일대다 양방향[1:N, N:1]

 

더보기

양방향 매핑에서 @OneToMany는 연관관계의 주인이 될 수 없다.

왜냐하면 관계형 DB의 특성상 일대다, 다대일 관계는 항상 다쪽에 외래 키가 있다.

 

그렇다고 해도 일대다 양방향 매핑이 완전히 불가능한 것은 아니다.

일대다 단방향 매핑 반대편에 같은 외래 키를 사용하는 다대일 단방향 매핑을 읽기 전용으로 하나 추가하면 된다.

 

자바 ORM 표준 JPA 프로그래밍

팀 엔티티

@Entity
public class Team
{
    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;
    
    private String name;
    
    @OneToMany
    @JoinColumnn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<Member>();
    
    // Getter, Setter ...

}

회원 엔티티

@Entity
public class Member
{
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    pricate String username;
    
    @ManyToOne
    @JoinColumn(name = "TEAM_ID", insertable = false, updatable = false)
    private Team team;
    
    // Getter, Setter ...
    
}

일대다 단방향 매핑 반대편에 단방향 매핑을 추가했다.

이렇게 되면 둘 다 같은 키를 관리하므로 문제가 발생할 수 있다.

따라서 반대편인 다대일 쪽에 insertable = false, updatable = false로 설정해서 읽기만 가능하게 했다.

 

이 방법은 일대다 양방향 매핑이라기 보다는 단방향 매핑 반대편에 읽기 전용으로 추가해서 양방향처럼 보이도록 하는 방법이다.

그래서 되도록이면 다대일 양방향 매핑을 사용하자.


3. 일대일[1:1]

일대일 관계는 양쪽이 서로 하나의 관계만 가진다.

예를 들어 회원은 하나의 사물함만 사용하고 사물함도 하나의 회원에 의해서만 사용된다.

 

일대일 관계는 다음과 같은 특징이 있다.

  • 일대일 관계는 그 반대도 일대일 관계다.
  • 테이블 관계에서 일대다, 다대일은 항상 다(N)쪽이 외래키를 가질수 있는 반면, 일대일 관계는 둘 중 어느곳이나 외래 키를 가질 수 있다.

테이블은 주 테이블이든 대상 테이블이든 외래 키 하나만 있으면 양쪽으로 조회할 수 있다.

그리고 일대일 관계는 그 반대쪽도 일대일 관계이기 때문에 누가 외래 키를 가질지 선택해야 한다.

 

  • 주 테이블에 외래 키

주 객체가 대상 객체를 참조하는 것처럼 주 테이블에 외래 키를 두고 대상 테이블을 참조한다.

외래 키를 객체 참조와 비슷하게 사용할 수 있어서 객체지향 개발자들이 주로 선호한다.

이 방법의 장점은 주 테이블이 외래 키를 가지고 있으므로 주 테이블만 확인해도 대상 테이블과 연관관계가 있는지 알 수 있다.

 

  • 대상 테이블에 외래 키

전통적인 DB 개발자들은 보통 대상 테이블에 외래 키를 두는 것을 선호한다.

이 방법의 장점은 테이블 관계를 일대일에서 일대다로 변경할 때 테이블 구조를 그대로 유지할 수 있다.

 

01. 주 테이블에 외래 키

더보기

일대일 관계를 구성할 때 객체지향 개발자들은 주 테이블에 외래 키가 있는 것을 선호한다.

JPA도 주 테이블에 외래키가 있으면 좀 더 편리하게 매핑할 수 있다.

 

단방향

MEMBER가 주 테이블이고 LOCKER는 대상 테이블이다.

자바 ORM 표준 JPA 프로그래밍
@Entity
public class Member
{
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
    ...
}

@Entity
public class Locker
{
    @Id
    @GerneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    ...
}

일대일 관계이므로 객체 매핑에 @OneToOne을 사용했고 DB에는 LOCKER_ID 외래 키에 유니크 제약 조건(UNI)을 추가했다.

참고로 이 관계는 다대일 단방향(@ManyToOne)과 거의 비슷하다.

 

다음은 반대 방향을 추가해서 일대일 양방향 관계이다.

자바 ORM 표준 JPA 프로그래밍
@Entity
public class Member
{
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @OneToOne
    @JoinColumn(name = "LOCKER_ID")
    private Locker locker;
    ...
}

@Entity
public class Locker
{
    @Id
    @GerneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    
    @OneToOne(mappedBy = "locker")
    private Member member;
    ...
}

양방향이므로 연관관계의 주인을 정해야 한다.

MEMBER 테이블이 외래 키를 가지고 있으므로 Member 엔티티에 있는 Member.locker가 연관관계의 주인이다.

따라서 반대 매핑인 사물함의 Locker.member는 mappedBy를 선언해서 연관관계의 주인이 아니라고 설정했다.

02. 대상 테이블에 외래 키

 

더보기

단방향

자바 ORM 표준 JPA 프로그래밍

일대일 관계 중 대상 테이블에 외래 키가 있는 단방향 관계는 JPA에서 지원하지 않는다.

그리고 이런 모양으로 매핑할 수 있는 방법도 없다.

이때는 단방향 관계를 Locker에서 Member방향으로 수정하거나, 양방향 관계로 만들고 Locker를 연관관계의 주인으로 설정해야 한다.

이 방법은 다음 양방향에서 나온다.

(참고로 JPA2.0부터 일대다 단방향 관계에서 대상 테이블에 외래 키가 있는 매핑을 허용했다. 하지만 일대일 단방향은 이런 매핑을 허용하지 않는다.)

 

 

양방향

자바 ORM 표준 JPA 프로그래밍
@Entity
public class Member
{
    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;
    
    private String username;
    
    @OneToOne(mappedBy = "member")
    private Locker locker;
    ...
}

@Entity
public class Locker
{
    @Id
    @GeneratedValue
    @Column(name = "LOCKER_ID")
    private Long id;
    
    private String name;
    
    @OneToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    ...
}

일대일 매핑에서 대상 테이블에 외래 키를 두고 싶으면 이렇게 양방향으로 매핑한다.

주 엔티티인 Member 엔티티 대신에 대상 엔티티인 Locker를 연관관계의 주인으로 만들고, LOCKER 테이블의 외래 키를 관리하도록 했다.


4. 다대다 [N:N]

자바 ORM 표준 JPA 프로그래밍

관계형 DB는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다.

때문에 보통 다대다 관계를 일대다, 다대일 관계로 풀어내는 연결 테이블을 사용한다.

 

예를 들어 회원들은 상품을 주문한다.

반대로 상품들은 회원들에 의해 주문된다.

 

둘은 다대다 관계다.

따라서 회원 테이블과 상품 테이블만으로는 이 관계를 표현할 수 없다.

자바 ORM 표준 JPA 프로그래밍

그래서 위 그림처럼 중간에 연결 테이블을 추가해야 한다.

이 테이블을 사용해서 다대다 관계를 일대다, 다대일 관계로 풀어낼 수 있다.

 

자바 ORM 표준 JPA 프로그래밍

그런데 객체는 테이블과 다르게 객체 2개로 다대다 관계를 만들 수 있다.

예를 들어 회원 객체는 컬렉션을 사용해서 상품들을 참조하고,

반대로 상품들도 회원 객체를 컬렉션을 사용해서 참조하면 된다.

 

@ManyToMany를 사용하면 위 그림처럼 다대다 관계를 편리하게 매핑할 수 있다.

 

01. 다대다 : 단방향

더보기

회원 엔티티

@Entity
public class Member
{
    @Id 
    @Column(name = "MEMBER_ID")
    private String id;
    
    private String username;
    
    @ManyToMany
    @JoinTable(
        name = "MEMBER_PRODUCT", 
        joinColumns = @JoinColumn(name = "MEMBER_ID"), 
        inverseJoinColums = @JoinColum(name = "PRODUCT_ID")
        )
    private List<Product> products = new ArrayList<Product>();
    ...
}

상품 엔티티

@Entity
public class Product
{
    @Id
    @Column(name = "PRODUCT_ID")
    private String id;
    
    private String name;
    ...
}

회원 엔티티와 상품 엔티티를 @ManyToMany로 매핑했다.

여기서 중요한 점은 @ManyToMany와 @JoinTable 을 사용해서 연결 테이블을 바로 매핑한 것이다.

따라서 회원과 상품을 연결하는 회원_상품(Member_Product) 엔티티 없이 매핑을완료할 수 있다.

 

다음은 @JoinTable의 속성이다.

  • @JoinTable.name : 연결 테이블을 지정한다. 여기서는 MEMBER_PRODUCT 테이블을 선택했다.
  • @JoinTable.joinColums : 현재 방향인 회원과 매핑할 조인 컬럼 정보를 지정한다. MEMBER_ID로 지정했다.
  • @JoinTable.inverseJoinColumns : 반대 방향인 상품과 매핑할 조인 컬럼 정보를 지정한다. PRODUCT_ID로 지정했다.

MEMBER_PRODUCT 테이블은 다대다 관계를 일대다, 다대일 관계로 풀어내기 위해 필요한 연결 테이블이 뿐이다.

@ManyToMany로 매핑한 덕분에 다대다 관계를 사용할 때는 이 연결 테이블을 신경 쓰지 않아도 된다.

저장

public void save()
{
    Product productA = new Product();
    productA.setId("productA");
    productA.setName("상품A");
    em.persist(productA);
    
    Member member1 = new Member();
    member1.setId("member1");
    member1.setUsername("회원1");
    member1.getProducts().add(productA) // 연관관계 설정
    em.persist(member1);
    
}

회원1과 상품A의 연관관계를 설정했으므로 회원1을 저장할 때 연결 테이블에도 값이 저장된다.

따라서 이 코드를 실행하면 다음과 같은 SQL이 실행된다.

INSERT INTO PRODUCT ...
INSERT INTO MEMBER ...
INSERT INTO MEMBER_PRODUCT ...

조회

public void find()
{
    Member member = em.find(Member.class, "member1");
    List<Product> products = member.getProducts(); // 객체 그래프 탐색
    for (Product p : products)
    {
        System.out.println("product.name = " + p.getName());
    }
}

member.getProducts()를 호출해서 상품 이름을 출력하면 다음 SQL이 실행된다.

SELECT * FROM MEMBER_PRODUCT MP
INNER JOIN PRODUCT P ON MP.PRODUCT_ID=P.PRODUCT_ID
WHERE MP.MEMBER_ID=?

실행된 SQL을 보면 연결 테이블인 MEMBER_PRODUCT와 상품 테이블을 주인해서 연관된 상품을 조회한다.

 

@ManyToMany 덕분에 복잡한 다대다 관계를 애플리케이션에서는 아주 단순하게 사용할 수 있다.

02. 다대다 : 양방향

더보기
@Entity
public class Product
{
    @Id
    private String id;
    
    @ManyToMany(mappedBy = "products") // 역방향 추가
    private List<Member> members;
    ...
}

위 코드를 보면 다대다 매핑이므로 역방향도 @ManyToMany를 사용한다.

그리고 양쪽 중 원하는 곳에 mappedBy로 연관관계의 주인을 지정한다(물론 mappedBy가 없는 곳이 연관관계의 주인이다.)

member.getProducts().add(product);
product.getMembers().add(member);

다대다의 양방향 연관관계는 다음처럼 설정하면 된다.

 

public void addProduct(Product product)
{
    ...
    products.add(product);
    product.getMembers().add(this);

양방향 연관관계는 연관관계 편의 메소드를 추가해서 관리하는 것이 편리하다.

위 코드처럼 회원 엔티티에 연관관계 편의 메소드를 추가했다.

 

연관관계 편의 메소드를 추가했으므로 .addProduct() 메소드로 간단히 연관관계를 설정할 수 있게 되었다.

public void findInverse()
{
    Product product = em.find(Product.class, "productA");
    List<Member> members = product.getMembers();
    for (Member m : members)
    {
        System.out.println("member = " + member.getUsername());
    }
}

양방향 연관관계로 만들었으므로 product.getMembers()를 사용해서 역방향으로 객체 그래프를 탐색할 수 있다.

03. 다대다 : 매핑의 한계와 극복, 연결 엔티티 사용

더보기

@ManyToMany를 사용하면 연결 테이블을 자동으로 처리해주므로 도메인 모델이 단순해지고 여러 가지로 편리하다.

하지만 이 매핑을 실무에서 사용하기에는 한계가 있다.

자바 ORM 표준 JPA 프로그래밍

예를 들어 회원이 상품을 주문하면 연결 테이블에 단순히 주문한 회원 아이디와 상품 아이디만 담고 끝나지 않는다.

보통은 연결 테이블에 주문 수량 컬럼이나 주문한 날짜 같은 컬럼이 더 필요하다.

 

위 그림을 보면 연결 테이블에 주문 수량(ORDERAMOUNT) 과 주문 날짜(ORDERDATE) 컬럼을 추가했다.

이렇게 컬럼을 추가하면 더는 @ManyToMany를 사용할 수 없다.

왜냐하면 주문 엔티티나 상품 엔티티에는 추가한 컬럼들을 매핑할 수 없기 때문이다.

 

자바 ORM 표준 JPA 프로그래밍

결국 위 그림처럼 연결 테이블을 매핑하는 연결 엔티티를 만들고 이곳에 추가한 컬럼들을 매핑해야 한다.

그리고 엔티티 간의 관계도 테이블 관계처럼 다대다에서 일대다, 다대일 관계로 풀어야 한다.

여기서는 회원상품(MemberProduct)엔티티를 추가했다.

@Entity
public class Member
{
    @Id
    @Column(name = "MEMBER_ID")
    private String id;
    
    // 역방향
    @OneToMany(mappedBy = "member")
    private List<MemberProduct> memberProducts;
    
    ...
}

연결 테이블에 주문 수량(ORDERAMOUNT)과 주문 날짜(ORDERDATE) 컬럼을 추가했고 나머지 테이블은 기존과 같다.

 

회원과 회원상품을 양방향 관계로 만들었다.

회원상품 엔티티 쪽이 외래키를 가지고 있으므로 연관관계의 주인이다.

따라서 연관관계의 주인이 아닌 회원의 Member.memberProducts에는 mappedBy를 사용했다.

@Entity
public class Product
{
    @Id
    @Column(name = "PRODUCT_ID")
    private String id;
    
    private String name;
    
    ...
}

위 코드를 보면 상품 엔티티에서 회원상품 엔티티로 객체 그래프 탐색 기능이 필요하지 않다고 판단해서 연관관계를 만들지 않았다.

 

회상품 엔티티 코드

@Entity
@IdClass(MemberProdudctId.class)
public class MemberProduct
{
    @Id
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member; // MemberProductId.member와 연결
    
    @Id
    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product; // MemberProductId.product와 연결
    
    private int orderAmount;
    
    ...
}

 

회원상품 식별자 클래스

public class MemberProductId implements Serializable
{
    private String member; // MemberProduct.member와 연결
    private String product; // MemberProduct.product와 연결
    
    // hashCode and equals
    
    @Override
    public boolean equals(Object o) {...}
    
    @Override
    public int hashCode() {...}
}

회원상품(MemberProduct)엔티티를 보면 기본 키를 매핑하는 @Id와 외래 키를 매핑하는 @JoinColumn을 동시에 사용해서 기본 키 + 외래 키를 한번에 매핑했다.

그리고 @IdClass를 사용해서 복합 기본 키를 매핑했다.

 

  • 복합 기본 키

회원상품 엔티티는 기본 키가 MEMBER_ID와 PRODUCT_ID로 이루어진 복합 기본지(간단히 복합키)다.

JPA에서 복합 키를 사용하려면 별도의 식별자 클래스를 만들어야 한다.

그리고 엔티티에 @IdClass를 사용해서 식별자 클래스를 지정하면 된다.

여기서는 MemberProductId 클래스를 복합 키를 위한 식별자 클래스로 사용한다.

 

복합키를 위한 식별자 클래스는 다음과 같은 특징이 있다.

  • 복합 키별도의 식별자 클래스로 만들어야 한다.
  • Serializable을 구현해야 한다.
  • equals와 hashCode 메소드를 구현해야 한다.
  • 기본생성자가 있어야 한다.
  • 식별자 클래스는 public 이어야 한다.
  • @IdClass를 사용하는 방법 외에 @EmbeddedId를 사용하는 방법도 있다.

 

  • 식별 관계

회원상품은 회원과 상품의 기본 키를 받아서 자신의 기본 키로 사용한다.

이렇게 부모 테이블의 기본 키를 받아서 자신의 기본 키 + 외래 키로 사용하는 것을 DB용어로 식별관계라고 한다.

 

종합해보면

회원상품(MemberProduct)은 회원의 기본 키를 받아서 자신의 기본 키로 사용함과 동시에 회원과의 관계를 위한 외래 키로 사용한다.

그리고 상품의 기본 키도 받아서 자신의 기본 키로 사용함과 동시에 상품과의 관계를 위한 외래 키로 사용한다.

또한 MemberProductId 식별자 클래스로 두 기본 키를 묶어서 복합 기본 키로 사용한다.

 

public void save()
{
    // 회원 저장
    Member member1 = new Member();
    member1.setId("member1");
    member1.setUsername("회원1");
    em.persist(member1);
    
    // 상품 저장
    Product productA = new Product();
    productA.setId("productA");
    productA.setName("상품1");
    em.persist(productA);
    
    // 회원상품 저장
    MemberProduct memberProduct = new MemberProduct();
    memberProduct.setMember(member1); 	// 주문 회원 - 연관관계 설정
    memberProduct.setProduct(productA); // 주문 상품 - 연관관계 설정
    memberProduct.setOrderAmount(2); 	// 주문 수량
    
    em.persist(memberProduct);
}

위 코드로 회원상품 엔티티를 만들면서 연관된 회원 엔티티와 상품 엔티티를 설정했다.

회원상품 엔티티는 DB에 저장될 때 연관된 회원의 식별자와 상품의 식별자를 가져와서 자신의 기본 키 값으로 사용한다.

 

public void find()
{
    // 기본 키 값 생성
    MemberPRoductId memberProductId = new MemberProductId();
    memberProductId.setMember("member1");
    memberPRoductId.setProduct("productA");
    
    MemberProduct memberProduct = em.find(MemeberProduct.class, memberProductId);
    
    Member member = memberProduct.getMember();
    Product product = memberProduct.getProduct();
    
    System.out.println("member = " + member.getUsername());
    System.out.println("product = " + product.getName());
    System.out.println("orderAmount = " + memberProduct.getOrderAmount());
    
}

지금까지는 기본 키가 단순해서 기본 키를 위한 객체를 사용하는 일이 없었지만 복합 키가 되면 이야기가 달라진다.

복합 키는 항상 식별자 클래스를 만들어야 한다.

em.find()를 보면 생성한 식별자 클래스로 엔티티를 조회한다.

 

복합 키를 사용하는 방법은 복잡하다. 

단순히 컬럼 하나만 기본 키로 사용하는 것과 비교해서 복합 키를 사용하면 ORM 매핑에서 처리할 일이 상당히 많아진다.

복합 키를 위한 식별자 클래스도 만들어야 하고 @IdClass 또는 @EmbeddedId도 사용해야 한다.

그리고 식별자 클래스에 equals, hashCode도 구현해야 한다.

 

04. 다대다 : 새로운 기본 키 사용

더보기

추천하는 기본 키 생성 전략은 DB에서 자동으로 생성해주는 대리 키를 Long값으로 사용하는 것이다.

이것의 장점은 간편하고 거의 영구히 쓸 수 있으며 비즈니스에 의존하지 않는다.

그리고 ORM 매핑 시에 복합 키를 만들지 않아도 되므로 간단히 매핑을 완성할 수 있다.

 

자바 ORM 표준 JPA 프로그래밍

이번에는 연결 테이블에 새로운 기본 키를 사용할 것이다.

이 정도 되면 회원상품(MemberProduct)보다는 주문(Order)이라는 이름이 더 어울릴 것이다.

 

ORDER_ID라는 새로운 기본 키를 하나 만들고 MEMBER_ID, PRODUCT_ID 컬럼은 외래 키로만 사용한다.

@Entity
public class Order
{
    @Id
    @GeneratedValue
    @Column(name = "ORDER_ID")
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "MEMBER_ID")
    private Member member;
    
    @ManyToOne
    @JoinColumn(name = "PRODUCT_ID")
    private Product product;
    
    private int orderAmount;
    ...
}

대리 키를 사용함으로써 이전에 보았던 식별 관계에 복합 키를 사용하는 것보다 매핑이 단순하고 이해하기 쉽다.

 

다음은 저장하고 조회하는 코드이다.

public void save()
{
    // 회원 저장
    Member member1 = new Member();
    member1.setId("member1");
    member1.setUsername("회원1");
    em.persist(member1);
    
    // 상품 저장
    Product productA = new Product();
    productA.setId("productA");
    productA.setName("상품1");
    em.persist(productA);
    
    // 주문 저장
    Order order = new Order();
    order.setMember(member1);	// 주문 회원 - 연관관계 설정
    order.setProduct(productA); // 주문 회원 - 연관관계 설정
    order.setOrderAmount(2); 	// 주문 수량
    em.persist(order);
}
728x90

'JPA' 카테고리의 다른 글

JPA TIL - 09 값 타입  (0) 2024.05.15
JPA TIL - 07 고급 매핑  (0) 2024.05.08
JPA TIL - 05 연관관계 매핑 기초  (0) 2024.04.09
JPA TIL - 04 엔티티 매핑  (0) 2024.04.02
JPA TIL - 03 영속성 관리  (0) 2024.03.26
728x90

이번 글의 목표는

객체의 참조와 테이블의 외래키를 매핑하는 것이다.

 

다음은 연관관계 매핑을 알기 위한 핵심 키워드이다.

 

방향(Direction) 

더보기

단방향, 양방향이 있다.

예를 들어 회원과 팀이 연관관계일 때 [회원 -> 팀] 또는 [팀 -> 회원]처럼 한쪽만 참조하는 것을 단방향 관계라고 하고,

[회원 -> 팀], [팀 -> 회원] 처럼 양쪽이 같이 참조하는 것을 양방향 관계라고 한다.

방향은 객체관계에서만 존재하고, 테이블 관계는 항상 양방향이다.

 

다중성(Multiplicity)

더보기

[다대일(N:1) / 일대다(1:N) / 일대일(1:1) / 다대다(N:M)] 다중성이 있다.

예를들어 회원과 팀이 연관관계일 때 여러 회원은 한 팀에 속하므로 다대일(N:1)관계이다.

반대로, 팀에는 여러 회원이 속하므로 일대다(1:N)관계이다.

 

연관관계의 주인(Owner)

더보기

객체를 양방향 관계로 만들면 연관관계의 주인을 정해야 한다.


 

1. 단방향 연관 관계

  • 회원과 팀이 있다
  • 회원은 하나의 팀에만 소속될 수 있다
  • 회원과 팀은 다대일 관계다

객체 연관 관계

  • 회원 객체는 Member.Team의 필드로 팀 객체와 연관관계를 맺고있다.
  • 회원 객체와 팀 객체는 다대일(N:1)관계이다.
  • 회원은 Member.Team필드를 통해서 속해있는 팀을 알 수 있지만, 팀은 속한 회원을 알 수 없다.

 

테이블 연관관계

  • 회원 테이블은 TeamId 외래 키로 팀 테이블과 연관관계를 맺는다.
  • 회원 테이블과 팀 테이블은 양방향 관계다.
  • 회원 테이블의 TeamId 외래 키를 통해서 회원과 팀을 조인할 수 있고, 반대로 팀과 회원도 조인할 수 있다.
  • 예를 틀어 회원 테이블의 TeamId 외래 키 하나로 [회원 JOIN 팀] 과 [팀 JOIN 회원]둘 다 가능하다.

 

다음은 회원과 팀을 조인하는 SQL이다.

SELECT * 
FROM MEMBER M 
JOIN TEAM T ON M.TeamId = T.TeamId;

 

다음은 반대인 팀과 회원을 조인하는 SQL이다.

SELECT * 
FROM TEAM T
JOIN MEMBER M ON T.TeamId = M.TeamId;

 

객체 연관관계와 테이블 연관관계의 가장 큰 차이

  • 참조를 통한 연관관계는 항상 단방향이다.
  • 객체간의 연관관계를 양방향으로 만들고 싶으면 반대쪽에도 필드를 추가해서 참조를 보관해야 한다. 결국 연관관계를 하나 더 만들어야 한다.
  • 이렇게 양쪽에서 서로 참조하는 것을 양방향 연관관계라고 한다.
  • 하지만 정확히 이야기 하면 양방향 관계가 아닌 서로 다른 관계 2개다.
  • 반면에 테이블은 외래키 하나로 양방향 조인할 수 있다.

01. 순수한 객체 연관관계

다음은 JPA를 사용하지 않은 순수한 회원과 팀 클래스의 코드다.

public class Member
{
    private String id;
    private String name;
    private Team team;

    public void setTeam(Team team)
    {
        this.team = team;
    }
    //Getter, Setter...
}



class Team
{
    private String id;
    private String name;

    //Getter, Setter...

}

다음은 코드 실행부다.

public static void main(String[] args) {
    Member member1 = new Member("member1", "회원 1");
    Member member2 = new Member("member2", "회원 2");
    Team team1 = new Team("team1","팀 1");

    member1.setTeam(team1);
    member2.setTeam(team1);

    Team findTeam = member1.getTeam();
}

 

클래스 관계

 

인스턴스 관계

위 그림을 보면 회원1과 회원2는 팀1에 소속했다.

그리고 다음 코드로 회원1이 속한 팀1을 조회할 수 있다.

Team findTeam = member1.getTeam();

이처럼 객체는 참조를 사용해서 연관관계를 탐색할 수 있는데 이것을 객체 그래프 탐색이라 한다.

 

02. 테이블 연관관계

 

아래 코드는 회원 테이블과 팀 테이블의 DDL이다.

추가로 회원 테이블의 TeamId에 외래 키 제약 조건을 설정했다.

CREATE TABLE MEMBER (
    MemberId VARCHAR(255) NOT NULL,
    TeamId VARCHAR(255),
    NAME VARCHAR(255),
    PRIMARY KEY (MemberId)
)

CREATE TABLE TEAM (
    TeamId VARCHAR(255) NOT NULL,
    NAME VARCHAR(255),
    PRIMARY KEY (TeamId)
)

ALTER TABLE MEMBER ADD CONSTRAINT FK_MEMBER_TEAM
    FOREIGN KEY (TeamId)
    REFERENCES TEAM;

 

다음 SQL을 실행해서 회원1과 회원2를 팀1에 소속시켰다.

INSERT INTO TEAM(TeamId, NAME) VALUES('team1', '팀1');
INSERT INTO MEMBER(MemberId, TeamId, NAME)
    VALUES('member1', 'team1', '회원1');
INSERT INTO MEMBER(MemberId, TeamId, NAME)
    VALUES('member2', 'team2', '회원2');

 

다음 SQL을 실행해서 회원1이 소속된 팀을 조회한다.

SELECT T.*
FROM MEMBER M
    JOIN TEAM T ON M.TeamId = T.TeamId
    WHERE M.MemberId = 'member1';

 

이처럼 DB는 외래 키를 사용해서 연관관계를 탐색할 수 있는데 이것을 조인이라 한다.

 

03. 객체 관계 매핑

지금까지 객체만 사용한 연관관계와 테이블만 사용한 연관관계를 각각 알아보았다.

이제 JPA를 사용해서 둘을 매핑할 것이다.

 

  • 객체 연관관계 : 회원 객체의 Member.team필드 사용
  • 테이블 연관관계 : 회원 테이블의 Member.TeamId 외래키 컬럼 사용
@Entity // 엔티티 선언
@Table(name = "MEMBER")
public class Member {
    @Id
    @Column(name="ID")
    private String id;
    @Column(name="NAME", nullable = false, length = 10)
    private String username;

    @ManyToOne
    @JoinColumn(name = "TeamId")
    private Team team;
    
    // Getter, Setter...

 

위 코드에서 회원 엔티티를 매핑했다.

Member.team과 MEMBER.TeamId를 매핑하는 것이 연관관계 매핑이다.

 

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

회원 엔티티에 있는 연관관계 매핑 부분에 새로운 어노테이션들이 있다.

  • @ManyToOne : 이름 그대로 다대일(N:1) 관계라는 매핑 정보다. 회원과 팀은 다대일 관계다. 연관관계를 매핑할 때 이렇게 다중성을 나타내는 어노테이션을 필수로 사용해야 한다.
  • @JoinColumn(name="TeamId") : 조인 컬럼은 외래 키를 매핑할 때 사용한다. name속성에는 매핑할 외래 키 이름을 지정한다. 회원과 팀 테이블은 "TeamId"외래 키로 연관관계를 맺으므로 이 값을 지정하면 된다. 이 컬럼은 생략할 수 있다.

2. 연관관계 사용

연관관계를 등록, 수정, 삭제, 조회하는 예제를 통해 연관관계를 어떻게 사용하는지 알아보자.

 

01. 저장

public void testSave()
{
    // 팀1 저장
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    // 회원 1,2 저장
    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1); // 연관관계 설정 : member1 -> team1
    em.persist(member1);

    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1); // 연관관계 설정 : member2 -> team1
    em.persist(member1);

}

위 코드는 회원 1,2와 팀을 저장하는 코드이다. 

member1.setTeam(team1); // 연관관계 설정 : member1 -> team1
em.persist(member1);

회원 엔티티는 팀 엔티티를 참조하고 저장했다.

JPA는 참조한 팀의 식별자(Team.TeamId)를 외래 키로 사용해서 적절한 등록 쿼리를 생성한다.

 

02. 조회

연관관계가 있는 엔티티를 조회하는 방법은 크게 2가지다.

  • 객체 그래프 탐색(객체 연관관계를 사용한 조회)
  • 객체지향 쿼리 사용(JPQL)

객체 그래프 탐색

member.getTeam()을 사용해서 member와 연관된 team엔티티를 조회할 수 있다.

Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
System.out.println("팀 이름 = " + team.getName();

// 출력 결과 : 팀 이름 = 팀1

이처럼 객체를 통해 연관된 엔티티를 조회하는 것을 객체 그래프 탐색이라 한다.

 

객체지향 쿼리 사용

만약 회원을 대상으로 조회하는데 팀1에 소속된 회원만 조회하려면 회원과 연관된 팀 엔티티를 검색 조건으로 사용해야 한다.

SQL은 연관된 테이블을 조인해서 검색조건을 사용하면 된다.

JPQL도 조인을 지원한다.

 

private static void queryLogicJoin(EntityManager em)
{
    String jpql = "select m from Member m join m.team t where "
        + "t.name=:teamName";
    
    List<Member> resultList = em.createQuery(jpql, Member.class)
        .setParameter("teamName", "팀1");
        .getResultList();
        
    for (Member member : resultList)
    {
        System.out.println("[query] member.username="
            + member.getUsername());
    }
}

// 결과 : [query] member.username=회원1
// 결과 : [query] member.username=회원2

JPQL의 from Member m join m.team t 부분을 보면 회원이 팀과 관계를 가지고 있는 필드(m.team)를 통해서 Member와 Team을 조인했다.

그리고 where 절을 보면 조인한 t.name을 검색조건으로 사용해서 팀1에 속한 회원만 검색했다.

 

select m from Member m join m.team t
where t.name=:teamName

위 코드에서 :teamName과 같이 :로 시작하는 것은 파라미터를 바인딩 받는 문법이다.

위 코드를 실행하면 생성되는 SQL은 다음과 같다.

 

SELECT m.* FROM MEMBER MEMBER
INNER JOIN
    TEAM TEAM ON MEMBER.TeamId = TEAM1.TeamId
WHERE
    TEAM1_.NAME="팀1";

실행된 SQL과 JPQL을 비교하면 JPQL은 객체(엔티티)를 대상으로 하고 SQL보다 간결하다.

 

03. 수정

 

private static void updateTeam(EntityManager em)
{
    // 새로운 팀
    Team team2 = new Team("team1", "팀1");
    em.persist(team2);

    // 회원1에 새로운 팀2 설정
    Member member = em.find(Member.class,"member1");
    member.setTeam(team2);
}

위 코드는 팀 소속을 바꾸는 메서드이다.

해당 코드가 실행되면 다음과 같은 수정 SQL문이 실행된다.

UPDATE MEMBER
SET
    TeamId = 'team2', ...
WHERE
    MemberId='member1';

앞서 3일차에 이야기했듯이 수정은 em.update()같은 메서드가 아니라 불러온 엔티티의 값만 변경해두면 자동으로 변경을 감지해서 변경사항을 DB에 자동으로 반영한다.

 

04. 연관관계 제거

private static void deleteRelation(EntityManager em)
{
    Member member = em.find(Member.class,"member1");
    member.setTeam(null); // 연관관계 제거
}

위의 코드는 연관관계를 제거하는 메서드이다.

해당 코드가 실행되면 다음과 같은 수정 SQL문이 실행된다.

UPDATE MEMBER
SET
    TeamId = null, ...
WHERE
    MemberId='member1';

 

 

04. 연관된 엔티티 삭제

연관된 엔티티를 삭제하려면 기존에 있던 연관관계를 먼저 제거하고 삭제해야한다.

그렇지 않으면 외래 키 제약조건으로 인해, DB에서 오류가 발생한다.

member1.setTeam(null); // 회원1 연관관계 제거
member2.setTeam(null); // 회원2 연관관계 제거
em.remove(team); // 팀 삭제

팀1에는 회원1과 회원2가 소속되어있다.

이때 팀1을 삭제하려면 연관관계를 먼저 끊어야 한다.


3. 양방향 연관관계

지금까지 회원에서 팀으로만 접근하는 다대일 단방향 매핑을 알아보았다.

이번에는 반대방향인 팀에서 회원으로 접근하는 관계를 추가할 것이다.

출처 : 자바 ORM 표준 JPA 프로그래밍

위의 그림과 같이 회원과 팀은 다대일 관계다.

반대로 팀에서 회원은 일대다 관계다.

일대다 관계는 여러 건과 연관관계를 맺을 수 있으므로 컬렉션을 사용해야 한다.

때문에 Team.members를 List 컬렉션으로 추가했다.

객체 연관관계를 정리하면 다음과 같다.

  • 회원 -> 팀 (Member.Team)
  • 팀 -> 회원 (Team.members)

 

DB테이블은 외래 키 하나로 양방향으로 조회할 수 있다.

두 테이블의 연관관계는 외래 키 하나만으로 양방향 조회가 가능하므로 처음부터 양방향 관계이다.

따라서 DB에 추가할 내용은 전혀 없다.

 

앞서 말했듯 TeamId 외래키를 사용해서 MEMBER JOIN TEAM이 가능하고 반대로 TEAM JOIN MEMBER도 가능하다.

 

01. 양방향 연관관계 매핑

 

@Entity // 엔티티 선언
@Table(name = "MEMBER")
public class Member {
    @Id
    @Column(name="ID")
    private String id;
    @Column(name="NAME", nullable = false, length = 10)
    private String username;

    @ManyToOne
    @JoinColumn(name = "TeamId")
    private Team team;
    
    // Getter, Setter...
@Entity // 엔티티 선언
@Table(name = "MEMBER")
public class Team {
    @Id
    @Column(name="TeamId")
    private String id;
    
    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<Member>();
    
    // Getter, Setter...

 

회원 엔티티는 변경사항이 없고, 팀 엔티티에는 변경사항이 있다.

팀과 회원은 일대다 관계다.

따라서 엔티티에 컬렉션인 List<Member> members를 추가했다.

 

그리고 일대다 관계를 매핑하기 위해 @OneToMany 매핑 정보를 사용했다.

mappedBy 속성은 양방향 매핑일 때 사용하는데 반대쪽 매핑의 필드 이름을 값으로 주면 된다.

반대쪽 매핑이 Member.team이므로 team을 값으로 주었다.

 

02. 일대다 컬렉션 조회

public void biDirection()
{
    Team team = em.find(Team.class, "team1");
    List<Member> members = team.getMembers(); // 팀 -> 회원
    
    
    for (Member member : members)
    {
        System.out.println("member.name = " +
            member.getName());
    }
}

// 결과 
// member.name = 회원1
// member.name = 회원2

 


4. 연관관계의 주인 

@OneToMany는 직관적으로 이해가 된다.

하지만 mappedBy는 왜 필요할까?

 

엄밀히 말하자면 객체에는 양방향 연관관계라는 개념이 없다.

서로 다른 단방향 연관관계 2개를 애플리케이션 로직으로 잘 묶는 것이다.

 

반면에 DB 테이블은 앞서 말했듯 외래 키 하나만으로 양쪽이 서로 조인할 수 있다.

따라서 테이블은 외래 키 하나만으로 양방향 연관관계를 맺는다.

 

객체 연관관계는 다음과 같다.

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

테이블 연관관계는 다음과 같다.

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

테이블은 외래 키 하나로 두 테이블의 연관관계를 관리한다.

엔티티를 단방향으로 매핑하면 참조를 하나만 사용하므로 이 참조로 외래 키를 관리하면 된다.

그런데 양방향으로 매핑하면 회원 -> 팀, 팀 -> 회원 두곳에서 서로를 참조한다.

따라서 객체의 연관관계를 관리하는 포인트가 2곳이 된다.

 

엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나다.

따라서 둘 사이에 차이가 발생한다.

 

이런 차이로 인해 JPA에서는 두 객체 연관관계 중 하나를 정해서 테이블의 외래 키를 관리해야 하는데 이것을 연관관계의 주인(Owner)라고 한다.

 

 

01. 양방향 매핑의 규칙 : 연관관계의 주인

양방향 연관관계 매핑 시 지켜야 할 규칙이 있는데 두 연관관계 중 하나를 연관관계의 주인으로 정해야 한다.

연관관계의 주인만이 DB 연관관계와 매핑되고, 외래 키를 관리(등록, 수정, 삭제)할 수 있다.

반면에 아닌 쪽은 읽기만 가능하다.

 

어떤 연관관계를 주인으로 정할지는 mappedBy속성을 사용하면 된다.

 

  • 주인은 mappedBy속성을 사용하지 않는다.
  • 주인이 아니면 mappedBy속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 한다.

그렇다면 Member.team, Team.members 둘 중 어느 것을 연관관계의 주인으로 정해야할까?

 

  • 회원 -> 팀(Member.team)방향
class Member
{
    @ManyToOne
    @JoinColumn(name="TeamId")
    private Team team;
    ...
}
  • 팀 -> 회원(Team.members) 방향
class Team
{
    @OneToMany
    private List<Member> members = new ArrayList<Member>();
    ...
}

 

연관관계의 주인을 정한다는 것인 사실 외래 키 관리자를 선택하는 것이다.

여기서는 회원 테이블에 있는 TeamId 왜래 키를 관리할 관리자를 선택해야 한다.

출처 : 자바 ORM 표준 JPA 프로그래밍

만약 회원 엔티티에 있는 Member.team을 주인으로 선택하면 자기 테이블에 있는 외래 키를 관리하면 된다.

하지만 팀 엔티티에 있는 Team.members를 주인으로 선택하면 물리적으로 전혀 다른 테이블의 외래키를 관리해야 한다.

왜냐하면 이 경우 Team.members가 있는 Team 엔티티는 TEAM 테이블에 매핑되어 있는데 관리해야 할 외래 키는 MEMBER테이블에 있기 때문이다.

 

02. 연관관계의 주인은 외래 키가 있는 곳

연관관계의 주인은 테이블에 외래 키가 있는 곳으로 정해야 한다.

여기서는 회원 테이블이 외래 키를 가지고 있으므로 Member.team이 주인이 된다.

주인이 아닌 Team.members에는 mappedBy="team"속성을 사용해서 주인이 아님을 설정한다.

"team"은 연관관계의 주인인 Member엔티티의 team필드를 말한다.

출처 : 자바 ORM 표준 JPA 프로그래밍

 

정리하면 연관관계의 주인만 DB 연관관계와 매핑되고 외래 키를 관리할 수 있다.

 


5. 양방향 연관관계 저장

 

public void testSave()
{
    // 팀1 저장
    Team team1 = new Team("team1", "팀1");
    em.persist(team1);

    // 회원1 저장
    Member member1 = new Member("member1", "회원1");
    member1.setTeam(team1); // 연관관계 설정 member1 -> team1
    em.persist(member1);

    // 회원2 저장
    Member member2 = new Member("member2", "회원2");
    member1.setTeam(team1); // 연관관계 설정 member2 -> team1
    em.persist(member1);
}

위의 코드를 보면 

팀 1을 저장하고

회원 1, 회원2에 연관관계의 주인인 Member.team 필드를 통해서 회원과 팀의 연관관계를 설정하고 저장했다.

 

참고로 이 코드는 당방향 연관관계의 회원과 팀을 저장하는 코드와 완전히 같다.

 

양방향 연관관계에서는 주인만 작업을 할 수 있다고 했다.

때문에

team1.getMembers().add(member1); // 무시
team1.getMembers().add(member2); // 무시

위 코드는 Team.members는 주인이 아니기 때문에 코드가 무시되고,

member1.setTeam(team1); // 연관관계 설정
member2.setTeam(team1); // 연관관계 설정

위 코드는 Member.team은 주인이기 때문에 코드가 반영된다.

 


6. 양방향 연관관계의 주의점

 

위에서도 말했듯 주인만 연관관계를 설정할 수 있다고 했다.

 

01. 순수한 객체까지 고려한 양방향 연관관계

 

사실은 객체 관점에서 양쪽 방향에 모두 값을 입력해주는 것이 가장 안전하다.

양쪽 방향 모두 값을 입력하지 않으면 JPA를 사용하지 않는 순수한 객체 생태에서 심각한 문제가 발생할 수 있다.

 

만약 JPA를 사용하지 않고 엔티티에 대한 테스트 코드를 작성한다고 가정하자.

ORM은 객체와 관계형 DB 둘다 중요하다.

DB뿐만 아니라 객체도 함께 고려해야 한다.

public void test()
{
    // 팀1
    Team team1 = new Team("team1", "팀1");
    Member member1 = new Member("member1", "회원1");
    Member member2 = new Member("member2", "회원1");
    
    member1.setTeam(team1); // 연관관계 설정 member1 -> team1
    member2.setTeam(team1); // 연관관계 설정 member2 -> team1
    
    List<Member> members = team1.getMembers();
    System.out.println("members.size = " + members.size());
}
// 결과 : members.size = 0

위 코드는 JPA를 사용하지 않는 순수한 객체다.

코드를 보면 Member.team에만 연관관계를 설정했고, 반대 방향은 연관관계를 설정하지 않았다.

당연하게도 members 리스트의 크기는 변하지 않았을 것이다.

public void test()
{
    // 팀1
    Team team1 = new Team("team1", "팀1");
    Member member1 = new Member("member1", "회원1");
    Member member2 = new Member("member2", "회원1");
    
    member1.setTeam(team1); // 연관관계 설정 member1 -> team1
    team1.getMembers().add(member1) // 연관관계 설정 team1 -> member1
    member2.setTeam(team1); // 연관관계 설정 member2 -> team1
    team1.getMembers().add(member2) // 연관관계 설정 team1 -> member2
    
    List<Member> members = team1.getMembers();
    System.out.println("members.size = " + members.size());
}
// 결과 : members.size = 2

위 코드에서는 양쪽 모두 관계를 설정했다.

결과도 기대했던 2가 출력된다.

 

객체까지 고려하면 이렇게 양쪽 다 관계를 맺어야 한다.

 

만약 위의 코드가 JPA로 작성한 코드여도 연관관계의 주인인 Member.Team만 적용될 것이기 때문에 상관없다.

앞서 말한것 처럼 객체까지 고려해서 주인이 아닌 곳에도 값을 입력하자.

 

결론 : 객체의 양방향 연관관계는 양쪽 모두 관계를 맺어주자.

 

02. 연관관계 편의 메소드

양방향 연관관계는 결국 양쪽 다 신경 써야 한다.

member.setTeam()과 team.getMembers().add()를 각각 호출하다 보면 실수로 둘 중 하나만 호출해서 양방향이 깨질 수 잇따.

 

public class Member
{
    private Team team;
    
    public void setTeam(Team team)
    {
        this.team = team;
        team.getMembers().add(this);
    }
}
...

위의 코드로 양방향 관계를 모두 설정하도록 변경했다.

member1.setTeam(team1);
member2.setTeam(team1);

이렇게만 해도 양방향 관계를 모두 설정할 수 있다.

 

이렇게 한 번에 양방향 관계를 설정하는 메소드를 연관관계 편의 메소드라고 한다.

 

03. 연관관계 편의 메소드 작성 시 주의사항

사실 위의 코드에는 버그가 있다.

만약 연관관계를 끊기 위해 Team을 null로 입력했다면?

 

member1은 팀과의 연관관계를 끊었지만 팀은 member1과의 연관관계가 끊이지 않았다.

때문에 삭제하는 코드에서는 양 방향 모두에서 연관관계를 삭제하는 코드를 추가해야 한다.

728x90
728x90

JPA를 사용하는데에 가장 중요한 일은 엔티티와 테이블을 정확히 매핑하는 것이다.

 

JPA는 다양한 매핑 어노테이션을 제공한다.

다음은 크게 4가지로 구분한 대표 어노테이션들이다.

 

  • 객체와 테이블 매핑 : @Entity, @Table
  • 기본 키 매핑 : @Id
  • 필드와 컬럼 매핑 : @Column
  • 연관관계 매핑 : @ManyToOne, @JoinColumn

 

 

01. 객체와 테이블 매핑

 

더보기

@Entity

 

JPA를 사용해서 테이블과 매핑할 클래스는 @Entity 어노테이션을 필수로 붙여야 한다.

 

@Entity 적용 시 주의사항

  • 기본 생성자 필수 (public/protected)
  • final 클래스, enum, interface, inner클래스에서는 사용할 수 없다.
  • 저장할 필드에 final를 붙이면 안된다.

 

@Table

 

@Table은 엔티티와 매핑할 테이블을 지정한다.

 

 

다양한 매핑 사용

 

JPA에서 개발하던 회원 관리 프로그램에 다음 요구사항이 추가되었다.

  1. 회원은 일반 회원과 관리자로 구분해야 한다.
  2. 회원 가입일과 수정일이 있어야 한다.
  3. 회원을 설명할 수 있는 필드가 있어야 한다. 이 필드는 길이 제한이 없다
  1. roleType : 자바의 enum을 사용해서 회원의 타입을 구분했다. 이처럼 자바의 enum을 사용하려면 @Enumerated 어노테이션으로 매핑해야 한다.
  2. createdTime, lastModifiedDate : 자바의 날짜 타입은 @Temporal을 사용해서 매핑한다.
  3. description : 회원을 설명하는 필드는 길이 제한이 없다. 따라서 DB의 VARCHAR 타입 대신에 CLOB 타입으로 저장해야한다.

DB 스키마 자동 생성

JPA는 DB 스키마를 자동으로 생성하는 기능을 지원한다.

클래스의 매핑정보를 보면 어떤 테이블에 어떤 컬럼을 사용하는지 알 수 있다.

JPA는 이 매핑정보와 DB 방언을 사용해서 DB 스키마를 생성한다.

 

스키마 자동 생성 기능

아래의 속성을 persistence.xml에 추가

<property name="hibernate.hbm2ddl.auto" value="create" />

이 속성을 추가하면 애플리케이션 실행 시점에 DB 테이블을 자동으로 생성한다.

 

DDL 생성 기능

ddl이란?

"데이터 정의어"로 DB를 정의하는 언어.

데이터를 CRUD하는 등의 데이터의 전체의 골격을 결정하는 역할을 하는 언어.

 

회원 이름은 필수로 입력되어야 하고, 10자를 초과하면 안된다는 제약조건이 추가되었다.

@Column(name="NAME", nullable = false, length = 10)
private String username;

@Column 매핑정보의 nullable 속성 값을 false로  지정하면 자동 생성되는 DDL에 not null 제약조건을 추가할 수 있다.

또한 length 속성 값을 사용하면 자동 생성되는 DDL에 문자의 크기를 지정할

수있다.

 

create table MEMBER(
    ID varchar(255) not null,
    NAME varchar(10) not null,
    ...
    primary key (ID)
)

위의 DDL의 NAME컬럼을 보면 not null 제약조건이 추가되었고, varchar(10)으로 문자의 크기가 10자리로 제한된 것을 확인할 . 수있다.

 

@Entity // 엔티티 선언
@Table(name="MEMBER", uniqueConstraints =
        {
                @UniqueConstraint(
                    name="NAME_AGE_UNIQUE",
                    columnNames = {"NAME", "AGE"}
                )
        }) // MEMBER 테이블
public class Member {

유니크 제약조건이 추가되었다.

ALTER TABLE MEMBER
	ADD CONSTRAINT NAME_AGE_UNIQUE (NAME,AGE)

 생성된 DDL을 보면 유니크 제약조건이 추가되었다. 

앞서 본 @Column의 length와 nullable 속성을 포함해서 이런 기능들은 단지 DDL을 자동 생성할 때만 사용되고, JPA 실행 로직에는 영향을 주지 않는다.

 

대신 이 기능을 사용하면 애플리케이션 개발자가 엔티티만 보고도 손쉽게 다양한 제약조건을 확인할 수 있다.

 

02. 기본 키

 

더보기
@Entity
public class Member
{
    @Id
    @Column(name="ID")
    private String id;
    ...
}

지금까지 위의 코드처럼 @Id 어노테이션만 사용해서 회원의 기본키를 직접 할당했다.

기본키를 애플리케이션에서 직접 할당하는 대신에 DB가 생성해주는 값을 사용하려면 어떻게 매핑해야 할까?

 

JPA가 제공하는 DB 기본 키 생성 전략은 다음과 같다.

  • 직접 할당 : 기본 키를 애플리케이션에서 직접 할당한다.
  • 자동 생성 : 대리 키 사용 방식
    • IDENTITY : 기본 키 생성을 DB에 위임한다.
    • SEQUENCE : DB 시퀀스를 사용해서 기본 키를 할당한다.
    • TABLE : 키 생성 테이블을 사용한다.

자동 생성 전략이 이렇게 다양한 이유는 DB종류마다 지원하는 방식이 다르기 때문이다.

 

 

01. 기본 키 직접 할당 전략

기본 키를 직접 할당하려면 다음 코드와 같이 @Id로 매핑하면 된다.

@Id
@Column(name="id")
private String id;

@Id 적용 가능 자바 타입은 다음과 같다.

  • 자바 기본형
  • 자바 래퍼(Wrapper)
  • String
  • java.util.Date
  • java.sql.Date
  • java.math.BigDecimal
  • java.math.BigInteger

기본 키 직접 할당 전략은 em.persist()로 엔티티를 저장하기 전에 애플리케이션에서 기본 키를 직접 할당하는 방법이다.

 

02. IDENTITY 전략

 IDENTITY는 기본 키 생성을 DB에 위임하는 전략이다.

예를 들어 MySQL의 AUTO_INCREMENT 기능은 DB가 기본키를 자동으로 생성해준다.

CREATE TABLE BOARD(
    ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    DATA VARCHAR(255)
);

INSERT INTO BOARD(DATA) VALUES('A');
INSERT INTO BOARD(DATA) VALUES('B');

테이블을 생성할 때 기본 키 컬럼인 ID에 AUTO_INCREMENT를 추가했다.

이제 DB값을 저장할 때 ID 컬럼을 비워두면 DB가 순서대로 값을 채워준다.

 

IDENTITY 전략은 지금 설명한 AUTO_INCREMENT를 사용한 예제처럼 DB에 값을 저장하고 나서야 기본 키 값을 구할 수 있을 때  사용한다.

 

개발자가 엔티티에 직접 식별자를 할당하면 @Id 어노테이션만 있으면 된다.

하지만 지금처럼 식별자가 생성되는 경우에는 아래 코드와 같이 @GeneratedValuestrategy 속성 값을 Generation.IDENTITY로 지정하면 된다.

 

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

 

 

03. SEQUENCE 전략

DB 시퀀스는 유일한 값을 순서대로 생성하는 특별한 DB 오브젝트이다.

SEQUENCE 전략은 이 시퀀스를 사용해서 기본 키를 생성한다.

 

아래는 시퀀스 DDL이다.

CREATE TABLE BOARD(
    ID BIGINT NOT NULL PRIMARY KEY,
    DATE VARCHAR(255)
)

// 시퀀스 생성
CREATE SEQUENCE BOARD_SEQ START WITH 1 INCREMENT BY 1;

 

아래는 시퀀스를 매핑하는 코드이다.

@Entity
@SequenceGenerator(
    name = "BOARD_SEQ_GENERATOR",
    sequenceName = "BOARD_SEQ", // 매핑할 DB 시퀀스 이름
    initialValue = 1,
    allocationSize = 1
)
public class Board
{
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "BOARD_SEQ_GENERATOR")
    private Long id;
    ...
}
  1. 우선 사용할 DB 시퀀스를 매핑해야 한다.
  2. 위 코드에선 @SequenceGenerator를 사용해서 BOARD_SEQ_GENERATOR라는 시퀀스 생성기를 등록했다.
  3. 그리고 sequenceName의 속성으로 "BOARD_SEQ"를 지정했는데, JPA는 이 시퀀스 생성기를 실제 DB의 BOARD_SEQ 시퀀스와 매핑한다.

 

 

SEQUENCE전략은 em.persist()를 호출할 때 먼저 DB 시퀀스를 사용해서 식별자를 조회, 그리고 조회한 식별자를 엔티티에 할당 후에 영속성 컨텍스트에 저장한다.

이후 트랜잭션을 커밋해서 플러시가 일어나면 DB에 저장된다.

 

반대로 IDENTITY 전략은 먼저 엔티티를 DB에 저장한 후에 식별자를 조회해서 엔티티에 식별자에 할당한다. 

 

 

04. TABLE 전략

TABLE 전략은 키 생성 전용 테이블을 하나 만들고 여기에 이름과 값으로 사용할 컬럼을 만들어 DB 시퀀스를 흉내내는 전략이다.

 

다음은 TABLE 전략 키 생성 DDL이다.

crate table MY_SEQUENCES (
    sequence_name varchar(255) not null,
    next_val bigint,
    primary key ( sequence_name )
)

 

sequence_name 컬럼을 시퀀스 이름으로 사용하고 next_val 컬럼을 시퀀스 값으로 사용한다.

 

@Entity
@TableGenerator(
    name = "BOARD_SEQ_GENERATOR",
    table = "MY_SEQUENCE",
    pkColumnValue = "BOARD_SEQ", allocationSize = 1
)
public calss Board
{
    @Id
    @GeneratedValue(stratege = GenerationType.TABLE, generator = "BOARD_SEQ_GENERATOR")
    private Long id;
    ...
)

 

  1. 먼저 @TableGenerator를 사용해서 테이블 키 생성기를 등록한다.
  2. 여기서는 "BOARD_SEQ_GENERATOR"라는 이름의 테이블 키 생성기를 등록하고 방금 생성한 MY_SEQUENCE 테이블을 키 생성용 테이블로 매핑했다.
  3. 다음으로 TABLE전략을 사용하기 위해 GenerationType.TABLE을 선택했다.
  4. 그리고 @GeneratedValue.generator에 방금 만든 테이블 키 생성기를 지정했다.
  5. 이제부터 id식별자 값은 BOARD_SEQ_GENERATOR 테이블 키 생성기가 할당한다.

 


정리

영속성 컨텍스트는 엔티티를 식별자 값으로 구분한다.

때문에 엔티티를 영속상태로 만들려면 식별자 값이 무조건 있어야 한다.

 

em.persist()를 호출한 직후에 발생하는 일을 식별자 할당 전략별로 정리하면 다음과 같다.

 

  • 직접 할당 : em.persist()를 호출하기 전에 애플리케이션에서 직접 식별자 값을 할당해야 한다.
  • SEQUENCE : DB 시퀀스에서 식별자 값을 휙득한 후 영속성 컨텍스트에 저장한다.
  • TABLE : DB 시퀀스 생성용 테이블에서 식별자 값을 휙득한 후 영속성 컨텍스트에 저장한다.
  • IDENTITY : DB에 엔티티를 저장해서 식별자 값을 휙득한 후 영속성 컨텍스트에 저장한다.

 

03. 필드와 컬럼 매핑 : 레퍼런스

 

더보기

@Column

@Column은 객체 필드를 테이블 컬럼에 매핑한다.

속성중에 name, nullable이 주로 사용되고 나머지는 잘 사용되지 않는 편이다.

 

@Enumerated

자바의 enum타입을 매핑할 때 사용한다.

 

@Temporal

날짜 타입을 매핑할 때 사용한다.

 

@Lob

DB BLOB, CLOB 타입과 매핑한다.

 

@Access

JPA가 엔티티 데이터에 접근하는 방식을 지정한다.

728x90
728x90

JPA가 제공하는 기능은 크게 2가지로 나눌 수 있다.

엔티티와 테이블을 매핑하는 설계부분

매핑한 엔티티를 실제로 사용하는 부분

 

매핑한 엔티티를 엔티티 매니저를 통해 어떻게 사용하는지 알아볼 것이다.

 

엔티티 매니저는 엔티티를 저장, 수정, 조회, 삭제하는 등 엔티티와 관련된 작업을 한다.

 

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

DB를 하나만 사용하는 애플리케이션은 일반적으로 엔티티 매니저 팩토리를 하나만 생성한다.

다음은 엔티티 매니저 팩토리를 생성하는 코드이다.

// [엔티티 매니저 팩토리] 생성 (비용이 많이 듬)
EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook");

Persistence.createEntityManagerFactory("jpabook")을 호출하면 META-INF/persistence.xml에 있는 정보를 바탕으로 엔티티 매니저 팩토리를 생성한다.

 

이제 필요할 때마다 엔티티 매니저 팩토리에서 엔티티 매니저를 생성하면 된다.

 

 

위 사진을 보자.

  • 웹 어플리케이션에서 엔티티 매니저 팩토리는 엔티티 매니저를 생성한다.
  • 엔티티 매니저 중 하나는 커넥션을 아직 사용하지 않았다.
  • 엔티티 매니저는 트랜젝션이 시작되면 커넥션을 얻는다.

02. 영속성 컨텍스트란?

JPA를 이해하는 데에 가장 중요한 용어는 영속성 컨텍스트(persistence context)이다.

해석으로는 "엔티티를 영구 저장하는 환경"이라고 한다.

em.persist(member);

이 코드를 단순히 회원 엔티티를 저장한다고 표현했다.

정확히 이야기 하면 "회원 엔티티를 엔티티 매니저를 사용해서 영속성 컨텍스트에 저장한다." 라고할 수 있다.

 


03. 엔티티의 생명주기

출처 : 자바 ORM 표준 JPA 프로그래밍 중

  • 비영속(new/transient) : 영속성 컨텍스트와 관계가 아예 없다.
  • 영속(managed) : 영속성 컨텍스트에 저장된 상태
  • 준영속(detached) : 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제(removed) : 삭제된 상태

 

비영속

 

더보기

엔티티 객체를 생성했다.

지금은 순수 객체 상태이고, 아직 저장되지 않았다.

때문에 이 객체는 영속성 컨텍스트와 DB랑은 아무런 관계가 없다.

String id = "id1";
Member member = new Member();
member.setId(id);
member.setUsername("홍길동");
member.setAge(2);

영속

 

더보기

엔티티 매니저를 통해 엔티티를 영속성 컨텍스트에 저장했다.

이렇게 영속성 컨텍스트가 관리하는 엔티티의 상태를 영속 상태라고 한다.

즉, 영속 상태라는 것은 영속성 컨텍스트에 의해 관리된다는 뜻이다.

또한 em.find()나 JPQL을 사용해서 조회된 엔티티 또한 영속상태이다.

// 등록
em.persist(member);

준영속

 

더보기

영속성 컨텍스트가 관리하던 영속 상태의 엔티티를 영속성 컨텍스트가 관리하지 않으면 준영속 상태가 된다.

특정 엔티티를 준영속 상태로 만드려면 em.detach()를 호출하면 된다.

em.close()를 호출해서 영속성 컨텍스트를 닫거나,

em.clear()를 호출해서 영속성 컨텍스트를 초기화 해도 준영속 상태가 된다.

삭제

더보기

엔티티를 영속성 컨텍스트에서 삭제한다.

04. 영속성 컨텍스트의 특징

영속성 컨텍스트의 특징

1. 영속성 컨텍스트와 식별자 값

영속성 컨텍스트는 엔티티를 식별자값(@Id로 테이블과 기본 키와 매핑한 값)으로 구분한다.

따라서 영속 상태는 식별자값이 무조건 있어야 한다.

2. 영속성 컨텍스트와 DB 저장

영속성 컨텍스트에 엔티티를 저장하면 이 엔티티는 언제 DB에 저장될까?

JPA는 보통 트랜젝션을 커밋하는 순간 영속성 컨텍스트에 새로 저장된 엔티티를 DB에 반영하는데, 이것을 플러시(Flush)라고 한다.

3. 영속성 컨텍스트가 엔티티를 관리하면 생기는 장점

  • 1차 캐시
  • 동일성 보장
  • 트랜젝션을 지원하는 쓰기 지연
  • 변경 감지
  • 지연 로딩

엔티티 조회

더보기

영속성 컨텍스트는 내부에 캐시를 가지고 있는데, 이를 1차 캐시라고 부른다.

영속 상태의 엔티티들은 모두 1차 캐시에 저장된다.

쉽게 말하면, 영속성 컨텍스트에 Map이 있다. 

이 Map의 key값은 @Id로 매핑한 식별자이고, value는 엔티티 인스턴스이다.

String id = "id1";
Member member = new Member();
member.setId(id);
member.setUsername("홍길동");
member.setAge(2);

// 등록
em.persist(member);

이 코드를 실행하면 아래 그림처럼 1차 캐시에 회원 엔티티를 저장한다.

이 엔티티는 아직 플러시(Flush)가 되지 않았으므로 DB에는 저장되지 않았다.

1차 캐시의 key는 식별자 값이다.

그리고 식별자 값은 DB의 기본 키와 매핑되어있다.

따라서 영속성 컨텍스트에 데이터를 저장하고 조회하는 모든 기준은 DB의 기본 키이다.

 

다음은 조회 예이다.

Member findMember = em.find(Member.class, "id2");

이 코드는 기본 키가 'id2'인 엔티티를 조회하는 코드이다.

조회하는 코드가 1차 캐시에 없으면 DB에서 접근한다.

 

아래 코드에서는 식별자가 'id1'인 엔티티를 조회한다.

Member findMember = em.find(Member.class, "id1");

1차 캐시에 'id1'인 key가 있으니까 value값인 Member1을 반환한다.

 

위 사진은 1차 캐시에 없는 값을 조회하는 과정이다.

영속 엔티티의 동일성 보장

더보기

다음 코드를 통해 식별자가 같은 엔티티 인스턴스를 비교해보자.

Member findMember1 = em.find(Member.class, "id1");
Member findMember2 = em.find(Member.class, "id1");
boolean isEqual = findMember1 == findMember2; // true

여기서 isEqual은 참일까 거짓일까?

em.find()을 호출할 때 식별자 값이 같다면 엔티티 매니저는 항상 같은 인스턴스를 반환한다.

 

 

엔티티 등록

더보기

다음은 엔티티를 등록하는 과정이다.

persist()를 호출하여 엔티티를 1차 캐시에 저장한다.

DB에 등록하는 SQL문을 저장소에 보관한다.

SQL 쿼리를 모아두는 것을 쓰기 지연 이라고 한다.

아직 DB에는 등록되어 있지 않은 상태이다.

앞서 트랜젝션들(등록 행위)을 커밋하는 것을 플러쉬 라고했다.

플러쉬를 하게 되면 모든 SQL쿼리가 DB로 전송되고, 엔티티들이 등록된다.

엔티티 수정

더보기

SQL 수정 쿼리의 문제점

SQL을 사용하면 수정 쿼리를 직접 작성해야 한다.

그런데 프로젝트가 점점 커지면서 수정 쿼리도 늘어나게 된다.

 

다음은 회원의 이름과 나이를 수정하는 SQL이다.

UPDATE MEMBER SET NAME = "이름1", AGE = 22 WHERE id = 'id1';

회원의 이름과 나이를 수정하는 기능을 만들었는데, 회원의 등급이 변경되는 기능이 추가되면 다른 수정 SQL을 추가로 작성한다.

 

결국 이렇게 되면 비즈니스 로직이 SQL에 의존적이게 된다.

 

변경 감지

ㅇㅇㅇㅇ
  1. JPA는 엔티티를 엔티티 컨텍스트에 보관할 때, 최초 상태를 복사하여 저장하는데 이것을 스냅샷이라고 한다.
  2. 그리고 플러시 시점에서 엔티티와 스냅샷을 비교하여 변경된 엔티티를 찾는다.
  3. 변경된 엔티티가 있으면 해당 엔티티의 변경 내용을 UPDATE SQL로 작성한다.
  4. 쓰기 지연 저장소의 SQL을 DB에 전송한다.
  5. DB에서 트랜젝션을 커밋한다.

05. 플러시

플러시(flush)는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다.

플러시를 실행하면 다음과 같은 일이 일어난다.

  • 변경 감지가 동작해서 영속성 컨텍스트에 있는 모든 엔티티를 스냅샷과 비교하여 수정된 엔티티를 찾는다.
  • 수정된 엔티티는 수정 쿼리를 만들고, 쓰기 지연 SQL저장소에 등록한다.
  • 쓰기 지연 SQL 저장소의 쿼리를 DB에 전송한다.(등록, 수정, 삭제 쿼리)

영속성 컨텍스트를 플러시하는 방법

 

더보기

1. em.flush()를 직접 호출한다.

  • 엔티티 매니저의 flush() 메서드를 직접 호출해서 영속성 컨텍스트를 강제로 플러시한다.
  • 테스트나 다른 프레임워크와 JPA를 함께 사용할 때르 제외하고 거의 사용하지 않는다.

2. 트랜젝션 커밋을 하면 자동으로 플러시가 호출된다.

  • DB에 변경내용을 SQL로 전달하지 않고 트랜젝션만 커밋하면 어떤 데이터도 DB에 반영되지 않는다.
  • 따라서 트랜젝션을 커밋하기 전에 꼭 플러시를 호출해서 영속성 컨텍스트 변경 내용을 DB에 반영해야 한다.
  • JPA는 이런 문제를 예방하기 위해 트랜젝션을 커밋할 때 플러시를 자동으로 호출한다.

3. JPQL 쿼리 실행 시 자동으로  플러시가 호출된다.

 

 

플러시 모드 옵션

더보기

엔티티 매니저에 플러시 모드를 직접 지정하려면 javax.persistence.FlushModeType 을 사용하면 된다.

  • FlushModeType.AUTO : 커밋이나 쿼리를 실행할 때 플러시(기본값)
  • FlushModeType.COMMIT : 커밋할 때만 플러시 

06. 준영속

영속 -> 준영속의 상태 변화를 알아보자

 

영속성 컨텍스트가 관리하는 영속상태의 엔티티가 영속성 컨텍스트에서 분리된 것을 준영속 상태라고 한다.

따라서 준영속 상태의 엔티티는 영속성 컨텍스트가 제공하는 기능을 사용할 수 없다.

 

영속상태의 엔티티를 준영속 상태로 만드는 방법을 크게  3가지이다.

더보기

1. em.detach(entity) : 특정 엔티티만 준영속 상태로 전환한다.

 

 

1차 캐시에 특정 엔티티의 값을 삭제한다.

특정 엔티티와 관련된 SQL을 제거한다.

 

 

 

 

 

 

 

 

 

 

 

 

2. em.clear() : 영속성 컨텍스트를 완전히 초기화한다.

 

모든 엔티티를 1차 캐시에서 제거한다.

 

 

 

 

 

 

 

 

 

 

 

 

 

3. em.close() : 영속성 컨텍스트를 종료한다.

 

 

영속성 컨텍스트가 종료되어 더는 member1이 관리되지 않는다.

 

 

 

 

 

 

 

 

 

준영속 상태의 특징

 

그럼 준영속 상태인 회원 엔티티는 어떻게 되는 걸까?

더보기

1. 거의 비영속 상태에 가깝다.

영속성 컨텍스트가 관리하지 않으므로 1차 캐시, 쓰기 지연, 변경 감지, 지연 로딩을 포함한 영속성 컨텍스트가 제공하는 어떠한 기능도 동작하지 않는다.

 

2. 식별자 값을 가지고 있다.

비영속 상태는 식별자 값이 없을 수 있지만, 준영속 상태는 이미 한번 영속 상태였으므로 반드시 식별자 값을 가지고 있다.

 

3. 지연 로딩을 할 수 없다.

지연 로딩은 실제 객체 대신 프록시 객체를 로딩해두고 해당 객체를 실제 사용할 때 영속성 컨텍스트를 통해 불러오는 방법이다.

 

병합 : merge()

준영속 상태의 엔티티를 다시 영속 상태로 변경하려면 병합을 사용하면 된다.

merge()메서드는 준영속 상태의 엔티티를 받아서 그 정보로 새로운 영속 상태의 엔티티를 반환한다.

 

더보기
출처 : 자바 ORM 표준 JPA 프로그래밍
Member mergeMember = em2.merge(member);

1. merge()를 실행한다.

2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.

2-1. 만약 1차 캐시에 엔티티가 없으면 DB에서 엔티티를 조회하고, 1차 캐시에 저장한다.

3. 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워 넣는다.

4. mergeMember를 반환한다.

 

병합이 끝나고, tx.commit을 호출해서 트랜잭션을 호출하면 어떻게 될까?

이름이 처음엔 "회원 1"에서 "회원명변경"으로 변경되었으므로 변경이 감지기능이 동작하여 변경 내용을 DB에 반영한다.

 

하지만 이렇게 구동하면 처음에 파라미터로 들어온 member은 아직 준영속 상태이다.

즉, 준영속 상태인 member 엔티티는 사용할 필요가 없다.

때문에 아래 코드로 변경해주는 것이 좋다.

member = em2.merge(member);
728x90
728x90

예제를 위한 member 테이블이다.

CREATE TABLE MEMBER (
    ID VARCHAR(255) NOT NULL, // 기본 키
    NAME VARCHAR(255),
    AGE INTEGER,
    PRIMARY KEY(ID)
)

더보기

01. @Entity

이 클래스를 테이블과 매핑한다는 표시이다.

이렇게 @Entity를 작성한 클래스를 엔티티 클래스라고 부른다.

 

02. @Table

이 엔티티 클래스와 매핑 할 테이블 정보를 알려준다.

여기서는 name속성을 MEMBER로 설정해서 Member클래스와 MEMBER 테이블을 매핑했다.

이 값을 생략하면 클래스명으로 대신하여 테이블과의 매핑을 진행한다.

 

03. @Id

엔티티 클래스의 필드를 기본 키(Primary Key)로 지정한다.

여기서는 엔티티 필드의 Id를 테이블의 ID컬럼과 매핑했다.

이렇게 @Id가 사용된 필드를 식별자 필드라고 한다.

 

04. @Column

필드를 컬럼에 매핑한다.

 

05. 생략

여기서 엔티티 필드의 age는 어노테이션이 없다.

이는 필드 자체의 이름을 테이블의 컬럼과 매핑한다.


JPA는 persistance.xml파일을 작성하여 필요한 설정 정보를 관리한다.

 

위의 코드는 크게 3가지 부분으로 나뉜다.

 

1. 엔티티 매니저 설정

더보기

01. 엔티티 매니저 팩토리 생성

// [엔티티 매니저 팩토리] 생성
EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook");
  • JPA를 사용하려면 우선 persistence.xml의 설정 정보를 가져와서 엔티티 매니저 팩토리를 생성해야 한다.
  • 이때, Persistence클래스를 사용하는데 이 클래스는 엔티티 매니저 팩토리를 생성해서 JPA를 사용할 수 있도록 준비한다.
  • 위의 코드에서는 "jpabook"이라는 영속성 유닛을 찾아서 엔티티 매니저 팩토리를 생성한다.

엔티티 매니저 팩토리의 생성 비용은 크기 때문에,

전체에서 딱 한번만 생성하고 공유해서 사용해야 한다.

 

02. 엔티티 매니저 생성

// [엔티티 매니저] 생성
EntityManager em = emf.createEntityManager();
  • 엔티티 매니저 팩토리에서 엔티티 매니저를 생성한다.
  • 대표적으로 엔티티 매니저를 사용해서 생성/삭제/수정/조회할 수 있다.
  • 데이터베이스 소스(데이터베이스 커넥션)를 유지하면서 데이터베이스와 통신한다.

따라서 개발자는 엔티티 매니저를 가상의 데이터베이스라고 생각할 수 있다.

참고로 엔티티 매니저는 데이터베이스 커넥션과 밀접한 관계에 있기 때문에 스레드간에 공유하거나 재사용하면 안된다.

 

03. 종료

마지막으로 사용이 끝난 엔티티 매니저는 다음처럼 반드시 종료해야 한다.

em.close(); // 엔티티 매니저 종료

애플리케이션을 종료할 때 엔티티 매니저 팩토리도 다음처럼 종료해야 한다.

emf.close(); // 엔티티 매니저 팩토리 종료

2. 트랜젝션 관리

더보기
EntityTransaction tx = em.getTransaction();

try
{
    tx.begin(); // [트랜젝션] 시작
    logic(em); // 로직 실행
    tx.commit(); // [트랜젝션] 커밋
}
catch (Exception e)
{
    tx.rollback();
}
  • JPA를 사용하려면 항상 트랜젝션 안에서 데이터를 변경해야 한다.
  • 트랜젝션 없이 데이터를 변경하면 예외가 발생한다.
  • 트랜젝션을 시작하려면 엔티티 매니저에서 트랜젝션을 받아온다.

트랜젝션 API를 활용해서 비즈니스 로직이 정상 작동하면 트랜젝션을 커밋하고,

예외가 발생했을 경우 트랜젝션을 롤백한다.

3. 비즈니스 로직

더보기

 

출력 결과는 다음과 같다

findMember : 홍길동, Age : 20
Member size = 1

 

로직을 보면 등록, 조회, 수정, 삭제의 과정이 모두 em(엔티티 매니저)를 통해 이루어지는 것을 알 수 있다.

 

01. 등록

  • 엔티티를 저장하려면 엔티티 매니저의 persist() 메서드에 저장할 엔티티를 넘겨주면 된다.
  • 예제를 보면 회원 엔티티를 생성하고, em.persist(member)를 실행해서 엔티티를 저장했다.

JPA는 회원 엔티티의 매핑정보(어노테이션)를 분석하여 다음과 같은 SQL문을 만들어서 DB에 전달한다.

INSERT INTO MEMBER (ID, NAME, AGE) values("id1", "홍길동", 2);

 

02. 수정

수정부분을 보면 이상한 점을 볼 수 있다.

  • member객체의 필드를 수정하기만 했는데 변경사항이 그대로 DB에 전달됐다.
  • 이는 JPA가 어떤 엔티티가 변경되었는지 추적하기 때문이다.

JPA는 추적하여 확인된 변경사항을 다음과 같은 SQL문을 만들어서 DB에 전달한다.

UPDATE MEMBER SET AGE = 20 WHERE ID="id1";

 

03. 삭제

  • 엔티티를 삭제하려면 엔티티 매니저의 remove()메서드에 삭제할 엔티티를 넘겨주면 된다.

JPA는 다음과 같은 SQL문을 만들어서 DB에 전달한다.

DELETE FROM MEMBER WHERE ID = 'id1';

 

04. 한 건 조회

  • find()메서드는 조회할 엔티티의 타입과 @Id로 데이터베이스 테이블의 기본키와 매핑한 식별자 값으로 엔티티 하나를 조회하는 메서드이다.

이 메서드를 호출하면 다음과 같은 SQL문을 만들어서 DB에 전달하고, 조회한 결과 값으로 엔티티 객체를 반환한다.

SELECT * FROM MEMBER WHERE ID = 'id1';

 

05. 여러 건 조회

List<Member> list =
     	em.createQuery("select m from Member m", Member.class).getResultList();
System.out.println("Member size : " + list.size());
  • JPA를 사용하면 개발자는 엔티티 객체를 중심으로 개발하고, DB에 관한 처리는 JPA에 맡겨야 한다.
  • 앞에서의 메서드들에서는 SQL문을 작성하지 않았다.
  • 그런데 테이블이 아닌 엔티티 객체를 대상으로 검색하려면 DB의 모든 데이터를 애플리케이션에 불러와서 엔티티 객체로 변경한 다음 검색해야 하는 데 이는 불가능하다.
  • 때문에 JPA는 JPQL이라는 객체지향 쿼리 언어를 지원한다.

JPA는 JPQL을 분석해서 다음과 같은 적절한 SQL을 만들어 DB에 전달하고, 데이터를 조회한다.

SELECT MEMBER.ID, MEMBER.NAME, MEMBER.AGE FROM MEMBER;

 

728x90

'JPA' 카테고리의 다른 글

JPA TIL - 06 다양한 연관관계 매핑  (0) 2024.04.16
JPA TIL - 05 연관관계 매핑 기초  (0) 2024.04.09
JPA TIL - 04 엔티티 매핑  (0) 2024.04.02
JPA TIL - 03 영속성 관리  (0) 2024.03.26
JPA TIL - 01 JPA 1장  (0) 2024.03.20
728x90

JPA (Java Persistence API)란?

자바 진영의 ORM 기술 표준

 

ORM?

  • Object-Relational-Mapping(객체 관계 매핑)
  • 객체는 객체대로 설계한다.
  • 관계형 DB는 관계형 DB대로 설계
  •  ORM 프레임워크가 중간에서 매핑( 하나의 값을 다른 값으로 대응시키는 것)

즉, 객체와 관계형 DB를 각각 설계한 후, ORM으로 매핑시킨다.

 

JPA는 애플리케이션과 JDBC(자바에서 데이터베이스에 접속할 수 있도록 하는 자바 API )사이에서 동작한다.

 


1. SQL을 직접 다룰 때 발생하는 문제점

1.1 반복 작업

더보기

SQL을 직접 다룰 때의 문제점을 보기 위한 기능 예시

 

public class Member
{
    private String memberId;
    private String name;
    
}

회원 객체

public class MemberManager
{
    public Member find(String memberId){
    
    // 1
    
    String sql = "SELECT MEMBER_ID, NAME FROME MEMBER M WHERE MEMBER_ID = ?";
    
    
    // 2
    
    Statement stmt;
    ResultSet res = stmt.excuteQuery(sql);
    
    
    // 3
    
    String memberId = res.getString("MEMBER_ID");
    String name = res. getString("NAME");
    
    Member member = new Member();
    member.setMemberId(memberId);
    member.setName(name);
    
    return member;
    
    }
}

회원 관리를 위한 Manager.

find(String memberId)로 회원을 조회한다.

  1. sql문 작성
  2.  sql문 실행
  3. 조회 결과를 Member객체로 매핑

회원 추가, 수정, 삭제 또한 SQL을 작성하고, JDBC API를 사용하는 비슷한 일을 반복할 것이다.

 

만약, 회원 객체를 데이터베이스가 아닌 자바 컬렉션에 보관한다면 다음 한 줄로 저장할 수 있다.

list.add(member);

하지만 데이터베이스는 객체 구조와는 다른 데이터 중심의 구조이다.

때문에 데이터베이스에 직접 저장하거나 조회할 수는 없다.

 

즉, 개발자가 객체지향 애플리케이션과 데이터베이스 중간에서 SQL과 JDBC API를 코드로 작성해야 한다.

 

결국 하나의 애플리케이션에는 수많은 DB 테이블이 있을 것이다.

그러면 이런 비슷한 일을 더 많이 반복해야 한다.

 

1.2 SQL에 의존적

더보기

앞에서 만든 회원 객체를 관리하는 MemberManager를 완성하였다.

그런데 갑자기 회원의 연락처도 함께 저장해달라는 요구가 들어왔다.

 

public class Member
{
    private String memberId;
    private String name;
    private String tel; // 추가
    
}

이렇게 되면 조회, 추가, 수정, 삭제의 코드 전체가 변동될 수 있다.

왜냐하면 매핑을 할 데이터가 늘어났으니까.

 

Member처럼 비즈니스 요구사항을 모델링한 객체를 엔티티라고 한다.

지금처럼 SQL에 모든것을 의존하는 상황에서는 개발자들이 Manager을 열어서 어떤 SQL이 실행되고 어떤 객체들이 함께 조회되는지 일일이 확인해야 한다.

 

즉,

  • 진정한 의미의 계층 분할이 어렵다.
  • 엔티티를 신뢰할 수 없다.
  • SQL에 의존적인 개발을 피하기 어렵다.

 

1.3 JPA와 문제 해결

더보기

간단히 JPA가 문제를 해결하는 법을 알아보자.

JPA를 사용하면 객체를 DB에 저장하고 관리할 때, 개발자가 직접 SQL을 작성하는 것이 아니다.

JPA가 제공하는 API를 사용하면 개발자 대신 적절한 SQL문을 생성해서 DB에 전달한다.

 

jpa.persist(member); // 저장

persist() 메소드는 객체를 데이터베이스에 저장한다.

이 메서드를 호출하면 JPA가 객체와 매핑정보를 보고 적절한 INSERT SQL을 생성해서 DB에 전달한다.

 

String memberId = "helloId";
Member member = jpa.find(Member.class, memberId); // 조회

find() 메소드는 객체 하나를 데이터베이스에서 조회한다.

이 메서드를 호출하면 JPA가 객체와 매핑정보를 보고 적절한 SELECT SQL을 생성해서 DB에 전달한다.

그 결과로 Member 객체를 생성해서 반환한다.

 

수정기능과 연관된 객체를 조회하는 기능은 나중에 설명하겠다.

 

 


2. 패러다임의 불일치

애플리케이션은 전부터 점점 발전해나가면서 그 내부의 복잡성도 점점 커지고 있다.

지속 가능한 애플리케이션을 개발하기 위해선, 유지보수가 중요하다.

객체지향 프로그래밍은 추상화, 캡슐화, 상속, 다형성 등의 특성이 있다.

그 덕분에 현대의 복잡한 애플리케이션은 대부분 객체지향 언어로 개발된다.

 

엔티티들로 객체를 모델링하면 객체지향 언어가 가진 장점을 활용할 수 있다.

하지만 이 모델링한 엔티티를 저장할 때 문제가 생길 수 있다.

 

문제

더보기
class Dog
{
    String name;
    public void move(){}
}

Dog라는 클래스의 객체를 저장해야 하는 상황이 있다.

이때는 해당 객체 의 필드만을 저장하면 나중에 DB에 접근해서 처리를 해야할 때 문제없이 이용할 수 있다.

 

하지만

만약 이 객체가 부모 객체를 상속받았다면?

만약 이 객체가 다른 객체를 참조하고 있다면?

이런 경우는 객체의 상태를 저장하기 쉽지 않다.

 

예를 들어 회원 객체를 저장할 때 팀 객체를 참조하고 있다면?

회원 객체를 저장할 때 팀 객체도 함께 저장해야 한다.

 

단순히 회원 객체만 저장하면 참조하는 팀 객체를 잃어버릴 수 있다.

 

 

객체와 관계형 데이터베이스는 지향하는 목적이 서로 다르다.

이것을 객체와 관계형 데이터베이스의 패러다임 일치 문제라고 한다.

 

이러한 불일치를 개발자가 중간에서 직접 해결해야 한다. 

이를 해결하는 시간에 너무 많은 시간과 코드를 소비하게 된다.


3. JPA로 ORM알아보기

더보기

ORM은 객체와 관계형 DB를 매핑한다고 했었다.

그리고 ORM 프레임워크는 패러다임 불일치 문제를 개발자 대신 해결해준다.

 

예를 들어 ORM 프레임워크를 사용하면 객체를 DB에 저장할 때 : 

개발자는 INSERT SQL을 직접 작성하지 않는다.

객체를 마치 자바 컬렉션에 저장하듯이 ORM 프레임워크에 저장하면 된다.

그러면 ORM 프레임워크가 적절한 INSERT SQL을 생성해서 DB에 저장해준다.

 

개발자는 그냥 매핑 방법만 ORM 프레임워크에게 알려주기만 하면 된다.


4. JPA 정리

더보기

1. 생산성

JPA를 사용하면 다음 코드처럼 자바 컬렉션에 객체를 저장하듯이 JPA에게 저장할 객체를 전달한다.

jpa.persist(member); // 저장
Member member = jpa.find(memberId) // 조회

따라서 지루하고 반복적인 CRUD용 SQL을 개발자가 직접 작성하지 않아도 된다.

 

2. 유지보수

SQL을 직접 다루게 되면 엔티티에 필드를 하나만 추가해도 SQL결과를 매핑하기 위한 JDBC API코드를 모두 변경해야 했다.

하지만 JPA를 사용함으로써 대신 SQL을 작성하기 때문에 필드를 추가하거나 삭제해도 수정해야 할 코드가 줄어든다.

 

3. 패러다임의 불일치 해결

객체와 관계형 데이터베이스는 지향하는 목적이 서로 다르다.

이것을 객체와 관계형 데이터베이스의 패러다임 일치 문제라고 한다.

JPA는 상속, 연관관계, 객체 그래프 탐색, 비교하기와 같은 패러다임의 불일치 문제를 해결해준다.

 

728x90

+ Recent posts