주말에 집에서 남는 시간에 작성하는 글...
이 글은, 필자가 오랫동안 사용하지 않아서 다 까먹었던 JPA 를 다시 공부하면서 느낀 점을 정리한 글이다. 노트에 정리한 내용을 대충 정리해서 빠르게 공유해본다. 글이 많이 지루하고, 중복 내용이 많으니.. 필요한 주제만 찾아서 읽기를 바란다.
스프링부트 & JPA 를 이제 막 공부하기 시작한 주니어 개발자가 읽으면 도움이 될 것 같다. Spring Data JPA 를 사용한다는 가정으로 작성하였다.
잘못된 내용이 있다면 꼭 제보해주길 바란다.
#1. JPA 란 무엇인가?
#2. 영속성 컨텍스트에 대한 이해
#3. 트랜잭션 커밋, JPA flush
#4. Spring Data JPA 는 나중에 공부하자.
#5. JPA는 SQL 쿼리를 개발자 대신 생성하고, 실행해준다.
#6. 연관관계 매핑
#7. 연관관계의 주인
#8. 스키마 자동생성 기능에 대해서
#9. JPA는 객체지향 프로그래밍이다. 하지만, 조심해야 한다.
#10. N+1 문제에 직면하다.
설명은 생략한다.
설명은 생략한다.
JPA에 대한 최소한의 이해가 없다면 이 글을 읽는 것이 어려울 것이다.
김영한 님의 인프런 기초편 강의를 수강하는걸 강력히 추천한다.
이 글은 기본적으로 호텔예약 시스템 을 샘플 사례로 소개한다. 호텔에는 다수의 객실이 있을 수 있고, 호텔:객실 은 일대다 관계이다.
호텔의 이름을 변경하는 코드를 작성해보자. 만약, 트랜잭션 설정 없이 엔티티를 영속성 컨텍스트에 가져온 후, 엔티티를 수정한 후, 메서드가 종료되면 데이터베이스에 반영이 될까?
트랜잭션 설정이 되어있지 않다면!!!
셀렉트 쿼리만 발생하며, 업데이트 쿼리는 수행하지 않는다. JPA flush 가 실행되지 않았기 때문에, 데이터베이스에 업데이트 되지 않는다. JPA 의 플러시를 실행시키는 방법은 여러가지가 있다. 엔티티매니저의 flush 메서드를 직접 실행해도 되고, 트랜잭션 커밋이 발생을 하는 경우에도 flush 가 자동으로 실행된다. 스프링 애플리케이션에서는 @Transactional 어노테이션 설정을 해주면, 메서드가 반환되는 시점에서 트랜잭션 커밋이 발생한다.
엔티티 객체를 수정한 후, 메서드가 종료될때 트랜잭션 커밋이 수행되며, JPA 플러시가 발동한다. 이때 데이터베이스로의 SQL 업데이트 쿼리가 실행된다!
정리하면..
@Transactional 어노테이션을 클래스 또는 메서드 상단에 선언해주면 된다.
위와 같은 방법을 JPA 더티 체킹이라고 한다.
엔티티를 업데이트 하는 또 다른 방법으로는, Spring Data JPA 에서 제공하는 save 메서드를 사용하는 방법이다.
save 메서드를 실행하면, Spring Data JPA 에서 제공하는 SimpleJpaRepository 의 save 메서드를 실행한다.
여기서 중요한 부분은 메서드 상단에, @Transactional 어노테이션이 선언되어있다는 사실이다. 클래스에 readOnly=true 속성으로 @Transactional 이 선언되어 있어서 기본적으로 모든 메서드에 읽기 전용 Transactional 이 적용되지만, 메서드에 @Transactional 어노테이션이 재정의가 되었기 때문에, 최종적으로는 readOnly 의 디폴트 설정이 false 이기 때문에, @Transactional(readOnly = false) 가 적용된다.
참고로 인터넷 강의, 블로그 등을 찾아보니 save 메서드를 사용하는 것보다는, 엔티티에서 필요한 속성만 직접 수정한 후 더티체킹에 의해서 업데이트 하는 방법이 더 좋다고 한다. 이 부분은, merge 에 대해서 별도로 공부를 하길 바란다.
(블로그 발행 후 내용 추가)
다시 읽어보니.. #3, #4 는... 중복 내용이 있다..
두가지 이유로 인해서, Spring Data JPA 는 나중에 공부하는 것이 좋겠다는 깨달음을 얻었다.
첫번째 이유 - 트랜잭션
#3에서 설명한 내용과 같지만... JPA 의 플러시가 발생하면, JPA 는 데이터베이스에 SQL 쿼리를 실행하는데, 플러시가 발생하는 조건은 여러가지가 있다. 대표적으로는, 트랜잭션이 커밋되면 플러시가 발생한다. 물론, 엔티티매니저에서 직접 flush 를 실행할 수 도 있다. 다들, 잘 알겠지만 Spring Data JPA 는 공통 메서드를 제공하는데 findById, findAll, save 와 같은 메서드이다. 아래와 같이 JpaRepository 인터페이스를 상속해주기만 하면 된다.
물론, 실제 save 메서드에 대한 동작은 JpaRepository 가 해주는건 아니고,
JpaRepository 의 구현체인 SimpleJpaRepository 클래스가 역할을 한다.
해당 클래스에 findById, findAll, save 와 같은 메서드가 정의되어 있다. 이때 중요한 사실은 클래스 상단에 @Transactional(readOnly = true) 가 선언되어 있고, save 메서드 상단에는 @Transactional 이 선언되어 있다.
무슨 의미냐면, hotelRepository 를 사용하고 싶은 곳에서, 의존성 주입을 한 후, save(엔티티) 를 실행한다면, save 메서드가 트랜잭션에 의해서 동작한다는 의미다. 즉, save 메서드를 사용하는 것만으로도 트랜잭션 커밋이 발생해서, JPA 플러시가 실행될 것이다. 즉, save 메서드가 실행되면 SQL 쿼리가 실행이 될 것이다.
주저리주저리... 무슨 얘기를 하고 싶냐면..
Spring Data JPA 의 save 를 개발자가 아무생각 없이 사용하면 안되고,
요 메서드가 어떻게 동작하는지, 어떤 원리로 동작하는지 이해하는것이 중요하다는 얘기를 하고 싶은 것이다.
JPA 의 영속성 컨텍스트, 트랜잭션, 플러시 등을 먼저 이해를 해야한다.
물론, Spring Data JPA 의 save 메서드를 사용하는 것보다는, 더티 체킹에 의해서 변경된 속성만 업데이트 하는게 더 좋을 수 있다. 아래와 같이, 메서드가 종료되는 시점에서 트랜잭션 커밋이 발생할 것이며, JPA 플러시가 실행이 되어서, 업데이트 쿼리가 실행이 될 것이다.
암튼 save 메서드를 사용하던, 직접 Transaction 을 사용하던...
중요한 사실은 트랜잭션에 의해서 동작하는 것을 이해하는 것이 중요하다. Spring Data JPA 를 먼저 시작하면, 이런 내용을 모를 수 있다.
두번 째 이유, findById, findAll 동작 방식의 차이
우리가 Spring Data JPA 를 사용하기 전에는, 엔티티 매니저를 직접 주입받아서 사용했었다. 하지만, Spring Data JPA 는 그럴 필요가 없다. 왜? SPring Data JPA 가 엔티티매니저를 주입해서 사용하기 때문이다. SimpleJpaRepository 클래스의 findById 메서드를 확인해보자.
엔티티매니저를 주입받으며, 엔티티 매니저의 find 메서드를 사용하다. Spring Data JPA 말고, 그냥 JPA 로 사용하던 방식이랑 같다. 그럼, findAll 은 어떻게 동작할까?
생략...
마찬가지로 엔티티매니저를 사용한다. 그리고, createQuery 메서드를 사용하는 것을 확인할 수 있다. findAll 은 JPQL 을 사용하는구나... 만약, 쿼리 메서드(예-findByName)와 같은 메서드를 개발자가 정의했다면, 이런 메서드들도 내부적으로 JPQL 을 사용할 것이다.
참고로, findById 는 연관관계 fetch 타입을 즉시로딩으로 설정한 경우에는 join 쿼리를 실행한다. 하지만, findAll 같은 메서드는 fetch타입을 즉시로딩으로 설정해도, JPQL 을 사용하기 때문에... join 쿼리가 아닌, N+1 쿼리가 실행될 것이다.
필자가 하고 싶은 얘기는... Spring Data JPA 를 사용하기 전에, 반드시 엔티티 매니저에 의해 사용되는 find, createQuery 등에 대한 이해가 선행되어야 한다는 사실이다..
이런 이유로 인해서, 가능하면 JPA 기본 개념을 이해하고 Spring Data JPA 를 시작하길 바란다.
내가 지금 무슨 말을 하고 싶어서, 글을 쓰고 있는건지 모르겠다..ㅠㅠ
JPA 는 SQL 쿼리를 개발자 대신에 생성하고, 실행 역시 대신해준다. 엄청 편하다. 하지만, 반대로 우리는, JPA 가 어떤 쿼리를 생성해주는지 알고 있어야 한다. 우리가 예상하지 못했던 쿼리가 실행될 수도 있다. JPA 라는 기술을 잘 이해해야할 것이다.
아무튼... 개발 단계에서는 아래와 같이 프로퍼티 설정을 show-sql:true 로 설정한 후, SQL 쿼리 로그를 확인해야 한다.
우리가, 예상하지 않은 너무 많은 쿼리가 실행되고 있다면, JPA 사용을 잠시 멈추고, 기본 개념부터 다시 공부해야할 것이다. 또한, 실서비스에서는 슬로우 쿼리가 발생하고 있는지, 모니터링을 해야한다.
연관관계 매핑은...
- 다대일, 일대다, 일대일, 다대다
- 단방향, 양방향
(인프런 강의와 각종 세미나에서 말하기를...) 기본적으로는 다대일 단방향 매핑으로 거의 모든 연관관계 설정이 가능하다고 한다. 불필요하게 양방향 매핑을 미리 설정할 필요가 전혀 없단다. 일대다 단방향은 사용하지 않아야 한다. 다대다 역시 사용하지 말라는데.. 사실 필자가 제대로 이해는 못했다.
다대일 단방향 : 사용
다대일 양방향 : 다대일 단방향으로 먼저 매핑한 이후, 반대방향으로 참조가 필요한 경우 사용
일대다 단방향 : 사용하지 말자.
일대다 양방향 : 사용하지 말자.
일대일 단방향 : 필요하면 사용.
일대일 양방향 : 필요하면 사용.
다대다 단방향 : 사용하지 말자는데.. 필자가 다시 공부 중
다대다 양방향 : 사용하지 말자는데.. 필자가 다시 공부 중
참고로, 일대다 단방향의 경우에 @JoinColumn 을 사용한 경우, 불필요한 Update SQL 을 시도하므로, 필요하다면 다대일 양방향 매핑을 사용하는게 좋겠다. 관련 내용은 하단 링크의 초반을 참고하길 바란다. 김영한님의 강의에도 비슷한 내용이 있다.
https://www.youtube.com/watch?v=rYj8PLIE6-k
이거 모르면 JPA 사용하면 안된다. 필자는 아직도 헷깔려서 다시 공부 중이다..
호텔 - 객실 에 대한 양방향 연관관계 매핑을 해보자. 객실 기준으로 다대일, 호텔 기준으로 일대다 이다.
연관관계의 주인은 Room 의 hotel 이 연관관계의 주인이다. 양방향 매핑 시 연관관계의 주인은 다 쪽에 둔다. 그리고, 외래키가 있는 곳이다. 아래와 같이 Room 클래스에 @ManyToOne 어노테이션, @JoinColumn 을 선언하였다. 연관관계의 주인이다.
반대편은, 연관관계의 주인이 아니며.. mappedBy 를 설정해줘야 한다.
그리고, 이쪽 방향은 읽기만 가능하다..
상세한 설명은, 백기선님의 영상을 찾아보길 바란다.
https://www.youtube.com/watch?v=brE0tYOV9jQ
JPA 는 스키마 자동 생성 기능을 제공한다. 데이터베이스에 스키마를 미리 생성하지 않아도, 애플리케이션에 의해서 자동으로 스키마가 생성되는 기능이다. 하지만, 해당 기능은 실제 상용 시스템에서는 절대 사용하면 안된다. 스키마 자동생성 설정으로 인해서 데이터베이스 테이블, 데이터 전부 삭제될 수 있다. 스프링부트 프로퍼티 설정에서 아래와 같이 설정하면... 어떻게 될까?
create 속성은 테이블이 존재한다면, 기존 테이블을 drop 하고 신규 테이블을 생성할 것이다.
ㄷㄷㄷ
아래와 같이 엔티티에 유니크 인덱스 를 생성하도록 설정하였다.
애플리케이션을 실행하면, JPA 의 스키마 자동생성 기능에 의해서 아래와 같이 유니크 인덱스를 생성하는 쿼리가 실행될 것이다.
당연히, 디비에도 유니크 인덱스는 잘 생성되었다.
혼자 개발하는 환경에서는 스키마 자동생성 기능을 사용해도 된다. 하지만, 실제 애플리케이션은 절대로 데이터베이스 스키마에 영향을 주면 안된다. 그래서 실서비스 설정은 아래와 같이 none 으로 설정해야 한다.
이 경우에는, 그 어떤 DDL 쿼리도 실행되지 않는다.
그렇다면, 상용 데이터베이스의 인덱스 설정은 어떻게하면 될까?
그냥... 애플리케이션과 별개로 따로 설정해주면 된다.
회사마다 데이터베이스 관리자가 있을 것이다. 데이터베이스 관리자에게 유니크 인덱스를 생성해도 되는지 문의한 후, 생성 요청을 하면 된다. 반대로 DBA 가 먼저 제안할 수도 있다. 개발자가 설계한 ERD 를 검수하면서, 데이터 정합성 및 성능 문제를 고려해서 유니크 인덱스를 설정할 것을 DBA 가 먼저 제안할수도 있다. 어쨋든, 애플리케이션 소스 코드에 스키마 자동 설정들은, 상용 데이터베이스에 전혀 영향을 주지 않도록 해야한다.
또 다른 예를 설명해보겠다. 아래와 같이 설정하면 어떻게 될까?
자동 스키마 생성 기능을 사용한다면, 애플리케이션 실행 시 DDL 쿼리가 아래와 같이 실행된다.
즉, 데이터베이스 에 자동으로 생성된 컬럼은 not null 설정으로 셋팅된다.
하지만... 상용 환경에서는, 그 어떤 DDL 쿼리도 실행되지 않도록 해야하기 때문에, 데이터베이스의 특정 컬럼이 not null 인지, null가능 인지 애플리케이션에서는 디비에 전혀 영향을 주지 않도록 해야한다.
만약, 실서비스 환경에 자동스키마 생성 기능을 동작하도록 하게 되면 어떻게 될까? 상상하고 싶지 않은 상황이 발생할 것이다. 아래 동영상을 보길 바란다.
https://www.youtube.com/watch?v=SWZcrdmmLEU
객체지향 프로그래밍에 대해서 얘기해보자. 아래와 같이 엔티티에 changeName 이라는 메서드를 선언해준다.
호텔의 이름을 변경하고 싶다면, 아래와 같이 코딩하면 된다.
위 코드에 의해서 두개의 SQL 쿼리가 자동으로 실행된다. 개발자는 그 어떤 SQL 쿼리도 작성할 필요가 없다.
개발자는, Hotel 이라는 엔티티를 객체라고 생각하고 개발하게 된다. Update 쿼리를 실행하지 않아도 되며, 단지, 속성을 변경하기 위해서 changeName 메서드를 호출하면 된다. 객체지향 스럽다.
Hotel 객체에서는 객실 객체 리스트를 참조하는데, 이때, EAGER 로 설정해주면 즉시로딩이 될 것이다.
Repository 의 findById 를 실행하는 순간, Hotel 엔티티와 함께 객식 정보를 함께 가져오는데.. 이때, Fetch 타입을 EAGER 로 설정하였기 때문에, 조인쿼리를 실행할 것이다.
실행하는 순간 아래와 같이 쿼리가 실행된다.
EAGER 는 사실 실무에서 거의 사용하지 않지만, 한번의 SQL Join 쿼리로 호텔과 객실 데이터까지 전부 가져왔다. 그래서 아래와 같이 룸 리스트를 알고 싶다면 getRoomList 를 호출하면 되며, 추가적인 SQL 쿼리가 실행되지 않는다.
하지만, 매번 객식 정보가 필요하지 않을 것이다. 항상 Join 쿼리를 실행하는게 과연 바람직할까? 일단, 즉시로딩은 우리가 예상하지 못한 쿼리를 실행시킬 수 있기 때문에 기본적으로는, 무조건 지연로딩을 사용하는 방향이 좋다는 의견이다. (각종 강의에서..)
즉시 로딩을 사용하지 않는게 좋다면, 즉, 지연 로딩으로 설정하면
두번의 쿼리가 실행될 것이다.
즉시로딩, 지연로딩 차이가 있지만.. 둘다 좋은 방법은 아닐 것이다. 소스 코드 자체는 getRoomList 라는 객체 지향스러운 호출이지만, 실제로 SQL 쿼리는 최적화가 되지 않았을 수도 있다.
대안으로는, 지연 로딩으로 설정 후, 페치 조인을 사용하거나, @Query 또는 @EntityGraph 등을 사용하는게 좋을 것이다.
필자가 무슨 말을 하고 싶은것인지 헷깔리는데,
#9에서 하고 싶은 얘기는, 객체지향 코드이지만, JPA 가 실해하는 SQL 쿼리를 반드시 이해해야 한다는 의미이다.
N+1 문제는 즉시로딩, 지연로딩 어느 상황에서도 발생할 수있다.
객실-호텔 의 연관관계를 다대일 양방향 매핑을 설정해보자.
# OneToMany 즉시로딩
OneToMany 에서의 FetchType 은 디폴트 값이 LAZY 이다. 즉시로딩으로 변경해보자. Hotel 엔티티에서 Room 객체 매핑 시 Fetch 타입을 EAGER 로 변경해보자.
Spring Data JPA 에서 제공하는 findById 를 사용하는 경우에는, Join 쿼리를 사용한다.
하지만, findById 가 아닌, findAll, findByName 같은 메서드는 조인 쿼리를 사용하지 않는다. 도시 이름으로 호텔을 검색하는 findByCity 라는 이름의 쿼리 메서드를 만들어보자.
Spring Data JPA 에서 제공해주는 쿼리 메서드는 내부적으로 하이버네이트에서 제공해주는 CriteriaQuery 로 쿼리가 실행 될 것이다.
그리고, hotelRepository 에서 findCity 메서드를 사용해서 Hotel 리스트를 조회한다. 단, Room 에 대한 매핑 조건이 즉시로딩이기 때문에 이 경우에는 hotelRepository.findCity 실행 시 Room 테이블을 조회하게 된다.
"인천" 에 위치한 호텔이 2개라고 가정한다.
JPA 는 아래와 같이 쿼리를 실행한다.
1. SELECT ... FROM HOTEL WHERE CITY = "인천"
2. SELECT ... FROM ROOM WHERE HOTEL_ID = "1"
3. SELECT ... FROM ROOM WHERE HOTEL_ID = "2"
인천에 위치한 호텔을 먼저 조회하기 위해서 1개의 쿼리를 실행하며, 인천 호텔의 개수만큼 각각 쿼리를 실행한다.
즉, 쿼리를 1 + N 번 실행한다.
# OneTOMany 지연로딩
Hotel 엔티티에서 fetch 타입을 LAZY 로 설정해보자. 참고로 OneToMany 매핑에서의 기본 fetch 타입은 Lazy 이다.
이 경우에는, findByCity 메서드는 호텔에 대한 정보만 가져온다. Room 에 대한 쿼리를 실행하지 않는다. room 에 대해서는 실제 데이터가 아닌, 프록시 객체를 생성 할것이다.
1. SELECT ... FROM HOTEL WHERE CITY = "인천"
위 쿼리만 실행한다.
하지만, Room 객체 참조가 필요하면, 그때 SQL 쿼리를 실행할 것이다.
2. SELECT ... FROM ROOM WHERE HOTEL_ID = "1"
3. SELECT ... FROM ROOM WHERE HOTEL_ID = "2"
지연로딩이지만, N+1 문제가 발생하였다.
즉, 정리하면
즉시로딩으로 하든, 지연로딩으로 하던,, N+1 문제는 발생하게 된다.
해결 방법..은 여러가지가 있다. 이 글에서는 정답을 얘기하지는 않는다.
예를 들어서, @EntityGraph 를 사용하면 해결할 수 있을 것이다.
자세한 내용은 생략
자세한 내용은 생략
사실.. N+1 문제는 JPA이라서 발생하는 것은 아니다. JPA 아니더라도, N+1 은 얼마든지 발생할 수 있다.
반대의 경우인 객실 입장에서의, ManyToOne 를 생각해보자
ManyToOne 은 디폴트가 즉시로딩이다. findById 의 경우에는 join 쿼리를 실행하며, EntityManger 의 find 를 실행한다.
실제 쿼리를 보자
ManyToOne 즉시로딩인 경우, findById 는 크게 문제가 될 것 같지는 않다. 하지만, 만약. findAll 을 실행하는 경우에는 어떻게 될까?
findAll 의 경우에는 내부적으로 JPQL 을 사용한다. 이 경우에는 join 쿼리를 실행하지 않는다.
이 경우에 join 쿼리를 실행하지 않기 때문에, 아래 쿼리와 같이 N + 1 문제가 발생할 것이다.
1. SELECT ... FROM ROOM
2. SELECT ... FROM HOTEL WHERE ID = 1
3. SELECT ... FROM HOTEL WHERE ID = 2
시간도 없고...
글이 너무 길어서 도저히 깔끔히 정리가 잘 안되는데.....
일단 발행하고 나중에 기회가 되면 다시 정리하겠다.. 끝...