[스프링 부트와 JPA 활용1] 강의는 원래 이번 강의를 듣고 이론을 학습한 뒤 실습을 진행하는 내용이다. 추천 로드맵을 보면 실습 - 이론 - 실습(복습)으로 진행하는 게 더 효율적이라길래 따라하고 있다. 이번 섹션에선 JPA 이론을 학습하기 전, JPA의 필요성과 기능에 대해 설명한다.
JPA와 모던 자바 데이터 저장 기술
1. SQL 중심적인 개발의 문제점
SQL에 의존하는 개발
애플리케이션을 개발할 땐 보통 Java나 Scala, C# 같은 객체 지향 언어를 사용하고, Oracle이나 MySQL 같은 관계형 데이터베이스(RDB)에 데이터를 저장한다.
- 객체를 영구 보관하는 저장소는 RDB 말고도 NoSQL, File 등으로 다양하다. 그러나 현실적인 대안으로는 주로 RDB를 사용한다.
- 객체를 RDB에 저장하려면 어떻게 해야 할까? 아쉽게도 [저장하기] 버튼 하나로 해결할 수 있을 만큼 간단하지 않다. RDB에서 CRUD라고 불리는 생성, 조회, 수정, 삭제 등을 하기 위해서는 SQL을 사용해야 한다.
- 개발자들은 지루한 SQL을 계속해서 작성해야 했고, 만약 객체에 필드가 추가된 경우 관련된 모든 SQL에 필드를 추가해야 했기 때문에 SQL에 의존적인 개발을 할 수밖에 없었다.
객체와 RDB의 패러다임 불일치
객체를 RDB에 저장하려면 객체를 그에 맞춰 SQL(INSERT)로 변환하고, SQL을 통해 RDB와 통신해야 한다. RDB에서 객체를 조회할 때도 SQL(SELECT)로 조회한 다음 객체로 변환해야 한다.
- 이 역할을 하는 게 개발자로, 객체와 RDB 사이에서 일하는(?) SQL 매퍼라고 봐도 된다.
- 객체와 RDB의 패러다임 차이를 더 자세히 알아보고, JPA가 도대체 왜 필요한지 이해해 보자.
a. 상속
객체에는 상속 관계가 존재하지만, DB 테이블엔 객체에서 생각할 수 있는 상속 관계는 없다. 그래서 보통 RDB에선 오른쪽 그림처럼 슈퍼타입(부모)과 서브타입(자식)으로 나눠 테이블을 구성한다. 이 방식을 사용하면 데이터를 분리해 뒀다가 필요할 때 조인해서 가져오고 다시 풀어둔다.
- 예를 들어 Book 데이터를 저장할 때는 Book 객체를 분해한 뒤 ITEM과 BOOK 테이블에 INSERT 문을 각각 날려야 한다. 조회할 때도 마찬가지로, ITEM과 BOOK 테이블에 대한 조인 SQL을 작성하고 Book 객체를 생성해 반환한다.
- 글로만 봐도 복잡한데 코드로는 더 복잡하기 때문에 DB에 저장할 객체엔 되도록 상속 관계를 쓰지 않고 설계한다.
자바 컬렉션에 저장하는 방식은 한 줄이면 된다. 조회도 마찬가지다. 객체 세상은 간단하지만 DB는 간단하지 않다.
저장: list.add(book);
조회: Item item = list.get(bookId);
b. 연관관계
객체는 참조를 사용해 연관 관계를 맺는다. 그에 비해 테이블은 외래 키로 테이블을 조인해 연관 관계를 맺는다.
- 참조: member.getTeam()
- 외래 키: JOIN ON M.TEAM_ID = T.TEAM_ID
보통은 객체를 테이블에 맞춰 모델링을 하기 때문에 Member에 Team team보단 Long teamId 필드를 넣어 설계한다. 그래야 INSERT나 JOIN을 편하게 진행할 수 있기 때문이다.
- 물론 객체답게 모델링을 진행하려면 Member에 Team team 필드를 넣어 설계해도 된다. 대신 SQL 관점에서 로직이 굉장히 까다로워진다는 문제가 생긴다. 객체로 JOIN 하려면 참조로 접근해 필드 값을 받아와야 한다.
- 예를 들어 객체 모델링을 통해 회원을 조회하려면 아래와 같이 복잡한 로직을 구현해야 한다.
// 회원 조회
public Member find(String memberId) {
Member member = new Member();
...
Team team = new Team();
...
member.setTeam(team);
return member;
}
이것도 자바 컬렉션에선 한 줄이면 가능하다.
저장: list.add(member);
조회: Member member = list.get(memberId);
c. 탐색 범위
객체는 자유롭게 객체 그래프를 탐색할 수 있어야 한다. 참조에 참조를 통해 Member에서 Category까지도 접근할 수 있다.
그러나 테이블은 처음 실행하는 SQL에 따라 탐색 범위가 결정된다. 예를 들어 아래 SQL처럼 MEMBER 테이블과 TEAM 테이블을 JOIN 했다면 member.getTeam()은 가능하지만, ORDER 테이블은 JOIN 하지 않았기 때문에 member.getOrder()은 null로 뜬다.
SELECT M.*, T.*
FROM MEMBER M
WHERE TEAM T ON M.TEAM_ID = T.TEAM_ID;
따라서 엔티티에 대한 신뢰 문제가 생기게 된다. 예를 들어 아래 process() 메서드에서 memberDAO에서 find() 메서드로 찾아온 Member 객체의 탐색 범위가 어디까지인지 확인해야만 member 객체에서 어디까지 값을 가져올 수 있는지 알 수 있다.
- 계층을 분할한다는 건 다른 계층에 대한 신뢰를 바탕으로 진행된다. 내가 데이터를 주면 원하는 데이터가 반환된다는 신뢰성이 중요하다. 그런 의미에서 엔티티를 신뢰할 수 없다는 건 진정한 의미의 계층 분할이 어려워진다는 것을 뜻한다.
- 그럼 모든 객체를 미리 로딩해 두면 되지 않냐고 생각할 수 있지만... 당연히 SQL 길이나 쿼리 성능에 문제가 생긴다.
public void process() {
Member member = memberDAO.find(memberId);
member.getTeam(); // 가능할까?
member.getOrder().getDelivery(); // 가능할까?
}
d. 데이터 타입 및 식별 방법
같은 아이디를 가진 회원을 조회한 뒤 두 회원이 같은지 비교하는 것에서도 차이가 있다.
- SQL을 사용하면 DB에서 조회할 때마다 새로운 인스턴스로 만들어 반환한다. 따라서 아이디가 같은 회원이라도 ==로 비교하게 되면 false가 나온다.
- 자바 컬렉션에서 조회할 땐 list.get(memberId) 방식을 사용하면 list에 있는 똑같은 회원을 반환하기 때문에 ==로 비교해도 true가 나온다.
객체 지향적으로 설계하는 게 좋다고는 하지만, 객체답게 모델링을 진행하면 SQL로 변환하는 과정에서 비용이 너무 많이 들고 상속 관계나 연관 관계까지 생각하면서 과정이 복잡해진다. 그래서 개발자가 해야 하는 매핑 작업만 늘어나게 된다.
이 문제를 해결하는 게 바로 JPA(Java Persistence API)라는 기술이다.
2. JPA 소개
ORM과 JPA
JPA는 Java Persistence API의 약자로, 자바 진영의 ORM 기술 표준으로 규정돼 있다.
여기서 ORM은 Object-Relational Mapping의 약자로 객체-관계 매핑을 뜻한다. 위에서 말했듯, 개발자는 객체와 RDB 사이에서 SQL 매퍼 역할을 한다. 여기서 ORM 프레임워크를 사용하면 객체는 객체대로 설계하고, RDB는 RDB대로 설계해도 중간에서 알아서 객체-관계를 매핑해 준다.
JPA는 애플리케이션과 JDBC 사이에서 동작한다. 자바 애플리케이션에서 DB랑 통신하기 위해선 JDBC API를 사용해야 하는데, 중간에 낀 JPA가 대신 사용해 준다고 보면 된다.
여기까지만 보면 JDBC 템플릿이나 MyBatis 등의 ORM과 비슷하지만 JPA가 훨씬 간편하고, 객체와 RDB 간의 패러다임 불일치를 해결할 수 있다는 특징이 있다. 자바 컬렉션에 저장하고 조회할 때처럼 코드 한 줄로 간편하게 DB와 통신할 수 있다는 게 가장 큰 장점이다.
- 엔티티 저장 과정을 살펴보자. JPA는 persist() 요청을 받으면 전달받은 엔티티를 분석하고 INSERT SQL을 알아서 생성한다. 이후 JDBC API를 사용해 DB에 접근하여 엔티티를 저장한다.
- 엔티티 조회 과정을 살펴보자. JPA는 find() 요청을 받으면 SELECT SQL을 알아서 생성하고 JDBC API를 사용해 DB에 접근한다. 반환받은 결과를 ResultSet으로 매핑하고 엔티티로 변환한 다음 우리에게 반환해 준다.
Hibernate와 JPA(표준 명세)
기존에 자바 표준 ORM 기술로 쓰이던 EJB의 엔티티 빈 기술은 사용하기에 너무 어렵고 성능도 떨어졌다. 엔티티 빈을 쓰다가 못 버틴 개발자가 오픈 소스로 만든 게 하이버네이트(Hibernate)다. 훨씬 사용하기 편하고 단점도 보완했기 때문에 많은 사람들이 쓰기 시작했지만, 자바 표준으로 규정된 기술은 아니었다.
이후 Java 진영에서 하이버네이트 개발자에게 자바 표준 ORM 명세를 새로 만들어 달라고 요청했다. 하이버네이트를 거의 복사 붙여 넣기 수준으로 만들고, 표준 기술답게 용어를 정제한 다음 출시한 것이 JPA다. JPA는 오픈소스에서 출발한 표준 기술이기 때문에 굉장히 실용적이라고 볼 수 있다.
JPA는 표준 명세로, 인터페이스의 모음이라고 보면 된다. JPA 2.1 표준 명세를 구현한 3가지 구현체(Hibernate, EclipseLink, DataNucleus)가 있는데, 주로 하이버네이트만 쓰긴 한다.
JPA 버전
- JPA 1.0(JSR 220) 2006년: 초기 버전으로, 복합 키나 연관관계 기능이 부족했다.
- JPA 2.0(JSR 317) 2009년: 대부분의 ORM 기능을 포함하며 JPA Criteria가 추가됐다.
- JPA 2.1(JSR 338) 2013년: 스토어드 프로시저 접근과 컨버터(Converter), 엔티티 그래프 기능이 추가됐다.
객체와 RDB의 패러다임 불일치 해결
a. JPA와 상속
위에서 엔티티 하나를 저장하려면 테이블 2개에 INSERT SQL을 생성해 날려야 했다. JPA를 사용하면 코드 한 줄(persist(), find() 등)만 적으면, 개발자가 직접 하던 나머지 과정을 JPA가 알아서 처리해 준다.
b. JPA와 연관관계
원래는 외래 키를 사용해 JOIN 하고 연관관계를 맺어야 하지만, JPA를 사용하면 참조를 통해 연관관계를 맺을 수 있다.
c. JPA와 객체 그래프 탐색
객체 그래프 탐색도 자유롭게 가능하기 때문에 엔티티에 대한 신뢰 문제가 해결되고, 계층이 분할돼 있더라도 믿고 사용할 수 있다.
public void process() {
Member member = memberDAO.find(memberId);
member.getTeam(); // OK
member.getOrder().getDelivery(); // OK
}
d. JPA와 비교(==)하기
같은 아이디를 가진 회원을 조회한 뒤 두 회원이 같은지 비교할 때도, JPA를 사용하면 동일한 트랜잭션 내에서 조회한 엔티티의 동일성을 보장한다. 따라서 ==로 비교해도 true가 나오게 된다.
- 자바 컬렉션에서 꺼내 비교할 때랑 같다고 보면 된다.
성능 최적화 기능
a. 1차 캐시와 동일성(identity) 보장
같은 트랜잭션 안에서는 같은 엔티티를 반환한다.
- 만약 한 트랜잭션에서 엔티티를 조회할 일이 2번 이상 있다면, SQL을 한 번만 실행하여 조회해 둔 엔티티를 1차 캐시에 저장해 두고, 다음 조회부턴 1차 캐시에서 꺼내오면 되기 때문에 약간의 조회 성능이 향상된다는 장점이 있다.
String memberId = "100";
Member m1 = jpa.find(Member.class, memberId); // SQL 사용
Member m2 = jpa.find(Member.class, memberId); // 1차 캐시에서 꺼내 옴
// 같은 트랜잭션 내에서 같은 엔티티를 2번 이상 조회할 때, SQL을 1번만 실행해도 됨
DB isolation Level이 Read Commit이어도, 애플리케이션에서 Repeatable Read를 보장한다.
어려워서 강의 중 넘어간 내용이다.
- 고급데이터베이스 강의에서 배운 개념이다. DB는 일관성(consistency)를 유지하기 위해 여러 개의 트랜잭션을 동시에 실행하는 동안 serializability를 보장해야 한다. 그런데 일관성에만 신경쓰다 보면, 동시성(concurrency)을 보장할 수 없게 된다. 그래서 몇몇 애플리케이셔에선 동시에 더 많은 트랜잭션을 실행하기 위해(동시성을 보장하기 위해) 일관성을 약화시키기도 한다. 그 방법 중 하나가 트랜잭션의 고립 수준을 약화시키는 것이다.
- 고립 수준(isolation level)은 높은 순서대로 [Serializable - Repeatable Read - Read Committed - Read Uncommitted]의 4단계로 나뉜다.
- Serializable: serializable 실행만 보장한다.
- Repeatable Read: 커밋된 데이터만 읽을 수 있고, 같은 트랜잭션 안에선 같은 데이터를 읽으면 항상 같은 값을 반환한다.
- Read Committed: 커밋된 데이터만 읽을 수 있고, 같은 트랜잭션 안에서 같은 데이터를 읽더라도 다른 값이 반환될 수 있다.
- Read Uncommitted: 커밋되지 않은 데이터도 읽을 수 있다.
- 모든 단계에서 중단되거나 커밋되지 않은 다른 트랜잭션에서 쓴 데이터 값에 다른 값을 덮어 씌우는 dirty write를 허용하지 않는다.
- 쉽게 요약하면... '일관성(≒Serializability) ↔ 동시성(≒Performance)'이라고 보면 된다.
b. 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
INSERT
- 트랜잭션을 커밋할 때까지 INSERT SQL을 모아둔다. 이후 커밋할 때 JDBC BATCH SQL 기능을 사용해 한 번에 SQL을 전송한다.
transaction.begin(); // 트랜잭션 시작
em.persist(m1);
em.persist(m2);
em.persist(m3);
// 여기까지 INSERT SQL을 DB에 보내지 않는다.
transaction.commit(); // 트랜잭션 커밋
// 커밋하는 순간 DB에 INSERT SQL을 모아서 보낸다.
UPDATE, DELETE
어려워서 강의 중 넘어간 내용이다.
- UPDATE, DELETE로 인한 로우(ROW) 락 시간을 최소화한다. 이후 트랜잭션 커밋 시 UPDATE, DELETE SQL을 실행하고, 바로 커밋한다.
- 엔티티를 수정하거나 삭제하면, DB 일관성을 위해 그 행에 접근할 수 없도록 로우 락을 건다. 트랜잭션이 커밋될 때까지 로우 락을 걸어두면 당연히 성능이 저하되기 때문에 시간을 최소화하여 성능을 높인다.
transaction.begin(); // 트랜잭션 시작
changeMember(m1);
deleteMember(m2);
비즈니스_로직_수행(); // 비즈니스 로직 수행 동안 DB에 로우 락이 걸리지 않는다.
transaction.commit(); // 트랜잭션 커밋
// 커밋하는 순간 DB에 UPDATE, DELETE SQL을 보낸다.
c. 지연 로딩(Lazy Loading)
지연 로딩
- 객체가 실제 사용될 때 해당 객체를 로딩한다.
Member member = memberDAO.find(memberId); // SELECT * FROM MEMBER;
Team team = member.getTeam();
String teamName = team.getName(); // SELECT * FROM TEAM;
즉시 로딩
- JOIN SQL로 한 번에 연관된 객체까지 미리 조회한다.
Member member = memberDAO.find(memberId); // SELECT M.*, T.* FROM MEMBER M JOIN TEAM T ...
Team team = member.getTeam();
String teamName = team.getName();
보통은 전부 지연 로딩으로 설정해 놓고, 즉시 로딩으로 성능을 향상할 수 있는 부분만 따로 적용하는 방식을 사용한다.