JPA 는 많은 기능을 지원한다. 객체간 서로 참조를 하는 부분도 양방향 연관관계를 잘 사용만 하면 큰 문제 없이 사용할 수 있다. 하지만 이런 방법을 잘 사용하는게 최선이라고는 할 수 없다. 객체간의 관계가 복잡해지면, 사용하는 입장에서 관계들을 이해하는게 어려울 수 밖에 없다. 따라서 가능하다면 연관관계를 줄이고, 필요한 경우 단방향 연관관계를 통해서 문제를 해결하도록 하자.
실무에서 가장 많이 사용하는 N:1 연관관계이다.
테이블 입장에서 생각을 해봤을 때도 N 쪽에 FK 가 들어가는 만큼 비즈니스 코드와 그에 따른 SQL이 자연스럽게 이어질 가능성이 높다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
private int age;
@ManyToOne
private Team team;
public Member(String name, int age) {
this.name = name;
this.age = age;
}
public void joinTeam(Team team) {
this.team = team;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
public Team(String name) {
this.name = name;
}
}
///// Member 의 ManyToOne 활용
public static void main(String[] args) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Team teamA = new Team("teamA");
em.persist(teamA);
Member member = new Member("jaden", 29);
em.persist(member);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
findMember.joinTeam(teamA);
em.persist(findMember);
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
emf.close();
}
// mfindMember.joinTeam(teamA) 쿼리 결과는 member 객체를 조작해
// Member 테이블에 대한 update 쿼리가 발생하여 자연스럽다.
update
Member
set
age=?,
name=?,
team_id=?
where
id=?
불가피하게 사용하게 된다면 @JoinColumn 을 사용한다. 그렇지 않으면 기본적으로 JoinTable 방식을 사용하게 되어 의도하지 않은 테이블을 생성하게 된다. 의도한 경우가 아니라면 @JoinColumn 을 사용한다.
따라서 1:n 단방향 매핑을 사용해야하는 경우라면 n:1 양방향 매핑을 고려한다.(권장한다)
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
private int age;
public Member(String name, int age) {
this.name = name;
this.age = age;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany
@JoinColumn(name = "member_id")
private List<Member> members = new ArayList<>();
public Team(String name) {
this.name = name;
}
public void addMember(Member member) {
members.add(member);
}
}
///// team 에 OneToMany 활용
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
Member member = new Member("jaden", 29);
em.persist(member);
Team teamA = new Team("teamA");
em.persist(teamA);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
Team findTeam = em.find(Team.class, teamA.getId());
findTeam.addMember(findMember);
// findTeam.addMember(findMember) 쿼리 결과는 team 을 조작하지만
// Member 테이블에 대한 update 쿼리가 발생하여 부자연스럽다.
// 더불어 업데이트할 member 를 조회해야 해 성능적으로도 불리하다.
Hibernate:
select
members0_.member_id as member_i4_0_0_,
members0_.id as id1_0_0_,
members0_.id as id1_0_1_,
members0_.age as age2_0_1_,
members0_.name as name3_0_1_
from
Member members0_
where
members0_.member_id=?
Hibernate:
/* create one-to-many row me.jaden.Team.members */ update
Member
set
member_id=?
where
id=?
1:1 연관관계는 FK 를 원하는 테이블에 둘 수 있다.
예를들어 회원과 락커라는 엔티티가 있다면 회원 테이블에 락커ID 를 FK 로 둬도, 락커 테이블에 회원ID 를 FK로 두어도 된다.
비즈니스 적으로 회원이 주도적이라고 생각한다면, FK를 회원에 두고 사용하는 것이 편할 것이다.
예를들어 회원이 락커를 등록할 수 있다고 생각해보자.
// 회원 엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
private int age;
@OneToOne
@JoinColumn(name = "locker_id")
private Locker locker;
public Member(String name, int age) {
this.name = name;
this.age = age;
}
public void registerPersonalLocker(Locker locker) {
this.locker = locker;
}
}
// 락커 엔티티
@Entity
@NoArgsConstructor
@Getter
public class Locker {
@Id
@GeneratedValue
private Long id;
private int number;
public Locker(int number) {
this.number = number;
}
}
// 회원이 락커 등록
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Locker firstLocker = new Locker(1);
em.persist(firstLocker);
Member member = new Member("jaden", 29);
em.persist(member);
em.flush();
em.clear();
Locker getLocker = em.find(Locker.class, firstLocker.getId());
Member getMember = em.find(Member.class, member.getId());
# 회원이 락커를 등록한다.
getMember.registerPersonalLocker(getLocker);
tx.commit();
}...
// getMember.registerPersonalLocker(getLocker); 에 대한 SQL
update
Member
set
age=?,
locker_id=?,
name=?
where
id=?
의도한 대로 동작하고 있는 것을 확인할 수 있었다. 하지만, DB관점에서 생각해도록 하자.
어떠한 회원은 락커를 등록하지 않을 수 도있다. 그러면 회원 테이블의 FK 에는 null 이 들어갈 것이다.
혹은, 한명의 회원이 여러개의 락커를 사용할 수 있게 비즈니스 로직을 수정해야 한다면 테이블이 수정되어야 한다. 그렇다면 FK를 락커로 옮기고 단방향으로 Member를 통해 락커를 등록할 수 있을까? 아쉽지만 JPA 는 해당 부분을 지원하기 어렵다. 대신 양방향 관계를 맺어 사용할 수 는 있다.
@Entity
@NoArgsConstructor
@Getter
public class Locker {
@Id
@GeneratedValue
private Long id;
private int number;
@OneToOne
@JoinColumn(name = "member_id")
private Member owner;
public Locker(int number) {
this.number = number;
}
public void setOwner(Member owner) {
this.owner = owner;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
private int age;
@OneToOne(mappedBy = "owner")
private Locker locker;
public Member(String name, int age) {
this.name = name;
this.age = age;
}
public void registerPersonalLocker(Locker locker) {
this.locker = locker;
locker.setOwner(this);
}
}
// FK 는 락커테이블에 있지만 양방향관계를 통해 Member 에서 락커등록
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Locker firstLocker = new Locker(1);
em.persist(firstLocker);
Member member = new Member("jaden", 29);
em.persist(member);
em.flush();
em.clear();
Locker getLocker = em.find(Locker.class, firstLocker.getId());
Member getMember = em.find(Member.class, member.getId());
System.out.println("====");
getMember.registerPersonalLocker(getLocker);
tx.commit();
}...
# getMember.registerPersonalLocker(getLocker); 에 대한 SQL 쿼리
update
Locker
set
number=?,
member_id=?
where
id=?
다만, 이 경우 지연로딩(LazyLoading)을 사용할 수 없게된다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
private int age;
@OneToOne(mappedBy = "owner", fetch = FetchType.LAZY) // lazyLoading 설정
private Locker locker;
public Member(String name, int age) {
this.name = name;
this.age = age;
}
public void registerPersonalLocker(Locker locker) {
this.locker = locker;
locker.setOwner(this);
}
}
///// 사용코드
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Locker firstLocker = new Locker(1);
em.persist(firstLocker);
Member member = new Member("jaden", 29);
em.persist(member);
em.flush();
em.clear();
System.out.println("====");
Member getMember = em.find(Member.class, member.getId()); // member 를 조회할 때 locker 를 lazyLoading 해오는지 확인해보자
tx.commit();
# 쿼리 확인
====
Hibernate:
select
member0_.id as id1_1_0_,
member0_.age as age2_1_0_,
member0_.name as name3_1_0_,
member0_.team_id as team_id4_1_0_
from
Member member0_
where
member0_.id=?
Hibernate:
/* load me.jaden.Locker */ select
locker0_.id as id1_0_1_,
locker0_.number as number2_0_1_,
locker0_.member_id as member_i3_0_1_,
member1_.id as id1_1_0_,
member1_.age as age2_1_0_,
member1_.name as name3_1_0_,
member1_.team_id as team_id4_1_0_
from
Locker locker0_
left outer join
Member member1_
on locker0_.member_id=member1_.id
where
locker0_.member_id=?
쿼리 결과를 보면 알 수 있듯이 lazyLoading 을 설정해도 eagerLoading 을 해버린다.
이유는 JPA 가 프록시 객체를 생성할 때 Member 의 Locker 가 null 인지 아닌지 확인하기 FK가 Locker 에 있기 때문에 Locker 를 어쩔 수 없이 조회해버리기 때문이다.
@ManyToMany 는 실무에서 잘 사용하지 않는다. (연결테이블을 JPA가 관리함으로 연결테이블의 컬럼들을 컨트롤 할 수 없기 떄문)
관계형 DB 에서는 다대다 관계를 테이블 2개로는 풀어낼 수 없다. 연결테이블을 만들어 일대다+다대일 관계로 풀어내야 한다.
그래서 @ManyToMany 를 사용하면 JPA 는 매핑정보를 보고 연결테이블을 생성해 사용하게되는데, 단순 열결이 목적이라면 불필요하면 엔티티 클래스를 생성하지 않아도 된다는 장점이 있을 수 있지만, 실무에서 단순 연결만하는 경우가 극히 드물기 떄문에 일반적으로 @ManyToMany 를 사용하기 보다는 연결테이블을 엔티티로 만들어 필요한 기능들을 집어넣어 사용하게 된다.
LazyLoading 과 같은 기법을 사용하기 위해 JPA는 프록시 객체를 활용한다.
JPA 가 프록시를 사용하기에 주의해야할 부분이 몇 가지 있는데, 비즈니스 로직에서 객체의 타입을 체크해야할 때, 해당 타입이 원본 객체일지 프록시 객체일지 명확한 구분이 힘들기에 == 대신 instance of 를 통해 타입을 비교한다.
영속성컨택스트에 프록시객체로 저장되면 프록시객체가 반환되고, 실제객체가 저장되면 실제객체가 반환되기 때문이다.
만약 프록시 객체가 들어간 상태에서 xxxx.getClass() == xxx.class 와 같이 타입을 비교하면 false 가 나오게 되어 원하지 않는 결과를 얻게된다.
JPQL 을 통해 연관관계를 가진 경로를 탐색할 시 묵시적 내부 조인이 발생한다. (join 을 명시적으로 사용하지 않아도 join이 된다)
팀 전체가 JPA와 JPQL에 익숙하다면 괜찮을지 모르겠지만, 그렇지 않다면 묵시적 내부조인이 일어나는 경우에도 명시적으로 join 을 사용하도록 하자.
컬렉션 페치조인 페이징을 해결하는 방법?
1. 일대다 관계인 경우 반대방향(다대일)에서 쿼리를 날려 페이징을 한다.
2. 페치조인 없이 조회 한 후 @BatchSize 활용해 lazyLoading 을 한다. (비교적 n+1 문제에서 자유로움)
batch fetch size 는 글로벌 설정도 가능하다.
엔티티를 수정해야하는 경우 JPA 의 변경감지 기능을 사용한다.
merge() 를 사용할 경우 엔티티 전체를 변경하는 형태이기에, 변경하지 않은 필드에 null 이나 0 과같은 값이 세팅될 수 있기 때문이다.
n+1 문제를 해결하기 위해 fetchType 을 eager 로 설정한다고 해도 JPQL 이 실행되는 경우에는 n+1 문제가 해결되지 않는다. JPQL 은 그대로 SQL 로 번역되어 DB에 결과를 가져오고 JPA가 eager 패치타입을 확인 후 아직 채워지지 않은 데이터들을 채우기 위해 n+1 select 쿼리가 발생한다.
entityManager.find() 만을 사용하는 경우라면 원하는대로 join 쿼리를 JPA 가 생성해 보내지만, JPQL 을 사용하는 순간 원하지 않는 N+1 문제가 발생할 수 있음으로 fetchtype 을 EAGER 로 설정하지 않도록 하자
일대다관계(컬렉션)을 조인하여 가져올 때 조회의 주체를 기준으로 데이터가 부풀려진다. (n 관계의 데이터를 조회하니 당연한 일이다.) 이를 해결하기 위해서는 distinct 를 사용해 중복을 제거해야할 필요가 있다. 다만 hibernate6 기준으로는 일대다관계를 fetch join 하는 경우 distinct 를 선언하지 않아도 적용된 데이터를 가져온다.
일대다관계(컬렉션)을 가져오면서 페이징이 필요한 경우에는, xxxToOne 관계는 모두 fetchJoin 을 통해 가져온 후, 컬렉션은 지연로딩을 사용하여 가져온다. 다만 이 때 batchSize 를 설정함으로써 성능최적화를 이끌어낸다.
-- yml 을 통한 global 배치사이즈 설정
spring:
jpa:
properties:
default_batch_fetch_size: 100 # 배치사이즈 100
-- @BatchSize 애노테이션 설정
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
@BatchSize(size = 100)
private List<OrderItem> orderItems = new ArrayList<>();
spring.jpa.open-in-view: true (기본값)
기본값에 따라 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다.
-> API controller 나 viewTemplate 에서 지연로딩이 가능해짐
-> 커넥션을 너무 오래물고 있어 커넥션이 부족해지는 상황을 초래한다.
OSIV 설정을 끄면 트랜잭션이 종료될 때 반환한다.
page.map 을 활용하면 쉽게 변환 가능
public static Page<MemberDto> pageable(Page<Member> members) {
return members.map(member -> new MemberDto(member.getUsername(), member.getAge()));
}
Spring Data Jpa 를 사용하면, JpaRepository 인터페이스의 구현체 SimpleJpaRepository 를 사용하는 경우가 많다.
이 때, save() 를 확인해보면 새로운 객체인 경우 persist() 를 호출하고 기존에 있는 객체라면 merge() 를 호출하는 다음의 코드를 확인 할 수 있다.
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
새로운 객체의 판단여부는 id 값이 null인지(래퍼런스타입) 혹은 0인지(프리미티브타입)를 통해 판단하게 되는데, @GeneratedValue 를 사용한다면 persist 이후에 id 가 세팅되지만, 만약 직접 id 를 생성해주는 경우라면 null 이 아니기에 merge 가 호출되고 이는 성능악화(select 이후 save) 를 야기한다.
따라서 직접 id 를 세팅해주는 경우라면 Pesistable 을 엔티티에서 구현하여 isNew() 메소드를 오버라이딩해야한다.
가장 간단한 방법은 createdDate 를 사용하여 해당 필드가 null 이라면 새로운객체라고 판단하는 방법이 있다.
@Entity
public class Item implements Persistable<String> {
@Id
private String id;
@CreatedDate
private LocalDateTime createdDate;
@Override
public String getId() {
return this.id;
}
@Override
public boolean isNew() {
return createdDate == null;
}
}
```