이번 장에서는 컨테이너 환경에서 JPA 가 동작하는 내부 동작 방식을 이해하고, 발생할 수 있는 문제점과 해결방안을 정리합니다.

13.1 트렌젝션 범위의 영속성 컨텍스트

스프링이나 J2EE 컨테이너 환경에서 JPA 를 사용하면 컨테이너가 제공하는 전략을 따라야합니다.

13.1.1 스프링 컨테이너의 기본 전략

스프링 컨테이너는 트렌젝션 범위의 영속성 컨텐스트 전략을 기본으로 합니다. 즉, 트렌젝션을 시작할 때 영속성 컨텍스트를 생성하고 끝날 때 영속성 컨텍스트를 종료합니다. 그리고, 같은 트렌젝션 안에서는 항상 같은 영속성 컨텍스트에 접근합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Controller
class HelloController{

@Autowired HelloService helloService;

public void hello(){
//반환된 member 엔티티는 준영속 상태
Member member = helloService.logic();
}
}

@Service
class HelloService{

// 엔티티 메니저 주입
@PersistenceContext
EntityManager em;

@Autowired Repository1 repository1;
@Autowired Repository2 repository2;

//트랜잭션 시작
@Transactional
public void logic(){
repository1.hello();

//Member 는 영속상태
Member member = repository2.findMember();

return member;
}
//트렌젝션 종료
}

@Repository
class Repository1 {

@PersistenceContext
EntityManager em;

public void hello(){
em.xxx(); //영속성 컨텍스트 접근
}
}

@Repository
class Repository2 {

@PersistenceContext
EntityManager em;

public Member findMember() {
return em.find(Member.class, "id1"); //영속성 컨텍스트 접근
}
}

13.2 준영속 상태와 지연 로딩

조회한 엔티티가 서비스와 리포지토리 계층에서는 영속성 컨텍스트에 관리되면서 영속 상태를 유지하지만, 컨트롤러나 뷰 같은 프리젠테이션 계층에서는 준영속 상태가 됩니다. 따라서, 변경감지와 지연로딩이 동작하지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
public class Order{
@Id @GeneratedValue
private Long id;

@ManyToOne(fetch = FetchType.LAZY) //지연로딩
private Member member; //주문 회원
}

class OrderController {
public String view(Long orderId){
Order order = orderService.findOne(orderId);
Member member = order.getMember();
member.getName(); //지연로딩 시 예외 발생
}
}
  • 준영속 상태와 변경 감지

변경감지 기능이 프리젠테이션 계층에서 동작하지 않는것은 문제가 되지 않습니다. 변경 감지 기능이 프리젠테이션 계층에서도 동작하면 애플리케이션 계층이 가지는 책임이 모호해지고, 데이터를 어디서 어떻게 변경했는지 프리젠테이션 계층까지 다 찾아야 하므로 유지보수하기 어렵습니다. 비즈니스 로직은 서비스 계층에서 끝내야합니다.

  • 준영속 상태와 지연 로딩

준영속 상태의 지연 로딩을 해결하는 방법은 두 가지 입니다.

  1. 뷰가 필요한 엔티티를 미리 로딩
  2. OSIV

뷰가 필요한 엔티티를 미리 로딩하는 방법은 어디서 미리 로딩 하느냐에 따라 세가지가 있습니다.

  1. 글로벌 페치 전략 수정
  2. JPQL Fetch Join
  3. 강제 초기화
13.2.1 글로벌 페치 전략 수정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Order{
@Id @GeneratedValue
private Long id;

@ManyToOne(fetch = FetchType.EAGER) //즉시 로딩 전략
private Member member; //주문 회원
}

//Presentation Logic
class OrderController {
public String view(Long orderId){
Order order = orderService.findOne(orderId);
Member member = order.getMember();
member.getName(); //이미 로딩된 엔티티
}
}

글로벌 페치 전략에 즉시 로딩 사용시 단점은 두가지가 있습니다.

  1. 사용하지 않는 엔티티를 로딩

order 를 조회하면서 사용하지 않는 member 도 함께 조회

  1. N+1 문제
1
2
3
4
5
6
7
Order order = em.find(Order.class, 1L);

//실행된 SQL
select o.*, m.*
from Order o
left outer join Member m on o.MEMBER_ID = m.MEMBER_ID
where o.id = 1

여기까지 보면 글로벌 즉시 로딩 전략이 좋아보이지만, 문제는 JPQL 을 사용할 때 발생합니다.

1
2
3
4
5
6
7
8
9
List <Order> orders = em.createQuery("select o from Order o", Order.class).getResultList();

//실행된 SQL
select * from Order //JPQL 로 실행된 SQL
select * from Member where id = ? //EAGER 로 실행된 SQL
select * from Member where id = ? //EAGER 로 실행된 SQL
select * from Member where id = ? //EAGER 로 실행된 SQL
select * from Member where id = ? //EAGER 로 실행된 SQL
...

JPA 가 JPQL 을 분석해서 SQL 을 생성할 때, 글로벌 패치 전략을 참고하지 않고 오직 JPQL 자체만 사용합니다. 따라서, 즉시로딩이든 지연 로딩이등 구분하지 않고 JPQL 쿼리 자체에 충신한 SQL 을 만듦니다.

이런 N+1 문제는 이어서 소개할 JPQL Fetch Join 으로 해결할 수 있습니다.

13.2.2 JPQL Fetch Join
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Fetch Join 사용 전
JPQL : select o from Order o
SQL : select * from Order

//Fetch Join 사용 후
JPQL :
select o
from Order o
join fetch o.member

SQL :
select o.*, m.*
from Order o
join Member m on o.MEMBER_ID = m.MEMBER_ID

Fetch Join 을 사용하면, SQL JOIN 을 사용해서 페치 조인 대상까지 함께 조회합니다. N+1 문제가 발생하지 않습니다.

  • JPQL Fetch Join 의 단점

무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가할 수 있습니다. 결국 프리젠테이션 계층이 데이터 접근 계층을 침범하는 것입니다.

13.2.3 강제로 초기화

영속성 컨테스트가 살아있을 때 프리젠테이션 계층이 필요한 엔티티를 강제로 초기화해서 반환하는 방법입니다.

1
2
3
4
5
6
7
8
9
class OrderService{

@Transactional
public Order findOrder(id){
Order order = oderRepository.findOrder(id);
order.getMember().getName(); //프록시 객체를 강제로 초기화
return order;
}
}

글로벌 페치 전략을 지연로딩으로 설정하면, 연관된 엔티리를 실제 엔티티가 아닌 프록시 객체로 조회합니다. 프록시 객체는 실제 사용하는 시점에 초기화됩니다. order.getMember() 까지만 호출하면. 단순히 프록시 객체만 반환합니다. 아직 초기화 하지 않습니다. member.getName() 처럼 실제 값을 사용할 때 초기화됩니다.

프록시 초기화 하는 역하을 서비스 계층이 담당하면, 뷰가 필요한 엔티티에 따라 서비스 계층의 로직을 변경해야 합니다. 비즈니스 로직을 담당하는 서비스 계층에서 프리젠테이션 계층을 위한 프록시 초기화 역할을 하는 FACADE 계층이 그 역할을 담당해줍니다.

13.2.4 FACADE 계층 추가
  • 프리젠테이션 계층과 도메인 모델 계층간의 논리적 의존성을 분리합니다.

  • 프리젠테이션 계층에서 필요한 프록시 객체를 초기화합니다.

  • 서비스 계층을 호출해서 비즈니스 로직을 실행합니다.

  • 리포지토리를 직접 호출해서 뷰가 요구하는 엔티티를 찾습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class OrderFacade{
@Autowired OrderService orderService;

public Order findOrder(id){
Order order = orderService.findOrder(id);

//프리젠테이션 계층이 필요한 프록시 객체를 강제 초기화
order.getMember().getName();
return order;
}
}

class OrderService{
public Order findOrder(id){
return orderRepository.findOrder(id);
}
}

13.3 OSIV (Open Session in View)

영속성 컨텍스트를 뷰까지 열어둔다는 뜻입니다.

13.3.1 과거 OSIV : 요청 당 트렌젝션

OSIV 의 핵심은 뷰에서도 지연로딩이 가능하도록 하는 것입니다. 가장 단순한 구현은 클라이언트의 요청이 들어오자마자 서플릿 필터나 스프링 인터셉터에서 트렌젝션을 시작하고 요청을 끝날 때 트렌젝션도 끝내는 것입니다. 이것을 요청 당 트렌젝션 방식의 OSIV 라고 합니다.

문제는, 프레센테이션 계층이 엔티티를 변경할 수 있다는 것입니다.

1
2
3
4
5
6
7
class MemberControlelr{
public String viewMember(Long id){
Member member = memberService.getMember(id);
member.setName("XXX");
model.addAttribute("member", member);
}
}

개발자의 의도는 단순히 뷰에 노출할 때만 고객이름을 XXX 로 변경하고 싶은 것이지, 데이터베이스에 있는 고객 이름까지 변경하고자 하는 것이 아니었습니다. 하지만 요청당 트렌젝션 방식은 뷰 렌더링 이후에 트렌젝션으 커밋합니다. 커밋을 하면 영속성 컨텍스트를 플러쉬합니다. 영속성 컨텍스트의 변경 감지 기능이 동작해서 변경된 엔티티를 데이터베이스에 반영해버립니다.

따라서, 프레젠테이션 계층에서 엔티티를 수정하지 못하게 해야합니다.

  • 엔티티를 읽기 전용 인터페이스로 제공
  • 엔티티 레핑
  • DTO 만 반환
엔티티를 읽기 전용 인터페이스로 제공
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface MemberView{
public String getName();
}

@Entity
class Member implements MemberView{
...
}

class MemberService {
public MemberView getMember(id){
return memberRepository.findById(id);
}
}
엔티티 레핑

엔티티의 읽기 전용 메소드만 가지고 있는 엔티티를 감싼 객체를 만들고, 이것을 프리젠테이션 계층에 반환하는 방법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
class MemberWrapper{
private Member member;

public MemberWrapper(member){
this.member = member;
}

// 읽기 전용 메소드만 제공
public String getName(){
member.getName;
}
}
DTO 만 반환
1
2
3
4
5
6
7
8
9
10
class MemberDTO {
private STring name;

//GETTER, SETTER
}

...
MemberDTO memberDTO = new MemberDTO();
memberDTO.setName(member.getName());
return memberDTO;

최근에는 비즈니스 계층에서만 트렌젝션을 유지하는 방식의 OSIV 를 사용합니다. 스프링 프레임워크가 제공하는 OSIV 방식입니다.

13.3.2 스프링 OSIV : 비즈니스 계층 트렌젝션

클라이언트의 요청이 들어오면 영속성 컨텍스트를 생성합니다. 이 때, 트렌젝션을 시작하지 않습니다. 서비스 계층에서 트렌젝션을 시작하면 앞에서 생성해둔 영속성 켄텍스트에 트렌젝션을 시작합니다. 비즈니스 로직을 실행하고 서비스 계층이 끝나면 트렌젝셩르 커밋하면서 영속성 컨텍스트를 플러쉬합니다. 이때, 트렌젝션만 종료하고 영속성 컨텍스트를 살려둡니다. 클라이언트의 요청이 끝날 때 영속성 컨텍스트를 종료합니다.

엔티티를 변경하지 않고 단순히 조회만 할 때는 트렌젝션이 없어도 되는데, 이것을 트렌젝션 없이 읽기라고 합니다. 영속성 컨텍스트는 트렌젝션 범위 안에서 엔티티를 조회하고 수정할 수 있습니다. 영속성 컨텍스트는 트렌젝션 범위 밖에서 엔티리를 조회만 할 수 있습니다.

Comment and share

11장은 Spring Framework 와 JPA 를 사용해서 Web Application 을 만들어보는 장입니다. 진행 순서는 다음과 같습니다.

  1. 프로젝트 환경설정
  2. 도메인 모델과 테이블 설계
  3. 기능 구현

코드 위주의 챕터이기 때문에, 핵심 키워드 위주로 정리하려고 합니다.

