자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 (김영한님)
1. 들어가며
Spring의 진입 장벽에는 여러가지가 있을 것 같습니다. AOP, JPA 등 다른 프레임워크보다 어렵게 느껴질 것들이 많습니다. 다른 언어와 비교해도 Python Django의 ORM이 처음 접하고 CRUD를 다루기까지 시간이 얼마 걸리지 않는 것에 비해 Spring은 처음부터 알 수 없는 에러들과 싸움을 하는 경우가 많습니다. 물론 Django도 DRF와 여러 복잡한 쿼리를 사용한다면 복잡도가 더 올라가겠지만요! 하지만 익숙해진다면 그만큼 강력한 프레임워크라고 생각합니다. 그리고 무엇보다 Spring에는 가장 큰 메리트가 있는데, 김영한님의 강의가 있다는 점이라고 생각합니다. 주변 지인들과 프레임워크 얘기를 할 때 난 Django가 더 어려웠어~ 라고 해서 당황한 적이 있는데, 그분 말은 Spring은 강의가 다 있어서 훨씬 배우기 편했다고 했던 기억이 있습니다. 물론 아무리 생각해도 Django의 초기 러닝 커브가 Spring보다 어렵다는 말은 동의하기 힘들지만요! 그럼 김영한님이 어떻게 JPA에 대해 다루는지 정리해보겠습니다 :)
2. 객체 지향 언어와 관계형 DB의 괴리, JPA의 필요성
백엔드가 대표적으로 하는 일은 사용자의 요청을 받아 데이터베이스에서 알맞은 정보를 입력, 삭제, 조회, 갱신해주는 일일 것입니다. 앞 강의에서 살펴보았듯이 사용자의 요청에 대해 socket을 열고 request를 파싱하는 등의 반복적인 일은 Java에선 Servlet이라는 기술을 사용한다고 하였습니다. 그렇다면 데이터베이스에 접근할 때는 어떨까요?
기본적으로 데이터베이스에 대한 접근은 SQL로 이루어집니다. 그렇다면 우린 백엔드에서 수많은 SQL을 써가면서 데이터베이스를 조회하면 될 것 같습니다. 가능한 방법이지만 문제가 있습니다. SQL로만 데이터베이스를 다루는 서버를 만들어보셨다면 느꼈겠지만 이건 생각보다 끔찍한 경험인데 1. 쿼리 내용을 객체 지향 언어에 맞게 사용하기 위해 파싱하고 객체에 매핑하는 일이 반복됩니다. 2. 관계형 데이터베이스라고 하더라고 객체 지향 언어와 완벽하게 어우러지진 않습니다.
예를 들면 객체 간 상속은 데이터베이스에서 어떻게 나타날 수 있을까요? 또는 외래키로 join되는 테이블의 경우 객체 간에 어떻게 mapping될 수 있을까요? 모두 쉽지 않은 문제임을 알 수 있습니다.
그래서 servlet이 비즈니스 로직에 집중할 수 있도록 다른 기능을 맡아주는 것처럼 JPA도 Java 진영에서 데이터베이스에 객체 지향적으로 쿼리를 보낼 수 있도록 해주는 기술입니다. 그 과정 속에서 트랜잭션, 지연 로딩, 1차 캐시, 동일성 보장 등 다양한 성능을 위한 기술도 얹을 수 있으니 일석이조라고 할 수 있습니다! 또한 특정 Database에 종속적이지 않게 (각 Database는 고유한 기능, 조금씩 다른 쿼리문을 가지고 있는 경우가 있습니다.) 개발할 수 있도록 해줍니다.
물론 모든 DB 데이터를 객체로 변환해서 SELECT해오는 것은 힘듭니다. 그래서 객체 대상으로 쿼리할 수 있는 JPQL까지 제공하고 있습니다. 여기서 객체란 Spring에서 정의한 Entity들을 말합니다.
3. 영속성 관리
데이터베이스를 공부해보셨다면
위 그림을 접해보신 적이 있을 것입니다. 데이터베이스의 실제 값들이 있는 disk와 해당 데이터가 로드된 Main memory의 관계입니다. Spring의 영속성 컨텍스트란 위 그림의 buffer같은 역할을 해줍니다.
JPA는 DB에 대한 요청을 받으면 EntityManagerFactory에서 EntityManager를 생성하여 요청을 handling 해줍니다. Entity Manager는 요청을 받아 바로 DB로 접근하는 것이 아니라 이 영속성 컨텍스트로 먼저 접근을 하게 됩니다. 이렇게 된다면 여러 이점이 생기게 되는데
1. 동일성 보장
Member findMember1 = em.find(Member.class, 101L);
Member findMember2 = em.find(Member.class, 101L);
두 findMember1, 2는 == 비교 시 어떻게 될까요? 이 둘은 영속성 컨텍스트에서 같은 객체를 가리키고 있기 때문에 동일성이 보장이 됩니다. 이걸 만약 JPA의 영속성 컨텍스트 없이 순수 SQL로 직접 쿼리해온다면 어떻게 될까요? DB 상으로는 같은 row를 가리키겠지만 불러온 Java 상으로는 다른 메모리 주소에 할당되기 때문에 동일성이 보장될 수 없습니다.
2. 1차 캐시
위 코드에서 두번째 멤버를 찾아올 때 우리의 Entity Manager는 DB까지 가서 SELECT query를 날릴 필요가 있을까요? 이미 첫번째 멤버에서 해당 member를 찾아왔고 그건 1차 캐시에 올라와있기 때문에 두번 쿼리를 날릴 필요 없이 찾아올 수 있게 됩니다.
3. 트랜잭션을 지원하는 쓰기 지연
위의 1차 캐시는 그럼 언제 DB로 보내지게 될까요? 트랜잭션을 커밋해서 영속성 컨텍스트를 플러시 하는 순간 DB로 쿼리가 보내지게 됩니다. 이는 여러 이점이 있는데 커밋 전까지는 데이터 베이스에 접근하지 않기 때문에 데이터 베이스 row의 lock 시간을 최소화한다는 것입니다.
4. 변경 감지
Java에서 객체를 다루듯이 DB에 데이터를 다룰 수 있는 것으로 엔티티가 변경되었을 경우 스냅샷과 비교하여 update 쿼리를 날려주게 됩니다.
5. 지연 로딩
다대일 관계로 한 테이블이 다른 테이블의 외래키를 가지고 있다고 해보면 이는 객체에서 어떻게 나타날 수 있을까요? 예를 들어 Team과 Member의 관계라고 해볼까요?
테이블 상에선 위와 같은 관계를 가지고 있습니다. 하지만 객체 지향 언어에서는 Member가 TeamId를 가지고 있는 것보단 Team 자체를 가지고 있는 것이 자연스럽습니다. 그리고 Team에서 Member 정보들을 가지고 있길 원한다면 Member들의 List를 가지고 있는 것이 자연스러울 것입니다. 이런 객체 지향 모델링을 지원하기 위해
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
private int age;
// @Column(name = "TEAM_ID")
// private Long teamId;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
…
위와 같은 방법을 지원하고 있습니다. Member Entity에서 Team 객체를 언제든지 가져다 쓸 수 있는 것입니다. 하지만 여기서 문제가 발생합니다. 실제 DB에서는 다른 Table 관계이기 때문에 Member 객체를 가져올 때마다 Team을 가져오게 될텐데 그럼 항상 join문이 발생하고 원치 않는 쿼리문 추가, 특히 join이기 때문에 성능 저하가 발생할 수 있습니다. 여기선 다대일 관계이지만 반대로 일대다 관계라면 어떨까요? 즉 Team에서 Member List를 가져오게 된다면 N+1 문제까지 발생할 수 있게 됩니다. 이런 것을 막기 위해서 JPA는 지연 로딩을 지원하고 있습니다. 위처럼 외래키로 매핑된 관계인 경우 저 Team 객체를 실제로 사용하기 전까지는 프록시 객체로 올려놓고 사용할 때 query를 날려 Team을 가져오는 것입니다.
4. JPA 사용하기.
그럼 JPA에 대한 개념은 익혔으니 어떻게 사용하는지에 대해... 쓰기보단 중요한 사항들만 정리하고 실제 사용 방법은 강의를 참조해주시길 바랍니다!
4.1 데이터베이스 스키마 자동 생성 : 설정한 Entity에 따라 데이터베이스를 자동으로 생성해주는 기능, 운영 장비에는 절대 create, create-drop, update를 사용하면 안되며
개발 초기 단계에는 create 또는 update
테스트 서버에는 update 또는 validate
스테이징과 운영 서버에는 validate 또는 none을 사용해야 합니다.
실수로 운영 서버의 수많은 데이터가 있는 DB에 개발 단계에 있는 DDL들이 마구마구 날라가버릴 수도 있습니다!
4.2 @Enumerated : Java Enum 타입을 매핑할 수 있는데 ORDINAL을 사용해서는 안됩니다. 이는 enum 순서를 숫자로 DB에 저장하는 것인데 순서 특성상 추후 ENUM을 추가할 때 순서가 뒤바뀌어 버리면... 걷잡을 수 없게 됩니다. 꼭 STRING을 사용합시다.
4.3 기본키의 경우 Long을 반드시 사용하며 테이블에 의존적인 기본키를 사용하지 않고 키 생성 전략 + 대체키를 사용합시다. 예를 들어 주민번호와 같은 unique하다고 생각한 값을 user의 기본키로 사용한다고 하면, 추후에 주민번호를 DB에 저장할 수 없는 보안의 세상이 온다면... 끔찍한 일이 벌어지게 됩니다.
4.4 연관관계의 주인 : 연관관계의 주인이 실제 Table에서 외래키를 관리하게 되며 주인이 아닌 쪽은 읽기만 가능합니다. 주인은 mapped by 속성을 사용하지 않습니다. 그러므로 주인을 정하는 기준은 외래 키가 있는 곳으로! 또한 기본적으로는 단방향 연관관계로 설정하되 필요한 경우에만 양방향 연관관계로 바꾸도록 합시다.
4.5 반드시 모든 연관관계에서는 지연 로딩을 사용합시다. 이유는 3. 영속성 컨텍스트의 지연 로딩에서 살펴보았습니다 :) 그리고 필요한 경우 영속성 전이를 사용하면 됩니다.
4.6 고아 객체는 참조하는 곳이 단 하나일 때 사용해야 합니다. 즉 특정 엔티티가 개인 소유하는 관계일 때만 사용해야 합니다.
4.7 임베디드 타입은 테이블에 영향을 주지 않으면서 Java에서는 더욱 OOP스러운 코드를 짤 수 있게 도와줍니다. 그리고 객체의 공유 참조는 위험합니다. 불변 객체를 사용합시다. 값 타입 콜렉션은 사용하지 않는 편으로, 일대다 관계에 영속성 전이와 고아 객체 제거를 차라리 사용합시다.
4.8 페치 조인은 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능으로 즉시 로딩이라고 할 수 있습니다. N+1 문제를 해결하기도 좋고 성능상 이점이 많아 실무에 자주 쓰이는 아주 중요한 개념입니다. 물론 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요 한 데이터들만 조회해서 DTO로 반환하는 것이 효과적입니다.
4.9 Named 쿼리의 경우 애플리케이션 로딩 시점에 쿼리를 검증하여 실수를 줄일 수 있습니다. 로딩 시점이기 때문에 동적 쿼리는 불가능합니다.
4.10 벌크 연산의 경우 성능에 좋지만 영속성 컨텍스트를 무시하기 때문에 동일성 보장이 안됩니다. 따라서 영속성 컨텍스트에 해당 값들이 없는게 확실하다면 벌크 연산을 먼저 실행하거나 그런 보장이 없다면 벌크 연산 수행 후 영속성 컨텍스트를 초기화해줍시다.