오늘은 이전에 공부를 했었던 JPA에 대해서 다시 복습할겸 공부를 할 것이다. 정리하여 공유까지 한다.
JPA는 Java Persistence API의 줄임말이다.
JPA는 자바 진영에서 ORM 기술 표준으로 사용하는 인터페이스의 모음이다.
ORM은 DB와 실제 객체간의 간격을 매핑해주는 Object Relational Mapping 기술이다.
약간의 설명을 더 붙이자면, DB의 CRUD 동작을 모두 메소드로 처리할 수 있게 한다.
JPA는 인터페이스의 모음으로, 구현체가 따로 없다. 일반적으로 사용되는 구현체는 Hibernate가 있다.
JPA를 무적권 써야하는 이유는 없다. 다만, 코드를 작성하는 것은 사람이다. 그래서 여러가지의 편한 점과 장점이 존재한다.
Query같은 경우는 컴파일 시, 오류가 있는 지 알기가 어렵다. 실제로 해당 쿼리가 동작하는 기능이 수행되어야 하기 때문에 배포 후 오류가 발견되면 안좋은 상황이 일어날 수 있다.
협업 상황 시, 다른 사람 코드를 이해하기가 더 쉽다.
전 세계 수많은 사람들이 이용하기 때문에, 최적화 및 Bug Report에 대한 보장이 확실하다.
무엇보다 개발자는 비즈니스 로직에 집중할 수 있다.
정형화 되어있는 쿼리 튜닝이 일어나기 때문에, 결국에 Query문을 직접 작성해야 하는 경우가 생길 수 있다.
서비스가 JPA에 의존하게 된다.
서비스의 DB 구조가 복잡할 경우 JPA의 이용에도 많이 복잡할 수 있다.
JPA 단점은 따지고보면 JPA를 사용하지 않았을 때에도 해당 단점들과 마주하게 된다.
JPA가 어떤식으로 동작하는지 부터 살펴보자.
다음 그림을 살펴보자.
글로 살펴보자!
요청이 들어온다.
EnitityManager Factory에서 EntityManager를 생성한다.
EntityManager는 요청에서 DB가 필요하다면, DB 커넥션 풀에서 커넥션을 1개 사용한다.
WAS는 EntityManager를 이용하여 DB 조회와 다른 필요한 작업을 수행하고 요청에 응답한다.
EntityManager가 DB와 소통을 하면서 "영속성 컨텍스트"라는 개념을 사용한다.
JPA를 이용해본 사람이라면 누구나 "영속성 컨텍스트"에 대해서 들어보았을 것이다. 단어를 하나씩 뜯어보자.
영속성?
컴퓨터 공학에서 영속성은 프로세스가 생성했지만 별개로 유지되는 상태의 특징 중 한 가지이며, 별도의 기억 장치에 데이터를 보존하는 것을 목적으로 한다. 이 특징으로 인해 프로그래머는 저장 장치로부터 데이터를 전송하는 작업 및 자료 구조 등을 이용해 데이터를 보존하는 것이 가능하다.
컨텍스트?
누가 무엇을 어떤 의도를 가지고 언제 행위를 하였는지에 대한 정보를 통칭한다.
한마디로, 런타임 시 생성되는 정보이다.
🌸 영속성 컨텍스트는 런타임 시 JPA와 함께 사용되는 개발자의 의도에 대한 별도의 기억장치에 보존되어 있는 정보이다.
영속성 컨텍스트는 생명 주기를 가지고 있다. 생명 주기는 비영속, 영속, 준영속, 삭제 상태로 4개가 존재한다.
다음 그림을 보자.
이렇게 보면 이해가 안된다. 코드도 함께 보자!
단순히 객체를 생성했을 때가 대표적이다.
User user = new User();
생성한 객체를 영속성 컨텍스트에 담아주었을 때, 영속 상태가 된다.
em.persist(user);
영속 상태였던 객체를
영속성 컨텍스트에서 꺼내주었을 때 or
영속성 컨텍스트를 비웠을 때 or
영속성 컨텍스트를 종료했을 때
준영속 상태가 된다.
em.detach(user);
em.clear();
em.close();
영속성 컨텍스트에서 "삭제"라는 행위를 했을 때 삭제 상태가 된다.
이렇게 하면 DB에서도 삭제가 된다.
em.remove(user);
영속성 컨텍스트에 대해서 알아보았다. 그럼 JPA가 어떻게 영속성 컨텍스트를 관리하는지 알아보자.
캐시는 빠른 접근을 위해서 메모리상에 임시로 어떠한 데이터값을 저장하는 것이다.
JPA에도 캐시가 존재한다. 데이터베이스에 접근하는 시간 비용을 줄이기 위하여 조회한 데이터를 메모리에 캐싱해둔다.
JPA 캐시는 1차 캐시 & 2차 캐시로 나뉜다.
2차 캐시는 동시성을 위하여 캐시를 직접 반환하지 않고 복사본을 반환한다.
복사본을 만드는 이유는 캐시를 한 객체에서 그대로 반환해버리면 여러 곳에서 같은 객체를 동시에 수정하는 동시성 문제가 발생할 수 있다.
1차 캐시와는 달리, 어플리케이션 전체적으로 사용되는 캐시이다.
좀 더 이해가 쉽게 말하자면,
1차 캐시에 캐싱을 해둔 상태에서 외부 다른 시스템에 의하여 DB에 해당 캐시값이 변경되었다고 생각해보자. 그럴 경우 1차 캐싱과 실제 DB의 동기화 문제가 발생한다. 그래서 2차 캐시에 따로 저장해두어 지속적으로 동기화 작업을 한다.
🤔 1차 캐시와 2차 캐시중 1차만 쓸 지, 1차와 2차를 모두 쓸 지 정할 수 있는 듯한 느낌이다.
사용자가 유저 정보를 조회한다는 상황을 가정하자.
조회 시 1차 캐시에 해당 유저 정보가 있는지 살펴본다.
1차 캐시에 데이터가 없으면 데이터베이스에서 해당 엔티티를 조회하여 1차 캐시에 저장한다.
1차 캐시에 저장된 엔티티를 반환받고 유저 엔티티를 이용할 수 있다.
캐시를 이용하게 되면,
쓰기지연은 단어만으로 예상이 간다. 다음 그림을 통해서 이해해보자.
User user1 = new User();
userRepository.save(user); //1
User user2 = new User();
userRepository.save(user); //2
User user3 = new User();
userRepository.save(user); //3
위 처럼 코드가 있다고 생각해보자. 이렇게 될 경우에는 Query문이 언제 날아가야 할까?
코드적으로 1자리, 2자리, 3자리에 각각 날아가야 한다.
하지만 실제로 사용해보면 쿼리문은 1, 2, 3자리 모두 날아가지 않는다. 이유는 무엇일까
이유는 쓰기 지연 SQL 저장소가 존재한다.
위와 같이 save 코드가 실행되면 JPA는 해당하는 SQL을 저장소에 저장해둔다. 그리고 flush가 실행될 때, 모든 SQL을 DB에 보내게 된다.
다음 코드를 보자.
User user1 = new User();
user.setUsername("김");
userRepository.save(user); //1
user.setUsername("이");
userRepository.save(user); //2
user.setUsername("박");
userRepository.save(user); //3
이 경우에는 SQL이 몇번 날아갈까?
답은 1개이다.
이유는 다음과 같다.
JPA는 영속성 컨텍스트에 엔티티를 보관할 시점에, 해당 엔티티의 최초상태를 복사하여 저장한다(스냅샷) 그리고 플러시 시점에 엔티티와 비교하여 변경사항을 수정하여 쿼리를 보낸다.
제일제일 중요한 부분이다. JPA를 이용하면서 가장 골머리 아프고 스트레스 받는 부분이다.
실제로 DB를 사용하다보면 1개의 테이블만 존재하는 경우는 극히극히 드물다.
특히나 RDB를 사용하게 되면, 테이블간의 관계(외래 키)가 존재한다. 그것을 JPA 객체입장에서 관리하기 위한 여러가지 작업이 필요하다.
두 가지의 영역으로 매핑을 나눈다.
1. 단방향 & 양방향
2. ? 대 ? 관계
Data Base : 사실 Data Base에서는 단방향과 양방향이라는 의미가 없다. 이유는 외래 키를 1개 설정해두면 A <-> B 테이블 접근이 가능하기 때문이다.
Java Object : java의 객체에서는 한 쪽으로만 접근이 가능하다. 다음 코드를 보자.
class A{
B b;
}
Class B{
A a;
}
위의 코드를 보면 순환이 생긴다. 그래서 보통 A객체에 B객체를 가지고 있다.
java 객체와 db 테이블 간의 괴리감을 없애기 위하여 생긴 개념이다.
위의 예시와 같이, A객체에서 B객체로만 접근이 가능하도록 하는 경우를 "단방향 매핑"이라고 한다.
여기서 골머리가 아프다. 객체에서 양쪽으로 접근을 하게 하고 싶을 때 문제가 생긴다. 이러한 내용을 JPA는 어떻게 해결을 했을까?
JPA는 해당 관계를 "mappedBy" 속성을 통해서 해결하였다.
A객체에 mappedBy가 정의되어있으면, 사실상 A 테이블에는 B객체에 대한 정보가 없다. 다만, 접근이 가능하도록 조회만 가능하게 설정해둔 것이다.
위의 "mappedBy" 속성 설명의 연장선이다.
"연관관계 주인"이라는 새로운 정의가 생겼다.
두 객체의 연관관계 중에서 1개를 정해서 테이블의 외래키를 관리해야 한다. 이 외래키를 관리하는 쪽이 연관관계의 주인이라고 한다.
mappedBy가 없는 쪽이 연관관계 주인이다.
다음 코드를 보면서 좀 더 상세히 살펴보자.
class Team{
@OneToMany(mappedBy = "team")
private List<User> users = new ArrayList<User>();
}
class User{
@ManyToOne
private Team team;
연관관계 주인만이 외래키를 관리할 수 있다.
주인이 아닌 쪽은 읽기만 할 수 있다.
보통 일대다 관계에서 "일"이 연관관계 주인이 된다.
(이유는, 비즈니스 적으로 크게 상관없다. 다만, 개발자들 사이에서 "일"의 입장에서 정보를 수정하는 것이 인식하기가 편하기 때문이다.)
위의 코드로 예시를 들면, Team에서는 user에 대한 정보를 수정할 수 없다.
(team 객체에서 user.remove(user)를 수행하면 user가 실제 DB객체에 적용되지 않는다.)
외래키를 매핑할 때, 어떠한 키를 외래키로 지정해줄 수 있다.
생략이 가능하며, 생략할 경우 @Id값이 자동으로 외래키로 지정된다.
매핑에도 종류가 4가지가 있다.
일대일 : 두 객체의 관계가 1:1의 관계, 지원 어노테이션(@OneToOne)
일대다 : 두 객체의 관계가 1:N의 관계(@OneToMany)
다대일 : 두 객체의 관계가 N:1의 관계(@ManyToOne)
다대다 : 두 객체의 관계가 N:N의 관계(@ManyToMany)
양방향을 기준으로 매핑해보자.
위의 세 가지는 대부분 비슷하다. 그래서 한번에 살펴보자.
Team <-> Users 관계
class Team{
@OneToMany(mappedBy = "team")
List<User> users = new ArrayList<>();
}
class User{
@ManyToOne(mappedBy = "user)
Team team;
}
Team : team 입장에서는 team 1개에 N개의 User가 등록된다.(@OneToMany)
User : user 입장에서는 user N개가 1개의 Team에 등록된다. (
@ManyToOne)
이 경우를 지원해주는 어노테이션은 "@ManyToMany"가 된다. 하지만, 다대다로 매핑할 경우 상당히 비즈니스 로직을 작성하는데 어려움을 겪는다. 그래서 편법을 사용한다.
이전의 예시로 요구사항을 추가해보자.
이 경우에는 다대다로 매핑이 될 것이다.
관계 테이블이란 비즈니스적으로는 필요없지만 기술적으로는 필요한 테이블을 말한다.
Team과 User사이에 TeamUser테이블을 1개 더 생성한다. 그래서
Team (N) <-> (1) TeamUser (1) <-> (N) User
와 같이 매핑을 한다.
지연로딩과 즉시로딩에 대해서 들어본 사람이 있을 것이다.
위의 팀과 유저간의 연관관계에서 생각해보자.
유저의 정보를 조회해보자.
유저의 정보를 조회할 경우에, 쿼리가 2번 날아가야 한다.
하지만, 유저의 팀에 대한 정보는 필요없고 유저의 순수 정보만 필요한 경우가 있다.
이 때에, JPA에 설정할 수 있다.
class User{
@ManyToOne(FetchType.Lazy)
Team team;
}
코드를 살펴보면 "FetchType.Lazy"가 추가된 것을 볼 수 있다.
이렇게 할 경우에는 실제로 쿼리문을 2개 날리지 않는다.
이렇게 하여 지연로딩이라고 한다.
그와 반대로 즉시로딩은 "FetchType.Eager"로 설정을 하며, 엔티티에 존재하는 모든 연관관계를 조회하여 객체로 만들어준다.
JPA 또한 성능 상 문제가 생길 수 있다.
어떤 문제가 있는지 살펴보자!
User user = userRepository.findById(id);
위의 코드대로 실행된다면 어떤일이 일어날까?
콘솔에는
이렇게 총 2개의 쿼리가 나가는 것을 볼 수 있다.
개발자는 1개의 쿼리를 날리고 싶지만 실제로는 2개의 쿼리가 날아간다.
너무나 비효율적이다.
지연 로딩또한 결국에 2개의 쿼리가 나가는 것이다.
그럼 쿼리를 2개날릴 필요없이 1개만 날려서 받아오는 방법은 없을까?
조회하면서 모든 조회를 같이한다.
이럴 경우, 필요없는 데이터도 조회하게되는 상당히 비효율적인 상황이 놓일 수 있다.
왠만하면 "Eager"는 피하자.
패치조인이라는 의미는 사실상 SQL에 존재하지 않는 말이다.
어떠한 엔티티를 조회할 때, 관계있는 다른 엔티티까지 조회를 할 때 사용하는 JPQL 제공되는 기능이다.
사용할 때는 2가지 상황으로 나뉜다.
User에서 Team을 조회할 때를 말한다.
@Query("select m from user m join fetch m.team")
List<User> findAllJPQLFetch();
이럴 경우 team에 대한 모든 정보를 즉시 가져오는 것을 볼 수 있다.
Team에서 User를 조회할 때를 말한다.
@Query("select t from Team t join fetch t.user")
List<Team> findAllJPQLFetch();
이럴 경우, DB입장에서도 Inner Join을 사용하기 때문에 "DISTINCT"를 이용하여 중복 데이터를 제거해주어야 한다.
@Query("select distinct t from Team t join fetch t.user")
List<Team> findAllJPQLFetch();
그냥 둘 다 DISTINCT를 붙이는 것도 괜찮을 지도..?
JPQL Fetch Join을 사용하면 하드코딩을 하는 느낌이 든다. 그래서 또 다른 방법으로 Fetch Join을 해보자.
@EntityGraph(attributePaths = {"team"}, type = EntityGraphType.Fetch)
List<User> findAllEntityGraph();
이렇게 하면 간편하게 Fetch Join을 이용할 수 있다.
Fetch Join을 이용하는 방법 또한 단점이 존재한다.
@EntityGraph(attributePaths = {"team"}, type = EntityGraphType.Fetch)
List<User> findAllEntityGraph(Pageable pageable);
Pageable 객체는 JPA에서 만들어둔 객체이다. 페이징 처리할 때, 인자값으로 넘겨주면 된다.
@BatchSize(100)
@OneToMany(mappedBy = "user" , fetch = FetchType.LAZY)
List<User> userList = new ArrayList<>();
이렇게 하면 사실상 내부적으로는
지연로딩을 Batch Size만큼만 가져오는 것이다.
Fetch 방법으로 ~ToMany를 가져올 경우에 가져올 컬렉션이 너무나 많아져서 다음과 같은 예외가 터진다.
MultipleBagException
이 때의 해결 방법은 2가지가 있다.
QueryDSL은 JPA를 이용하여 동적쿼리 및 서브쿼리를 자바코드로 작성할 수 있는 기술이다.
실제 서비스의 필터링과 같은 기능을 생각해보면, 들어오는 파라미터에 따라 다른 쿼리를 날려주게 된다.
implementation 'com.querydsl:querydsl-jpa'
@Configuration
@EnableJpaAuditing
public class querydslConfig {
@PersistenceContext
private EntityManager entityManager;
public querydslConfig() {
}
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(this.entityManager);
}
}
@Repository
public class UserRepositoryQueryDSL{
private final JPAQueryFactory queryFactory;
public UserRepositoryImpl(JPAQueryFactory queryFactory) {
this.queryFactory = queryFactory;
}
QUser user = QUser.user;
public List<User> getUserList() {
return queryFactory
.selectFrom(user)
.fetch();
}
}
위 코드를 살펴보면, 자바코드로 Query가 반환된 것을 볼 수 있다.