11.1 프로젝트 환경설정

11.2 도메인 모델과 테이블 설계

11.2.1 요구사항 분석
  • 회원 기능
    • 등록
    • 조회
  • 상품 기능

    • 등록
    • 수정
    • 조회
  • 주문

    • 주문
    • 조회
    • 취소
  • 기타
    • 상품 종류 : 도서 / 음반 / 영화
    • 상품은 카테고리로 구분 가능
    • 상품 주문 시 배송 정보 입력 가능
11.2.2 도메인 모델 설계
  • 회원 / 주문 / 상품
    • 주문과 상품은 다대다 관계
      • 다대다 관계는 관계형 데이터베이스는 물론이고, 엔티티에서도 거의 사용하지 않음
      • 주문 상품이라는 엔티티를 추가해서 다다대 관계를 일대다, 다대일 관계로 풀어냄
    • 상품 분류
      • 상품은 도서, 음반, 영화로 구분되는데, 상품이라는 공통 속성을 사용하므로 상속 구조로 표현
11.2.3 테이블 설계
11.2.4 연관 관계 정리
  • 회원과 주문
    • 일대다 양방향 관계
    • 연관 관계의 주인을 정해야함. 외래키가 있는 주문이 연관 관계의 주인.
  • 주문 상품과 주문
    • 다대일 양방향 관계
    • 주문 상품이 연관관계의 주인
  • 주문 상품과 상품
    • 일대일 단방향 관계
  • 주문과 배송
    • 일대일 양방향 관계
  • 카테고리와 상품
    • @ManyToMany
11.2.5 엔티티 클래스

11.3 애플리케이션 구현

11.3.1 개발 방법
  • Controller
    • MVC 의 컨트롤러가 모여있는 곳
    • 서비스 계층을 호출하고 결과를 뷰에 전달
  • Service
    • 비즈니스 로직이 있고 트렌젝션을 시작
    • 데이터 접근 계층인 Repository를 호출
  • Repository
    • JPA 를 직접 사용하는 곳
    • Entity Manager 를 사용해서 Entity 를 저장하고 조회
  • Domain
    • 엔티티가 모여 있는 계층
    • 모든 계층에서 사용
11.3.2 회원 기능

순수 자바 환경에셔는 엔티티 메니저 팩토리에서 엔티티 메니저를 직접 생성해서 사용했지만, 스프링이나 J2EE 컨테이너를 사용하면 컨테이너가 엔티티 메니저를 관리하고 제공합니다. 그래서, 엔티티 메니저 팩토리에서 엔티티 메니저를 직접 생성해서 사용하지 않고, 컨테이너가 제공하는 엔티티 메니저를 사용합니다.

@PersistenceContext 는 컨테이너가 관리하는 엔티티 메니저를 주입하는 Annotation 입니다.

1
2
@PersistenceContext
EntityManager em;
11.3.3 상품 기능
11.3.4 주문 기능

주문 서비스의 주문과 주문 취소 메소드를 보면 비즈니스 로직 대부분이 엔티티에 있습니다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 합니다. 이처럼, 엔티티가 비즈니로스 로직을 가지고 객체지향의 특성을 활용하는 것을 도메인 모델 패턴이라고 합니다.

반대로, 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라고 합니다.

11.3.5 웹 계층 구현

준영속 엔티티를 수정하는 방법은 2가지입니다.

  • 변경 감지 기능 사용
  • 병합 사용

변경 감지 기능을 사용하는 방법은, 영속성 컨텍스트에서 엔티티를 다시 조회한 후에 데이터를 수정하는 방법입니다.

1
2
3
4
5
6
7
8
@Transactional
//itemParam : Parameter 로 넘어온 준영속 상태의 엔티티
void update(Item itemParam){
//같은 엔티티를 조회
Item findItem = em.find(Item.class, itemParam.getId());

findItem.setPrice(itemParam.getPrice()); //데이터 수정
}

위 코드처럼, 트렌젝션 안에서 준영속 엔티티의 식별자로 엔티티를 다시 조회하면, 영속 상태의 엔티티를 얻을 수 있습니다. 이렇게 영속 상태인 엔티티의 값을 파라미터로 넘어온 준영속 사앹의 엔티티 값으로 변경하면 됩니다. 이렇게 하면, 이후 트렌젝션이 커밋될때 변경감지 기능이 동작해서 데이터베이스에 수정사항이 반영됩니다.

병합은 파라미터로 넘긴 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회하고, 영속 엔티티의 값을 준영속 엔티티의 값으로 채워넣습니다.

1
2
3
4
5
@Transactional
//itemParam : Parameter 로 넘어온 준영속 상태의 엔티티
void update(Item itemParam){
Item mergeItem = em.merge(itemParam);
}

변경 감지 기능을 사용하면, 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경됩니다.

Comment and share

데이터 접근 계층 (Data Access Layer) 는 CRUD 로 불리는 등록, 수정, 삭제, 조회 코드를 반복해서 개발해야 합니다. JPA 를 사용해서 데이터 접근 계층을 개발할 때도 문제가 발생합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MemberRepository{

@PersistenceContext
EntityManager em;

public void save(Member member) {...}
public Member findOne(Long id) {...}
public List<Member> findAll() {...}

public Member findByUsername(String username) {...}
}

public class ItemRepository{

@PersistenceContext
EntityManager em;

public void save(Item item) {...}
public Member findOne(Long id) {...}
public List<Member> findAll() {...}
}

위 코드를 보면, 회원 리포지토리와 상품 리포지토리가 하는 일이 비슷합니다. 이 문제를 해결하려면 제네릭과 상속을 적절히 사용해서 공통 부분을 처리하는 부모 클래스를 만들면 됩니다. 이것을 보통 GenericDAO 라고 합니다. 하지만 이것은, 공통 기능을 구렿낳ㄴ 부모 클래스에 종속되고 구현 클래스 상속이 가지는 단점이 있습니다.

12.1 스프링 데이터 JPA 소개

스프링 데이터 JPA 는 스프링 프레임워크에서 JPA 를 편리하게 사용할수 있도록 지원하는 프로젝트입니다. 이 프로젝트는 데이터 접근 계층을 개발할 때 지루하게 반복되는 CRUD 문제를 세련된 방법으로 해결합니다. 데이터 접근 계층을 개발할 때 구현 클래스 없이 인터페이스만 작성해도 개발을 완료할 수있습니다.

1
2
3
4
5
6
public interface MemberRepository extends JpaRepository<Member, Long> {
Member findByUsername(String username);
}

public interface ItemRepository extends JpaRepository<Item, Long> {
}

회원과 상품 리포지토리 구현체는 애플리케이션 실행 시점에 스프링 데이터 JPA가 생성해서 주입해줍니다. 즉, 개발자가 직접 구현체를 개발하지 않아도 됩니다.

일반적인 CRUD 메소드는 JpaRepository 인터페이스가 공통으로 제공하지만, MemberRepository.findByUsername(…) 처럼 직접 작성한 공통으로 처리할 수 없는 메소드는 스프링 데이터 JPA 가 메소드 이름을 분석해서 JPQL 을 실행합니다.

12.1.1 스프링 데이터 프로젝트

스프링 데이터 JPA 프로젝트는 JPA에 특화된 기능을 제공합니다. 스프링 프레임워크 + JPA 를 사용한다면, 스프링 데이터 JPA 를 추천합니다.

12.2 스프링 데이터 JPA 설정

  • 필요 라이브러리
  • 환경 설정

12.3 공통 인터페이스 기능

1
2
3
4
5
6
7
8
// JpaRepository 공통 기능 인터페이스
public interface JpaRepository<T, ID extends Serializable> extends PagingAndSortingRepository<T, ID> {
...
}

// JpaRepository 를 사용하는 인터페이스
public interface MemberRepository extends JpaRepository<Member, Long> {
}

JpaRepsitory 인터페이스의 계층 구조는 다음과 같습니다.

12.4 쿼레 메소드 기능

스프링 데이터 JPA 가 제공하는 쿼리 메소드 기능은 크게 3가지입니다.

  • 메소드 이름으로 쿼리 생성
  • 메소드 이름으로 JPA NamedQuery 호출
  • @Query 어노테이션을 사용해서 리포지토리 인터페이스에 쿼리 직접 정의
12.4.1 메소드 이름으로 쿼리 생성
1
2
3
public interface MemberRepository extends Repository<Member, Long> {
List<Member> findByEmailAndName (String email, String name);
}

findByEmailAndName(…) 를 실행하면 스프링 데이터 JPA 는 메소드 이름을 분석해서 다음 JPQL을 생성하고 실행합니다.

1
select m from Member m where m.email = ?1 and m.name =?2
12.4.2 JPA NamedQuery

스프링 데이터 JPA 는 메소드 이름으로 JPA Named 쿼리를 호출하는 기능을 제공합니다.

1
2
3
4
5
6
7
8
@Entity
@NamedQuery{
name = "Member.findByUsername",
query = "select m from Member m where m.username = :username"
}
public class Member{
...
}

이렇게 정의한 Named 쿼리를 다음과 같이 호출합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//JPA를 직접 사용해서 Named Query 호출
public class MemberRepository{
public List<Member> findByUserName(String username){
...
List<Member> resultList =
em.createNaemdQuery("Member.findByUsername", Member.class)
.setParameter("username", "회원1")
.getResultList();
}
}

// 스프링 데이터 JPA 로 호출
public interface MemberRepository extends JpaRepository <Member, Long> {
List <Member> findByUserName(@Param("username") String username);
}

스프링 데이터 JPA 로 호출하는 경우, “도메인 클래스.메소드이름” 으로 Named Query 를 찾아서 실행합니다. 위 예제는, Member.findByUsername 이라는 Named Query 를 실행합니다. 만약, Named Query 가 없으면 메소드 이름으로 쿼리 생성 전략을 사용합니다.

12.4.3 @Query, 리포지토리 메소드에 쿼리 정의

실행할 메소드에 직접 정적 쿼리를 작성하므로, 이름 없는 Named Query 라고 할 수 있습니다.

1
2
3
4
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.username = ?1")
Member findByUserName(String username);
}
12.4.4 파라미터 바인딩

스프링 데이터 JPA 는 위치 기반 파라미터 바인딩과 이름 기반 파라미터 바인딩을 모두 지원합니다. 다음은 이름 기반 파라미터 바인딩 예제입니다.

1
2
3
4
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m where m.username = :name")
Member findByUserName(@Param("name") String username);
}
12.4.5 벌크성 수정 쿼리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// JPA 를 사용한 벌크성 수정 쿼리
int bulkPriceUp(String stockAmout){
...
String qlString = "update Product p set p.price = p.price * 1.1 where
p.stockAmout < :stockAmout";

int resultCount = em.createQuery(qlString)
.setParameter("stockAmout", stockAmout)
.executeUpdate();
}

