JPA를 사용할 때 "Open Session In View"라는 게 있다. 이 녀석이 은근 골치가 아픈 녀석인데 Spring Boot와 JPA를 같이 사용할 때 서버를 실행해 보면 (OSIV 관련 어떠한 작업도 하지 않았을 때) 이런 경고 문구가 노출된다.
2023-11-16T08:31:26.467+09:00 WARN 76673 --- [ restartedMain] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
그러니까 이 옵션은 기본값이 "true"인데 얘가 true이면 화면이 렌더링되는 시점에도 데이터베이스에 쿼리가 날아갈 수 있다는 경고 문구를 날리고 있다. 이 속성값은 application.yml 파일에서 spring.jpa.open-in-view 키로 값을 줄 수 있다.
반응형
SMALL
이 OSIV가 그래서 정확히 어떻게 동작을 하느냐? 아래 컨트롤러 코드를 보자.
@GetMapping("members")
public Result<List<MemberDto>> members() {
List<Member> findMembers = memberService.findMembers();
List<MemberDto> collect = findMembers
.stream()
.map(m -> new MemberDto(m.getUsername()))
.toList();
return new Result<>(collect.size(), collect);
}
코드를 보면 memberService.findMember()를 호출하고 있다. 이 MemberService를 들어가 보면 이렇게 생겼다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
public List<Member> findMembers() {
return memberRepository.findAll();
}
}
클래스 레벨에 @Transactional() 어노테이션이 달려있고 findMembers() 메소드는 데이터베이스에서 멤버를 전부 가져온다. 이때 OSIV 속성이 true라면 이 클래스의 메소드가 시작하고 끝나는 시점에 트랜잭션이 커밋이 되거나 영속 컨텍스트가 닫히지 않는다. 이 메소드를 호출한 컨트롤러도 트랜잭션 안에서 동작하게 된다. 이게 OSIV 속성이 true일 때 일어나는 현상이다.
즉, 트랜잭션이 활성화된 메서드가 끝나도 트랜잭션은 닫히지 않고 그 메서드를 호출한 녀석도 역시 트랜잭션이 활성화된 상태로 남아있는다. 그렇기 때문에 위 경고 문구처럼 화면을 뿌리는 컨트롤러가 화면을 렌더링 하는 시점에도 역시 데이터베이스 쿼리가 날아갈 수 있다는 경고 문구를 하고 있는 것.
그럼 OSIV 속성은 항상 false로 설정해야 할까?
OSIV가 활성화된 상태일 때 장단점
장점은 다음과 같다.
트랜잭션 어노테이션이 붙어 있는 곳이 아니더라도 그것을 호출한 곳도 트랜잭션이 활성화 된 상태이다 보니 지연로딩을 초기화하는데 좀 더 유연하다. 즉, 서비스에서 데이터베이스 쿼리를 하고 받은 데이터를 가지고 컨트롤러에서 지연 로딩 관련 데이터를 초기화할 수 있다는 얘기다.
단점은 다음과 같다.
트랜잭션이 예상하는 것보다 오래 살아 있기 때문에 네트워크 상황에 따라 트랜잭션이 부족할 수 있다. 즉, 컨트롤러에서 서비스를 호출해서 데이터베이스에 쿼리를 했고 받은 데이터를 컨트롤러가 받을 때 네트워크 상황이 좋지 않아 컨트롤러가 재빨리 응답을 줄 수 없는 경우 그 시간 동안 내내 트랜잭션은 유효하게 된다. 그러면 트래픽이 많은 애플리케이션은 이러한 경우 때문에 커넥션풀이 부족할 수 있고 부족하게 되는 경우 장애로 이어질 수 있다.
OSIV가 비활성화된 상태일 때 장단점
장점은 다음과 같다.
트랜잭션이 시작하고 끝나는 시점이 명확하고 짧아지기 때문에 커넥션 풀에 여유가 생긴다.
단점은 다음과 같다.
트랜잭션 하나가 영속 컨텍스트 하나와 매핑되기 때문에 트랜잭션이 끝나는 동시에 지연로딩을 초기화할 수 없고 그렇게 되면 지연 로딩으로 데이터를 가져올 때 언제나 트랜잭션 안에서 모든 지연로딩을 초기화한 상태로 돌려주어야 한다.
OSIV를 비활성화했을 때 단점을 극복하는 방법
만약, OSIV를 비활성화한 경우엔 모든 지연 로딩 엔티티를 트랜잭션 안에서 처리해야 하므로 다음과 같이 해결할 수 있을 것이다.
페치 조인을 사용해 데이터를 쿼리 하는 시점에 모두 받아온다.
페치 조인으로 모두 받아오지 못할 경우 지연 로딩을 초기화하는 새로운 트랜잭션 안에서 처리한다.
위 코드는 컨트롤러에서 주문 정보를 모두 가져와서 DTO로 반환 후 JSON으로 데이터를 돌려준다. 여기서 OrderDto 클래스는 지연로딩 엔티티를 받아 처리한다. 다음 코드를 보자.
@Data
static class OrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate;
private OrderStatus orderStatus;
private Address address;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getUsername();
orderDate = order.getOrderDate();
orderStatus = order.getOrderStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
itemName = orderItem.getItem().getName();
orderPrice = orderItem.getOrderPrice();
count = orderItem.getCount();
}
}
OrderDto의 생성자에서 OrderItems를 처리한다. OrderItem은 지연 로딩 엔티티이다. 그래서 OSIV가 비활성화된 경우 위 컨트롤러에서 지연로딩을 처리할 수 없는 상태이다. 그러나, 페치 조인으로 지연 로딩 데이터를 모두 한 번의 쿼리로 로딩해 온다면 가능하다.
public List<Order> findAllWithItem() {
return em.createQuery("SELECT DISTINCT o " +
"FROM Order o " +
"JOIN FETCH o.member m " +
"JOIN FETCH o.delivery d " +
"JOIN FETCH o.orderItems oi " +
"JOIN FETCH oi.item i", Order.class).getResultList();
}
지연 로딩을 초기화하는 새로운 트랜잭션에서 처리
다른 방법으로는 새로운 트랜잭션을 받아오면 된다. 지금 문제가 되는 부분은 레포지토리에서 받아온 주문 데이터들을 DTO로 변환하는 이 코드이다.
즉, 서비스 또는 레포지토리에서 모든것을 다 처리한 후 컨트롤러는 받기만 하고 돌려주는 것을 말한다.
나는 비즈니스 로직을 레포지토리에 넣는 것을 매우 싫어하므로 (그래서도 안되고 항상 참조 방향은 다음과 같아야 한다).
컨트롤러 -> 서비스 -> 레포지토리, 엔티티
역방향이 되면 안 된다. 유지보수에 굉장히 큰 타격을 주므로.
아무튼 지금 코드에서 레포지토리에서 모든 로직을 넣지 않을 것이다. 근데 만약 서비스를 호출해서 처리하는 경우라면 서비스 안에서 DTO로 변환까지 같이 해준 데이터를 컨트롤러가 받으면 된다.
결론
OSIV를 활성화 또는 비활성화는 Trade-off가 있다. 그래서 뭐가 더 좋고 나쁘다가 아니라 상황에 맞게 사용하면 된다. 예를 들어 트래픽이 많이 필요 없는 애플리케이션은 활성화 상태로 두면 코드의 복잡도가 떨어지는 장점을 가지면서 애플리케이션에 장애 없이 서비스를 할 수 있을 것이다. 그러나 트래픽이 많은 애플리케이션은 이 옵션을 활성화해서는 안된다. 트랜잭션이 부족할 가능성이 농후하기 때문에. 따라서 상황에 맞게 사용하는 것이 중요하다고 본다.
쿼리 한 번으로 여러 테이블 로우(레코드)를 변경하는 걸 말한다. UPDATE, DELETE, INSERT 같은 것들이 이제 벌크 연산이 가능하다.
뭐 별건 아닌데 조금 주의할 사항이 있다.
벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다.
이게 뭐가 문제냐면 벌크 연산을 처리한 전과 후에 만약 영속성 컨텍스트에 관리하고 있는 레코드(객체)가 있다면 벌크 연산이 적용되지 않은 채 영속성 컨텍스트에 그대로 관리될 수 있다. 그래서 이걸 해결하는 방법은 벌크 연산을 처리하고 영속성 컨텍스트를 초기화하는 것이다. 또는 영속성 컨텍스트에 뭔가를 관리하기 전 벌크 연산을 먼저 수행하는 것이다.
반응형
SMALL
예를 들어보자.
멤버 3명이 있고 최초에 age를 0으로 설정했다. 그리고 영속성 컨텍스트에 멤버 3명을 영속시켰는데 그 이후에 벌크 연산으로 멤버 모두의 age값을 20으로 설정했다. 설정한 후 3명의 멤버 중 한명을 아무나 잡고 age를 찍어보면 내가 기대하는 건 20인데 0으로 나온다. 왜냐하면, 영속성 컨텍스트에 관리되는 멤버의 age는 0에서 변경된 상태가 아니다. 위에서도 말했지만 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리하기 때문이다. 실제 결과 출력을 보면 다음과 같이 반영된 개수는 3개지만 그 중 하나를 임의로 찍었을 때 나이가 0으로 나온다. 이럴때가 문제가 될 수 있다.
그래서, 벌크 연산은 그냥 무조건 벌크 연산을 처리하고 영속성 컨텍스트를 초기화하자.
플러시를 한다고 초기화하는게 아니다. 플러시는 영속 컨텍스트 데이터를 DB에 반영하는거고 초기화는 clear()를 호출해야 한다.
참고로, 이렇게 JPQL을 사용하면 무조건 사용하는 시점에 flush()를 호출한다. 벌크 연산도 마찬가지다. 그래서 JPQL 호출하기 전에 flush()를 먼저하고 JPQL을 실행한다. 근데 그거랑 clear()는 다른 얘기다. 이미 영속성 컨텍스트에서 관리하는 객체가 있으면 flush()를 하더라도 그 데이터는 그대로 남아있는거다. 그러니까 벌크 연산을 수행할 때 flush()가 자동 호출되지만, clear()를 하지 않은 상태에서 영속성 컨텍스트에 있는 데이터를 그대로 가져오면 DB를 거쳐서 가져오는게 아니고 영속성 컨텍스트에 있는 데이터를 가져오고 이 데이터는 벌크 연산에 적용되는 데이터라고 할 지라도 데이터가 반영되어 있는 상태가 아닌건 똑같다. 그래서 결론은 벌크 연산은 반드시 수행하고 clear()를 호출하자!
그런데! 이 역시도 Spring Data JPA를 사용하면 굉장히 깔끔하고 편리하게 해결해준다. 아래 코드를 보자.
interface UserRepository extends Repository<User, Long> {
@Modifying
@Query("delete from User u where u.role.id = ?1")
void deleteInBulkByRoleId(long roleId);
}
지금 저기 NamedQuery가 있다. 그리고 저건 벌크 연산이다. Role이 특정 Role인 유저들을 모두 지우니까.
그런데, 그 위에 @Modifying 애노테이션이 있다. 저 애노테이션은 영속성 컨텍스트를 clear()하는 애노테이션이다. 즉, 이 쿼리가 수행된 후 clear()를 호출해주는 애노테이션이다. 그래서 이렇게 이쁘고 간단하게 벌크 연산이 가능하다.
근데 여기서 NamedQuery가 주는 이점은 컴파일 시점에 쿼리에 문제가 있으면 잡아준다는 것이다.
이 덕분에 쿼리에 문제, 문법 오류가 있는 경우를 방지할 수 있게 된다. 나중에 Spring Data JPA를 사용하면 다음과 같이 정말 편리하고 보기 좋게 사용할 수 있다.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select u from User u where u.emailAddress = ?1")
User findByEmailAddress(String emailAddress);
}
인터페이스에서 메서드 시그니쳐만 생성해놔도 @Query 어노테이션으로 쿼리를 작성하고 쿼리에 오류가 있는지도 잡아주고 가져다가 사용만 하면 된다.
페치 조인은 JPQL에서 엄청 엄청 중요한 내용 중 하나라는 생각이 든다. 왜 그러냐면 이 페치 조인이 연관된 엔티티를 SQL 한 번에 다 가져올 수 있는 방법인데 이게 왜 중요하냐? 이전에 N+1 문제에 대해 얘기하면서 이 N+1 문제는 즉시로딩뿐 아니라 지연로딩에서도 발생한다고 했다. 왜냐? 팀과 멤버를 생각해보면, 멤버를 전체 다 가져왔을 때 팀을 지연로딩으로 설정해 놓으면 최초 멤버를 가져올때는 팀을 쿼리해서 가져오지 않는다. 지연로딩이니까. 근데 이렇게 가져온 결과로 멤버에 접근해서 getTeam().getName() 이런 메서드를 호출하는 순간 팀을 가져오기 위한 프록시 초기화를 실행하고 이때 쿼리가 나간다. 즉, 지연로딩도 이렇게 N+1 문제가 발생한다는 이야기다. 그 문제를 해결할 수 있는 방법이 최초 한번의 SQL 쿼리로 멤버를 가져올 때 팀도 한번에 다 조인해서 가져오는 페치 조인을 사용하는 것이다.
사용법도 너무 간단하다. 아래처럼 JOIN FETCH로 조인할 연관 엔티티만 호출하면 된다.
SELECT m FROM Member m JOIN FETCH m.team
이 JPQL은 다음과 같은 SQL로 변환되어 나간다.
SELECT m.*, t.* FROM Member m INNER JOIN TEAM t ON m.team_id = t.id
m.*, t.*은 멤버와 팀의 모든 필드를 축약해서 작성한 것 뿐이고 그냥 내부 조인이 발생한다. 내부 조인이 발생하면 멤버와 팀의 데이터를 다 가져오기 때문에 한 번에 모든 데이터를 가져올 수 있는데 이게 사실 즉시 로딩이다. 그러나 즉시 로딩은 사용하면 안된다고 했었는데 즉시 로딩은 내가 멤버를 조회할 때 팀을 같이 조회할 필요가 없음에도 조인으로 가져오기 때문이었다. 그래서 지연 로딩이 기본으로 적용되어야 한다고 했는데 지연 로딩일 때 멤버를 조회하면 팀을 같이 조회해야 하는 경우 이 한 번의 쿼리로 모든 걸 다 해결할 수 있게 하는 것.
실제 사용 코드를 예시로 보자.
FETCH JOIN을 사용하지 않았을 때
package cwchoiit;
import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
try {
for (int i = 0; i < 3; i++) {
Team team = new Team();
team.setName("Team" + i);
entityManager.persist(team);
Member member = new Member();
member.setName("member" + i);
member.setTeam(team);
entityManager.persist(member);
}
entityManager.flush();
entityManager.clear();
List<Member> findMembers = entityManager
.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
for (Member findMember : findMembers) {
System.out.println("findMember.getName() = " + findMember.getName());
System.out.println("findMember.getTeam().getName() = " + findMember.getTeam().getName());
}
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();
}
}
멤버와 그 멤버가 속할 팀을 루프안에서 만든다. 3명의 멤버가 각각 다른 팀에 속해있다. 멤버를 영속 컨텍스트에 영속시킨 후 루프가 끝난 후 영속 컨텍스트를 플러시 한다. 그 후에 모든 멤버를 가져와 각 멤버의 정보를 출력한다.
이때 발생되는 결과 SQL쿼리를 확인해 보자.
Hibernate:
/* SELECT
m
FROM
Member m */ select
member0_.id as id1_0_,
member0_.name as name2_0_,
member0_.TEAM_ID as team_id3_0_
from
Member member0_
findMember.getName() = member0
Hibernate:
select
team0_.id as id1_3_0_,
team0_.name as name2_3_0_
from
Team team0_
where
team0_.id=?
findMember.getTeam().getName() = Team0
findMember.getName() = member1
Hibernate:
select
team0_.id as id1_3_0_,
team0_.name as name2_3_0_
from
Team team0_
where
team0_.id=?
findMember.getTeam().getName() = Team1
findMember.getName() = member2
Hibernate:
select
team0_.id as id1_3_0_,
team0_.name as name2_3_0_
from
Team team0_
where
team0_.id=?
findMember.getTeam().getName() = Team2
나가는 SQL 쿼리에 집중해 보자. 최초에 멤버를 전부 조회하는 쿼리가 한 번 나간다. 합리적이다. 그런데 멤버의 팀을 찍으려고 할 때마다 팀을 조회하는 쿼리가 매번 나간다. 왜냐하면 멤버를 가져왔을 때 멤버가 가지고 있는 팀은 엔티티가 아닌 프록시이기 때문에 실제 사용하는 시점에 프록시에 엔티티 값을 집어넣어 줘야 하므로. 보면 팀 3개를 출력하기 위해 3번의 SQL문이 나간 게 보이는가? 이게 N+1 문제다. 지연로딩이든 즉시로딩이든 어떻게 사용하느냐에 따라 N+1 문제는 발생할 수 있게 된다.
그럼 지연로딩이었던 것을 즉시로딩으로 바꿀까? 절대 안 된다. 이 경우는 멤버를 조회할 때 팀도 조회하는 비즈니스 로직이기 때문에 이렇게 팀을 모두 조회하지만 그렇지 않은 로직을 타는 경우에는 불 필요한 조인이 발생하면 안된다. 이걸 해결해 주는 것이 페치조인이다.
FETCH JOIN을 사용할 때
package cwchoiit;
import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
try {
for (int i = 0; i < 3; i++) {
Team team = new Team();
team.setName("Team" + i);
entityManager.persist(team);
Member member = new Member();
member.setName("member" + i);
member.setTeam(team);
entityManager.persist(member);
}
entityManager.flush();
entityManager.clear();
List<Member> findMembers = entityManager
.createQuery("SELECT m FROM Member m JOIN FETCH m.team", Member.class)
.getResultList();
for (Member findMember : findMembers) {
System.out.println("findMember.getName() = " + findMember.getName());
System.out.println("findMember.getTeam().getName() = " + findMember.getTeam().getName());
}
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();
}
}
코드에서 변경된 부분은 딱 하나. 쿼리문에 JOIN FETCH가 추가된 것뿐이다. 그러나 결과는 엄청난 차이가 있다. 결과문을 보자.
Hibernate:
/* SELECT
m
FROM
Member m
JOIN
FETCH m.team t */ select
member0_.id as id1_0_0_,
team1_.id as id1_3_1_,
member0_.name as name2_0_0_,
member0_.TEAM_ID as team_id3_0_0_,
team1_.name as name2_3_1_
from
Member member0_
inner join
Team team1_
on member0_.TEAM_ID=team1_.id
findMember.getName() = member0
findMember.getTeam().getName() = Team0
findMember.getName() = member1
findMember.getTeam().getName() = Team1
findMember.getName() = member2
findMember.getTeam().getName() = Team2
SQL문 딱 하나에 모든 데이터가 들어오고 한 번에 모두 조회가 가능해졌다. 위에서 고작 멤버 3명을 조회하기 위해 쿼리 4번이 나간 반면, 지금은 단 한 번의 쿼리로 모든 걸 만족시켰다. 이게 페치 조인이다.
엔티티 타입을 페치 조인해봤으니 컬렉션 값 타입도 페치 조인해보자. 살짝 다르고 알아야 할 부분이 있다.
컬렉션 페치 조인 (일대다 관계, 컬렉션 페치 조인)
당연히 ManyToOne에서 페치 조인(멤버에서 팀을 가져올 때)이 가능하면 OneToMany에서도 페치 조인(팀에서 멤버들을 가져올 때)이 가능할건데 이 일대다에서 조인을 했을 때 조심할 부분이 있다. 우선 페치 조인으로 데이터를 가져와보자. 이건 페치 조인을 떠나서 그냥 일대다 입장에서 조인을 할 때 항상 조심할 부분이다.
package cwchoiit;
import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
try {
Team team = new Team();
team.setName("TEAM1");
entityManager.persist(team);
Team team2 = new Team();
team2.setName("TEAM2");
entityManager.persist(team2);
Member member1 = new Member();
member1.setName("MEMBER1");
member1.setTeam(team);
Member member2 = new Member();
member2.setName("MEMBER2");
member2.setTeam(team);
Member member3 = new Member();
member3.setName("MEMBER3");
member3.setTeam(team2);
entityManager.persist(member1);
entityManager.persist(member2);
entityManager.persist(member3);
entityManager.flush();
entityManager.clear();
List<Team> findTeams = entityManager
.createQuery("SELECT t FROM Team t JOIN FETCH t.members", Team.class)
.getResultList();
for (Team findTeam : findTeams) {
System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());
}
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();
}
}
TEAM1, TEAM2 두 개의 팀이 있고, MEMBER1, MEMBER2, MEMBER3 세 명의 멤버가 있다.
TEAM1에는 MEMBER1, MEMBER2가 포함되어 있다.
TEAM2에는 MEMBER3이 포함되어 있다.
이 상태에서 팀을 뽑을건데 팀에 속한 Members를 페치 조인으로 가져와보자.
Hibernate:
/* SELECT
t
FROM
Team t
JOIN
FETCH t.members */ select
team0_.id as id1_3_0_,
members1_.id as id1_0_1_,
team0_.name as name2_3_0_,
members1_.name as name2_0_1_,
members1_.TEAM_ID as team_id3_0_1_,
members1_.TEAM_ID as team_id3_0_0__,
members1_.id as id1_0_0__
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
TEAM1|members.size(): 2
TEAM1|members.size(): 2
TEAM2|members.size(): 1
역시, 단 한번의 쿼리로 모든걸 해결했지만 이상한 점이 있다. TEAM1 데이터가 두번 찍힌다는 점이다. 즉, 가져온 findTeams에 3개의 레코드가 있다는 의미다. 왜 그럴까? 일대다 입장에서의 조인이기 때문이다.
아래 그림을 보면 이해가 된다.
팀을 기준으로 멤버와 조인하게 되면 팀A에 소속된 멤버가 두명이니까 두개의 레코드를 가진다. 이렇기에 팀A가 위처럼 두번 출력되는 것이다. 이렇게 일대다 조인은 데이터가 불어나게 되는데 이를 해결하기 위해서는 JPA에서는 해줄 수 있는게 없다. JPA입장에서는 이 데이터가 어떻게 만들어질지 전혀 알 지 못하는 상태이기 때문에.
그래서 DB로부터 조인한 쿼리로 받은 데이터는 이렇게 두 개가 들어오고 JPA는 그것을 객체로 표현해주기 때문에 다음과 같은 그림이 된다.
그래서 우선은 받은 객체가 두개니까 메모리에 주소값이 두개가 할당된다. 그러나, 그 두개의 객체는 같은 객체이므로 바라보는 주소는 같다. 영속 컨텍스트에는 같은 객체라면 하나만을 사용하면 되므로 영속 컨텍스트에는 하나의 객체만 있지만 조회한 findTeams 컬렉션에는 두개가 담겨있는 것. 그럼 중복을 제거하고 싶을 땐 어떻게 할까? DISTINCT를 사용하면 된다.
허나, SQL의 DISTINCT는 위 문제를 해결해주지 않는다. SQL에서 DISTINCT는 중복된 결과(완전히 똑같은)를 제거하는 명령이기 때문에 두 레코드를 중복으로 바라보지 않는다. 중복이 아니잖아. 레코드 기준으로 회원아이디도 회원이름도 다르니까.
그러나, JPQL의 DISTINCT는 2가지의 기능을 제공해준다.
SQL에 DISTINCT를 추가해주는 작업
애플리케이션에 올라온 객체에서 엔티티 중복(주소가 같은 Team 객체)을 제거해준다. 이 엔티티 중복을 제거해줄 때 위 상황 같은 중복이 제거가 된다.
근데 그럼 중복 제거가 되면 문제가 없나요? SQL에서 DISTINCT는 완전히 똑같아야 제거를 해주는데 완전히 똑같지는 않잖아요?
▶ 맞다. 그런데 우리는 지금 객체지향 세계에 있다. 우리는 하나의 Team에 속한 모든 Member들을 가져오는 리스트를 Team 객체가 가지고 있기 때문에 어차피 그 안에 회원 2명이 담겨 오기 때문에 팀에 대한 중복 제거가 되도 아무런 문제가 없다.
그래서 결론은 DISTINCT 예약어를 추가해보자.
List<Team> teams = em.createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class)
.getResultList();
실행 결과
Hibernate:
/* SELECT
DISTINCT t
FROM
Team t
JOIN
FETCH t.members */ select
distinct team0_.id as id1_3_0_,
members1_.id as id1_0_1_,
team0_.name as name2_3_0_,
members1_.name as name2_0_1_,
members1_.TEAM_ID as team_id3_0_1_,
members1_.TEAM_ID as team_id3_0_0__,
members1_.id as id1_0_0__
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
TEAM1|members.size(): 2
TEAM2|members.size(): 1
일반 조인과 페치 조인의 차이점
일반 조인과 페치 조인은 어떤점이 다를까? 일반 조인은 조인을 하는건 맞다. 근데 그 조인 대상의 데이터를 메모리에 즉각적으로 퍼올리지는 않는다. 코드로 보는게 제일 빠르니 코드로 봐보자.
package cwchoiit;
import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
try {
Team team = new Team();
team.setName("TEAM1");
entityManager.persist(team);
Team team2 = new Team();
team2.setName("TEAM2");
entityManager.persist(team2);
Member member1 = new Member();
member1.setName("MEMBER1");
member1.setTeam(team);
Member member2 = new Member();
member2.setName("MEMBER2");
member2.setTeam(team);
Member member3 = new Member();
member3.setName("MEMBER3");
member3.setTeam(team2);
entityManager.persist(member1);
entityManager.persist(member2);
entityManager.persist(member3);
entityManager.flush();
entityManager.clear();
List<Team> findTeams = entityManager
.createQuery("SELECT DISTINCT t FROM Team t JOIN t.members", Team.class)
.getResultList();
for (Team findTeam : findTeams) {
System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());
}
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();
}
}
딱 아래 코드 부분에 집중해보자. 페치 조인이 아닌 일반 조인이다.
List<Team> findTeams = entityManager
.createQuery("SELECT DISTINCT t FROM Team t JOIN t.members", Team.class)
.getResultList();
실행 결과
Hibernate:
/* SELECT
DISTINCT t
FROM
Team t
JOIN
t.members */ select
distinct team0_.id as id1_3_,
team0_.name as name2_3_
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
Hibernate:
select
members0_.TEAM_ID as team_id3_0_0_,
members0_.id as id1_0_0_,
members0_.id as id1_0_1_,
members0_.name as name2_0_1_,
members0_.TEAM_ID as team_id3_0_1_
from
Member members0_
where
members0_.TEAM_ID=?
TEAM1|members.size(): 2
Hibernate:
select
members0_.TEAM_ID as team_id3_0_0_,
members0_.id as id1_0_0_,
members0_.id as id1_0_1_,
members0_.name as name2_0_1_,
members0_.TEAM_ID as team_id3_0_1_
from
Member members0_
where
members0_.TEAM_ID=?
TEAM2|members.size(): 1
최초에 SQL 쿼리를 보면, 멤버를 조인을 하는건 맞다. 그러나, SELECT절엔 팀 관련 데이터밖에 없다. 그렇기 때문에 내가 가져온 팀들에서 멤버를 조회하려고 할 때 다시 멤버관련 SQL문이 나가게 된다.
즉, 일반 조인은 일반 조인 과정에서 관련 엔티티 데이터를 퍼올리지 않는다. 프록시와 비슷한 상태인거지. 그래서 결국 멤버까지 가져오려면 다시 쿼리를 날리게 된다. 이런 차이가 있다.
페치 조인 한계
페치 조인 대상에는 별칭을 줄 수 없다.(하이버네이트는 가능하나 가급적 사용하지 않아야 한다).
List<Team> findTeams = entityManager
.createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members m", Team.class)
.getResultList();
저 코드에서 보면 JOIN FETCH 이후에 `t.members m` 이렇게 `m`으로 별칭을 주고 있는데 이런식으로 사용할 수 없다는 얘기다. 물론 하이버네이트는 가능하게 해준다. 그러나, 진짜로 페치 조인을 몇단계로 가져가야 할 경우 아닌 이상 절대 쓰지 않기로 하자. 그 외에 별칭을 가져가야 할 경우가 있다고 생각이 들면 그땐 다른 쿼리로 해결할 수 있는 경우가 무조건 있다고 생각해라.
둘 이상의 컬렉션은 페치 조인 할 수 없다.
예를 들어, 팀이 '멤버'라는 일대다 관계를 가지고 있고, 또 다른 뭐 '업무분야'라는 일대다 관계를 가지고 있을 때 이 두개를 같이 페치 조인하면 안된다. 그 이유는 위에서 봤지만 일대다만 해도 데이터가 부풀려 나오는데 이 경우는 일대다대다이다. 더 심한 데이터 부풀림 현상이 일어날 가능성이 농후하고 데이터 정합성에 문제가 발생할 수 있기 때문에 안된다.
컬렉션을 페치 조인(일대다 조인)하면 페이징 API(setFirstResult(), setMaxResults())를 사용할 수 없다.
일대다 페치 조인일 때 데이터 부풀림 현상이 일어나는것을 확인했다. 그런데 이것을 페이징한다? 데이터 정합성에 문제가 생길 가능성이 농후하다. 그냥 하면 안된다. 근데 문제는 하이버네이트는 경고 로그를 남기고 메모리에 데이터를 올린 후 그 메모리의 데이터로 페이징을 결국은 해준다는 것이다. 절대 절대 사용하면 안된다. 그냥 일대다 페치 조인을 할거면 다대일로 페이징을 하면 된다. 아래는 경고 로그를 남기고 페이징을 하는 예시이다.
List<Team> findTeams = entityManager
.createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
실행 결과
WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
전 죽어도 일대다로 페이징 하고 싶은데요?
▶ 그러면 방법이 있다. 가장 좋은 방법은 다대일로 페이징을 하면 되지만, 정말 때려죽어도 일대다로 페이징을 하고 싶다면, 다음과 같이 해보자.
List<Team> findTeams = entityManager
.createQuery("SELECT t FROM Team t", Team.class)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();
for (Team findTeam : findTeams) {
System.out.println(findTeam.getName() + "|members.size(): " + findTeam.getMembers().size());
for (Member member : findTeam.getMembers()) {
System.out.println("member.getName() = " + member.getName());
}
}
우선은 페치 조인을 하지 말고 팀을 가져오는데 여기서 페이징을 원하는 만큼 한다. (위 코드에서는 0부터 2까지)
그 가져온 팀에서 멤버들을 가지고 와서 뿌려주면 된다. (위 코드에서 findTeam.getMembers()로 루프를 돌리는 부분)
그런데, 이렇게 되면 팀에 있는 멤버를 가져올때마다 지연로딩에 대한 초기화가 일어나기 때문에 쿼리가 계속 나간다 즉, N+1 문제가 발생한다. 이때 BatchSize를 설정해서 한번에 여러 멤버를 가져올 수가 있다! 아래 글로벌 설정 코드를 보자.
이렇게 batch_fetch_size를 100 정도로 설정을 하면, 팀에 있는 멤버들을 가져올 때 최대 100개까지 한번에 조회를 한다.
실행 결과
Hibernate:
/* SELECT
t
FROM
Team t */ select
team0_.id as id1_3_,
team0_.name as name2_3_
from
Team team0_ limit ?
Hibernate:
/* load one-to-many cwchoiit.jpql.Team.members */ select
members0_.TEAM_ID as team_id3_0_1_,
members0_.id as id1_0_1_,
members0_.id as id1_0_0_,
members0_.name as name2_0_0_,
members0_.TEAM_ID as team_id3_0_0_
from
Member members0_
where
members0_.TEAM_ID in (
?, ?
)
TEAM1|members.size(): 2
member.getName() = MEMBER1
member.getName() = MEMBER2
TEAM2|members.size(): 1
member.getName() = MEMBER3
위 쿼리를 보면 지연로딩으로 인한 프록시 초기화를 하기 위해 두번째 쿼리를 날리는데 그때 WHERE절을 잘보면 IN을 사용해서 TEAM_ID가 그 중 하나에 속했다면 멤버들을 다 가져오는 쿼리를 날리고 있다. 지금은 팀이 전체 2개밖에 없으니까 딱 IN 안에 2개만 들어갔지만, 만약 200개 300개가 넘으면 내가 설정한 BatchSize인 100개를 IN에 넣어 한번에 좀 여러개의 멤버를 가져올 수 있다. 이렇게 최적화를 하면서 일대다 페이징을 처리할 수도 있다.
페치조인 한계 마무리
모든 것을 페치 조인으로 해결할 수는 없다. (그러나, 거의 95%는 페치 조인으로 다 해결이 된다고 본다)
페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 그러니까 멤버와 팀이 있으면 각각이 가진 필드들이나 참조를 그대로 반환하고 사용하는 경우를 말한다. 그렇지 않은 경우가 아래 경우다.
여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면 페치 조인보다는 일반 조인을 사용해서 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다.
다형성 쿼리
그렇게 중요한 부분은 아닌데 알고 가면 좋으니까 추가적으로 작성한다. 이전 포스팅에서 상속 관계 매핑에서 배운 부모-자식 관계에 대한 엔티티가 있을 때에 대한 이야기이다.
이렇게 생겨먹은 엔티티 구조가 있을 때, 조회 대상을 특정 자식으로 한정하는 쿼리에 대해 알아보자.
예를 들어, "Item 중에 Book, Movie를 조회해라" 라는 질의가 있으면 이렇게 하면 된다.
// JPQL
SELECT i
FROM Item i
WHER TYPE(i) IN (Book, Movie)
// SQL
SELECT i
FROM Item i
WHERE i.DTYPE IN ('Book', 'Movie')
또는 캐스팅과 같은 방법도 있다. TREAT라는 키워드인데, 예를 들어 부모인 Item과 자식 Book이 있을 때, 이런식으로 작성할 수 있다.
// JPQL
SELECT i
FROM Item i
WHERE TREAT(i as Book).author = 'kim'
// SQL
SELECT i.*
FROM Item i
WHERE i.DTYPE = 'Book' AND i.author = 'kim'
엔티티 직접 사용
무슨 말이냐면, 바로 JPQL을 보자.
엔티티 직접 사용 - 기본키 값
// JPQL
SELECT COUNT(m.id) FROM Member m // 엔티티의 아이디를 사용
SELECT COUNT(m) FROM Member m // 엔티티를 직접 사용
이런 식으로 COUNT를 사용할 때 엔티티의 기본키를 사용하는게 아니라 엔티티 자체를 가지고 사용하는 걸 말하는데 이러면 어떻게 될까?
▶ SQL로 번역될 때 해당 엔티티의 기본키 값을 사용한다.
// 번역된 SQL
SELECT COUNT(m.id) as cnt FROM Member m
엔티티 직접 사용 - 파라미터 값
위와 같은 경우 말고, 파라미터로 전달할 때도 엔티티를 넘길 수 있는데 이것 역시 식별자로 변환된다.
엔티티를 파라미터로 전달
String jpql = "select m from Member m where m = :member";
List result = em.createQuery(jpql).setParameter("member", member).getResultList();
식별자를 전달
String jpql = "select m from Member m where m.id = :memberId";
List result = em.createQuery(jpql).setParameter("memberId", memberId).getResultList();
실행된 SQL (둘 다 마찬가지로)
SELECT m.*
FROM Member m
WHERE m.id=?
엔티티 직접 사용 - 외래키 값
이번엔 외래키 자리에 넣을 값도 엔티티 자체를 넘길수도 있는데 이것 역시 마찬가지로 식별자로 변환된다.
외래키 자리에 엔티티를 직접 전달
String jpql = "select m from Member m where m.team = :team";
List result = em.createQuery(jpql).setParameter("team", team).getResultList();
외래키 자리에 식별자를 전달
String jpql = "select m from Member m where m.team.id = :teamId";
List result = em.createQuery(jpql).setParameter("teamId", teamId).getResultList();
JPQL(Java Persistence Query Language)은 데이터베이스의 SQL과 유사하나 객체지향 쿼리 언어이며 그렇기에 테이블을 대상으로 쿼리하는 게 아니라 엔티티 객체를 대상으로 쿼리한다는 차이점이 있다. JPQL은 SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않는다. 이 말은 데이터베이스마다 다른 방언(MySQL은 LIMIT, Oracle은 ROWNUM 같은)에 상관없이 동작한다는 의미이다.
그러나 결국, DB는 SQL만을 받기 때문에 JPQL은 결국에는 SQL로 변환된다.
이 JPQL을 잘 이해하고 사용할 줄 알아야 기본적인 쿼리문을 사용하는데 문제가 없고 복잡한 쿼리를 처리해주는 QueryDsl도 편하게 사용할 수 있다. 그래서 JPQL을 잘 사용할 줄 알아야 한다.
근데, 개발을 하다보면 이런 간단한 쿼리도 있지만 복잡한 쿼리도 필요할 때가 있기 마련이다. 예를 들어, "유저의 이름이 중간에 'hello'가 들어가는 유저들" 이런 쿼리를 원한다면? 사실 이것도 복잡한 쿼리도 아니다. 근데 여기서 말하고자 하는건 이렇게 조건이 들어가는 경우가 비일비재 하다는 것이다.
그러면 이제, 다음과 같이 쿼리를 작성할 수 있다.
이게 바로 JPQL이다. 그리고 이게 지금 IDE가 잘 도와주고 있기 때문에 이렇게 보이는거지 사실 저건 다 문자열이다. 그래서 IDE의 도움을 받지 못하는 경우에는 문자열에 잘못된 부분이 있어도 컴파일러는 체크할 수 없다.
그리고 이 문자열로 쿼리를 만들어 낼 때 가장 까다로운 것은 동적 쿼리를 만들기가 정말 어렵다는 것이다. 그래서 JPA Criteria라는게 있는데 결론부터 말하면 이건 사용하지 마라. 근데 일단 스펙에 있기 때문에 뭔지는 알아야 하니까 말하자면 아래와 같이 사용하면 된다.
JPA Criteria 사용 예시
// Criteria 사용 준비
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);
//루트 클래스 (조회를 시작할 클래스)
Root<Member> m = query.from(Member.class);
// 쿼리 생성
CriteriaQuery<Member> cq = query.select(m).where(cb.equal(m.get("username"), "kim"));
List<Member> resultList = entityManager.createQuery(cq).getResultList();
문자가 아닌 자바 코드로 JPQL을 작성할 수 있다.
JPQL의 빌더 역할을 하는게 JPA Criteria이다.
JPA에서 공식적으로 지원하는 기능이다.
그러나, 너무 복잡하고 실용성이 없다. 실용성이 없다는 말은 저 코드를 보고 이게 어떤 쿼리인지 인지하기가 너무 어렵다. 지금이야 아주 간단한 쿼리니까 읽다보면 이해할 수 있겠지만, 조금만 복잡해져도 알아보기가 너무 어렵다.
그래서 결론을 말하면 그냥 딱 이렇게 생각하면 된다.
→ JPQL + QueryDsl 두 개를 같이 사용하면 거의 95% 경우의 쿼리를 다 수행할 수 있다. 만약, 정말 정말 복잡한 쿼리가 있어서 저 둘로 해결하지 못한다면, 그럴때 nativeQuery를 사용하면 된다. 실제로 SQL을 작성해서 그 SQL을 수행할 수 있게 해주는 녀석이다. 아래가 그 예시 코드이다.
String sql = "SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 'kim'";
List<Member> result = entityManager.createNativeQuery(sql, Member.class).getResultList();
JPQL문법
JPQL 문법이라고 SQL과 다를 것 없다. 다만 지켜야 할 규칙이 있다.
select m from Member as m where m.username = "member"
엔티티와 속성은 대소문자 구분을 한다 (Member, username)
JPQL 키워드는 대소문자 구분을 하지 않는다 (SELECT, FROM, where)
엔티티 이름을 사용한다 (테이블 이름이 아니다)
별칭은 필수(m), as는 생략 가능
TypedQuery와 Query 타입이 있는데 TypedQuery는 반환되는 데이터의 타입이 명확할 때를 말하고 Query는 명확하지 않을 때이다.
크게 중요한 내용은 아닌데 알아둬야 하니까.
TypedQuery<Member> members = em.createQuery("select m from Member m", Member.class);
이렇게 createQuery()에 두번째 인자로 타입을 지정할 수 있는데 아무 타입이나 지정할 수 있는건 아니고 거의 대부분은 엔티티를 지정한다. 이렇게 타입을 명확히 명시를 한 상태에서의 반환타입이 TypedQuery가 된다.
그러나 아래와 같이 id는 Long, username은 String인 데이터를 받아올 때는 타입을 명시할 수 없으므로 반환되는 데이터 타입이 Query가 된다. 이런 차이가 있다.
Query members = em.createQuery("select m.id, m.username from Member m");
그러나, 저런식으로 사용하지 않고 체인으로 사용하는게 일반적이고 그 예시는 다음과 같다.
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
Member singleMember = em.createQuery("select m from Member m where m.id=1", Member.class)
.getSingleResult();
이렇게 한번에 getResultList() 또는 getSingleResult()로 데이터를 받아온다. 메소드 명만 봐도 알겠지만 getResultList()는 복수의 데이터를 가져오는 방식이고 getSingleResult()는 단일값을 가져오는 방식이다.
그러나 주의할 점이 있다.
getResultList()는 데이터가 없는 경우 빈 리스트를 반환하기 때문에 큰 문제가 되지 않는다. 그러나 getSingleResult()는 무조건 딱 더도 덜도 말고 딱! 하나만 있어야 한다. 만약, getSingleResult()를 사용하는데, 데이터가 없는 경우NoResultException이 발생하고 데이터가 둘 이상인 경우NonUniqueResultException이 발생한다. 이것을 주의해야한다. (Spring Data JPA는 결과가 없으면 이 에러를 처리해서 null로 반환해주긴 함)
파라미터 바인딩 - 이름 기준, 위치 기준
파라미터를 던져줄 상황이 굉장히 많이 발생할텐데 사용방법은 또 간단하다.
이름 기준
List<Member> members = em
.createQuery("select m from Member m where m.username = :username", Member.class)
.setParameter("username", "Member1")
.getResultList();
username이라는 파라미터를 던져줄 때 setParameter()를 호출한다. 그리고 받는 쪽은 where m.username = :username 여기가 파라미터를 받는 부분이다.
위치 기준
위치기준은 사용하지 말자. 그러나 뭐가 위치 기준인지는 알아야 하니 일단 예시는 다음과 같다.
List<Member> members = em
.createQuery("select m from Member m where m.username=?1", Member.class)
.setParameter("1", "Member1")
.getResultList();
"?1" 이 부분이 파라미터를 받는 부분, 주는 부분은 setParameter("1", "value") 이 부분이다. 보면 알겠지만 코드 길어지거나 안보다가 보면 "1"이런 값들이 가시성이 확 떨어진다. 또한 중간에 추가하게 되면 순서가 달라지기 때문에 다 밀리게 되고 아주 불편하다.
프로젝션
프로젝션이란,SELECT절에 조회할 대상을 지정하는 것을 말한다.
프로젝션 대상
엔티티
임베디드 타입
스칼라 타입 (숫자, 문자 등 기본 데이터 타입)
엔티티 프로젝션
// 엔티티 프로젝션
SELECT m FROM Member m
멤버 전체를 가져오는 쿼리이므로 엔티티 프로젝션을 말한다.
그리고 이렇게 엔티티 프로젝션을 통해 엔티티를 가져오면, 엔티티 매니저에 의해서 영속성 컨텍스트가 관리하는 대상이 된다. 즉, 1차 캐시에도 들어가고 변경감지도 수행된다는 의미다.
// 엔티티 프로젝션
SELECT m.team FROM Member m
멤버의 팀을 가져오는 쿼리로 팀이 엔티티이기 때문에 엔티티 프로젝션이라 한다.
근데 이 코드를 수행하면 어떤 쿼리가 나갈까? 멤버가 속한 팀을 가져오고 있는데 그럼? 맞다. 조인을 하게 된다.
List<Team> result = entityManager
.createQuery("SELECT m.team FROM Member m", Team.class)
.getResultList();
실행된 쿼리
Hibernate:
/* SELECT
m.team
FROM
Member m */ select
team1_.id as id1_3_,
team1_.name as name2_3_
from
Member member0_
inner join
Team team1_
on member0_.TEAM_ID=team1_.id
이렇듯, 조인을 한다. 물론 당연한 소리다. 당연히 조인을 해야지. 근데 JPQL만 보면 조인을 한다고 명시적으로 적어주지 않았기 때문에 JPQL과 JPA에 대해 잘 이해하고 있는 사람이야 알겠지만 그렇지 않은 사람은 모를수도 있다. 즉, 명시적으로 JPQL을 작성해 주는게 훨씬 더 좋은 방향이라고 본다. 아래처럼 말이다.
List<Team> result = entityManager
.createQuery("SELECT t FROM Member m JOIN m.team t", Team.class)
.getResultList();
임베디드 타입 프로젝션
// 임베디드 타입 프로젝션
SELECT m.address FROM Member m
멤버의 값 타입으로 임베디드한 Address를 가져오는 쿼리이므로 임베디드 타입 프로젝션이라 한다.
스칼라 타입 프로젝션
// 스칼라 타입 프로젝션
SELECT m.username, m.age FROM Member m
멤버가 가지고 있는 문자, 숫자 등 기본 데이터 타입을 가져오므로 스칼라 타입 프로젝션이라 한다.
// DISTINCT로 중복을 제거
SELECT DISTINCT m.username, m.age FROM Member m
참고로 중복제거도 역시 가능하다. 그래서 풀 코드로 작성하면 이렇게 쓰면 된다.
Query members = em.createQuery("select distinct m.id, m.username from Member m");
근데 여기서 한가지 의문이 발생한다. "이 경우에는 타입을 지정못하는데 가져올 땐 어떻게 가져와야 하지?"
방법은 여러가지가 있다.
첫번째는, 위에서 작성한 코드처럼 Query 타입으로 가져오는 방법이다.
Query query = em.createQuery("select m.username, m.id From Member m");
List<Object[]> result = query.getResultList();
Object[] data = result.get(0);
System.out.println("username: " + data[0]);
System.out.println("id: " + data[1]);
//Output:
username: Member1
id: 1
모든 데이터 타입의 상위 타입은 Object 이므로, Object[]로 받는다. []로 받는 이유는 m.username, m.id가 []에 0번, 1번으로 들어가기 때문이다. 그래서 0과 1을 찍어보면 각각 username과 id를 출력한다.
그러나 좀 불편한 부분이 있어보인다. 그래서 좀 더 좋은 방법은 DTO 객체로 받아오는 것이다.
두번째는, DTO 객체로 받아오는 방법이다.
SelectMemberDTO
package org.example.entity.jpql;
public class SelectMemberDTO {
private Long id;
private String username;
public SelectMemberDTO(Long id, String username) {
this.id = id;
this.username = username;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
이렇게 받아줄 DTO 클래스를 만들고 이 객체로 데이터를 받아오는 방법이다. 아래 코드를 보자.
List<SelectMemberDTO> result = em
.createQuery(
"select new org.example.entity.jpql.SelectMemberDTO(m.id, m.username) From Member m",
SelectMemberDTO.class)
.getResultList();
for (SelectMemberDTO selectMemberDTO : result) {
System.out.println("selectMemberDTO id= " + selectMemberDTO.getId());
System.out.println("selectMemberDTO username= " + selectMemberDTO.getUsername());
}
보면 new org.example.entity.jpql.SelectMemberDTO(m.id, m.username) 이라고 작성되어 있다. 여기가 문자열이기 때문에 패키지명까지 쭉 작성해줘야하는 번거로움이 있긴하지만 위에 Object로 받는 방법보다는 훨씬 가시적이다.
그리고 이후에 QueryDsl을 배우고 사용하면 저렇게 패키지도 쭉 안 적어도 된다. 이후에 같이 배워보자!
페이징
JPA에서 페이징하는 방법이 너무 간단하게 잘 되어있다. setFirstResult(), setMaxResults()를 사용하면 끝이다.
List<Member> resultList = em
.createQuery("select m from Member m order by m.id desc", Member.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
setFirstResult(0), setMaxResults(10)을 주면 0부터 10개까지를 가져온다. 이게 끝이다. 결과는 다음과 같다.
Hibernate:
select
member0_.MEMBER_ID as member_i1_0_,
member0_.TEAM_ID as team_id3_0_,
member0_.username as username2_0_
from
Member member0_
order by
member0_.MEMBER_ID desc limit ?
member = Member99
member = Member98
member = Member97
member = Member96
member = Member95
member = Member94
member = Member93
member = Member92
member = Member91
member = Member90
조인
조인의 경우 크게 세 가지가 있다.
내부 조인: [INNER] JOIN
외부 조인: LEFT [OUTER] JOIN
세타 조인
내부 조인 ([INNER] JOIN)
내부조인은 아래 JPQL을 보면 멤버를 기준(`FROM Member m`)으로 조인하고 있는데 이때, 팀의 값이 있는 데이터들만 뽑아낸다. 그러니까 팀의 값이 없는 멤버 레코드는 아예 결과로 나오지가 않는게 내부 조인이다.
SELECT m FROM Member m [INNER] JOIN m.team t
외부 조인 (LEFT [OUTER] JOIN)
외부조인은 아래 JPQL을 보면 멤버를 기준(`FROM Member m`)으로 조인하고 있는데 이때, 팀의 값이 없어도 결과로 레코드가 나온다. 대신 팀의 값이 null로 표현된다. 참고로, LEFT [OUTER] JOIN이다. OUTER는 생략가능하고 보통은 LEFT JOIN이라고 많이 말한다. OUTER JOIN 이런건 없다!
SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
세타 조인
아무런 연관성이 없는 데이터들도 뽑아낸다. 보면 `m.team t` 가 아니라, `FROM Member m, Team t`다. 즉, 어떠한 연관도 없는 두 엔티티끼리도 조인이 가능한데 이것을 세타 조인이라고 한다.
SELECT count(m) FROM Member m, Team t WHERE m.username = t.name
그래서 조인을 사용하는 코드를 한번 작성해보면 다음과 같다.
// 내부 조인
String query = "select m from Member m join m.team t where t.name =:teamName";
List<Member> resultList = em
.createQuery(query, Member.class)
.setParameter("teamName", "Team1")
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
// 외부 조인
String query = "select m from Member m left join m.team t";
List<Member> resultList = em
.createQuery(query, Member.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
// 세타 조인
String query = "SELECT m FROM Member m, Team t WHERE m.username = t.name";
List<Member> resultList = em
.createQuery(query, Member.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
조인 대상 필터링 - ON 절
조인 하기 전 필터링을 해서 데이터 양을 좀 줄일 수 있고 JPQL도 역시 유사한 키워드로 지원한다.
List<Member> result = entityManager
.createQuery("SELECT m FROM Member m JOIN m.team t ON t.name = :teamName", Member.class)
.setParameter("teamName", "teamA")
.getResultList();
저렇게 ON절을 사용해서 팀의 이름을 특정하여 필터링을 할 수가 있다.
실제 나가는 쿼리는 어떻게 나갈까? 바로 아래와 같이 나간다.
SELECT m.*, t.*
FROM Member m
INNER JOIN Team t ON m.TEAM_ID = t.id AND (t.name = 'teamA')
연관 관계가 없는 엔티티의 외부 조인도 필터링을 걸 수 있다.
List<Member> result = entityManager
.createQuery("SELECT m FROM Member m JOIN Team t ON t.name = m.name", Member.class)
.getResultList();
보면 m.team을 사용한게 아니라 JOIN Team t 이렇게 작성했다. 이렇게 연관관계가 없는 상태에서도 조인을 하는게 가능하고 이걸 세타 조인이라고 하며, ON절을 사용해서 필터링할 수 있다.
실제 나가는 쿼리는 어떻게 나갈까? 바로 아래와 같이 나간다.
SELECT m.*, t.*
FROM Member m
INNER JOIN Team t ON m.name = t.name
서브 쿼리
SQL문에서 사용하던 서브 쿼리랑 같은 것을 말하고 JPQL도 역시 이를 지원한다.
SELECT m FROM Member m WHERE m.age > (SELECT avg(m2.age) FROM Member m2)
이렇게 서브 쿼리를 사용할 때 위에서처럼 m과 m2로 서브 쿼리랑 본 쿼리의 멤버를 분리해야 일반적으로 SQL 성능이 좋다.
서브 쿼리 지원 함수로 다음과 같은 것들이 있다.
[NOT] EXISTS (subquery): 서브 쿼리에 결과가 존재하면 참
# 팀A 소속인 회원
SELECT m FROM Member m WHERE EXISTS (SELECT t FROM m.team t WHERE t.name = '팀A')
{ALL | ANY | SOME} (subquery)
ALL: 모두 만족하면 참
ANY, SOME: 같은 의미, 조건을 하나라도 만족하면 참
# 전체 상품 각각의 재고보다 주문량이 많은 주문들
SELECT o FROM Order o WHERE o.orderAmount > ALL (SELECT p.stockAmount from Product p)
# 어떤 팀이든 팀에 소속된 회원
SELECT m FROM Member m WHERE m.team = ANY (SELECT t FROM Team t)
[NOT] IN (subquery): 서브쿼리의 결과 중 하나라도 같은 것이 있으면 참
# 어떤 팀이든 팀에 속한 멤버들
SELECT m FROM Member m WHERE m.team IN (SELECT t FROM Team t)
JPA 서브 쿼리 한계
JPA는 WHERE, HAVING 절에서만 서브 쿼리 사용 가능
SELECT절도 가능 (하이버네이트에서 지원)
FROM 절의 서브 쿼리는 현재 JPQL에서 불가능 (조인으로 풀 수 있으면 풀어서 해결)
FROM절의 서브 쿼리도 이제 가능하다! Hibernate 6.1.0.Final 버전부터 가능하다! (아래 참조링크 확인)
String query = "SELECT m.username, 'HELLO', TRUE, 10L " +
"FROM Member m " +
"WHERE m.memberType = org.example.entity.jpql.MemberType.ADMIN";
근데 보통은 파라미터를 바인딩해서 사용할테니까 저렇게까지 불편할 정도는 아니다. 파라미터 바인딩을 하면 다음과 같이 조금 더 편리해지고 보기도 좋아진다.
String query = "SELECT m.username, 'HELLO', TRUE, 10L, m.memberType " +
"FROM Member m " +
"WHERE m.memberType = :memberType";
List<Object[]> resultList = em
.createQuery(query)
.setParameter("memberType", MemberType.ADMIN)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();
엔티티 타입 →TYPE(m) = Member (상속 관계에서 사용)
이렇게 ITEM, BOOK이 상속관계일 때 ITEM으로부터 가져온 레코드 중 DTYPE이 Book인 녀석들만 가져오는 쿼리도 할 수 있다는 것을 보여준다.
String query = "SELECT i " +
"FROM Item i " +
"WHERE TYPE(i) = Book";
List<Item> resultList = em
.createQuery(query, Item.class);
.getResultList();
JPQL 기타 → EXISTS, IN, AND, OR, NOT, =, >, >=, <, <=, BETWEEN, LIKE, IS NULL 모두 다 가능하다.
String query = "SELECT m " +
"FROM Member m " +
"WHERE m.age BETWEEN 0 AND 10";
List<Member> resultList = em
.createQuery(query, Member.class);
.getResultList();
조건식
CASE 식
//기본 CASE식
SELECT
case when m.age <= 10 then '학생요금'
when m.age >= 60 then '경로요금'
else '일반요금'
end
FROM Member m
//단순 CASE식
SELECT
case t.name
when '팀A' then '인센티브110%'
when '팀B' then '인센티브120%'
else '인센티브105%'
end
FROM Team t
이렇게 생긴 문장 많이 봤을거다. SQL하면서. JPQL에서도 동일하게 지원해준다.
String query = "SELECT " +
" case when m.age <= 10 then '학생요금'" +
" when m.age >= 60 then '경로요금'" +
" else '일반요금'" +
" end " +
"FROM Member m";
List<String> resultList = em.createQuery(query, String.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
for (String s : resultList) {
System.out.println("s = " + s);
}
COALESCE, NULLIF
COALESCE는 조회 결과가 NULL이 아니면 반환하는 조건식이다.
SELECT COALESCE(m.username, '이름 없는 회원') FROM Member m
그러니까 찾은 유저의 m.username이 NULL인 경우 '이름 없는 회원'으로 대체한다는 의미이다.
NULLIF는 두 값이 같으면 NULL반환, 다르면 첫번째 값 반환
SELECT NULLIF(m.username, '관리자') FROM Member m
찾은 유저 각각의 유저네임이 관리자인 유저는 NULL로 유저네임을 반환하고 그게 아닌경우, 유저네임 그대로를 반환한다는 의미이다.
JPQL 함수
JPQL이 제공하는 표준 함수
JPQL이 표준으로 제공하는 함수들 (데이터베이스에 종속적이지 않아서 어떤 데이터베이스이든 상관없이 사용 가능하다)
CONCAT
SUBSTRING
TRIM
LOWER, UPPER
LENGTH
LOCATE
ABS, SQRT, MOD
SIZE, INDEX(JPA 용도)
// CONCAT
String query = "SELECT CONCAT('A', 'B') FROM Member m";
// SUBSTRING
String query = "SELECT SUBSTRING(m.username, 2, 3) FROM Member m";
....
//SIZE (컬렉션의 크기를 돌려줌)
String query = "SELECT SIZE(t.members) FROM Team t";
기본으로 제공하는 함수가 있는 반면 DB에 종속적인 방언적 함수가 각 DB마다 또 있을 수 있는데 그런 함수가 JPQL이 지원을 즉각적으로 안하는 경우 사용자가 직접 함수를 등록해서 사용할 수 있다.
사용자 정의 함수
사용자 정의 함수란, JPQL이 공식적으로는 지원하지 않지만 해당 데이터베이스에서 지원하는 함수를 사용하고 싶을 때 그 함수를 등록해서 사용하는 방법을 말한다. 예를 들어, H2 데이터베이스는 `group_concat`이라는 함수가 있다. 이 함수는 조회한 레코드들로 사용자로부터 받은 특정 컬럼을 한 줄로 쭉 이어 붙이는 함수인데 이건 H2 데이터베이스에서 지원하는 함수다. 그리고 내가 만약 이 함수를 사용하고 싶다면?
아래와 같이 Dialect를 커스텀할 클래스하나를 만들고 H2 데이터베이스의 H2Dialect를 상속받는다. 그리고 내가 원하는 함수를 생성자 안에서 등록한다.
package cwchoiit.jpql;
import org.hibernate.dialect.H2Dialect;
import org.hibernate.dialect.function.StandardSQLFunction;
import org.hibernate.type.StandardBasicTypes;
public class MyH2Dialect extends H2Dialect {
public MyH2Dialect() {
registerFunction("group_concat", new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
}
}
그리고 JPA 설정 파일에서 Dialect를 이 파일로 변경만 해주면 끝. 아래는 XML로 설정했기 때문에 XML 파일 형식인거고 요즘은 거의 YAML, properties 파일에서 설정할 것이다.
package cwchoiit;
import cwchoiit.jpql.Member;
import cwchoiit.jpql.Team;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
try {
Member member = new Member();
member.setName("member");
entityManager.persist(member);
Member member2 = new Member();
member2.setName("member2");
entityManager.persist(member2);
entityManager.flush();
entityManager.clear();
List<String> resultList = entityManager
.createQuery("SELECT FUNCTION('group_concat', m.name) FROM Member m", String.class)
.getResultList();
for (String s : resultList) {
System.out.println("s = " + s);
}
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();
}
}
실행 결과
Hibernate:
/* SELECT
FUNCTION('group_concat',
m.name)
FROM
Member m */ select
group_concat(member0_.name) as col_0_0_
from
Member member0_
s = member,member2
경로 표현식
경로 표현식은 크게 세가지로 분류할 수 있다.
상태 필드: 단순히 값을 저장하기 위한 필드 (예: m.username)
단일 값 연관 필드: @ManyToOne, @OneToOne 대상이 엔티티 (예: m.team)
컬렉션 값 연관 필드: @OneToMany, @ManyToMany. 대상이 컬렉션 (예: m.orders)
상태 필드: 경로 탐색의 끝, 이후 탐색 불가능
// m.username이 상태 필드 경로 표현식
select m.username from Member m;
단일 값 연관 경로: 묵시적 내부 조인 발생, 이후 탐색 가능
// m.team이 단일 값 연관 경로 표현식
select m.team from Member m;
컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 이후 탐색 불가능
// t.members가 컬렉션 값 연관 경로 표현식
select t.members from Team t;
여기서 솔직히 어려운 내용은 하나도 없다. 객체로 생각해보면 당연히 상태 필드는 그 녀석이 더 담고 있는 데이터가 없기 때문에 이후 탐색이 불가능한거고 특정 엔티티가 타입인 경우 그 녀석이 가지고 있는 다른 데이터가 있기 때문에 그 이후에 탐색이 가능한것이다. 컬렉션은 컬렉션의 하나하나의 데이터를 뽑아오는 게 아니라 컬렉션 자체를 반환하기 때문에 그 이후에 탐색이 불가능한 것이다.
이게 중요해서가 아니라 묵시적 내부 조인이 발생한다는 것이 중요하다.
// m.team이 단일 값 연관 경로 표현식
select m.team from Member m;
위 코드에서 멤버의 팀을 가져오기 위해서는 DB입장에서는 조인을 할 수 밖에 없다. 그래서 내부조인이 일어난다. 하지만 우리가 직접 명시하지 않았기 때문에 묵시적으로 조인이 발생한다. 코드 수행 결과를 한번 보면 다음과 같다.
String query = "SELECT m.team FROM Member m";
List<Team> resultList = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
for (Team team1 : resultList) {
System.out.println("team1 = " + team1);
}
실행 결과
select
team1_.TEAM_ID as team_id1_1_,
team1_.name as name2_1_
from
Member member0_
inner join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID limit ?
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
team1 = org.example.entity.jpql.Team@468dda3e
보다시피 내부 조인이 일어난다. 당연하다. 근데 작성한 코드에서는 명시적으로 조인을 사용하지 않았기에 조인을 사용할 것이라고 예측하기는 쉽지는 않다. JPA를 잘 이해하고 잘 하는 사람이야 바로 보고 알수도 있겠다만.
또 컬렉션에서도 경로 탐색이 더이상 불가능한데 다음 코드를 보자.
// t.members가 컬렉션 값 연관 경로 표현식
select t.members from Team t;
이렇게 생긴 쿼리를 실제로 수행하면 당연히 묵시적 내부 조인이 발생한다. 팀에 해당하는 멤버를 가져와야 하기 때문에.
그리고 여기서는 더 이상 탐색이 불가하다. 근데 탐색하고 싶을 수 있다. 멤버 하나하나의 이름이나 뭐 다른 데이터를 알고싶을 수 있잖아.
그럴 때는 FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.
String query = "SELECT m.username FROM Team t join t.members m";
이렇게 컬렉션은 위 방법으로 이후 탐색을 진행할 수 있다. 그러나, 가장 중요한 내용은 다음과 같다.
묵시적 조인이 발생하는 경우를 모두 차단해라.
묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵고 조인은 SQL 튜닝에 중요 포인트다. 그렇기 때문에 묵시적 조인은 가급적(아예) 사용하지 말자.
엔티티에 컬렉션으로 관리하는 데이터는 흔히 있을 수 있는 일이지만 DB는 기본적으로는 컬렉션 데이터를 지원하지 않는다. 물론 요즘은 여러 방법으로 컬렉션을 테이블에서 관리할 수 있지만(JSON으로 데이터를 저장한다든지 등) 그러나 정석적인 방법은 컬렉션 데이터를 테이블로 만들어 참조로 관리하는것이다.
반응형
SMALL
아래 그림을 보자.
멤버라는 엔티티가 관리하는 데이터 favoriteFoods와 addressHistory는 컬렉션 값 타입이다. 이런 엔티티를 테이블화 하기 위해서는 각 컬렉션 값 타입을 테이블로 분리해서 1:N 관계로 만드는 것이다. 이게 정석적인 방법이다.
위 그림을 보면 ADDRESS도 FAVORITE_FOOD도 모든 필드가 하나의 PK인데 이 이유는 컬렉션 '값 타입'이기 때문이다. 값 타입은 하나의 레코드 자체가 고유값이 되어야 하는 것이다. 만약, 여기서 어떤 구분할 수 있는 PK가 따로 있으면 그건 값 타입이 아니라 엔티티라고 불려야한다. 구현하는 방법도 간단하다. 코드를 보자.
Address
package cwchoiit.embedded;
import javax.persistence.Embeddable;
import java.util.Objects;
@Embeddable
public class Address {
private final String city;
private final String street;
private final String zipCode;
public Address() {
this.city = null;
this.street = null;
this.zipCode = null;
}
public Address(String city, String street, String zipCode) {
this.city = city;
this.street = street;
this.zipCode = zipCode;
}
public String getCity() {
return city;
}
public String getStreet() {
return street;
}
public String getZipCode() {
return zipCode;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Address address = (Address) o;
return Objects.equals(city, address.city) && Objects.equals(street, address.street) && Objects.equals(zipCode, address.zipCode);
}
@Override
public int hashCode() {
return Objects.hash(city, street, zipCode);
}
}
EmbedMember
package cwchoiit.embedded;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
public class EmbedMember {
@Id
@GeneratedValue
private Long id;
private String username;
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name="city", column=@Column(name = "OFFICE_CITY")),
@AttributeOverride(name="street", column=@Column(name = "OFFICE_STREET")),
@AttributeOverride(name="zipCode", column=@Column(name = "OFFICE_ZIPCODE"))
})
private Address officeAddress;
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FAV_FOOD")
private Set<String> favoriteFoods = new HashSet<>();
@ElementCollection
@CollectionTable(name = "ADDRESS", joinColumns = @JoinColumn(name = "MEMBER_ID"))
private List<Address> addressHistory = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Period getWorkPeriod() {
return workPeriod;
}
public void setWorkPeriod(Period workPeriod) {
this.workPeriod = workPeriod;
}
public Address getHomeAddress() {
return homeAddress;
}
public void setHomeAddress(Address homeAddress) {
this.homeAddress = homeAddress;
}
public Address getOfficeAddress() {
return officeAddress;
}
public void setOfficeAddress(Address officeAddress) {
this.officeAddress = officeAddress;
}
public Set<String> getFavoriteFoods() {
return favoriteFoods;
}
public void setFavoriteFoods(Set<String> favoriteFoods) {
this.favoriteFoods = favoriteFoods;
}
public List<Address> getAddressHistory() {
return addressHistory;
}
public void setAddressHistory(List<Address> addressHistory) {
this.addressHistory = addressHistory;
}
}
멤버 엔티티가 관리하는 컬렉션 두 개가 있다. favoriteFoods, addressHistory.
이런 컬렉션 형태의 필드를 테이블화하기 위해 두 가지의 어노테이션이 필요하다.@ElementCollection, @CollectionTable. @CollectionTable 어노테이션에name property는 테이블명을 의미한다.joinColumns는 이 테이블이 어떤 테이블과 조인될지를 선정한다. 즉, 외래키를 받는 부분이고 멤버에 속한 favoriteFoods이고 addressHistory니까 MEMBER_ID라는 외래키를 적용한다. 뭐 M_ID, MID라고 해도 상관은 없다.
favoriteFoods같은 경우엔 컬렉션에 들어있는 값이 String으로 된 문자열 단일 값이기 때문에 컬럼명을 지정해주기 위해 @Column 어노테이션도 사용했다. Address{city, street, zipCode} 같은 경우는 세 개의 필드가 한 객체로 만들어져 있으니 이런게 불가능하다. 사용하는게 필수는 아닌데 이런 경우에 이렇게 컬럼명도 지정할 수 있단 사실! 이렇게 두 개의 어노테이션만 있으면 자동으로 컬렉션 값 타입은 테이블로써 만들어지게 된다.
코드를 보면 멤버 객체를 하나 생성하고 멤버 객체의 favoriteFoods와 addressHistory를 가져와 추가했다. 저 두 개의 엔티티는 멤버라는 부모 엔티티에 의해 생명주기가 관리된다. 왜냐? 값 타입이기 때문이다. 즉, 멤버 객체가 생성되고 소멸되는 주기가 곧 저 두 엔티티의 생명주기이고 멤버에 의해 관리 되기 때문에 영속 컨텍스트에는 멤버만을 추가해도 알아서 새로이 추가된 favoriteFoods와 addressHistory가 INSERT문으로 나간다.
결과를 보자.
Hibernate:
insert
into
Member
(city, street, zipcode, name, MEMBER_ID)
values
(?, ?, ?, ?, ?)
Hibernate:
insert
into
ADDRESS_HISTORY
(MEMBER_ID, city, street, zipcode)
values
(?, ?, ?, ?)
Hibernate:
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Hibernate:
insert
into
FAVORITE_FOOD
(MEMBER_ID, FOOD_NAME)
values
(?, ?)
Member, AddressHistory, FavoriteFood 모두 INSERT문이 실행됐음을 확인할 수 있다. 컬렉션 타입은 이렇게 테이블화해서 관계 매핑으로 다루어야 한다.
package cwchoiit;
import cwchoiit.embedded.Address;
import cwchoiit.embedded.EmbedMember;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
import java.util.Set;
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
try {
EmbedMember member = new EmbedMember();
member.setUsername("member1");
member.setHomeAddress(new Address("city", "street", "zipcode"));
member.getFavoriteFoods().add("치킨");
member.getFavoriteFoods().add("피자");
member.getFavoriteFoods().add("족발");
member.getAddressHistory().add(new Address("city1", "street1", "zipCode1"));
member.getAddressHistory().add(new Address("city2", "street2", "zipCode2"));
entityManager.persist(member);
entityManager.flush();
entityManager.clear();
System.out.println("================================================================");
EmbedMember findMember = entityManager.find(EmbedMember.class, member.getId());
List<Address> addressHistory = findMember.getAddressHistory();
for (Address address : addressHistory) {
System.out.println("address = " + address.getCity());
}
Set<String> favoriteFoods = findMember.getFavoriteFoods();
for (String favoriteFood : favoriteFoods) {
System.out.println("favoriteFood = " + favoriteFood);
}
// 컬렉션 값 타입의 내용을 수정하는 방법
favoriteFoods.remove("치킨");
favoriteFoods.add("소고기");
// 컬렉션 값 타입의 내용을 수정하는 방법 (remove 하는건 똑같다, 근데 여기는 레퍼런스를 받기 때문에 반드시 equals 를 구현해 놓은 상태여야 한다)
addressHistory.remove(new Address("city1", "street1", "zipCode1"));
addressHistory.add(new Address("city3", "street3", "zipCode3"));
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();
}
}
새 멤버를 만들 때 addressHistory, favoriteFoods를 추가한 후 멤버만을 persist()해도 flush()를 호출하면 다음과 같이 데이터베이스에 Member, addressHistory, favoriteFoods가 모두 추가된다. 그 이유는 값 타입은 어떤것이든 엔티티의 생명주기에 종속적이기 때문이다.
이 말은, 멤버의 favoriteFoods를 가져와 요소를 날리면? favoriteFoods의 해당 레코드는 삭제가 된다. 반대로 멤버의 favoriteFoods를 가져와 요소를 추가하면? favoriteFoods에 추가한 요소가 레코드로 저장된다. 그리고 favoriteFoods는 컬렉션에 들어가는 타입이 String이라서 remove() 호출 시에 동등 비교를 위해 따로 해줄 일이 없다. 기본으로 equals()가 잘 구현되어 있기 때문이다.
그러나, addressHistory 같은 경우, 컬렉션에 들어가는 타입이 Address라는 우리가 직접 만든 객체이므로, 리스트에서 이 요소를 제거하려면 equals()가 반드시 구현되어 있어야 한다. 그래야 remove() 호출 시 내부에서 equals()를 호출해서 동일한 요소를 찾아내 지울 수 있기 때문이다.
그래서 위에 Address 코드를 보면, equals() hashCode()를 구현한 모습을 볼 수 있다.
그럼 실행해보자. 어떤 쿼리가 나갈까?
아래 파란 박스는 favoriteFoods 관련 쿼리이고, 위에 빨간 박스는 addressHistory 관련 쿼리이다.
favoriteFoods는 보다시피, 원하는 값을 하나 삭제하고 원하는 값을 하나 추가했다. 아주 마음에 든다.
그러나 문제는 addressHistory이다. 얘를 보면 지금, 세개의 쿼리가 나갔는데 처음 쿼리가 전체를 삭제하는 쿼리다! 그리고 원래 있던 레코드를 하나 추가하고, 새로 추가하려고 했던 레코드를 추가한 것이다.
전체를 삭제해버린 후 기존에 남은 하나와 새로 추가한 하나를 나란히 INSERT한다. 이것이 값 타입 컬렉션을 테이블로 관리할 때 가장 큰 문제가 된다. 값 타입 컬렉션 테이블은 식별자가 따로 없기 때문에 JPA가 삭제할 때 어떤 데이터를 가져와 삭제해야 하는지 알지 못한다. 그렇기 때문에 전체를 삭제한 후 종속된 엔티티가 가지고 있는 모든 데이터를 다시 하나씩 추가한다.
근데 Set은 하나만 딱 잘 골라서 삭제하고 원하는거 추가하는데 List는 왜 안돼요? → Set과 List의 차이 그대로이기 때문이다. Set은? 중복값이란게 존재할 수가 없는 자료구조이다. 그렇기 때문에 내가 원하는 것을 삭제한다고 하면 JPA도 그 값을 그대로 찾아서 삭제하면 된다. List는? 중복값이란게 존재할 수 있는 자료구조다. 그 말은 내가 삭제하고자 하는 데이터가 한개뿐이 아니라 두개 세개가 있을 수도 있는데 이걸 JPA는 알지 못하기 때문에 다 날리고 새로 넣을 것들을 다시 넣게 되는 것이다.
🟠 결론은, 이 값 타입 컬렉션을 쓰면 안된다. 🟠
값 타입 컬렉션을 엔티티로 변경하라
값 타입 컬렉션의 가장 큰 문제는 식별자가 없기에 수정 쿼리를 수행할 때 JPA가 찾아내지 못한다는 것이다. 그리고 반대로 말하면 식별자가 있는 테이블은 엔티티라고 표현해야 한다. 그럼 위 코드의 문제점을 어떻게 수정하면 될까?
AddressEntity라는 엔티티를 만들고 기존의 값 타입 컬렉션을 엔티티로 승격시켜 식별자를 가지게 하는것이다.
AddressEntity
package cwchoiit.embedded;
import javax.persistence.*;
@Entity
public class AddressEntity {
@Id
@GeneratedValue
private Long id;
private Address address;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "MEMBER_ID")
private EmbedMember member;
public AddressEntity() {
}
public AddressEntity(Address address) {
this.address = address;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
public EmbedMember getMember() {
return member;
}
public void setMember(EmbedMember member) {
this.member = member;
}
}
package cwchoiit.embedded;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
public class EmbedMember {
@Id
@GeneratedValue
private Long id;
private String username;
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name="city", column=@Column(name = "OFFICE_CITY")),
@AttributeOverride(name="street", column=@Column(name = "OFFICE_STREET")),
@AttributeOverride(name="zipCode", column=@Column(name = "OFFICE_ZIPCODE"))
})
private Address officeAddress;
@ElementCollection
@CollectionTable(name = "FAVORITE_FOOD", joinColumns = @JoinColumn(name = "MEMBER_ID"))
@Column(name = "FAV_FOOD")
private Set<String> favoriteFoods = new HashSet<>();
@OneToMany(mappedBy = "member")
private List<AddressEntity> addressHistory = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Period getWorkPeriod() {
return workPeriod;
}
public void setWorkPeriod(Period workPeriod) {
this.workPeriod = workPeriod;
}
public Address getHomeAddress() {
return homeAddress;
}
public void setHomeAddress(Address homeAddress) {
this.homeAddress = homeAddress;
}
public Address getOfficeAddress() {
return officeAddress;
}
public void setOfficeAddress(Address officeAddress) {
this.officeAddress = officeAddress;
}
public Set<String> getFavoriteFoods() {
return favoriteFoods;
}
public void setFavoriteFoods(Set<String> favoriteFoods) {
this.favoriteFoods = favoriteFoods;
}
public List<AddressEntity> getAddressHistory() {
return addressHistory;
}
public void setAddressHistory(List<AddressEntity> addressHistory) {
this.addressHistory = addressHistory;
}
}
여기서는 다대일 양방향으로 매핑했다. 여기서 누군가는 "그냥 Member가 외래키 관리(일대다 방식)하고 CRUD 편리하게 리스트로 하면 안되나요? 일대다로 하면 안되나요?" 그렇게 해도 된다! 이 경우에는 AddressEntity가 멤버에 종속된 느낌이 강하게 들기 때문에 그렇게 만들어도 될 것 같다.
그러나, 그렇게 만들면 이제 알아두어야 할 점은 결국 데이터베이스 테이블 관점에서는 Member에 외래키가 있는게 아니고 AddressEntity에 외래키가 있다는 점은 인지해야 하고, 그렇기 때문에, Member의 AddressHistory를 가져와서 값을 추가하고 빼고 해도 나가는 쿼리는 AddressEntity의 업데이트 쿼리라는 것을 인지하고 있으면 된다.
임베디드 값 타입은 꽤나 사용성을 높여준다. 임베디드 값 타입은 무엇이냐면 특정 엔티티에 필요한 필드를 클래스타입으로 받는 경우이다.
아래 예를 보자.
좌측 테이블은 기본타입으로만 설정된 테이블이다. 물론 이게 잘못된 건 아니다. 근데 기본 타입이 아니고 클래스로 설계된 타입을 사용할 때 얻는 이점이 매우 많기 때문에 임베디드 값 타입을 사용하는 것을 고려해볼 수 있다. 그래서 위와 같이 만들면 아래 사진 처럼 된다.
객체 입장에서는, Period, Address 라는 클래스가 Member 엔티티에 들어있게 되지만, DB에서는 이전과 다를 것 없이 그냥 각 필드들이 컬럼으로 만들어진다. 그럼 어차피 DB 테이블에선 이전과 다를 것 없이 동일하게 필드로 표시되는데 임베디드 값 타입을 사용시 어떤 이점이 있을까?
재사용성
높은 응집도
응집도가 높다는 것은, 의미 있는 메서드를 만들어 사용할 수 있고 그에 따라 객체 지향형 설계가 가능해짐 예를 들어, Period.isWork()와 같은 메서드를 만들어서 해당 객체에서만 사용되는 메서드를 Period 클래스에서 구현 가능
임베디드 값 타입을 포함한 모든 값 타입은, 값 타입을 소유한 엔티티에 생명주기를 의존함
그럼 임베디드 값 타입을 사용하는 방법을 알아보자. 우선 임베디드 값 타입을 사용하는 방법은 다음과 같다.
임베디드 값 타입으로 만든 클래스는 내부에 임베디드 값 타입으로 만든 클래스도 당연히 가질 수 있다. 위 그림에서 Address가 Zipcode를 가지고 있는 모습을 볼 수 있다. 근데, 재밌는게, 임베디드 값 타입으로 만든 클래스가 엔티티도 가질 수 있다. 위 그림에서 PhoneNumber가 PhoneEntity를 가지고 있음을 볼 수 있다.
그러니까 아래 코드처럼 말이다.
package cwchoiit.embedded;
import cwchoiit.fetch.Team;
import javax.persistence.Embeddable;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import java.time.LocalDateTime;
@Embeddable
public class Period {
private LocalDateTime startDate;
private LocalDateTime endDate;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
...
}
이 Period라는 임베디드 값 타입 클래스는 아래와 같이 Team 이라는 엔티티도 가질 수 있다. (물론, 그렇게 자주 사용되는 방식은 아니다)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
@AttributeOverrides와@AttributeOverride
또 한가지는 같은 임베디드 값 타입의 클래스를 여러개 사용하고 싶을수가 있다. 예를 들면 아래 코드를 보자.
EmbedMember
package cwchoiit.embedded;
import javax.persistence.*;
@Entity
public class EmbedMember {
@Id
@GeneratedValue
private Long id;
private String username;
@Embedded
private Period workPeriod;
@Embedded
private Address homeAddress;
@Embedded
private Address officeAddress;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Period getWorkPeriod() {
return workPeriod;
}
public void setWorkPeriod(Period workPeriod) {
this.workPeriod = workPeriod;
}
public Address getHomeAddress() {
return homeAddress;
}
public void setHomeAddress(Address homeAddress) {
this.homeAddress = homeAddress;
}
public Address getOfficeAddress() {
return officeAddress;
}
public void setOfficeAddress(Address officeAddress) {
this.officeAddress = officeAddress;
}
}
지금 코드를 보면, 하나의 Address 객체를 만들고 두개의 EmbedMember의 HomeAddress에 적용시켰다. 이러면 두 회원은 같은 값 타입을 참조하고 있게 된다.
그리고, 이렇게 코드를 작성하면 컴파일러 단계에서 이를 방지할 수 있는 방법이 있나? 없다. 자바는 그냥 HomeAddress를 세팅하기 위한 세터에 적절한 타입의 Address를 받았다고 생각하기 때문에 이 시점에 컴파일러는 아무런 문제를 인지하지 못한다. 이게 핵심이다. 그리고 실제로 지금까지는 아무런 문제도 없다.
실행 결과
두 회원이 모두 같은 주소를 가지고 있다. 여기까지는 아무런 문제가 없다. 그런데 내가 여기서 둘 중 하나의 주소를 변경하는 시점부터 문제가 발생한다. 저 코드에서 딱 한줄을 추가해보자.
이런 부작용이 발생할 수가 있다. 그러니까, 애시당초에 부작용을 발생시키지 않으려면 값의 변경을 할 수 없게 막아야 한다. 어떻게? 불변객체로 만들어서! 그래서 불변객체로 만들어야 한다는 것이다. 그래서 이렇게 강제하는 것이다. "한번 세팅된 값을 바꾸고 싶어? 그러면 새로 객체 만들어!"
그래서 불변 객체로 만든 Address는 이렇게 만들 수 있다.
package cwchoiit.embedded;
import javax.persistence.Embeddable;
@Embeddable
public class Address {
private final String city;
private final String street;
private final String zipCode;
public Address() {
this.city = null;
this.street = null;
this.zipCode = null;
}
public Address(String city, String street, String zipCode) {
this.city = city;
this.street = street;
this.zipCode = zipCode;
}
public String getCity() {
return city;
}
public String getStreet() {
return street;
}
public String getZipCode() {
return zipCode;
}
}
아까 위에서 본 그대로이다. 세터를 제거하고 필드를 전부 `final`로 변경했다. 그리고 오로지 생성자로만 값을 세팅할 수 있게 했다.
여기서 좀 더 친절한 코드가 되고 싶으면 새로운 불변객체를 만들어내는 with(...), from(...) 등의 메서드를 만들어서 변경하고자 하는 값을 받고 그 값을 세팅한 새로운 객체를 뱉어내는 메서드들도 만들 수 있겠다.
일단 가장 먼저 알고 넘어갈 건 이 CASCADE는 연관관계나 지연로딩 즉시로딩과 전혀 아무런 상관이 없다.
그저 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티를 어떻게 할건지를 지정한다. 예를 들면 부모를 저장할 때 부모에 연관된 자식도 같이 영속하는 경우를 말한다.
말로는 잘 이해가 안되고 코드로 보면 바로 이해가 된다.
Parent
package org.example.entity.cascade;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Parent {
@Id @GeneratedValue
@Column(name = "PARENT_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "parent")
private List<Child> childList = new ArrayList<>();
public void addChild(Child child) {
this.getChildList().add(child);
child.setParent(this);
}
}
Child
package org.example.entity.cascade;
import javax.persistence.*;
@Entity
public class Child {
@Id @GeneratedValue
@Column(name = "CHILD_ID")
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "PARENT_ID")
private Parent parent;
}
이렇게 두 엔티티가 있다고 가정했을 때, Parent가 2개의 Child를 가지는 코드를 작성한다고 해보자.
package org.example;
import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.fetch.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Parent parent = new Parent();
parent.setName("parent");
Child child = new Child();
child.setName("childA");
Child childB = new Child();
childB.setName("childB");
parent.addChild(child);
parent.addChild(childB);
em.persist(parent);
em.persist(child);
em.persist(childB);
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
그럼 이렇게 코드가 작성될거다. 근데 사실 이게 좀 귀찮은 작업이다. parent에 child, childB가 속해지는데 parent, child, childB 모두 영속시키는 코드를 작성해줘야 하니까. child, childB가 어차피 parent에 속한다면 그냥 parent 하나를 영속시켜서 한번에 모두 다 영속시키면 더 편리하지 않을까? 그게 가능하다. 다음 코드를 보자.
Parent가 가지고 있는 childList 필드에 @OneToMany로 연관관계를 걸어놓은 상태에서 CascadeType.ALL 속성을 CASCADE에 부여하면 이 @OneToMany 애노테이션이 달린 필드의 타입인 Child들도 전이를 시키겠다는 것이다. 그러니까 CASCADE는 딱 이렇게 생각하면 좋다. → 이 cascade 옵션을 설정한 필드 타입은 (여기서는 List<Child>이니까 Child를 말함) 이 Parent를 persist()로 영속시키거나 remove()로 삭제할 때 이 Parent가 가지고 있는 Child들도 다 영속되거나 다 삭제된다고 생각하면 좋다.
이렇게 변경하고 실행 코드에서 child들을 persist() 메소드로 호출하는 라인을 지워보자.
package org.example;
import org.example.entity.Team;
import org.example.entity.cascade.Child;
import org.example.entity.cascade.Parent;
import org.example.entity.fetch.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Parent parent = new Parent();
parent.setName("parent");
Child child = new Child();
child.setName("childA");
Child childB = new Child();
childB.setName("childB");
parent.addChild(child);
parent.addChild(childB);
em.persist(parent);
//em.persist(child);
//em.persist(childB);
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
실행했을 때 INSERT문으로 parent, child, childB가 모두 들어간다. CASCADE는 이게 끝이다. 연관관계나 프록시와는 아무런 상관이 없다.
CASCADE 종류
CASCADE의 종류는 다음과 같다. 보통은 ALL, PERSIST, REMOVE 정도가 주로 사용된다. 부모를 영속시킬 때 자식들도 한번에 다 영속시키거나, 부모를 삭제하면 그 하위 자식들도 다 같이 삭제되는 그런 경우가 거의 일반적이니까.
ALL: 모두 적용
PERSIST: 영속
REMOVE: 삭제
MERGE: 병합
REFRESH: REFRESH
DETACH: 준영속
CASCADE는 자주 사용되는 편리한 옵션이지만, 주의할 사항이 있다. 위 예시처럼 Parent - Child 딱 하나로만 이루어진 연관 엔티티가 있는 경우는 CASCADE를 적용해도 무방하다. 근데 만약, Parent도 Child를 가지고 있고, 다른 어떤 엔티티(예를 들면.. 유치원 선생님 엔티티?)도 Child를 가지고 있다면 CASCADE 옵션을 사용하면 안된다. 위험하다. 예를 들어 A, B 엔티티가 있고 C라는 엔티티가 있을 때, A와 B 모두 C에 대한 부모가 되는 경우에 A에 CASCADE 옵션으로 REMOVE 또는 ALL을 적용해버리면 A를 삭제하면 C도 삭제가 될 것이다. 근데 B는 이를 모르고 있는 상태가 된다. 영문도 모른채 본인의 자식 엔티티 객체가 사라져버린다. 이런 이유때문에 조심해야한다. 다음 코드가 그 예시다.
Parent
package org.example.entity.cascade;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class Parent {
@Id @GeneratedValue
@Column(name = "PARENT_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<Child> childList = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<Child> getChildList() {
return childList;
}
public void setChildList(List<Child> childList) {
this.childList = childList;
}
public void addChild(Child child) {
this.getChildList().add(child);
child.setParent(this);
}
}
ParentTwo
package org.example.entity.cascade;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
public class ParentTwo {
@Id @GeneratedValue
@Column(name = "PARENTTWO_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "parentTwo", cascade = CascadeType.ALL)
private List<Child> childList = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<Child> getChildList() {
return childList;
}
public void setChildList(List<Child> childList) {
this.childList = childList;
}
public void addChild(Child child) {
this.childList.add(child);
child.setParentTwo(this);
}
}
Child
package org.example.entity.cascade;
import javax.persistence.*;
@Entity
public class Child {
@Id @GeneratedValue
@Column(name = "CHILD_ID")
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "PARENT_ID")
private Parent parent;
@ManyToOne
@JoinColumn(name = "PARENTTWO_ID")
private ParentTwo parentTwo;
public ParentTwo getParentTwo() {
return parentTwo;
}
public void setParentTwo(ParentTwo parentTwo) {
this.parentTwo = parentTwo;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Parent getParent() {
return parent;
}
public void setParent(Parent parent) {
this.parent = parent;
}
}
주의 깊게 볼 내용은 Parent, ParentTwo 클래스 모두 Child와 관계를 가지고 있고, Parent와 ParentTwo 둘 모두 CASCADE옵션을 ALL로 지정했다는 것이다. 여기서 만약, Parent 또는 ParentTwo 둘 모두에게 연결된 Child가 있는 Parent 또는 ParentTwo 레코드가 있고 그 레코드를 삭제하면 어떻게 될까? Child도 삭제가 된다. 왜냐하면 CASCADE옵션을 ALL로 주었기 때문에 PERSIST, REMOVE, DETACH등 모든 옵션이 적용된다. 그럼 다른 부모 입장에서 갑자기 자식이 사라진 셈이다. 망한거다.
엔티티 매니저로 부모 객체를 찾아서 부모 객체가 가지고 있는 자식을 remove() 했다. 그럼, 0번에 해당하는 Child 객체는 이제 더이상 부모인 Parent 객체의 리스트에서 관리되지 않는다. (여기서 혼동이 많은데, 지금 remove()를 호출한다고 한들, 당연히 DB에는 아무런 영향이 없다! 즉, 0번 Child의 PARENT_ID 외래키가 없어지는게 아니다! 그저 객체 관점에서 0번 Child가 더 이상 부모 참조가 없어진 것 뿐이다. 그러나! `orphanRemoval = true` 활성화를 하면 객체가 고아가 되면 DB에도 영향을 준다는 것이다. DB에서도 삭제가 되니 말이다.)
Hibernate:
insert
into
Parent
(name, PARENT_ID)
values
(?, ?)
Hibernate:
insert
into
Child
(name, PARENT_ID, PARENTTWO_ID, CHILD_ID)
values
(?, ?, ?, ?)
Hibernate:
select
parent0_.PARENT_ID as parent_i1_2_0_,
parent0_.name as name2_2_0_
from
Parent parent0_
where
parent0_.PARENT_ID=?
Hibernate:
select
childlist0_.PARENT_ID as parent_i3_0_0_,
childlist0_.CHILD_ID as child_id1_0_0_,
childlist0_.CHILD_ID as child_id1_0_1_,
childlist0_.name as name2_0_1_,
childlist0_.PARENT_ID as parent_i3_0_1_,
childlist0_.PARENTTWO_ID as parenttw4_0_1_,
parenttwo1_.PARENTTWO_ID as parenttw1_3_2_,
parenttwo1_.name as name2_3_2_
from
Child childlist0_
left outer join
ParentTwo parenttwo1_
on childlist0_.PARENTTWO_ID=parenttwo1_.PARENTTWO_ID
where
childlist0_.PARENT_ID=?
Hibernate:
delete
from
Child
where
CHILD_ID=?
위에서부터 차례로 부모와 자식이 INSERT된 후 부모를 조회하는데 부모를 조회한 후 자식을 호출하는 과정에서 자식을 찾아내는 조인쿼리가 실행된다. (왜냐? OneToMany는 기본이 지연로딩(LAZY)이고 아무것도 설정하지 않았기 때문에 초기화하는 시점에 자식을 찾아온다) 그리고 나서 실행된 DELETE문이 보인다. 이게 고아 객체를 제거하는 쿼리다.
그리고 이 경우에도 주의할 점은! CASCADE와 마찬가지 이유로, 반드시 참조하는 곳이 하나일 때 사용해야 한다! 즉, 특정 엔티티가 개인 소유하는 경우에만 사용해야 한다. (예: 게시글 - 첨부파일 관계)
참고로, 개념적으로 부모를 제거하면 자식은 고아가 된다. 따라서 `orphanRemoval = true` 활성화하면, 부모를 제거할 때 자식도 함께 제거된다. 이것은 CASCADE의 REMOVE처럼 동작한다.
영속성 전이 + 고아 객체
만약, CascadeType.ALL + orphanRemoval = true 이 두가지 옵션을 모두 설정을 하면, 부모 엔티티를 통해서 자식의 생명 주기를 관리할 수 있게 된다. 영속성 전이를 통해 영속을 시키든, 삭제를 시키든 그 하위 엔티티도 동일하게 동작할 것이고, 부모에서 자식을 떼버리면 고아 객체가 된 객체는 DB에서 삭제되기 때문에 완전히 부모를 통해 자식의 생명 주기를 관리할 수 있게 된다.
지연로딩과 즉시로딩에 대해 공부한 내용을 적고자 한다. 내가 JPA를 처음 이론적인 공부를 하지 않고 그냥 무작정 사용했을 때 이런 내용이 있는지도 사실 모르고 데이터를 받아올 때 무수히 많은 SQL문을 남발하곤 했는데, 그 남발하게된 SQL문의 원인 중 하나가 여기에 있다.
우선 지연로딩과 즉시로딩은 JPA가 데이터를 데이터베이스로부터 조회할 때 조회할 레코드에 참조 객체(테이블 관점에서는 외래키)가 있는 경우 해당 데이터까지 한꺼번에 다 가져올지 말지를 정하는 기준을 말한다.
다음 상황을 가정해보자.
팀 엔티티와 멤버 엔티티가 있고 팀과 멤버는 일대다 관계이다. 이 때 멤버를 조회할 때 팀도 한번에 조회해야 할까?
코드로 이를 직접 비교해보자.
지연로딩 (Lazy Fetch)
Member
package org.example.entity.fetch;
import org.example.entity.Team;
import javax.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Team getTeam() {
return team;
}
public void setTeam(Team team) {
this.team = team;
}
}
멤버는 팀을 참조하고, LAZY(지연 로딩)를 Fetch 전략으로 가지고 있다.
Team
package org.example.entity;
import org.example.entity.fetch.Member;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@SequenceGenerator(
name = "TEAM_SEQ_GENERATOR",
sequenceName = "TEAM_SEQ",
initialValue = 1,
allocationSize = 100
)
public class Team {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "TEAM_SEQ_GENERATOR")
@Column(name = "TEAM_ID")
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<Member> getMembers() {
return members;
}
public void setMembers(List<Member> members) {
this.members = members;
}
}
팀은 별다른 내용은 없고 멤버와 양방향으로 참조한다. 이런 두 엔티티가 있을 때 멤버를 조회해보자.
Main
package org.example;
import org.example.entity.Team;
import org.example.entity.fetch.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setTeam(team);
member.setUsername("memberA");
em.persist(member);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember: " + findMember.getUsername());
System.out.println("findMember Team Class: " + findMember.getTeam().getClass());
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
지연 로딩으로 설정하면 참조 객체에 대해 바로 엔티티로 찾아오는게 아니라 전 시간에 공부했던 프록시로 가져온다. 즉, 위 코드로 실행하면 멤버의 팀은 프록시로 가져와야 한다. 실행 결과는 다음과 같다.
찾은 멤버의 팀은 현재 Proxy로 가져온 상태임을 확인할 수 있다. 이게 지연로딩이고 실제 SQL문을 살펴봐도 팀과 조인한 SQL문이 실행되지 않았다. 이렇게 되면 만약 멤버를 조회했을 때 팀을 알 필요가 전혀 없는 상태라면 이런 SELECT문이 훨씬 이점을 가져갈 것이다.
이번에는 즉시로딩으로 실행해보자. 참고로 @ManyToOne, @OneToOne은 기본이 즉시로딩(EAGER)이다.
즉시로딩 (Eager Fetch)
위에서 작성한 코드에서 수정할 내용은 LAZY를 EAGER로 변경만 하면 된다.
package org.example.entity.fetch;
import org.example.entity.Team;
import javax.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue
@Column(name = "MEMBER_ID")
private Long id;
private String username;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "TEAM_ID")
private Team team;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Team getTeam() {
return team;
}
public void setTeam(Team team) {
this.team = team;
}
}
EAGER로 변경 후 실행해보자. SQL문을 확인해보면 다음과 같이 출력된다.
멤버를 조회할 때 바로 조인을 해서 TEAM 정보도 가져오는 SELECT문을 확인할 수 있다. 그리고 가져온 TEAM의 클래스를 찍어보면 이번엔 Proxy가 아니라 실제 엔티티를 가져왔다. 이게 즉시로딩이다.
결론
결론을 내리자면 즉시로딩은 가급적 사용하지 않기로 하자. 아무리 멤버를 조회할 때 팀도 바로 가져오는 상황이 있다고 한들 지연로딩으로 가져와서 팀 프록시를 초기화하는 방향으로 사용하는 걸 생각하자.
왜 그러냐면 다음과 같은 이유들이 있다.
즉시 로딩의 문제1 - 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생한다.
이건 위에서 봤듯 분명 아래 코드밖에 사용하지 않았다.
Member findMember = em.find(Member.class, member.getId());
물론, 여기서는 이 즉시로딩을 적용했고 이 코드를 사용하면 팀도 같이 가져온다는 걸 인지하고 있고 코드양이 적기 때문에 예상할 수 있는 SQL문이지만, 코드가 길어지고 메서드가 많아지면 많아질수록 실행되는 SQL문에서 왜 팀을 조인하는지 알아보기 힘들어질 가능성이 너무 크다. 거기다가, 지금이야 Team 하나만 즉시로딩으로 설정했으니 망정이지, 즉시로딩이 만약 3개, 4개 넘어가면 조인도 그만큼 많이 하게 된다.
즉시 로딩의 문제2 - 즉시 로딩은 JPQL 사용을 할 때 N+1 문제가 생긴다.
이게 진짜 큰 문제인데 JPQL은 종종 사용된다. 복잡한 쿼리를 작성할 일이 많기 때문에 그래서 예시를 위해 다음 JPQL 코드를 살펴보자.
package org.example;
import org.example.entity.Team;
import org.example.entity.fetch.Member;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
// ! Transaction 시작
tx.begin();
try {
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setTeam(team);
member.setUsername("memberA");
em.persist(member);
em.flush();
em.clear();
List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
위 코드부분에서 여기 부분이 JPQL을 사용하는 부분이다.
List<Member> result = em.createQuery("select m from Member m", Member.class).getResultList();
이 코드를 수행해보면 다음과 같은 SQL문이 출력된다.
Hibernate:
select
member0_.MEMBER_ID as member_i1_0_,
member0_.TEAM_ID as team_id3_0_,
member0_.username as username2_0_
from
Member member0_
Hibernate:
select
team0_.TEAM_ID as team_id1_1_0_,
team0_.name as name2_1_0_
from
Team team0_
where
team0_.TEAM_ID=?
SELECT를 두번한다. 왜 두번하냐 ? 우선 위에 작성한 JPQL은 멤버를 조회한다. 멤버를 조회했을 때 멤버가 1개면 1개의 레코드, 2개면 2개의 레코드가 출력되는데 이 때 즉시로딩은 멤버가 가지고 있는 팀도 같이 가져와야하기 때문에 각 멤버별 팀까지 가져와야 하는 SELECT문이 나간다. 그럼 이게 멤버가 10명이 있을때, 각 멤버가 서로 다 다른 팀에 속한 경우, 팀을 가져오는 SELECT문 10번이 같이 나간다는 소리다. (물론, 멤버 10명이 다 같은 팀에 속해 있다면, 당연히 팀을 가져오는 쿼리는 한번만 나간다. 두번째부터는 이미 가져온 팀이니까)
그래서 N+1은 멤버를 조회하는 쿼리 1개 + 멤버가 가지고 있는 팀을 조회하는 쿼리 N개가 나간다. 그럼 최악의 경우, 멤버가 10명이고 각 멤버가 속한 팀이 서로 다 다르다면 한번에 10 + 1 쿼리가 나가게된다.
심지어! 이건 멤버가 팀만 가지고 있는 경우를 말한거지 만약 다른 참조객체가 2개, 3개, 여러개가 있으면 20 + 1, 30 + 1, N + 1이 된다는 소리다. 끔찍한 경우다. 그래서 즉시로딩은 사용하면 안된다.
그럼 지연로딩으로 설정만 하면 이런 N + 1 문제가 아예 없어지나요!?
→ 안타깝게도 아니다. 그러나, 최소한 지연로딩으로 설정하면 손 쓸 수도 없게 문제를 바라만봐야 하는 상황은 막을 수 있다.
왜 근데 아닐까? 지연로딩으로 설정하고 아래와 같이 팀 2개, 유저 2개를 만들어서 DB에 넣고 영속성 컨텍스트를 깔끔하게 정리한 후 다시 조회해보자.
package cwchoiit;
import cwchoiit.fetch.Team;
import cwchoiit.fetch.Users;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import java.util.List;
public class JpaMain {
public static void main(String[] args) {
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hellojpa");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
try {
Team team = new Team();
team.setName("TEAM1");
Team team2 = new Team();
team2.setName("TEAM2");
Users users = new Users();
users.setUsername("choi");
users.setTeam(team);
Users users2 = new Users();
users2.setUsername("kim");
users2.setTeam(team2);
entityManager.persist(team);
entityManager.persist(team2);
entityManager.persist(users);
entityManager.persist(users2);
entityManager.flush();
entityManager.clear();
List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();
entityManager.getTransaction().commit();
} catch (Exception e) {
entityManager.getTransaction().rollback();
} finally {
entityManager.close();
}
entityManagerFactory.close();
}
}
그리고 아래 코드처럼 JPQL로 모든 유저들을 가져오는 쿼리를 날리고 그 결과를 보자.
List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();
실행 결과
Hibernate:
/* SELECT
u
FROM
Users u */ select
users0_.USER_ID as user_id1_12_,
users0_.TEAM_ID as team_id3_12_,
users0_.username as username2_12_
from
Users users0_
...
보다시피, 당연히 지연로딩이기 때문에 팀에 대한 정보는 아직 조회를 하지 않는다. 그러나, 만약 이 리스트를 순회해서 팀을 가져온다면?
그땐, 당연히 N + 1 문제가 결국엔 나타나게 된다.
...
List<Users> findUsers = entityManager.createQuery("SELECT u FROM Users u", Users.class).getResultList();
for (Users findUser : findUsers) {
System.out.println(findUser.getTeam().getName());
}
...
실행 결과
...
Hibernate:
/* SELECT
u
FROM
Users u */ select
users0_.USER_ID as user_id1_12_,
users0_.TEAM_ID as team_id3_12_,
users0_.username as username2_12_
from
Users users0_
Hibernate:
select
team0_.TEAM_ID as team_id1_11_0_,
team0_.name as name2_11_0_
from
Team team0_
where
team0_.TEAM_ID=?
TEAM1
Hibernate:
select
team0_.TEAM_ID as team_id1_11_0_,
team0_.name as name2_11_0_
from
Team team0_
where
team0_.TEAM_ID=?
TEAM2
...
실행 결과를 보면 역시 마찬가지로 각 멤버가 속한 팀에 대한 쿼리를 하나씩 날리고 있다. 즉, 지연로딩으로 해도 N + 1 문제는 여전히 발생할 수 있다는 소리다. 그러나 차이점은 명확하다. 즉시로딩은 JPQL 실행 시, 개발자가 손 쓸 수도 없게 바로 N + 1 문제가 발생하지만, 지연로딩은 JPQL 실행 시 바로 N + 1 문제는 발생하지 않는다. 그래서 만약, 순회를 하더라도 팀을 조회하는 코드가 없다면 N + 1 문제는 지연로딩만으로도 해결할 수 있다. (물론, 반쪽짜리 해결이고 사실 해결한 것이라 볼 수 없지만.)
JPA에서 중요한 개념 중 하나인 프록시라는 게 있다. 중요한 개념이라기보단 중요하게 사용되는 지연로딩과 즉시로딩을 사용하려면 반드시 깊은 이해가 필요하다고 개인적으로 생각한다. 왜 그러냐면 회사에서 프로젝트를 진행 중에 이해도가 깊지 않은 상태에서 지연로딩을 마구잡이로 썼다가 프록시 초기화 오류를 진짜 무진장 만났던 기억이 아직도 생생하다.
우선 서사는 이렇다.
멤버라는 테이블과 팀이라는 테이블이 있을 때를 가정해보자. 멤버는 팀에 속하고 아래 그림과 같다.
이런 구조를 가진 두 테이블이 있을 때 멤버를 조회한다고 가정해보자. 특정 멤버를 조회할 때 팀도 같이 조회해야 할까?
예를 들어 이런 메서드가 있다고 생각해보자.
private static void printMember(EntityManager em) {
Member member = em.find(Member.class, 1L);
System.out.println("member = " + member.getName());
}
private static void printMemberAndTeam(EntityManager em) {
Member member = em.find(Member.class, 1L);
System.out.println("member name = " + member.getName());
System.out.println("member's team = " + member.getTeam());
}
printMemberAndTeam(EntityManager em)은 멤버와 팀을 모두 출력하는 메서드이다.
printMember(EntityManager em)은 멤버만 출력하는 메서드이다.
printMemberAndTeam()을 호출한다면, 멤버를 조회할 때 팀까지 같이 조회가 되면 훨씬 효율적이겠지만, printMember()를 호출한다면, 멤버를 조회할 때 팀까지 조회되는게 비효율적일 것이다. (팀을 가져오기 위해 JOIN을 할테니)
이런 상황을 해결하기 위해 '프록시'가 있다.
Proxy
우선 프록시를 깊이 있게 알기 전에 엔티티 매니저는 두 가지 핵심 메서드가 있다. (핵심 메서드라고 말은 했지만, 사실 순수 JPA를 사용할 일은 거의 없고 Spring Boot + Spring Data JPA를 같이 사용하는 게 거의 99%이고, 이 경우에는 entityManager.find(), entityManager.getReference() 같은 메서드를 사용하지 않아서 이 메서드를 막 외우고 그럴 필요는 없다. 다만 이런것으로부터 프록시와 초기화에 대해 이해하는게 정말 중요하기 때문에 이 내용을 알아야 한다.)
em.find()
em.getReference()
우선 find() 메서드는 이미 여러번 사용했기 때문에 잘 알고 있다. 영속성 컨텍스트 또는 데이터베이스로부터 특정 객체(레코드)를 반환하는 메서드이다. 그럼 getReference()는 무엇일까? 데이터베이스로 조회를 지연시키는 가짜 엔티티(프록시) 객체를 반환한다.
이해하기 위해 코드를 실행해보자.
package org.example;
import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member = new Member();
member.setName("member1");
em.persist(member);
em.flush();
em.clear();
Member findMember = em.find(Member.class, member.getId());
tx.commit();
} catch (Exception e) {
System.out.println(e.getMessage());
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
실행 결과
Hibernate:
insert
into
Member
(createdAt, description, email, lastModifiedAt, name, roleType, TEAM_ID, id)
values
(?, ?, ?, ?, ?, ?, ?, ?)
Hibernate:
select
member0_.id as id1_3_0_,
member0_.createdAt as createda2_3_0_,
member0_.description as descript3_3_0_,
member0_.email as email4_3_0_,
member0_.lastModifiedAt as lastmodi5_3_0_,
member0_.name as name6_3_0_,
member0_.roleType as roletype7_3_0_,
member0_.TEAM_ID as team_id8_3_0_,
team1_.TEAM_ID as team_id1_5_1_,
team1_.name as name2_5_1_
from
Member member0_
left outer join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
where
member0_.id=?
실제 데이터베이스로부터 멤버를 조회하기 위한 SELECT문이 수행된다. 근데 getReference()를 사용해보면 어떻게 될까?
package org.example;
import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member = new Member();
member.setName("member1");
em.persist(member);
em.flush();
em.clear();
Member findMember = em.getReference(Member.class, member.getId());
tx.commit();
} catch (Exception e) {
System.out.println(e.getMessage());
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
실행 결과
Oct 22, 2023 7:06:06 PM org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProviderImpl$PoolState stop
INFO: HHH10001008: Cleaning up connection pool [jdbc:h2:tcp://localhost/~/h2/test]
Process finished with exit code 0
SELECT문이 출력되지 않는다. 즉 데이터베이스에 직접 조회를 하지 않았단 소리다. 이게 프록시다. 우선 객체를 찾아내는 조회 과정을 미루는것이다. 그럼 언제 실제로 조회할까? 실제로 사용할 때!조회를 그때가서야 한다. 그리고 이 조회를 하는 순간을 "프록시를 초기화 한다" 라고 말한다.
이렇게 getReference()를 호출하면, Proxy 객체를 반환하는데 이 상태에서는 DB로부터 조회하는 단계는 없다.
그럼, 이 프록시 객체를 초기화하는 순간을 보자.
package org.example;
import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member = new Member();
member.setName("member1");
em.persist(member);
em.flush();
em.clear();
Member findMember = em.getReference(Member.class, member.getId());
System.out.println("No SELECT QUERY Until this line");
// Execute SELECT Query
System.out.println("findMember = " + findMember.getName());
tx.commit();
} catch (Exception e) {
System.out.println(e.getMessage());
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
프록시 객체가 가지고 있는 메서드를 호출할 때 비로소 프록시는 실제 데이터를 DB로부터 조회해온다. 실행 결과는 다음과 같다. "No SELECT QUERY Until this line"이 출력된 후 SELECT문이 실행됨을 알 수 있다.
No SELECT QUERY Until this line
Hibernate:
select
member0_.id as id1_3_0_,
member0_.createdAt as createda2_3_0_,
member0_.description as descript3_3_0_,
member0_.email as email4_3_0_,
member0_.lastModifiedAt as lastmodi5_3_0_,
member0_.name as name6_3_0_,
member0_.roleType as roletype7_3_0_,
member0_.TEAM_ID as team_id8_3_0_,
team1_.TEAM_ID as team_id1_5_1_,
team1_.name as name2_5_1_
from
Member member0_
left outer join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
where
member0_.id=?
findMember = member1
그래서 이 과정을 그림으로 보면 다음과 같다.
프록시 객체는 실제 대상 객체인 target을 가지고 있다. 이 target은 초기화되기 전까지는 값이 없는 상태다.
Client가 getName()과 같은 객체를 통해 가져와야 하는 값을 호출하는 무언가를 호출했을때 프록시는 초기화 요청을 진행하낟.
그럼 DB로부터 실제 데이터를 조회한다.
조회한 데이터에 대한 엔티티를 만들고 그 엔티티를 프록시 객체가 가지고 있는 target에 집어넣는다.
target이 가진 getName()을 호출하고 그 값을 Client에게 반환한다.
그래서 프록시는 이러한 특징을 가지고 있다.
실제 클래스를 상속 받아서 만들어진다.
실제 클래스와 겉 모양이 같다.
사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용할 수 있다.
프록시 객체는 실제 객체의 참조(Target)를 보관
프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드를 호출
프록시 객체는 처음 사용할 때 한 번만 초기화
프록시 객체를 초기화할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다. 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능하다.
프록시 객체는 원본 엔티티를 상속받기 때문에 타입 체크 시 주의 요망 (`==`를 사용하면 안되고, `instance of`를 사용해야 한다)이게 무슨 말이냐면, 아래 코드를 보자.
Member member = new Member();
member.setName("member1");
em.persist(member);
Member member2 = new Member();
member2.setName("member2");
em.persist(member2);
em.flush();
em.clear();
Member m1 = em.find(Member.class, member.getId());
Member m2 = em.getReference(Member.class, member2.getId());
System.out.println("m1 = " + m1.getClass()); //m1 = class org.example.entity.Member
System.out.println("m2 = " + m2.getClass()); //m2 = class org.example.entity.Member$HibernateProxy$H8YFX6fR
System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass())); //false
m1은 find() 메서드를, m2는 getReference() 메서드를 사용했다. 그러면 가져오는 클래스는 Member와 MemberProxy가 된다. 이 때 이 두 객체 모두 타입을 Member로 받아왔지만 (프록시가 원본 엔티티를 상속받기 때문에 프록시 객체여도 Member 타입으로 받아올 수 있음) 두 객체의 클래스를 비교하면 false를 리턴한다는 뜻이다.
영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환이라고 되어 있으나, 좀 더 정확히는 같은 트랜잭션 내에서 JPA는 무결성과 일관성을 유지해주는 속성 때문에 getReference()가 아니라 find() 메서드를 사용하더라도 이미 영속성 컨텍스트에 프록시가 있으면 find() 메서드로 호출해도 프록시로 반환한다. 반대로 이미 영속성 컨텍스트에 찾은 동일한 멤버 객체가 실제로 있으면 getReference()를 호출해도 실제 엔티티를 반환한다는 의미다. 아래 코드로 예시를 살펴보자.
Member member = new Member();
member.setName("member1");
em.persist(member);
em.flush();
em.clear();
Member m1 = em.find(Member.class, member.getId());
Member m2 = em.getReference(Member.class, member.getId());
System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());
System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass()));
위 코드를 보면, 영속성 컨텍스트를 깔끔히 정리한 후에 find() 메서드를 먼저 호출하고 getReference() 메서드를 호출한다. 이 때 find()메서드를 통해 실제 엔티티를 가져와 영속성 컨텍스트에 넣었기 때문에 getReference()를 호출해서 가져와도 실제 엔티티를 가져온다. 그 결과는 다음과 같다.
Hibernate:
select
member0_.id as id1_3_0_,
member0_.createdAt as createda2_3_0_,
member0_.description as descript3_3_0_,
member0_.email as email4_3_0_,
member0_.lastModifiedAt as lastmodi5_3_0_,
member0_.name as name6_3_0_,
member0_.roleType as roletype7_3_0_,
member0_.TEAM_ID as team_id8_3_0_,
team1_.TEAM_ID as team_id1_5_1_,
team1_.name as name2_5_1_
from
Member member0_
left outer join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
where
member0_.id=?
m1 = class org.example.entity.Member
m2 = class org.example.entity.Member
m1 == m2: true
반대도 마찬가지다. 만약 아래와 같은 코드가 있을 때를 보자.
Member member = new Member();
member.setName("member1");
em.persist(member);
em.flush();
em.clear();
Member m2 = em.getReference(Member.class, member.getId());
Member m1 = em.find(Member.class, member.getId());
System.out.println("m1 = " + m1.getClass());
System.out.println("m2 = " + m2.getClass());
System.out.println("m1 == m2: " + (m1.getClass() == m2.getClass()));
이번엔 getReference()를 먼저 호출해서 멤버를 조회하면 이 때 m2는 프록시로 가져온다. 그럼 m1은 어떻게 가져올까? 그렇다. 프록시로 가져온다. 왜냐? JPA는 같은 트랜잭션 내 동일한 레코드에 대해서 동일성(`==`) 보장이라는 매커니즘이 있기 때문이다.
이 코드의 결과는 다음과 같다.
Hibernate:
select
member0_.id as id1_3_0_,
member0_.createdAt as createda2_3_0_,
member0_.description as descript3_3_0_,
member0_.email as email4_3_0_,
member0_.lastModifiedAt as lastmodi5_3_0_,
member0_.name as name6_3_0_,
member0_.roleType as roletype7_3_0_,
member0_.TEAM_ID as team_id8_3_0_,
team1_.TEAM_ID as team_id1_5_1_,
team1_.name as name2_5_1_
from
Member member0_
left outer join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
where
member0_.id=?
m1 = class org.example.entity.Member$HibernateProxy$cFfoNpx3
m2 = class org.example.entity.Member$HibernateProxy$cFfoNpx3
m1 == m2: true
find() 메서드로 인해 SELECT문으로 실제 데이터베이스로부터 조회해도 가져오는 값은 Proxy이다. 근데 이제 복잡하게 생각말고, 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 프록시가 아닌 엔티티를 가져온다. 라고 한 줄로 요약하는 것이다.
또, 아주 중요한 내용이 하나 있다. 바로 다음 한 줄이다.
영속성 컨텍스트의 도움을 받을 수 없는 준영속(Detach)상태 일 때, 프록시를 초기화하면 에러 발생
이게 가장 중요한 부분이다. 준영속 상태가 되는 상황은 크게 세가지 정도가 있다.
엔티티 매니저의 detach(해당 객체)
엔티티 매니저의 clear()
엔티티 매니저의 close()
어떤 경우가 됐든 준영속 상태가 되었다면 프록시를 초기화할 때 LazyInitializationException이 발생한다. 영속성 컨텍스트의 도움을 받을 수 없다라는 말은 영속성 컨텍스트는 결국 하나의 엔티티 매니저당 하나의 영속성 컨텍스트를 가진다. 그럼 엔티티 매니저가 끝나면 영속성 컨텍스트도 끝나는 것이다. 이게 이제 스프링 부트를 같이 사용하면 @Transactional 이라는 애노테이션을 사용해서 간편하게 엔티티 매니저를 만들고 영속성 컨텍스트를 만들어 주는데, 이 @Transactional이 적용된 메서드가 끝난 상태에서 초기화를 시도하면 영속성 컨텍스트는 없기 때문에 동일하게 LazyInitializationException 에러가 발생한다.
코드로 보면 좀 더 이해가 빠르게 되더라. 코드를 보자.
package org.example;
import org.example.entity.Member;
import org.example.entity.inheritance.Item;
import org.example.entity.inheritance.Movie;
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Member member = new Member();
member.setName("member1");
em.persist(member);
em.flush();
em.clear();
Member m2 = em.getReference(Member.class, member.getId());
em.detach(m2); //준영속 상태
System.out.println("m2 Name: " + m2.getName());
tx.commit();
} catch (Exception e) {
e.printStackTrace();
tx.rollback();
} finally {
em.close();
emf.close();
}
}
}
위 코드처럼 프록시를 가져온 후 가져온 detach() 하고나서, 프록시를 초기화하려고 시도하면 다음과 같은 에러를 마주할 수 있다.
org.hibernate.LazyInitializationException: could not initialize proxy [org.example.entity.Member#1] - no Session
at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:176)
at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:322)
at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:45)
at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:95)
at org.example.entity.Member$HibernateProxy$AFKAFeFy.getName(Unknown Source)
at org.example.Main.main(Main.java:37)
이 준영속 상태는 다시 말해 영속성 컨텍스트의 도움을 못받는 경우를 말하고, 영속성 컨텍스트는 하나의 트랜잭션이 곧 하나의 엔티티 매니저와 1:1로 매핑된다고 아주아주 이전에 말한 것 처럼 엔티티 매니저가 닫히거나(하나의 트랜잭션이 끝남), 비워지거나 등 어떤 식으로든 영속성 컨텍스트의 도움을 받지 못하는 경우 위 에러를 마주한다. 이런 상황을 이해해야 지연 로딩과 즉시 로딩에 대해 깊은 이해와 사용성을 높일 수 있다. 나도 직접 이런 경우를 실제 회사 프로젝트에서 많이 많이 겪다보니까 정말 힘들었는데.. 알고 나니 어려운 게 하나도 아니더라.
프록시 유틸 메소드
프록시 관련해서 쓸만한 메서드들을 소개하자면, 프록시가 초기화 된 상태인지 아닌지 확인 가능한 isLoaded()가 있다.