스프링과 JPA는 자바 엔터프라이즈(기업) 시장의 주력 기술이다. 스프링이 DI 컨테이너를 포함한 애플리케이션 전반의 다양한 기능을 제공한다면, JPA는 ORM 데이터 접근 기술을 제공한다.
JPA는 스프링 만큼 방대하고 학습해야할 분량도 많다. 하지만 그만큼 데이터 접근 기술에서 매우 큰 생산성 향상을 경험할 수 있다. JPA를 사용하면 SQL을 JPA가 대신 작성하고 처리해준다.
실무에서는 JPA를 더욱 편리하게 사용하기 위해 스프링 데이터 JPA와 Querydsl 이라는 기술을 함께 사용한다.
핵심은 JPA이고, 스프링 데이터 JPA와 Querydsl은 JPA를 편리하게 사용하도록 도와주는 도구라고 볼 수 있다.
ORM 기술은 방대하기 때문에 따로 카테고리를 두어 더 상세하게 다루는 포스트를 작성할 것이며, 이 카테고리에서는 각 기술의 기본적인 부분과 장단점에 대해 다룬다.
기존 SQL 중심적 개발의 문제
애플리케이션 코드는 Java와 같은 객체 지향 언어이고, 데이터베이스는 보통 관계형 데이터베이스를 사용한다.
객체를 관계형 DB에 관리한다고 볼 수 있다. 이 과정에서 문제가 발생한다.
SQL 중심적 개발의 문제
매번 INSERT INTO, UPDATE,, 이 후 자바 객체를 SQL로 바꾸고, SQL을 자바 객체로 바꾸는 작업을 반복해야 한다. JdbcTemplate, MyBatis 등을 사용하면 많은 부분이 해결되지만 SQL은 여전히 직접 작성해야 한다.
여기서 발생하는 문제가 무엇이냐.
만약 Member 객체가 아래와 같이 있었고, SQL을 그에 맞게 작성했다고 가정하자.
1
2
3
4
public class Member {
private String memberId;
private String name;
}
1
2
3
INSERT INTO member(member_id, name) values
select member_id, name from member m
update member set...
그런데 member 객체에 전화번호같은 필드가 추가된다면?
기존에 작성했던 SQL을 다시 작성하고 수정해야 한다. 즉, SQL에 의존적인 개발을 피하기 어렵다.
객체와 관계형 데이터베이스의 차이에서 오는 문제
- 상속
- 연관 관계
- 데이터 타입
- 데이터 식별 방법
이 부분에서 차이가 있고, 문제가 발생한다.
상속
좌측과 같이 객체의 상속 관계가 있고, 우측과 같이 데이터베이스의 관계가 있다고 하자.
ALBUM을 관계형 DB에 저장한다고 가정하면,
- 객체 분해
- INSERT INTO ITEM…
- INSERT INTO ALBUM…
객체를 분해해 나오는 데이터 중 ALBUM에 대한 데이터는 ALBUM에 ITEM에 대한 데이터는 ITEM에 따로 SQL을 작성해 저장해야 한다.
그리고 ALBUM을 관계형 DB에서 조회한다고 가정하면,
- 각각의 테이블에 따른 조인 SQL 작성
- 각각의 객체 생성
- …
매우 복잡하다.
반면 JAVA에서 컬렉션에 객체를 저장하고 조회하는 것은?
1
2
3
4
5
6
//삽입
list.add(album);
//조회
Album album = list.get(albumId);
Item item = list.get(albumId); //다형성도 활용 가능
단순하게 코드 한 줄이면 된다. 이렇게 간단한 부분을 관계형 DB와 함께하게 되면 SQL로 변환하는 과정에서 여러 복잡한 일들이 생겨난다.
연관관계
객체는 참조를 사용하고, 테이블은 외래 키를 사용한다.
- 객체의 참조: member.getTeam()
- 테이블 외래키 사용: JOIN ON M.TEAM_ID = T.TEAM_ID
객체처럼 관계형 데이터베이스의 MEMBER에 TEAM 객체를 넣을 수 없기 때문에 보통 객체를 테이블에 맞추어 모델링 하게 된다.
1
2
3
4
5
6
7
8
9
10
class Member {
String id;
Long teamId; //TEAM_ID FK 컬럼 사용
String username;
}
class Team {
Long id; //TEAM_ID PK 사용
String name;
}
이렇게 되면 객체는 테이블에 맞춰진 행동을 하게 된다.
1
INSERT INTO MEMBER(MEMBER_ID, TEAM_ID, USERNAME) VALUES (...)
데이터 타입
만약 객체를 객체답게 모델링하여 teamId 대신 Team 객체를 넣으면 SQL로 member를 저장할 때 insert team_id를 위해 member.getTeam().getId(); 등 번잡한 과정을 거쳐야하는 문제가 발생한다.
1
2
3
4
list.add(member);
Member member = list.get(memberId);
Team team = member.getTeam();
SQL을 신경쓰지 않으면 객체를 객체답게 쓸 수 있고, 코드 자체도 훨씬 간결해질 수 있다.
문제는 더 있다.
객체의 장점을 살리려면 객체는 자유롭게 객체 그래프를 탐색할 수 있어야 한다. Member 객체에서는 Team, Order를 가져올 수 있어야 한다.
그러나 SQL은 그렇지 않다.
1
2
3
SELECT M.*, T.*
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
처음 실행하는 SQL에 따라 탐색 범위가 제한된다.
1
2
member.getTeam(); //가능
member.getOrder(); //불가
여기서 엔티티 신뢰 문제가 파생된다.
1
2
3
Member member = memberDAO.find(memberId);
member.getTeam();
member.getOrder().getDelivery();
객체에서 Team을 가져오고, Order를 가져오는데 이 값들이 제대로 들어있을 지 확신할 수 없는 것이다. 확신하려면 SQL을 직접 확인해야 한다.
그렇다고 아래처럼 모든 객체를 미리 로딩할 수는 없다.
1
2
3
memberDAO.getMember();
memberDAO.getMemberWithTeam();
memberDAO.getMemberWithOrderWithDelivery();
계층형 아키텍처가 진정한 의미의 계층 분할이 어려워지는 것이다.
이게 무슨 의미냐하면, 위의 코드를 보면 memberDAO에서 객체의 정보를 가져오는 Repository의 역할을 한다. 그러나 memberDAO로 Repository 역할을 하는 즉, 각각의 계층을 나누었다.
그러나 객체가 실제로 가져와지는지 신뢰할 수 없어 memberDAO의 코드를 확인하며 memberDAO를 이용해야 하는 문제가 생기는 것이다. 이것은 계층분할이 되어있지 않은 것과 마찬가지다.
데이터 식별 방법
SQL과 Java의 비교가 다르다.
1
2
3
4
5
6
7
8
9
10
11
String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);
class MemberDAO {
public Member getMember(String memberId) {
String sql = "select * from member where member_id = ?";
/...
return new Member(...);
member != member2;
SQL을 실행한 결과를 각각 새로운 Member 객체에 담아 반환하기 때문에 값은 같더라도 서로 다른 객체인 것이다.
반면 Java는?
1
2
Member member1 = list.get(memberId);
Member member2 = list.get(memberId);
관계형 DB대신 컬렉션에서 꺼내오는 것이기 때문에 같은 객체인 것이다.
정리
종합해보면 객체와 관계형 DB의 차이에서 발생하는 문제때문에 객체 지향적 언어임에도 객체를 객체답게 사용할 수 없는 단점이 생겨버린 것이다.
객체를 객체답게 모델링 할수록 매핑작업만 늘어나게 된다.
객체를 컬렉션에 저장하듯 DB에 저장할 수 없나.. 에서 생겨난 것이 ORM이다.
ORM 기술의 등장
위에서 언급했듯 ORM은 결국 관계형 DB를 사용하는 과정에서 객체를 객체답게 사용하지 못했던 점들을 해결하기 위해 나왔다.
- Object-relational mapping(객체 관계 매핑)의 줄임말
- 객체는 객체대로 설계
- 관계형 데이터베이스는 관계형 데이터베이스대로 설계
- ORM 프레임워크가 중간에서 매핑
- 대중적인 언어에는 대부분 ORM 기술이 존재한다.
JPA
- Java Persistensce API 의 줄임말
- 자바 진영의 ORM 기술 표준
이전에도 엔티티 빈이라는 ORM 기술이 있었는데 너무 복잡했다. 그래서 어떤 한 개발자가 하이버네이트라는 오픈소스 ORM 기술을 만들었고, 하이버네이트를 개발한 개발자를 데리고 와 자바진영에서 자바표준인 JPA를 만들게 되었다.
JPA는 표준 명세이다.
JPA는 인터페이스의 모음이다. JPA 2.1 표준 명세를 구현한 3가지 구현체가 있다.
- Hibernate, EclipseLink, DataNucleus
대부분 구현체로 Hibernate를 사용한다.
JPA를 왜 사용해야 하는가
- SQL 중심적인 개발에서 객체 중심으로 개발
- 생산성 증대
- 유지보수의 편리함
- 패러다임의 불일치 해결
- 성능
- 데이터 접근 추상화와 벤더 독립성
- 표준
간단히 저장 및 조회하는 코드를 보자면
1
2
jpa.persist(member)
Member member = jpa.find(memberId)
생산성 측면에서 월등할 것임을 알 수 있다.
유지보수 측면에서도 위의 SQL 중심적 개발의 문제점에서 객체의 필드가 하나 추가될때마다 SQL도 전부 수정해야 하는 단점을 언급했는데, JPA를 사용하면 필드만 추가하면 되는 것이다.
작동 방식
- JPA는 애플리케이션과 JDBC 사이에서 동작한다.
- 데이터를 저장하고 싶다고 애플리케이션에서 객체를 전달한다.
- JPA는 Entity를 분석하고 SQL을 생성한다.
- JPA는 JDBC API를 사용해 DB에 SQL을 전달하게 된다.
- 조회도 마찬가지이다.
- JPA가 SELECT SQL을 생성해주고 JDBC API를 이용해 DB에서 결과를 받는다.
- JPA가 ResultSet을 매핑해준다.
이러한 과정을 거쳐 개발자는 결과물로 객체를 받아 단순히 컬렉션에서 조회하듯 사용할 수 있는 것이다.
JPA 패러다임 불일치 해결
상속관계에서의 문제
SQL 중심 개발의 상속관계에서의 문제에서 데이터를 저장하는 예시를 들 때
- 객체 분해
- INSERT INTO ITEM…
- INSERT INTO ALBUM…
이러한 과정을 거쳤었다.
이를 JPA를 사용하면
- 개발자: jpa.persist(album);
- JPA
- INSERT INTO ITEM …
- INSERT INTO ALBUM …
개발자는 단순히 JPA를 이용해 객체를 저장하듯 album을 저장하면 JPA가 번거로웠던 부분을 처리해준다.
연관관계, 객체 그래프 탐색 문제
연관관계에서 Member객체는 Team객체를 저장할 수 있지만 관계형 데이터베이스와의 차이때문에 Team 객체를 저장할 수 없었다.
이에 따라 객체에서 객체를 가져오고 하는 과정에서의 엔티티 신뢰성 문제도 생겼었다.
JPA를 이용하면,
1
2
3
4
5
6
7
//연관관계 저장
member.setTeam(team);
jpa.persist(member);
//객체 그래프 탐색
Member member = jpa.find(Member.class, memberId);
Team team = member.getTeam();
객체를 객체답게 쓸 수 있게 된다.
비교에서의 차이 문제
SQL을 통해 조회한 결과를 각각 새로운 객체에 담아 반환하여 값은 같지만 다른 객체인 것에 대한 문제가 있었다.
JPA를 사용하면
1
2
3
4
Member member1 = jpa.find(Member.class, memberId);
Member member2 = jpa.find(Member.class, memberId);
member1 == member2
동일한 트랜잭션에서 조회한 엔티티는 같음을 보장한다.
JPA의 성능 최적화 기능
- 1차 캐시와 동일성(identity) 보장
- 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
- 지연 로딩(Lazy Loading)
JPA는 많은 부분의 성능 최적화를 중간에서 자동으로 해준다.
1차 캐시와 동일성 보장
- 같은 트랜잭션 안에서는 같은 엔티티를 반환 - 약간의 조회 성능 향상
- DB Isolation Level이 Read Commit이어도 애플리케이션에서 Repeatable Read 보장
1
2
Member member1 = jpa.find(Member.class, memberId); //SQL
Member member2 = jpa.find(Member.class, memberId); //캐시
위와 같이 SQL을 한번 실행하게 된다.
트랜잭션을 지원하는 쓰기 지연
- 트랜잭션을 커밋할 때 까지 INSERT SQL을 모은다.
- JDBC BATCH SQL 기능을 사용해 한번에 SQL을 전송한다.
1
2
3
4
5
6
7
8
9
10
transaction.begin();
em.persist(memberA);
em.persist(memberB);
em.persist(memberC);
//여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
transaction.commit();
//커밋하는 순간 데이터베이스에 INSERT SQL을 모아 보낸다.
지연 로딩과 즉시 로딩
- 지연 로딩: 객체가 실제 사용될 때 로딩
1
2
3
Member member = memberDAO.find(memberId); => //select * from member
Team team = member.getTeam();
String teamName = team.getName(); => // select * from team
지연 로딩은 위와 같이 객체가 실제 사용될 때 로딩을 한다.
- 즉시 로딩: JOIN SQL로 한 번에 연관된 객체까지 미리 조회
1
2
3
Member member = memberDAO.find(memberId); => //select M.*, T.* from member join team...
Team team = member.getTeam();
String teamName = team.getName();
즉시 로딩은 JOIN SQL로 한 번에 연관된 객체까지 미리 조회해 놓는다.
성능에 따라 지연 로딩과 즉시 로딩을 선택해야할 때가 있는데, JPA를 사용하면 지연 로딩과 즉시 로딩을 몇 가지 설정만으로 선택해서 사용할 수 있게 된다.
JPA 사용
이제 JPA를 애플리케이션 코드에 적용해본다.
JPA 설정
spring-boot-starter-data-jpa 라이브러리를 사용하면 JPA와 스프링 데이터 JPA를 스프링 부트와 통합하고, 설정도 간단히 할 수 있다.
build.gradle 추가.
1
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
참고로 jpa나 mybatis는 spring-boot-starter-jdbc를 포함하고 있기 때문에 spring-boot-stater-jdbc가 있다면 제거해도 된다.
그리고 로그를 통한 학습을 위해 아래 설정도 추가한다.
application.properties 추가
1
2
3
4
5
6
#JPA log
logging.level.org.hibernate.SQL=DEBUG
#(1)
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
#(2)
- (1): 하이버네이트가 생성하고 실행하는 SQL을 확인할 수 있다.
- (2): SQL에 바인딩 되는 파라미터를 확인할 수 있다.
적용
JPA에서 가장 중요한 부분은 객체와 테이블을 매핑하는 것이다. JPA가 제공하는 애노테이션을 사용해 Item 객체와 테이블을 매핑한다.
Item 객체
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
@Entity
public class Item {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "item_name", length = 10)
private String itemName;
private Integer price;
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
- @Entity
- JPA가 사용하는 객체라는 뜻이다. 이 애노테이션이 붙어야 JPA가 인식할 수 있다. @Entity가 붙은 객체를 JPA에서는 엔티티라고 한다.
- @Id
- 테이블의 PK와 해당 필드를 매핑한다.
- @GeneratedValue
- PK 생성 값을 데이터베이스에서 생성하는 IDENTITY 방식을 사용한다.
- @Column
- 객체의 필드를 테이블의 컬럼과 매핑한다.
- 객체는 ItemName이지만 테이블의 컬럼은 item_name이기 때문에 위와 같이 작성했다.
- length = 10: JPA의 매핑 정보로 DDL(‘create table’)도 생성할 수 있는데 그 때의 컬럼 길이 값으로 활용된다.
- @Column을 생략할 경우 필드 이름을 테이블 컬럼 이름으로 사용하는데 스프링 부트와 통합해서 사용하면 필드 이름을 테이블 컬럼 이름으로 변경할 때 카멜 케이스를 언더스코어로 자동 변환해주기 때문에 사실상 위의 경우에는 생략해도 무방하다.
JPA는 public 또는 protected의 기본 생성자가 필수이다. 기본 생성자를 반드시 넣어주자.
이렇게 단순하게 매핑은 끝이다.
Repository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Slf4j
@Repository
@Transactional
public class JpaItemRepository implements ItemRepository {
private final EntityManager em;
public JpaItemRepository(EntityManager em) {
this.em = em;
}
@Override
public Item save(Item item) {
em.persist(item);
return item;
}
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
Item findItem = em.find(Item.class, itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
@Override
public Optional<Item> findById(Long id) {
Item item = em.find(Item.class, id);
return Optional.ofNullable(item);
}
@Override
public List<Item> findAll(ItemSearchCond cond) {
String jpql = "select i from Item i";
Integer maxPrice = cond.getMaxPrice();
String itemName = cond.getItemName();
if (StringUtils.hasText(itemName) || maxPrice != null) {
jpql += " where";
}
boolean andFlag = false;
if (StringUtils.hasText(itemName)) {
jpql += " i.itemName like concat('%',:itemName,'%')";
andFlag = true;
}
if (maxPrice != null) {
if (andFlag) {
jpql += " and";
}
jpql += " i.price <= :maxPrice";
}
log.info("jpql={}", jpql);
TypedQuery<Item> query = em.createQuery(jpql, Item.class);
if (StringUtils.hasText(itemName)) {
query.setParameter("itemName", itemName);
}
if (maxPrice != null) {
query.setParameter("maxPrice", maxPrice);
}
return query.getResultList();
}
}
- EntityManager
- 스프링을 통해 엔티티 매니저라는 것을 주입받는다.
- JPA의 모든 동작은 EntityManager를 통해 이루어 진다.
- 엔티티 매니저는 내부에 데이터소스를 가지고 있고, 데이터베이스에 접근할 수 있다.
- JPA를 설정하려면 원래 EntityManagerFactory, JPA 트랜잭션 매니저 등등 다양한 설정을 해야하는데 스프링부트가 이러한 부분을 대신 해준다.
- @Transactional
- JPA의 모든 데이터 변경은 트랜잭션 안에서 이루어져야 한다.
- 따라서 JPA에서는 데이터 변경 시 트랜잭션이 필수이다.
- 일반적으로는 데이터의 변경은 서비스 계층에서 트랜잭션을 시작하는데, 위의 예제 코드는 복잡한 비즈니스 로직이 없어 서비스 계층에서 트랜잭션이 걸려있지 않아 일단 Repository에 건다. => 보통은 서비스 계층에 트랜잭션을 걸어주는 것이 맞다.
Repository 분석
- save()
- em.persist(item): JPA에서 객체를 테이블에 저장할 때는 엔티티 매니저가 제공하는 persist() 메서드를 사용한다.
JPA가 만들어 실행한 SQL을 보면 id에 값이 null이나 default 혹은 빠져있다. PK 키 생성 전략을 IDENTITY로 했기 때문에 JPA가 이러한 쿼리를 만들어 실행한 것이다. JPA가 INSERT SQL 실행 이후 생성된 ID 결과를 받아서 넣어준다.
- update()
- em.update()같은 메서드를 전혀 호출하지 않는다.
- JPA는 트랜잭션이 커밋되는 시점에 변경된 엔티티 객체가 있는지 확인한다. 특정 엔티티 객체가 변경된 경우 알아서 UPDATE SQL을 실행한다.
- 테스트의 경우 마지막에 트랜잭션이 롤백되어 JPA는 UPDATE SQL을 실행하지 않는다. 테스트에서 UPDATE SQL을 확인하고 싶다면 @Commit을 붙이면 된다.
- findById()
- JPA에서 엔티티 객체를 PK 기준으로 조회할 때는 find()를 사용하고 조회 타입과 PK값을 주면 된다.
JPA가 실행한 SQL은 as 별칭이 다소 복잡하게 되어있다. 조인이 발생하거나 복잡한 조건에서도 문제 없도록 기계적으로 만들다보니 이러한 결과가 나오는 듯 하다.
- findAll()
- JPA는 JPQL이라는 객체 지향 쿼리 언어를 제공한다. 주로 여러 데이터를 복잡한 조건으로 조회할 때 사용한다.
- SQL이 테이블을 대상으로 한다면, JPQL은 엔티티 객체를 대상으로 SQL을 실행한다고 보면 된다.
- 엔티티 객체를 대상으로 하기 때문에 from 다음 Item 엔티티 객체 이름이 들어간다.
- 결과적으로 JPQL을 실행하면 그 안에 포함된 엔티티 객체의 매핑 정보를 활용해 SQL을 만들게 된다.
- JPA는 JPQL이라는 객체 지향 쿼리 언어를 제공한다. 주로 여러 데이터를 복잡한 조건으로 조회할 때 사용한다.
1
2
3
jpql=select i from Item i
where i.itemName like concat('%',:itemName,'%')
and i.price <= :maxPrice
실행 결과를 보면 위와 같이 실행되는 것을 볼 수 있다.
JPQL에 대한 자세한 내용은 추후 다룬다.
그런데 findAll() 메서드를 보면 JPA를 사용함에 있어 동적 쿼리 문제가 생기는 것을 볼 수 있다. 이는 Querydsl을 함께 사용하면 해결된다.
JPA 예외 변환
JPA의 경우 예외가 발생하면 JPA 예외가 발생한다.
- EntityManager는 순수한 JPA 기술이고, 스프링과는 관계가 없기 때문에 엔티티 매니저는 예외가 발생하면 JPA 예외를 발생시킨다.
- JPA는 PersistenceException과 그 하위 예외를 발생시킨다.
- 추가로 JPA는 IllegalStateException과 IllegalArgumentException을 발생시킬 수 있다.
- 그러면 JPA 예외를 어떻게 스프링 예외 추상화 (DataAccessException)로 변환할까?
- @Repository가 그 역할을 해준다.
예외 변환 전의 그림이다. JPA 예외를 계속해서 전달받게 된다. 그러나 @Repository를 붙인다면?
- @Repository의 기능
- @Repository가 붙은 클래스는 컴포넌트 스캔의 대상이 된다.
- @Repository가 붙은 클래스는 예외 변환 AOP의 적용 대상이 된다.
- 스프링과 JPA를 함께 사용하는 경우 JPA 예외 변환기를 등록한다.
- 예외 변환 AOP 프록시는 JPA 관련 예외가 발생하면 JPA 예외 변환기를 통해 발생한 예외를 스프링 데이터 접근 예외로 변환한다.
- EntityManager에서 JPA 예외를 발생시킨다.
- 런타임 Exception이고, 리포지토리에서 처리하지 못해 다음으로 던져진다.
- AOP Proxy가 그 예외를 받고 해당 JPA 예외를 스프링 예외 추상화로 변환시켜준다.
- 그러면 서비스 계층은 이제 스프링 예외 추상화를 그대로 사용할 수 있게 된다.
결과적으로 리포지토리에 @Repository 애노테이션이 있으면 스프링이 알아서 예외 변환을 처리하는 AOP를 만들어준다.