// 스프링 데이터 JPA 를 사용한 벌크성 수정 쿼리
@Modifying
@Query("update Product p set p.price = p.price * 1.1 where
p.stockAmout < :stockAmout")
int bulkPriceUp(@Param("stockAmout") String stockAmout);
12.4.6 반환 타입

결과가 한건 이상이면 컬렉션 인터페이스, 단건이면 반환 타입을 지정합니다.

1
2
List<Member> findByName (String name); //컬렉션
Member findByEmail (String email); //단건
12.4.7 페이징과 정렬

쿼리 메소드에 페이징과 정렬 기능을 사용할 수 있도록 2가지 파라미터를 제공합니다.

  • org.springframework.data.domain.sort
  • org.springframework.data.domain.Pageable
    • 파라미터에 Pageable 을 사용하면, 반환타입으로 List 나 org.springframework.data.domain.Page 사용 가능
    • 반환 타입으로 Page 사용하면 검색된 전체 데이터 건수 조회하는 count 쿼리 추가로 호출
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 페이징과 정렬 사용 예제
Page<Member> findByName(String name, Pageable pageable);
List<Member> findByName(String name, Pageable pageable);
List<Member> findByName(String name, Sort sort);

// Page 사용 예제 정의 코드
public interface MemberRepository extends Repository<Member, Long> {

Page<Member> findByNameStartingWith(String name, Pageable pageable);
}

// Page 사용 예제 실행 코드
PageRequest pageRequest = new PageRequest(0, 10, new Sort(Direction.DESC, "name"));

Page<Member> result = memberRepository.findByNameStartingWith("김", pageRequest);

List<Member> members = result.getContent();
int totalPage = result.getTotalPages();
boolean hasNextPage = result.hasNextPage();

Pageable 은 인터페이스입니다. 실제 사용할 때는 이를 구현한 PageRequest 객체를 사용합니다.

12.4.8 힌트

SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트입니다.

1
2
3
@QueryHints(value = {@QueryHint(name = "org.hibernate.readOnly",
value = "true")}, forCounting = true)
Page<Member> findByName (String name, Pagable pageable);
12.4.9 Lock

12.5 명세

도메인 주도 설계에서 명세라는 개념을 소개하는데, 스프링 데이터 JPA 는 JPA Criteria 로 이 개념을 사용할 수 있습니다.

명세를 이해하기 위한 핵심 단어는 술어입니다. 이것은 단순히 참이나 거짓으로 평가됩니다. 스프링 데이터 JPA 는 이 술어를 org.springframework.data.jpa.domain.Specification 클래스로 정의했습니다. Specification 은 컴포지트 패턴으로 구성되어 있어서 여러 Specification 으로 조합할 수 있습니다.즉, 다양한 검색 조건을 조립해서 새로운 검색 조건을 쉽게 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// JpaSpecificationExecutor 상속
pubilc interface OrderRepsitroy extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order>{
}

// JpaSpecificationExecutor 인터페이스
public interface JpaSpecificationExecutor<T> {
T findOne (Specification<T> spec);
...
}

// 명세 사용 코드
// Specification 은 명세들을 조립할 수 있도록 도와주는 클래스인데,
// where(), and(), or(), not() 메소드를 제공
public List<Order> findOrders (String name) {
List<Order> result = orderRepository.findAll(
where(memberName(name)).and(isOrderStatus())
);
return result;
}

12.6 사용자 정의 리포지토리 구현

스프링 데이터 JPA 로 리포지토리를 개발하면 인터페이스만 정의하고 구현체는 만들지 않습니다. 하지만, 다양한 이유로 메소드를 직접 구현해야 할 때도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 사용자 정의 인터페이스
public interface MemberRepositoryCustom{
public List<Member> findMemberCustom();
}

// 사용자 정의 구현 클래스
// 클래스 이름 짓는 규칙 : 리포지토리 인터페이스 이름 + Impl
// 이렇게 하면 스프링 데이터 JPA 가 사용자 정의 구현 클래스로 인식
public class MemberRepositoryImpl implements MemberRepositoryCustom{
@Override
public List<Member> findMemberCustom(){
// 사용자 정의 구현
// ...
}
}

// 사용자 정의 인터페이스 상속
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom{
}

12.7 Web 확장

스프링 데이터 프로젝트는 스프링 MVC 에서 사용할 수 있는 기능을 제공합니다.

  • 식별자로 도메인 클래스를 바로 바인딩 해주는 도메인 클래스 컨버터 기능
  • 페이징과 정렬 기능
12.7.1 설정
1
2
3
4
5
6
@Configuration
@EnableWebMvc
@EnableSpringDataWebSupport
public class WebAppConfig{
...
}

설정을 완료하면, 도메인 클래스 컨버터와 페이징과 정렬을 위한 HandlerMethodArgumentResolver 가 스프링 빈으로 등록됩니다.

12.7.2 도메인 클래스 컨버터 기능

도메인 클래스 컨버터는 HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩해줍니다.

12.7.3 페이징과 정렬 기능

스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC 에서 편리하게 사용할 수 있도록 HandlerMethodArgumentResolver 를 제공합니다.

12.8 스프링 데이터 JPA 가 사용하는 구현체

스프링 데이터 JPA 가 제공하는 공통 인터페이스는 org.springframework.data.jpa.repository.support.SimpleJpaRepository 클래스가 구현합니다.

1
2
3
4
5
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID extends Serializable> implements JpaRepository<T, ID>, JpaSpecificationExecutor<T> {
...
}

Comment and share

10 장에서 다루는 내용입니다.

  • 객체지향 쿼리 소개
  • Criteria
  • QueryDSL
  • Native SQL
  • 객체지향 쿼리 심화

10.1 객체지향 쿼리 소개

EntityMangager.find() 메서드를 사용하면 식별자로 엔티티 하나를 조회하고, 조회한 엔티티에 객체 그래프 탐색을 사용해서 연관된 엔티티를 찾을 수 있습니다.

  • 식별자로 조회 : EntityMangager.find()
  • 객체 그래프 탐색 : a.getB().getC()

만약 30살 이상인 회원을 모두 검색하고 싶으면 ? 모든 엔티티를 메모리에 올려두고 검색하는 것은 현실적이지 않습니다. 결국 데이터는 DB에 있으므로 SQL 로 최대한 걸러야합니다. 하지만 ORM 을 사용하면 DB table 이 아닌, 엔티티 객체를 대상으로 검색하므로 검색도 테이블이 아닌 엔티티 객체를 대상으로 하는 방법이 필요합니다. 그래서 만들어진 것이 JPQL 입니다.

다음은, 검색 방법으로 JPA 가 공식 지원하는 기능입니다.

  • JPQL (Java Persistence Query Language)
  • Criteria Query
  • Native SQL

다음은, JPA 가 공식 지원하는 기능은 아니지만 알아둘 가치가 있습니다.

  • QueryDSL
  • JDBC 직접 사용 / MyBatis 같은 SQL Mapper 프레임워크 사용
10.1.1 JPQL 소개
1
2
String jpql = "select m from Member as m where m.username = 'kim'";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();
10.1.2 Criteria Query 소개

JPQL을 생성하는 빌더 클래스입니다. 문자가 아닌, query.select(m).where(…) 처럼 프로그래밍 코드로 JPQL을 작성할 수 있습니다.

장점은 다음과 같습니다.

  • 컴파일 시점에 오류 발견
  • IDE를 사용하면 코드 자동 완성 지원
  • 동적 쿼리 작성 편이

하지만, 복잡하고 장황해서 Criteria 로 작성한 코드가 한눈에 들어오지 않는 단점이 있습니다.

10.1.3 QueryDSL 소개

Criteria 처럼 JPQL 빌더 역할을 합니다. Criteria 에 비해, 작성한 코드가 한눈에 들어오고 단순하고 사용하기 쉽습니다.

JPA 표준이 아니고 오픈 소스 프로젝트입니다. JPA 뿐만 아니라, JDO, MongoDB, Java Collection, Lucene, Hibernate Search 도 거의 같은 문법으로 지원합니다.

10.1.4 Native SQL

SQL 을 직접 사용하는 기능입니다. 그래서, 데이터베이스를 변경하면 네이티브 SQL 로 수정해야합니다.

10.1.5 JDBC 직접 사용 / MyBatis 같은 SQL Mapper 프레임워크 사용

JDBC connection 에 직접 접근하고 싶으면, JPA는 JDBC connection 을 획득하는 API 를 제공하지 않으므로, JPA 구현체가 제공하는 방법을 사용해야 합니다.

10.2 JPQL

1절에서 엔티티를 쿼리하는 다양한 방법을 소개했지만, 어떤 방법을 사용하든 JPQL 에서 모든 것이 시작합니다. 다음은 JPQL의 특징입니다.

  • 객체지향 쿼리 언어입니다. 엔티티 객체를 대상으로 쿼리합니다.
  • 특정 데이터베이스에 SQL 에 의존하지 않습니다.
  • JPQL 도 결국 SQL 로 변환됩니다.
10.2.1 기본 문법과 쿼리 API

EntityManger.persist() 메소드를 사용하면 되기 때문에 INSERT 문은 없습니다.

SELECT 문
1
SELECT m FROM Member AS m where m.username = 'Hello'
TypeQuery, Query

반환할 타입을 명확히 지정할 수 있으면 TypeQuery, 반환할 타입을 명확히 지정할 수 없으면 Query

1
2
3
TypeQuery<Member> query = em.createQuery("SELECT m FROM Member AS m", Member.class);

Query query = em.createQuery("SELECT m.username¸ m.age FROM Member AS m");
결과 조회

다음 메소드를 호출하면 실제 쿼리를 실행해서 데이터베이스를 조회합니다.

  • query.getResultList()
  • query.getSingleResult()
10.2.2 파라미터 바인딩
이름 기준 파리미터

위치 기준 파라미터 바인딩 방식보다 명확합니다.

1
2
3
4
5
String usernameparam = "user1";

TypeQuery<Member> query = em.createQuery("SELECT m FROM Member m where m.username = :username", Member.class);

query.setParameter("username", usernameparam);
위치 기준 파라미터
1
2
3
4
5
6
String usernameparam = "user1";

List<Member> members = em
.createQuery("SELECT m FROM Member m where m.username = ?1", Member.class)
.setParameter(1, usernameParam)
.getResultList();
10.2.3 프로젝션

SELECT 절에 조회할 대상을 지정하는 것입니다.

  • 엔티티 프로젝션
    • 조회한 엔티티는 영속성 컨텍스트에서 관리됩니다.
  • 임베디드 타입 프로젝션
    • 임베디드 타입은 값 타입입니다. 따라서, 조회한 임베디드 타입은 영속성 컨텍스트에서 관리되지 않습니다.
  • 스칼라 타입 프로젝션
    • 기본 데이터타입입니다.
    • SELECT username…. / SELECT AVG(o.orderAmout)…
  • 여러 값 조회
    • 꼭 필요한 데이터들만 조회하는 경우입니다.
  • New 명령어
    • 반환 받을 클래스를 지정하여 이 클래스의 생성장에 JPQL 조회 결과를 넘길 수 있습니다.
    • TypeQuery 를 사용할 수 있어서 지루한 객체 변환 작업을 줄일 수 있습니다.
10.2.4 페이징 API

페지징을 다음 두 API 로 추상화했습니다.

  • setFirstResult(int startPosition)
    • 조회 시작 위치
  • setMaxResults(int maxResult)
    • 조회할 데이터 수
10.2.5 집합과 정렬

집합은 통계 정보를 구할 때 사용합니다.

10.2.6 JPQL 조인

SQL 조인과 기능은 같고 문법만 약간 다릅니다.

내부 조인
1
SELECT m FROM Member m INNER JOIN m.team t WHERE t.name = :teamName
외부 조인
1
SELECT m FROM Member m LEF [OUTER] JOIN m.team t
컬렉션 조인

일대다 관계나 다대다 관계처럼 컬렉션을 사용하는 곳에 조인하는 것입니다.

세타 조인

전혀 관계 없는 엔티티도 조회 할 수 있습니다.

1
2
3
select count(m) 
from Member m, Team t
where m.username = t.name
JOIN ON 절 (JPA 2.1)

조인 대상을 필터링하고 조인할 수 있습니다.

1
2
3
select m, t 
from Member m
left join m.team t on t.name = 'A'
10.2.7 페치 조인

연관된 엔티티나 컬렉션을 한 번에 같이 조회합니다.

엔티티 페치 조인
1
2
select m
from Member m join fetch m.team
컬렉션 페치 조인
1
2
3
select t
from Team t join fetch t.members
where t.name = '팀A'
페치 조인과 DISTINCT
1
2
3
select distint t
from Team t join fetch t.members
where t.name = '팀A'
페치 조인과 일반 조인의 차이

다음은, 팀만 조회하고 조인했던 회원은 전혀 조회하지 않습니다.

1
2
3
select t
from Team t join t.members m
where t.name = '팀A'

JPQL은 결과를 반환할 때 연관관계까지 고려하지 않습니다. 단지 SELECT 절에 지정한 엔티티만 조회합니다.

페치 조인의 특징과 한계
  • 페치 조인 대상에는 별칠을 줄 수 없습니다.
  • 둘 이상의 컬렉션을 페치할 수 없습니다.
  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없습니다.
10.2.8 경로 표현식

. 을 찍어서 객체 그래프를 탐색하는 것입니다.

  • 상태 필드
    • 단순히 값을 저장하기 위한 필드
  • 연관 필드
    • 객체 사이의 연관 관계를 위한 필드, 임베디드 타입 포함
경로 표현식과 특징
  • 상태 필드 경로
    • 경로 탐색의 끝
  • 단일 값 연관 경로
    • 묵시적으로 내부조인
    • 계속 탐색 가능
  • 컬렉션 값 연관 경로
    • 묵시적으로 내부조인
    • 더는 탐색 불가능 (단, FROM 절에서 조인 통해 별칭 얻으면 별칭으로 탐색 가능)
10.2.9 서브 쿼리

WHERE, HAVING 절에서만 사용 가능합니다.

서브 쿼리 함수
  • EXISTS
  • ALL|ANY|SOME
  • IN
10.2.10 조건식
10.2.11 다형성 쿼리

부모 엔티티를 조회하면 그 자식 엔티티도 함께 조회합니다.

10.2.12 사용자 정의 함수 호출 (JPA 2.1)
10.2.13 기타 정리
10.2.14 엔티티 직접 사용

#####10.2.15 Named Query : 정적 쿼리

미리 정의한 쿼리에 이름을 부여해서 필요할 때마다 사용하는 정적인 쿼리입니다.

10.3 Criteria

Criteria Query 는 JPQL 을 자바 코드로 작성하도록 도와주는 빌더 클래스 API 입니다.

10.4 QueryDSL

쿼리를 문자가 아닌 코드로 작성해도 쉽고 간결하며 그 모양도 쿼리와 비슷하게 개발할 수 있는 프로젝트입니다. JPA Criteria 를 대체할 수 있습니다.

10.4.1 설정
10.4.2 시작
1
2
3
4
5
6
7
8
9
10
11
12
public void queryDSL(){

EntityManager em = emf.createEntityManager();

JPAQuery query = new JPAQuery(em);
QMember qMember = new QMember("m"); //생성되는 JPQL의 별칭이 m
List<Member> members =
query.from(qMember)
.where(qMember.name.eq("회원1"))
.orderBy(qMember.name.desc())
.list(qMember);
}
기본 Q 생성

쿼리 타입(Q) 은 사용하기 편리하도록 기본 인스턴스를 보관하고 있습니다. 하지만, 같은 엔티티를 조인하거나 같은 엔티티를 서브쿼리에 사용하면 같은 별칭이 사용되므로, 별칭을 직접 지정해야합니다.

1
2
3
4
public class QMember extends EntityPathBase<Member> {
public static final QMember member = new QMember("member1");
...
}

쿼리 타입은 다음과 같이 사용합니다.

1
2
QMember qMember = new QMember("m"); //직접 지정
QMember qMember = QMember.member; //기본 인스턴스 사용
10.4.3 검색 조건 쿼리
1
2
3
4
5
JPAQuery query = new JPAQuery(em);
QItem item = QItem.item;
List<Item> list = query.from(item)
.where(item.name.eq("goodProduct").and(item.price.gt(20000)))
.list(item); //조회할 프로젝션 지정

#####10.4.4 결과 조회

쿼리 작성 후에, 결과 조회 메소드를 호출하면 실제 데이터베이스를 조회합니다. 대표적인 결과 조회 메소드는 다음과 같습니다.

  • uniqueResult()
    • 조회 결과가 한건일 때 사용
  • singleResult()
    • uniqueResult() 와 결과가 같지만, 결과가 하나 이상이면 처음 데이터를 반환
  • list()
    • 조회 결과가 하나 이상일 때 사용

#####10.4.5 페이징과 정렬

1
2
3
4
5
6
7
QItem item = QItem.item;

query.from(item)
.where(item.price.gt(20000))
.orderBy(item.price.desc(), item.stockQuantity.asc())
.offset(10).limit(20)
.list(item)

#####10.4.6 그룹

1
2
3
4
query.from(item)
.groupBy(item.price)
.having(item.price.gt(1000))
.list(item)

#####10.4.7 조인

1
2
3
4
5
6
7
8
QOrder order = QOrder.order;
QMember member = QMember.member;
QOrderItem orderItem = QOrderItem.orderItem;

query.from(order)
.join(order.member, member)
.leftJoin(order.orderItems, orderItem)
.list(order);

#####10.4.8 서브 쿼리

1
2
3
4
5
6
7
8
QItem item = QItem.item;
QItem itemSub = new QItem("itemSub");

query.from(item)
.where(item.price.eq(
new JPASubQuery().from(itemSub).unique(itemSub.price.max())
))
.list(item)

#####10.4.9 프로젝션과 결과 반환

프로젝션 대상이 하나

프로젝션 대상이 하나면 해당 타입으로 반환합니다.

여러 칼럼 반환과 튜플

프로젝션 대상으로 여러 필드를 선택하면 QueryDSL 은 기본으로 com.mysema.query.Tuple 이라는 Map 과 비슷한 내부 타입을 사용합니다.

1
2
3
4
5
6
7
8
9
QItem item = QItem.item;

List<Tuple> result = query.from(item).list(item.name, item.price);
// List<Tuple> result = query.from(item).list(new QTuple(item.name, item.price)); 와 같음

for(Tuple tuple : result){
System.out.println("name = " + tuple.get(item.name));
System.out.println("name = " + tuple.get(item.price));
}
빈 생성

쿼리 결과를 엔티니가 아닌 특정 객체로 받고 싶으면 빈 생성 기능을 사용합니다. 객체를 생성하는 방법은 다음과 같은 것들이 있습니다.

  • 프로퍼티 접근
  • 필드 직접 접근
  • 생성자 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ItemDTO{

private String username;
private int price;

public ItemDTO() {}

public ItemDTO(String username, int price){
this.username = username;
this.price = price;
}

//GETTER, SETTER ..
}

// 프로퍼티 접근
QItem item = QItem.item;
List<ItemDTO> result = query.from(item).list(
Projections.bean(ItemDTO.class, item.name.as("username"), item.price));

//필드 직접 접근
List<ItemDTO> result = query.from(item).list(
Projections.fields(ItemDTO.class, item.name.as("username"), item.price));

//생성자 사용
List<ItemDTO> result = query.from(item).list(
Projections.constructor(ItemDTO.class, item.name, item.price));
DISTINCT
1
query.distinct().from(item)

#####10.4.10 수정, 삭제, 배치 쿼리

10.4.11 동적 쿼리

com.mysema.query.BooleanBuilder 를 사용하면 특정 조건에 따른 동적 쿼리를 편리하게 생성할 수 있습니다.

10.4.12 메소드 위임

쿼리 타입에 검색 조건을 직접 정의할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//검색 조건 정의
public class ItemExpression{

@QueryDelegate(Item.class) // 이 기능을 적용할 엔티티 지정
public statc BooleanExpression isExpensive(QItem item, Integer price){
return item.price.gt(price);
}
}

//쿼리 타입에 생성된 결과
public class QItem extends EntityPathBase<Item> {
...
public com.mysema.qeury.types.expr.BooleanExpression isExpensive(Integer price){
return ItemExpression.ixExpensieve(this, price);
}
}

//메소드 위임 기능 사용
query.from(item).where(item.isExpensive(30000)).list(item);

10.5 Native SQL

다양한 이유로 JPQL 을 사용할 수 없을 때 JPA 는 SQL 을 직접 사용할 수 있는 기능을 제공하는데, 이것을 Native SQL 이라고 합니다. 네이티브 SQL 을 사용하면 엔티티를 조회할 수 있고 JPA 가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있습니다. 반면에 JDBC API 를 직접 사용하면 단순히 데이터의 나열을 조회할 뿐입니다.

네이티브 SQL 도 JPQL 을 사용할 때와 마찬가지로 Query, TypeQuery(Named Native Query 의 경우) 를 반환합니다. 따라서 JPQL API 를 그대로 사용할 수 있습니다.

10.5.1 Native SQL 사용

네이티브 쿼리 API 는 세 가지 입니다.

  • 결과 타입 정의
1
public Query createNativeQuery(String sqlString, Class resultClass);
  • 결과 타임 정의 할 수 없을 때
1
public Query createNativeQuery(String sqlString);
  • 결과 매핑 사용
1
public Query createNativeQuery(String sqlString, String resultSetMapping);
10.5.5 스토어드 프로시저

JPA 2.1 부터 지원합니다.

10.6 객체 지향 쿼리 심화

10.6.1 벌크 연산

여러 건을 한번에 수정하거나 삭제할 때 벌크 연산을 사용합니다. 벌크 연산을 사용할 때는 벌크 연산이 영속성 컨텍스트를 무시하고 데이터이스에 직접 쿼리합니다.

10.6.2 영속성 컨텍스트와 JPQL
  • JPQL은 항상 데이터베이스를 조회합니다.
  • JPQL로 조회한 엔티티는 영속 상태입니다.
  • 영속성 컨텍스트에 이미 존재하는 엔티티가 있으면 기존 엔티티를 반환합니다.
10.6.3 JPQL 과 플러쉬 모드

Comment and share

JPA의 데이터 타입은 엔티티 타입과 값 타입으로 나눌 수 있습니다. 값 타입은 다음 세 가지로 나눌 수 있습니다.

  • 기본값 타입
    • 자바 기본 타입 : int, double …
    • 래퍼 클래스 : Integer …
    • String
  • 임베디드 타입
  • 컬렉션 값 타입

9.1 기본값 타입

1
2
3
4
5
6
7
@Entity
public class Member{
@Id @GeneratedValue
private Long id; // 기본값 타입
private String name; // 기본값 타입
private int age; // 기본값 타입
}

9.2 임베디드 타입 (복합 값 타입)

새로운 값 타입을 직접 지정해서 사용할 수 있습니다. 이것을 JPA 에서는 임베디드 타입이라고 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Entity
public abstract 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){
...
}
}

@Embeddable // 값 타입을 정의하는 곳에 표시
public class Address{

}
9.2.1 임베디드 타입과 테이블 매핑

임베디드 타입은 엔티티의 값일 뿐입니다. 따라서, 값이 속한 엔티티의 테이블에 매핑합니다.

9.2.2 임베디드 타입과 연관관계

엔티티는 공유 될수 있으므로 참조한다고 표현하고, 값 타입은 특정 주인에게 소속되고 개념상 공유되지 않으므로 포함한다고 합니다.

9.2.3 @AttributeOverride : 속성 재정의
1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
public abstract class Member{

@Id @GeneratedValue
private Long id;
private String name;

@Embedded
Address homeAddress;

@Embedded
Address companyAddress;
}

위 코드는 칼럼명이 중복된다는 문제가 있습니다. 이를 다음과 같이 해결합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Entity
public abstract 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;
}

보다시피 코드의 중복도가 높아집니다. 한 엔티티에 같은 임베디드 타입을 중복해서 사용하는 일은 많지 않습니다.

9.2.4 임베디드 타입과 NULL

임베디드 타입이 null 이면 매핑한 칼럼 값은 모두 null 이 됩니다.

9.3 값 타입과 불변 객체

9.3.1 값 타입 공유 참조
1
2
3
4
5
member1.setHomeAddress(new Address("city1"));
Address address = member1.getHomeAddress();

address.setCity("city2"); //공유해서 사용
member2.setHomeAddress(address);

위 코드는 회원2의 주소만 바뀌길 기대하지만, 회원1의 주소도 바뀌게 됩니다. 이렇듯, 뭔가를 수정했는데 전혀 예상하지 못한 곳에서 문제가 발생하는 것을 Side Effect 라고 합니다. 이를 막기 위해, 값을 복사 해서 사용해야합니다.

9.3.2 값 타입 복사
1
2
3
4
5
6
7
8
member1.setHomeAddress(new Address("city1"));
Address address = member1.getHomeAddress();

//복사
Address newAddress = address.clone();

newAddress.setCity("city2"); //공유해서 사용
member2.setHomeAddress(newAddress);

위의 코드 처럼 복사해서 사용하면 Side Effect 문제는 해결하지만, 임베디드 타입 처럼 직접 정의한 값 타입은 자바의 기본 타입이 아니라 객체 타입이란 것입니다.

1
2
3
int a = 10;
int b = a; //기본 타입은 항상 값을 복사
b = 4;
1
2
3
Address a = new Address("old");
Address b = a; //a와 b는 같은 인스턴스를 공유 참조
b.setCity("New");

복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법은 없습니다. 자바는 기본 타입이면 값을 복사해서 넘기고, 객체는 참졸르 넘길 뿐이기 때문입니다. 따라서, 객체의 공유 참조는 피할 수 없습니다. 이를 위해, 객체의 값을 수정하지 못하게 막아야합니다.

9.3.3 불변 객체

한 번 만들면 절대 수정할수 없는 객체를 불변 객체라고 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Embeddable
public abstract class Address{

private String city;

protected Address() {}

public Address(String city){
this.city = city;
}

public String getCity(){
return city;
}

//수정자 없음
}

9.4 값 타입의 비교

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

값 타입은 그 안에 값이 같으면 같은 것으로 봐야합니다. 따라서 동등성 비교를 해아합니다.

9.5 값 타입 컬렉션

1
2
3
4
5
6
7
8
9
10
11
12
@Entity
public class Member{

...

@ElementCollection
@CollectionTable(name = "ADDRESS", joinColums = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FOOD_NAME")
private List<Address> addressHistory = new ArrayList<Address>();

...
}

값 타입은 식별자가 없는 단순한 값들의 모임이기 때문에, 값을 변경하면 데이터베이스에 저장된 원본 데이터를 찾기 어렵습니다. 값 타입 컬렉션에 보관된 값 타입들은 별도의 테이블에 보관됩니다. 따라서 여기에 보관된 값 타입의 값이 변경되면 데이터베이스에 있는 원본 데이터를 찾기 어렵습니다.

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

Comment and share

이번 포스팅에서 다룰 내용입니다.

  • 프록시와 즉시로딩, 지연 로딩
  • 영속성 전이와 고아 객체

8.1 프록시

엔티티가 실제 사용될 때 까지 데이터베이스 조회를 지연하는 방법을 지연 로딩이라고 합니다.
지연 로딩 기능을 사용하려면 실제 엔티티 대신에 데이터베이스 조회를 지연할 수 있는 가짜 객체가 필요한데, 이것을 프록시 객체라고 합니다.

8.1.1 프록시 기초

JPA 에서 식별자로 엔티티 하나를 조회할 때는 EntityManager.find() 를 사용합니다. 이 메소드는 영속성 컨텍스트에 엔티티가 없으면 데이터베이스를 조회합니다.

엔티티를 실제 사용하는 시점까지 데이터베이스 조회를 미루고 싶으면 EntityManager.getReference()를 사용하면 됩니다. 이 메소드를 호출하면, 실제 엔티티 객체를 생성하지 않고 데이터베이스 접근을 위임한 프록시 객체를 반환합니다.

  • 프록시의 특징
    • 프록시 객체는 실제 객체에 대한 참조(target) 을 보관합니다.
    • 프록시 객체의 메소드를 호출하면, 프록시 객체는 실제 객체의 메소드를 호출합니다.
    • 영속성 컨텍스트에 이미 찾는 엔티티가 있으면, 데이터베이스를 조회할 필요가 없으므로 em.getReference()를 호출해도 실제 엔티티를 반환합니다.
  • 프록시 객체의 초기화

    • 프록시 객체의 초기화란, member.getName() 처럼 실제 사용될 때 데이터베이스를 조회해서 실제 엔티티를 생성하는 것을 말합니다.
    • 과정
      • member.getName() 호출해서 실제 데이터 조회합니다.
      • 영속성 컨텍스트는 데이터베이스를 조회해서 실제 엔티티 객체를 생성합니다.
      • 프록시 객체는 생성된 실제 엔티티 객체의 참조를 MemerProxy 의 멤버 변수로 보관합ㄴ디ㅏ.
      • 프록시 객체는 실제 엔티티 객체의 getName() 을 호출해서 결과를 반환합니다.
  • 준영속 상태의 초기화

    • 초기화는 영속성 컨텍스트의 도움을 받아야 가능합니다.
    • 준영속 상태의 프록시를 초기화하면 문제가 발생합니다.
    • Hibernate : LazyInitializationException 발생
8.1.2 프록시와 식별자

엔티티를 프록시로 조회할 때 식별자 값을 Parameter로 전달하는데 프록시 객체는 이 식별자 값을 보관합니다.구분 칼럼을 꼭 사용해야합니다.

으음..

8.1.3 프록시 확인

JPA가 제공하는 PersistenceUnitUtil.isLoaded(Object entity) 메소드를 사용하면 프록시 인스턴스의 초기화 여부를 확인할 수 있습니다. 조회한 엔티티가 진짜 엔티티인지 프록시로 조회한 것인지 확인하려면, 클래스명을 직접 출력해보면 됩니다.

8.2 즉시 로딩과 지연 로딩

프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용합니다.

연관된 엔티티의 조회 시점을 선택할 수 있는 두 가지 방법이 있습니다.

  • 즉시로딩
  • 지연 로딩
8.2.1 즉시 로딩
1
em.find(Member.class, "member1")

로 회원을 조회하는 순간 팀도 함께 조회합니다. 즉시 로딩을 최적화하기 위해 가능하면 조인쿼리를 사용합니다.

8.2.2 지연 로딩

위의 코드를 실행하면, 회원만 조회되고 팀은 조회되지 않습니다. 대신에 조회한 회원의 team 맴버 변수에 프록시 객체를 넣어둡니다.

1
Team team = member.getTeam() // 프록시 객체

반환된 팀 객체는 프록시 객체로, 실제 사용될때 까지 데이터 로딩을 미룹니다. 실제 데이터가 필요한 순간이 되어서야 데이터베이스를 조회해서 프록시 객체를 초기화합니다.

1
team.getName()

을 호출하면, 프록시 객체가 초기화 됩니다.

8.3 지연 로딩 활용

8.3.1 프록시와 컬렉션 래퍼

Hibernate 는 엔티티를 영속 상태로 만들 때, 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로, 원본 컬렉션을 Hibernate가 제공하는 내장 컬렉션으로 변경합니다. 이를 컬렉션 래퍼라고 합니다.

엔티티를 지연 로딩하면 프록시 객체를 사용해서 지연로딩을 수행하지만, 주문 내역과 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해줍니다. 즉, 컬렉션 레퍼도 프록시 역할을 합니다.

member.getOrder()를 호출해도 컬렉션은 초기화되지 않습니다. member.getOrders().get(0) 처럼 컬렉션에서 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화합니다.

8.3.2 JPA 기본 Fetch 전략
  • @ManyToOne, @OneToOne
    • FetchType.EAGER
  • @OneToMany, @ManyToMany
    • FetchType.LAZY

추천하는 방버은 모든 연관관계에 지연 로딩을 사용하는 것입니다. 그리고, 실제 사용하는 상황을 보고 꼭 필요한 곳에 즉시 로딩을 사용하도록 최적화 하면 됩니다.

8.3.3 컬렉션에 FetchType.EAGER 사용시 주의점
  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않습니다.
  • 컬렉션 즉시 로딩은 항상 OUTER JOIN 을 사용합니다.

8.4 영속성 전이 : CASCADE

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영송석 전이 기능을 사용하면 됩니다. JPA는 CASCADE 옵션으로 영속성 전이 기능을 제공합니다.

8.4.1 저장

부모만 영속화하면 CascadeType.PERSIST 로 설정한 자식 엔티티까지 함께 영속화해서 저장합니다.

8.4.2 삭제

CascadeType.REMOVE 설정하고 부모 엔티티만 삭제하면 연관된 자식 엔티티까지 함께 삭제됩니다.

8.5 고아 객체

JPA는 부모 엔티티와 연관 관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데, 이를 고아 객체 제거라고 합니다. 이 기능은 참조하는 곳이 하나 일때만 사용해야합니다. 즉, 특정 엔티티가 개인 소유하는 엔티티에만 이 기능을 적용해야합니다.

다음 코드는, 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제됩니다.

1
2
3
4
5
6
7
@Entity
public class Paren {
...
@OneToMany(mappedBy="parent", orphanRemoval = true)
private List<Child> children = new ArrayList<Child>();
...
}

다음 코드처럼,

1
2
Parent parent = em.find(Parent.class, id);
parent.getChildren.remove(0); // 자식 엔티티를 컬렉션에서 제거

컬렉션에서 첫 번째 자식을 제거하면, 데이터베잇의 데이터도 삭제됩니다. 주의할 점은, 고아 객체 제거 기능은 영속성 컨텍스트를 flush 할 때 적용되므로, flush 시점에 DELETE SQL 이 실행됩니다.

8.6 영속성 전이 + 고아객체, 생명주기

CascadeType.All + orphanRemove = true 를 동시에 사용하면?

일반적으로 엔티티를 em.persist()로 영속화되고, em.remove()로 제거됩니다. 이것은 엔티티 스스로 생명주기를 관리한다는 뜻입니다. 그런데 두 옵션을 활성화하면 부모 엔티티르 통해서 자식의 엔티티를 관리할 수 있습니다.

자식을 저장하려면,

1
2
Parent parent = em.find(Parent.class, id);
parent.addChild(child);

자식을 삭제하려면,

1
2
Parent parent = em.find(Parent.class, id);
parent.getChildren().remove(child);

Comment and share

이번 포스팅에서 다룰 고급 매핑은 다음과 같습니다.

  • 상속 관계 매핑
  • @MappedSupperclass
  • 복합 키와 식별 관계 매핑
  • 조인 테이블
  • 엔티티 하나에 여러 테이블 매핑하기

7.1 상속 관계 매핑

관계형 데이터베이스에는 상속이라는 개념이 없습니다. 대신에, Super-Type Sub-Type Relationship 이라는 모델링 기법이 객체의 상속 개념과 가장 유사합니다. ORM 의 상속 관계 매핑은 객체의 상속 구조와 데이터베이스의 Super-Type Sub-Type Relationship 을 매핑하는 것입니다.

Super-Type Sub-Type Relationship 논리 모델을 실제 물리 모델인 테이블로 구현할 때는 3가지 방법이 있습니다.

  • 각각의 테이블로 변환
    • 조인 전략
  • 통합 테이블로 변환
    • 단일 테이블 전략
  • 서브타입 테이블로 변환
    • 구현 클래스마다 테이블 전략
7.1.1 조인 전략

엔티티 각각을 모두 테이블로 만들고 자식 테이블이 부모 테이블의 기본 키를 받아서 기본 키 + 외래 키로 사용하는 전략입니다.
주의할 점은, 객체는 타입으로 구분할 수 있지만 테이블은 타입의 개념이 없기 때문에 타입을 구분하는 칼럼을 추가해야합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
@Inheritance (strategy = InheritanceType.JOINED) // 상속 매핑은 부모 클래스에 @Inheritance 를 사용
@DiscriminatorColumn (name = "DTYPE") // 부모 클래스에 구분 칼럼을 지정
public abstract class Item {

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

private String name;
private int price;
}

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

private String artist;
}

@Entity
@DiscriminatorColumn ("M") // 영화 엔티티를 저장하면 구분 칼럼인 DTYPE 에 값 M이 저장
public class Movie extends Item{

private String director;
private String actor;
}
  • 장점
    • 테이블이 정규화 됩니다.
    • 외래 키 참조 무결성 제약 조건을 활용할 수 있습니다.
    • 저장공간을 효율적으로 사용할 수 있습니다.
  • 단점
    • 조회할 때 조인이 많이 사용되므로 성능이 저하될 수 있습니다.
    • 조회 쿼리가 복잡합니다.
    • 데이터를 등록할 INSERT SQL 을 두 번 실행합니다.
  • 특징
    • JPA 표준 명세는 구분 칼럼을 사용하도록 하지만, 하이버네이트는 구분 칼럼 없이도 동작합니다.
7.1.2 단일 테이블 전략

테이블을 하나만 사용하고 구분 칼럼으로 어떤 자식 데이터가 저장되었는지 구분합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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
@DiscriminatorColumn ("A")
public class Album extends Item{
...
}

@Entity
@DiscriminatorColumn ("M")
public class Movie extends Item{
...
}
  • 장점
    • 조인이 필요 없으므로 일반적으로 조회 성능이 빠릅니다.
    • 조회 쿼리가 단순합니다.
  • 단점
    • 자식 엔티티가 매핑한 칼럼은 모두 null을 허용해야합니다.
    • 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있습니다. 상황에 따라서는 조회 성능이 느려질 수 있습니다.
  • 특징
    • 구분 칼럼을 꼭 사용해야합니다.
7.1.3 구현 클래스마다 테이블 전략

자식 엔티티마다 테이블을 만듦니다. 그리고, 자식 테이블에 각각에 필요한 칼럼이 모두 있습니다. 이 전략은 추천하지 않는 전략입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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{
...
}
  • 장점
    • 서브 타입을 구분해서 처리할 때 효과적입니다.
    • not null 제약조건을 사용할 수 있습니다.
  • 단점
    • 여러 자식 테이블을 함께 조회할 때 성능이 느립니다. (SQL 의 UNION 을 사용해야합니다.)
    • 자식 테이블을 통합해서 쿼리하기 어렵습니다.
  • 특징
    • 구분 칼럼을 사용하지 않습니다.

7.2 MappedSuperClass

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//테이블과 매핑할 필요가 없고 자식 엔티티에 공통으로 사용되는 매핑 정보만 제공
@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;
}

부모로부터 물련 받은 매핑 정보를 재정의 하려면,

1
2
3
4
5
6
@Entity
//부모에게 상속 받은 id 속성의 칼럼명을 MEMBER_ID 로 재정의
@AttributeOverride(name = "id", column = @Column(name = "MEMBER_ID"))
public class Member extends BaseEntity{
...
}

둘 이상을 재정을 하려면,

1
2
3
4
5
6
7
8
@Entity
@AttributeOverrides({
@AttributeOverride(name = "id", column = @Column(name = "MEMBER_ID")),
@AttributeOverride(name = "name", column = @Column(name = "MEMBER_NAME"))
})
public class Member extends BaseEntity{
...
}
  • @MappedSuperClass 의 특징
    • @MappedSuperClass 로 지정한 클래스는 엔티티가 아닙니다. 따라서, em.find() 나 JPQL 을 사용할 수 없습니다.
    • 이 클래스를 직접 생성해서 사용할 일은 거의 없으므로, 추상 클래스로 만드는 것을 권장합니다.

7.3 복합 키와 식별 관계 매핑

7.3.1 식별 관계 VS 비식별 관계
  • 식별 관계
    • 부모 테이블의 기본 키를 내려받아서, 자식 테이블의 기본 키 + 외래 키로 사용하는 관계입니다.
  • 비식별 관계
    • 부모 테이블의 기본 키를 내려받아서, 자식 테이블의 외래키로만 사용하는 관계입니다.
      • 필수적 비식별 관계
        • 외래 키에 NULL을 허용 하지 않습니다. 연관관계를 필수적으로 맺어야 합니다.
      • 선택적 비식별 관계
        • 외래 키에 NULL을 허용합니다. 연관관계를 맺을지 선택할 수 있습니다.
7.3.2 복합키: 비식별 관계 매핑

둘 이상의 칼럼으로 구성된 복합 기본키를 다음과 같이 해보면, 매핑 오류가 발생합니다.

1
2
3
4
5
6
7
8
9
@Entity
publi class Hello {

@Id
private String id;

@Id
private String id2;
}

JPA 에서 식별자를 둘 이상 사용하려면 별도의 식별자 클래스를 만들어야합니다.
복합키를 지원하기 위해, 관계형 데이터베이스에 가까운 방법인 @IdClass 와, 객체지향에 가까운 @EmbededId 두 가지 방법을 제공합니다.

IdClass
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Entity
@IdClass (ParentId.class)
public class Parent {

@Id @Column (name = "PARENT_ID1")
private String id1; //ParerntId.id1 과 연결

@Id @Column (name = "PARENT_ID2")
private String id2; //ParerntId.id2 과 연결

private String name;
}

public class ParentId implements Serializable { // Serializable 를 구현해야합니다.

// 식별자 클래스의 속성명과 엔티티에서 사용하는 식별자의 속성명이 같아야합니다.
private String id1; //Parernt.id1 과 연결
private String id2; //Parernt.id2 과 연결

public ParentId(){
}

public ParentId(String id1, String id2){
this.id1 = id1;
this.id2 = id2;
}

// equals 와 hashCode를 구현해야합니다.
@Override
public boolean equals(Object o){...}
@Override
public int hashCode(){...}

}

복합키를 사용하는 엔티티를 저장하면,

1
2
3
4
5
Parent parent = new Parent;
parent.setId1("myId1");
parent.setId2("myId2");
parent.setName("ParentName");
em.persist(parent)

영속성 컨텍스트에 엔티티를 등록하기 직전에, 내부에서 Parent.id1, Parent.id2 값을 사용해서 식별자 클래스인 ParentId 를 생성하고 영속성 컨텍스트의 키로 사용합니다.

ParentId 를 사용해서 엔티티를 조회합니다.

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

자식 클래스는,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
public class Child {

@Id
private String id;

// 부모 테이블의 기본 키 칼럼이 복합 키이므로, 자식 테이블의 외래 키도 복합키
// 외래 키 매핑 시 여러 칼럼을 매핑해야하므로 @JoinColums를 사용
@ManyToOne
@JoinColums({
@JoinColum(name = "PARENT_ID1", referencedColumnName = "PARENT_ID1"),
@JoinColum(name = "PARENT_ID2", referencedColumnName = "PARENT_ID2"),
})

private Parent parent;
}
EmbeddedID
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Entity
public class Parent {

@EmbeddedId
private ParentId id;

private String name;

...
}

@Embeddable
public class ParentId implements Serializable { //Serializable 를 구현

@Column(name = "PARENT_ID1")
private String id1;

@Column(name = "PARENT_ID2")
private String id2;

//equals and hashCode 구현
...
}

엔티티를 저장하면,

1
2
3
4
5
6
Parent parent = new Parent;
ParentId parentId = new ParentId("myId1", "myId2");

parent.setId(parentId);
parent.setName("ParentName");
em.persist(parent)

엔티티를 조회하면,

1
2
ParentId parentId = new ParentId("myId1", "myId2");
Parent parent = em.find(Parent.class, parentId);
7.3.3 복합키: 식별 관계 매핑

부모, 자식, 손자까지 계속 기본 키를 전달하는 식별관계를 생각해보려고 합니다.

IdClass
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Entity
public class Parent {

@Id @Column (name = "PARENT_ID")
private String id;
private String name;
...
}

@Entity
@IdClass(ChildId.class)
public class Child {

// 식별 관계는 기본 키와 외래키를 같이 매핑해야합니다.
// 따라서, 식별자 매핑인 @Id 와 연관관계 매팽인 @ManyToOne 을 같이 사용합니다.
@Id @ManyToOne @JoinColumn (name = "PARENT_ID")
private Parent parent;

@Id @Column (name = "CHILD_ID")
private Parent childId;

private String name;
...
}

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
@JoinColumns({
@JoinColumn(name = "PARENT_ID"),
@JoinColumn(name = "CHILD_ID")
})
private Child child;

@Id @Column (name = "GRANDCHILD_ID")
private String id;

private String name;
...
}

public class GrandChildId implements Serializable {

private ChildId child; //GrandChild.child 매핑
private String id; //GrandChild.id 매핑

//equals, hashCode
...
}
EmbeddeId
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@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")
private Parent parent;

private String name;
...
}

@Embeddable
public class ChildId implements Serializable {

private String parentId; // @MapsId("parentId") 로 매핑

@Column(name = "CHILD_ID")
private String id;

//equals, hashCode
...
}

@Entity
public class GrandChild {

@EmbededId
private GrandChildId id;

@MapsId ("childId") // GrandChildId.childId 매핑
@ManyToOne
@JoinColumns({
@JoinColumn(name = "PARENT_ID"),
@JoinColumn(name = "CHILD_ID")
})
private Child child;

private String name;
...
}

@Embeddable
public class GrandChildId implements Serializable {

private ChildId child; // @MapsId("childId") 로 매핑

@Column ( name = "GRANDCHILD_ID")
private String id;

//equals, hashCode
...
}
7.3.4 비식별 관계로 구현

방금 예를, 비식별 관계로 변경하려고 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Entity
public class Parent {

@Id @GeneratedValue @Column (name = "PARENT_ID")
private String 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;

...
}
7.3.5 일대일 식별 관계

자식 테이블의 기본 키 값으로 부모 테이블의 기본 키 값만 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Entity
public class Board {

@Id @GeneratedValue @Column (name = "BOARD_ID")
private String 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;
...
}

7.4 조인 테이블

데이터베이스 테이블의 연관관계를 설계하는 방법은 두 가지 입니다.

  • 조인 칼럼 사용 ( 외래키)
    • 회원이 사물함을 사용하기 전까지는 아직 둘 사이의 관계가 없으므로, MEMBER Table 의 LOKCER_ID 외래키에 null
    • 외래 키애 null 허용하는 관계를 선택적 비식별 관계라고 합니다.
    • null 허용하므로, 회원과 사물함 조인할 때는 OUTER JOIN 사용해야합니다.
    • 회원과 사물함이 아주 가끔 관계를 맺으면, 외래 키 대부분 값에 null 이 저장이 됩니다.
  • 조잍 테이블 사용 (테이블)
    • 조인 테이블에서 두 테이블의 외래 키를 가지고 연관 관계를 관리합니다.
7.4.1 일대일 조인 테이블
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Entity
public class Parent {

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

private String name;

@OneToOne
@JoinTable(name = "PARENT_CHILD", // 매핑할 조인 테이블 이름
joinColums = @JoinColumn(name = "PARENT_ID"), // 현재 엔티티를 참조하는 외래 키
inverseJoinColums = @JoinColum(name = "CHILD_ID") // 반대방향 엔티티를 참조하는 외래 키
)

private Child child;
...
}

@Entity
public class Child {

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

private String name;
...
}
7.4.2 일대다 조인 테이블
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Entity
public class Parent {

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

private String name;

@OneToMany
@JoinTable(name = "PARENT_CHILD", // 매핑할 조인 테이블 이름
joinColums = @JoinColumn(name = "PARENT_ID"), // 현재 엔티티를 참조하는 외래 키
inverseJoinColums = @JoinColum(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;
...
}
7.4.3 다대일 조인 테이블
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Entity
public class Parent {

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

private String name;

@OneToMany (mappedBy = "PARENT_ID")
private List<Child> child = new ArrayList<Child>();
...
}

@Entity
public class Child {

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

private String name;

@ManyToOne(optional = false)
@JoinTable(name = "PARENT_CHILD", // 매핑할 조인 테이블 이름
joinColums = @JoinColumn(name = "CHILD_ID"), // 현재 엔티티를 참조하는 외래 키
inverseJoinColums = @JoinColum(name = "PARENT_ID") // 반대방향 엔티티를 참조하는 외래 키
)

private Parent parent;
...
}
7.4.4 다대다 조인 테이블
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Entity
public class Parent {

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

private String name;

@ManyToMany
@JoinTable(name = "PARENT_CHILD", // 매핑할 조인 테이블 이름
joinColums = @JoinColumn(name = "PARNET_ID"), // 현재 엔티티를 참조하는 외래 키
inverseJoinColums = @JoinColum(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;
...
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Entity
@Table ( name = "BOARD") // BOARD 테이블과 매핑

// BOARD_DETAIL 테이블을 추가로 매핑
@SecondaryTable ( name = "BOARD_DETAIL", //매핑할 다른 테이블 이름
//매핑할 다른 테이블의 기본 키 칼럼 속성
pkJoinColums = @PrimaryJoinColumn(name = "BOARD_DETAIL_ID"))
public class Board {

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

private String title;

// content 필드는 BOARD_DETAIL 테이블의 칼럼에 매핑
@Column(table = "BOARD_DETAIL")
private String content;
...
}

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

Comment and share

이번 포스팅에서는 다양한 연관관계를 다룹니다. 엔티티의 연관 관계를 매핑 할 때는 다음 세 가지를 고려해야합니다.

  • 다중성
    • 다대일
    • 일대다
    • 일대일
    • 다대다
  • 단방향, 양방향
    • 테이블에는 방향이라는 개념이 없습니다.
    • 객체는 참조용 필드를 가지고 있는 객체만 연관된 객체를 조회할 수 있습니다.
  • 연관관계의 주인
    • 데이터베이스는 외래 키 하나로 두 데이블이 연관관계 맺습니다.
    • 엔티티를 양방향으로 매핑하면 A->B, B->A 2곳에서 서로를 참조하기 때문에, 객체의 연관관계를 관리하는 포인트는 2곳입니다. 따라서, 두 객체 연관관계 중 하나를 정해서 데이터베이스 외래 키를 관리해야합니다.

1. 다대일

데이터베이스 테이블의 일, 다 관계에서 외래 키는 항상 다쪽에 있습니다. 따라서, 객체 양방향 관계에서 연관관계의 주인은 항상 다쪽입니다.

1.1 다대일 단방향

회원은 팀 엔티티를 참조 가능합니다. 팀은 회원 참조하는 필드가 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
public class Member{

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

private String username;

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

//Getter, Setter ...
}
1
2
3
4
5
6
7
8
9
10
11
@Entity
public class Team{

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

private String name;

//Getter, Setter ...
}
1.1 다대일 양방향

양방향 연관관계는 항상 서로 참조해야합니다. 서로 참조하게 하려면 연관관계 편의 메소드를 작성하는게 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
public class Member{

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

private String username;

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

public void setTeam(Team team){
this.team = team;

//무한 루프에 빠지지 않도록 체크
if(!team.getMembers().contains(this)){
team.getMembers().add(this);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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);
}
}

//Getter, Setter ...
}

2. 일대다

일대다 단방향

일대다 단방향은 약간 특이합니다. 일대다 관계에서, 외래 키는 항상 다쪽 테이블에 있습니다. 하지만 다 쪽인 Memebr 엔티티에는 외래 키를 매핑할 수 있는 참조 필드가 없습니다. 대신에, Team 엔티티에는 참조 필드인 members 가 있습니다. 따라서 반대편 테이블의 외래키를 관리하는 특이한 모습이 나타납니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
public class Team{

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

private String name;

@OneToMany
@JoinColum (name = "TEAM_ID") //MEMBER 테이블의 TEAM_ID (FK)
private List<Member> members = new ArrayList<Member>();

//Getter, Setter ...
}
1
2
3
4
5
6
7
8
9
10
11
@Entity
public class Member{

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

private String username;

//Getter, Setter
}
  • 일대다 단방향 매핑의 단점
    • 매핑한 객체가 관리하는 외래 키가 다른 테이블에 있다는 점입니다.
    • 연관관계 처리를 위한 UPDATE SQL 을 추가로 실행해야합니다.
    • 다음 코드에서, Member 엔티티는 Team 엔티티를 모릅니다. 따라서 Member 엔티티를 저장할 때는 Member 테이블의 TEAM_ID 외래키에 아무 값도 저장되지 않습니다. 대신, Team 엔티티를 저장할 때 Team.members 의 참조 값을 확인해서 회원 테이블에 있는 TEAM_ID 외래 키를 업데이트 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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-member.fk, UPDATE-member2.fk

transaction.commit();
}

따라서, 일대다 단방향 매핑보다는 다대일 양방향 매핑을 권장합니다.

일대다 양방향

둘 다 같은 키를 관리하므로 문제가 발생할 수 있습니다. 따라서, 반대편인 다대일 쪽은 insertable =false, update = false 를 설정해서 읽기만 가능하게 합니다. 되록, 다대일 양방향 매핑을 사용해야합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
public class Team{

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

private String name;

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

//Getter, Setter ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
public class Member{

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

private String username;

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

//Getter, Setter ...
}

3. 일대일

일대일 관계는 주 테이블이나 대상 테이블 중에 누가 외래키를 가질지 선택해야합니다.

3.1 주 테이블에 외래 키

객체지향 개발자들은 주 테이블에 외래 키가 있는 것을 선호합니다.

  • 단방향
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
public class Member{

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

private String username;

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

}
1
2
3
4
5
6
7
8
9
10
@Entity
public class Locker{

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

private String name;

}
  • 양방향
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
public class Member{

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

private String username;

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

}
1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
public class Locker{

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

private String name;

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

}
3.2 대상 테이블에 외래 키
  • 단방향
    • 일대일 관계 중 대상 테이블에 외래 키가 있는 단방향 관계는 JPA 에서 지원하지 않습니다.
  • 양방향
1
2
3
4
5
6
7
8
9
10
11
12
13
@Entity
public class Member{

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

private String username;

@OneToOne (mappedBy ="member")
private Locker locker;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
public class Locker{

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

private String name;

@OneToOne
@JoinColumn(name ="MEMBER_ID")
private Member member;

}

4. 다대다

관계형 데이터베이스는 정규화된 테이블 2개를 다대다 관계로 표현할 수 없습니다. 그래서 일대다, 다대일 관계로 풀어내는 연결 테이블을 사용합니다. 그런데 객체는 테이블과 다르게 객체 2개로 다대다 관계를 만들 수 있습니다.

4.1 다대다 단방향
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
public class Member{

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

private String username;

@ManyToMany
@JoinTable(name = "MEMBER_PRODUCT", joinColums = @JoinColumn (name = "MEMBER_ID"),
inverseJoinColums = @JoinColumn(name = "PRODUCT_ID"))
private List<Product> products = new Arraylist<List>();

}
1
2
3
4
5
6
7
8
9
@Entity
public class Product{

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

private String name;

}

다음은 다대다 관계를 저장하는 예제입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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");
member.getProducts.add(productA); //연관관계 설정
em.persist(member1);

}

이 코드를 실행하면 다음 SQL이 실행됩니다.

1
2
3
INSERT INTO PRODUCT...
INSERT INTO MEMBER...
INSERT INTO MEMBER_PRODUCT...

다음은 다대다 관계를 탐색하는 예제입니다.

1
2
3
4
5
6
7
8
public void find(){

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

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

1
2
3
SELECT * FROM MEMBER_PRODUCT MP
INNER JOIN PRODUCT P ON MP>PRODUCT_ID = P.PRODUCT_ID
WHER MP.MEMBER_ID = ?
4.2 다대다 양방향

양쪽 중 원하는 곳에 mappedBy 로 연관관계의 주인을 지정합니다.

1
2
3
4
5
6
7
8
9
10
@Entity
public class Product{

@Id
private Long id;

@ManyTomany (mappedBy = "products") //역방향 추가
private List<Member> members;

}

다대다의 양방향 연관관계는 다음처럼 설정합니다.

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

회원 엔티티에 다음 편의 메소드를 추가합니다.

1
2
3
4
5
public void addProduct(Product product){
...
products.add(product);
product.getMembers.add(this);
}

역방향 탐색은 다음과 같습니다.

1
2
3
4
5
6
7
public void findInverse(){
Product product = em.find(Product.class, "productA");
List <Member> members = product.getMembers();
for (Member member : members){
System.out.println("member : " + member.getUsername);
}
}
4.3 다대다: 매핑의 한계와 극복, 연결 엔티티 사용

연결테이블에 컬럼을 추가하면, 더이상 @ManyToMany를 사용할 수 없습니다. 주문 엔티티나 상품 엔티티에는 추가한 칼럼들을 매핑할 수 없기 때문입니다. 따라서, 연결 엔티티를 만들고 이곳에 추가한 칼럼들을 매핑해야합니다.

1
2
3
4
5
6
7
8
9
10
11
@Entity
public class Member{

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

//역방향
@OneToMany (mappedBy = "member")
private List<MemberProduct> memberProducts;

}
1
2
3
4
5
6
7
8
9
@Entity
public class Product{

@Id @Column(name = "PRODUCT_ID")
private String id;

private String name;

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
@IdClass (MemberProductId.class) //복합 기본키를 매핑
public class MemberProduct{

// 기본키 + 외래키
@Id
@ManyToOne
@JoinColumn (name = "MEMBER_ID")
private Member member; //MemberProductId.product 와 연결

@Id
@ManyToOne
@JoinColumn (name = "PRODUCT_ID")
private Product product; //MemberProductId.product 와 연결

private int orderAmout;
}
1
2
3
4
5
6
7
8
// 복합 키를 위한 식별자 클래스
public class MemberProductId implements Serializable{

private String member; //MemberProduct.member 와 연결
private String product; //MemberProduct.product 와 연결

//hashCode cand equals ...
}
4.4 다대다: 새로운 키본 키 사용

추천하는 기본 키 생성 전략은 데이터베이스에서 자동으로 생성해주는 대리 키를 Long 값으로 사용하는 것입니다.

다음은, 연결 테이블에 새로운 기본키를 사용합니다. MemberProduct 보다 Order 이라는 이름으로 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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 orderAmout;
}
4.5 다대다 연관관계 정리

다대다 관계를 일대다, 다대일 관계로 풀어내기 위해 연결 테이블을 만들 때, 식별자 구성 방법은 다음 두 가지가 있습니다.

  • 식별관계
    • 받아온 식별자를 기본키 + 외래키로 사용
    • 데이터베이스에서는 이를 식별 관계라고 합니다.
  • 비식별 관계
    • 받아온 식별자는 외래키로만 사용하고 새로운 식별자를 추가
    • 데이터베이스에서는 이를 비식별 관계라고 합니다.
    • 이걸 추천합니다.

Comment and share

이번 포스팅에서는 객체의 참조와 테이블의 외래키를 매핑하는 것을 다룹니다.

1. 단방향 연관관계

회원과 팀의 다대일 단방향 관계 (맴버->팀) 를 생각해봅시다.

  • 객체 연관 관계
    • 회원은 Member.team 필드를 통해서 팀을 알 수 있습니다.
    • 팀은 회원을 알 수 없습니다.
  • 테이블 연관 관계
    • 회원 테이블은 TEAM_ID 외래 키로 팀 테이블과 연관관계를 맺습니다.
    • 회원 테이블과 팀 테이블은 양향관 관계입니다. 회원 테이블은 TEAM_ID 외래 키로 MEMBER JOIN TEAM 과 TEAM JOIN MEMBER 둘 다 가능합니다.
  • 개체 연관관계와 테이블 연관관계의 가장 큰 차이
    • 참조를 사용하는 객체의 연관관계는 언제나 단방향 입니다. 객체를 양방향으로 참조하려면 단방향 연관관계 2개를 만들어야합니다.
    • 테이블은 외래 키 하나로 양방향으로 조인할 수 있습니다.
1.1 객체 관계 매핑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
public class Member {

@Id
@Column(name = "MEMBER_ID")
private String id;

private String username;

// 연관관계 매핑
@ManyToOne // 회원관 팀은 다대일 관계
@JoinColumn(name = "TEAM_ID") //외래 키를 매핑할 때 사용. name 속성에는 매핑할 외래키 이름.
private Team team;

// 연관관계 설정
public void setTeam(Team team){
this.team = team;
}

// Getter, Setter ..

}
1
2
3
4
5
6
7
8
9
10
11
public class Team {

@Id
@Column (name = "TEAM_ID")
private String id;

private String name;

// Getter, Setter ..

}

2. 연관관계 사용

연관 관계를 등록, 수정, 삭제, 조회하는 예제를 통해 연관관계를 어떻게 사용하는지 확인합니다.

2.1 저장
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void save(){
// 팀 1 저장
Team team1 = new Team();
team1.setId("team1");
team1.setName("팀1");

// 회원 1 저장
Member member1 = new Member();
member1.setTeam(team1);
em.persist(member1); // 연관관계 설정 member1 -> team1

// 회원 2 저장
Member member2 = new Member();
member2.setTeam(team1);
em.persist(member2); // 연관관계 설정 member2 -> team1
}
2.2 조회

조회 방법은 두 가지 입니다.

  • 객체 그래프 탐색
    • memebr.getTeam();
  • JPQL
    • select m from Member m join m.team t where t.name=:teamName
    • :로 시작하는 것은 파라미터를 바인딩하는 문법입니다.
2.3 수정

수정은 update() 가 없습니다. 불러온 엔티티의 값만 변경하면 트렌잭션을 커밋할 때, flush 가 일어나면서 변경 감지 기능이 작동합니다.

1
2
3
4
5
6
7
8
9
public static void update(){
// 새로운 팀2
Team team2 = new Team();
em.persist(team2);

// 회원1에 새로운 팀2 설정
Member member = em.find(Member.class, "member1");
member.setTeam(team2);
}
2.4 제거
1
2
3
4
public static void delete(){
Member member = em.find(Member.class, "member1");
member.setTeam(null);
}
2.5 삭제

기존에 있던 연관관계르ㅓㄹ 먼저 제거하여 삭제해야합니다.

1
2
3
member1.setTeam(null); // 회원1 연관관계 제거
member2.setTeam(null); // 회원2 연관관계 제거
em.remov(team); // 팀삭제

3. 양방향 연관관계

회원에서 팀으로 접근하고, 반대 방향인 팀에서도 회원으로 접근하도록 합니다.

3.1 양방향 연관관계 매핑

회원 엔티티는 변경이 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Entity
public class Member {

@Id
@Column(name = "MEMBER_ID")
private String id;

private String username;

// 연관관계 매핑
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;

// 연관관계 설정
public void setTeam(Team team){
this.team = team;
}

// Getter, Setter ..

}

팀 엔티티에 Member list 를 추가했습니다. 그리고 일대다 관계를 매핑하기 위해 @OneToMany 를 설정했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity
public class Team {

@Id
@Column (name = "TEAM_ID")
private String id;

private String name;

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

// Getter, Setter ..

}

4. 연관관계의 주인

엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데, 외래 키는 하나입니다. 이런 차이로 인해 JPA 는 두 객체 연관관계 중 하나를 정해서 테이블의 외래키를 관리해야 하는데 이것을 연관관계의 주인이라고 합니다.

4.1 양방향 매칭의 규칙 : 연관관계의 주인

연관계의 주인만이 데이터베이스 연관관계와 매핑되고, 외래 키를 관리 할 수 있습니다. 주인이 아닌 쪽은 읽기만 가능합니다. 연관관계의 주인을 정한다는 것은 외래 키 관리자를 선택한다는 것입니다.

4.2 연관 관계의 주인은 외래 키가 있는 곳

데이터베이스 테이블의 다대일, 일대다 관계에서는 항상 다 쪽이 외래키를 가집니다. 따라서, 다 쪽인 @ManyToOne 은 항상 연관계의 주인이므로 mappedBy 속성을 설정할 수 없습니다.

5. 양방향 연관관계의 주의점

연관관계의 주인에는 값을 입력하지 않고, 주인이 아닌 곳에만 값을 입력하는 경우를 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void save(){

// 회원 1 저장
Member member1 = new Member();
em.persist(member1); // 연관관계 설정 member1 -> team1

// 회원 2 저장
Member member2 = new Member();
em.persist(member2); // 연관관계 설정 member2 -> team1

Team team1 = new Team();
team1.setId("team1");
team1.setName("팀1");

//주인이 아닌 곳에만 연관관계 설정
team1.getMemberList().add(member1)
team1.getMemberList().add(member2)

}

데이터베이스에서 회원테이블을 조회해 봅시다.

1
2
3
4
5
6
SELECT * FROM MEMBER;

//결과
Memer_ID / USERNAME / TEAM_ID
member1 / 회원 1 / null
member2 / 회원 2 / null
5.1 순수한 객체까지 고려한 양방향 연관관계

JPA를 사용하지 않고 순수한 객체로 테스트해 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void test(){

Team team1 = new Team();
team1.setId("team1");
team1.setName("팀1");

Member member1 = new Member();
member1.setId("member1");
member1.setUsername("회원1");

Member member2 = new Member();
member2.setId("member2");
member2.setUsername("회원2");

member1.setTeam(team1); // 연관 관계 설정 member1 -> team1
member2.setTeam(team1); // 연관 관계 설정 member2 -> team1

List<Member> memberList = team1.getMemberList();
System.out.println("member size : " + memberList.size()); // 출력 결과 : 0
}

회원 -> 팀 을 설정하면 다음 코드 처럼 반대 방향인 팀 -> 회원도 설정해야합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void test(){

Team team1 = new Team();
team1.setId("team1");
team1.setName("팀1");

Member member1 = new Member();
member1.setId("member1");
member1.setUsername("회원1");

Member member2 = new Member();
member2.setId("member2");
member2.setUsername("회원2");

member1.setTeam(team1); // 연관 관계 설정 member1 -> team1
team1.getMemberList.add(member1); // 연관 관계 설정 team1 -> member1

member2.setTeam(team1); // 연관 관계 설정 member2 -> team1
team1.getMemberList.add(member2); // 연관 관계 설정 team1 -> member2

List<Member> memberList = team1.getMemberList();
System.out.println("member size : " + memberList.size()); // 출력 결과 : 2
}

JPA를 적용하면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void test(){

// 팀1 저장
Team team1 = new Team();
team1.setId("team1");
team1.setName("팀1");
em.persist(tema1);

Member member1 = new Member();
member1.setId("member1");
member1.setUsername("회원1");

// 양방향 연관관계 설정
member1.setTeam(team1); // 연관 관계 설정 member1 -> team1
team1.getMemberList.add(member1); // 연관 관계 설정 team1 -> member1
em.persist(member1);

Member member2 = new Member();
member2.setId("member2");
member2.setUsername("회원2");

// 양방향 연관관계 설정
member2.setTeam(team1); // 연관 관계 설정 member2 -> team1
team1.getMemberList.add(member2); // 연관 관계 설정 team1 -> member2
em.persist(member2);
}
5.2 연관관계 편의 메서드
1
2
member1.setTeam(team1); // 연관 관계 설정 member1 -> team1
team1.getMemberList.add(member1); // 연관 관계 설정 team1 -> member1

양방향 관계에서는 위 두 코드를, 다음과 같이 하나인 것처럼 사용하는 것이 안전합니다.

1
2
3
4
5
6
7
8
9
public class Member{

private Team team;

public void setTeam(Team team){
this.team = team;
team.getMemberList().add(this);
}
}

따라서 다음과 같이 양방향 관계를 설정할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void test(){

// 팀1 저장
Team team1 = new Team();
team1.setId("team1");
team1.setName("팀1");
em.persist(tema1);

Member member1 = new Member();
member1.setId("member1");
member1.setUsername("회원1");

// 양방향 연관관계 설정
member1.setTeam(team1);
em.persist(member1);

Member member2 = new Member();
member2.setId("member2");
member2.setUsername("회원2");

// 양방향 연관관계 설정
member2.setTeam(team1); // 연관 관계 설정 member2 -> team1
em.persist(member2);
}
5.3 연관관계 편의 메서드 작성시 주의 사항
1
2
3
member.setTeam(teamA);
member.setTeam(teamB);
Member findMember = teaA.getMember(); //member1이 여전히 조회

이는, teamB로 변경할 때, teamA -> member 관계를 제거 하지 않았기 때문입니다. 따라서 편의 메서드를 다음과 같이 수정해야 합니다.

1
2
3
4
5
6
7
8
9
10
public void setTeam(Team team){

// 기존 팀과 관계를 제거
if(this.team != null){
this.team.getMemberList.remove(this);
}

this.team = team1; // 연관 관계 설정 member -> team
team.getMemberList.add(this); // 연관 관계 설정 team -> member
}

Reference

자바 ORM 표준 프로그래밍 <김영한>

Comment and share

이번 포스팅에서는 Mapping 한 Entity 를 Entity Manager 를 통해 어떻게 사용하는지 정리합니다.

Entity Manager Factory, Entity Manager

엔티티 메니저 펙토리는 한 개만 만들어서 애플리케이션 전체에서 공유합니다. 엔티티 메니저 펙토리는 서도 다른 스레드 간에 공유해도 되지만, 엔티티 메니저는 여러 스레드가 동시에 접근하면 동시성 문제가 발생하므로 스레드 간에 절대 공유하면 안됩니다.

Persistence Context

엔티티를 여구 저장하는 환경입니다. 엔티티 메니저로 엔티티를 저장하거나 조회하면 엔티티 메니저는 영속성 컨텍스트에 엔티티를 보관하고 관리합니다.

엔티티의 생명주기

4가지 상태가 있습니다.

  • 비영속
    • 영속성 컨텍스트와 관계 없는 상태
  • 영속
    • 영속성 컨텍스트에 저장된 상태
  • 준영속
    • 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제
    • 삭제된 상태

영속성 컨텍스트의 특징

  • 영속 상태는 식별자 값이 반드시 있어야합니다.
  • 보통 Transaction 을 Commit 하는 순간 영속성 컨텍스트에 저장된 엔티티를 데이터베이스에 반영합니다. (Flush)
  • 1차 캐쉬 / 동일성 보장 / 쓰기 지연 / 변경 감지 / 지연 로딩

엔티티 조회

영속성 컨텍스트는 내부에 1차 캐시를 가지고 있습니다. 쉽게 이야기해, Map 처럼 키는 @Id로 매핑한 식별자이고 값은 엔티티 인스턴스입니다. 1차 캐시에 저장된 엔티티를 조회할 때 , 1차 캐시에 엔티티가 있으면 데이터베이스를 조회하지 않고 메모리에 있는 1차 캐시에서 엔티티를 조회합니다. 만약, 1차 캐시에 없으면 엔티티 메니저는 데이터베이스를 조회해서 엔티티를 생성하고 1차 캐시에 저장한 후에 영속 상태의 엔티티를 반환합니다.

1
entityManger.find(Member.class, "member1");

영속 엔티티의 동일성 보장

영속성 컨텍스트는 성능상 이점과 엔티티의 동일성을 보장합니다. 따라서, 다음 코드의 결과는 참입니다.

1
2
3
4
Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");

System.out.println(a==b);

엔티티 등록

엔티티 메니저는 트랜잭션을 커밋하기 직전까지 데이터베이스에 엔티티를 저장하지 않고 내부 쿼리 저장소에 INSERT SQL을 모아둡니다. 그리고 커밋할 때 모아둔 쿼리를 데이터베이스에 보냅니다. 이것을 Transactional Write Behind (쓰기 지연) 라고 합니다.

엔티티 수정

엔티티의 변경사항을 데이터베이스에 자동으로 반영하는 기능을 Dirty Checking (변경 감지) 이라고 합니다. 순서는 다음과 같습니다.

  1. 커밋하면 엔티티 메니저 내부에서 flush() 가 호출됩니다.
  2. 엔티티와 스냅샷 (JPA는 엔티티를 영속성 컨텐스트에 보관할 때, 최초 상태를 복사해서 저장합니다 )을 비교해서 변경된 엔티티를 찾습니다.
  3. 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보냅니다.
  4. 데이터베이스 트랜잭션을 커밋합니다.

그리고 UPDATE SQL 을 생성할 때, 변경된 부분만 사용해서 동적으로 생성되는 것이 아니라, 엔티티의 모든 필드를 업데이트 합니다.

엔티티 삭제

엔티티 등록과 비슷하게 삭제 쿼리를 쓰기 지연 SQL 저장소에 등록하고 트랜잭션을 커밋해서 flush()를 호출하면 데이터베이스에 삭제 쿼리를 전달합니다.

1
2
Member a = em.find(Member.class, "member1");
em.remove(a);

플러쉬

영속성 컨테르를 플러쉬하는 방법은 3가지입니다.

  • em.flush() 직접 호출
  • 트랜잭션 커밋 시 플러쉬 자동 호출
  • JPQL 쿼리 실행시 플러쉬 자동 호출

Reference

자바 ORM 표준 프로그래밍 <김영한>

Comment and share

Author's picture

junhee.ko

Always Learning


Engineer


Incheon