[스프링부트 + JPA] JPA 소개

다음은 글은 infrean의 "김영한의 스프링 부트와 JPA 실무 완전 정복 로드맵" 강의의 학습 목적으로 작성된 것입니다.
강의의 디테일한 내용이나, 코드 등은 빠져있을 수 있습니다.
1. JPA에 대한 이해 : 들어가기 전
스프링부트를 공부하는 개발자라면 한 번쯤은 이런 코드를 작성해보았을 것이다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String Name;
}
// 회원 저장
memberRepository.save(member);
// 회원 조회
Member findMmeber = memberRepository.findById(1L).get();
정말 자주쓰고, 무의식적으로 쓰는 이러한 코드들.
우리는 JPA를 통해 몇 줄로 DB의 데이터를 저장하고 조회했었다.
하지만 과연 이론적으로, 구조적으로 JPA가 어떻게 동작하는지 아는 개발자가 몇 명이나 있을까?
잠깐 멈춰서 생각해보자.
- 이 코드 뒤에서는 어떤 일이 벌어지고 있는가?
- JPA는 어떻게 우리가 작성한 JAVA Object를 DB 테이블로 변환할까?
- 그리고 언제 (When) 실제 SQL이 실행되는 걸까?
나 또한 JPA를 사용하면서 "영속성 컨텍스트" 등의 문제에 많이 부딪히고 시간 허비를 많이 했었다.
흔히 말해 삽질한 시간이 너무 많다...
그래서 이번에 제대로 [스프링 부트와 JPA]에 대해 깊게 공부해보려고 한다.
JPA 내부 동작을 이해하고 공부하는 데에는 다음과 같은 목적이 있다.
- JPA에 대해 발생하는 문제를 쉽게 해결할 수 있고, 더 나은 설계를 결정할 수 있다.
- 성능 최적화를 할 수 있다.
천천히 내부 동작 원리를 하나씩 파헤쳐보자.
2. SQL 중심적인 개발 ; 무엇이 문제인가?
SQL 매퍼가 되어버린 서버 개발자...
많은 개발자들이 JPA를 처음 다루기 시작하기 전 시대는 어떤 코딩을 했을까?
다음과 같은 코딩을 하루종일 하고 있었을 것이다.
// 회원 조회용 SQL 작성
String sql = "SELECT MEMBER_ID, NAME, TEAM_ID, ORDER_ID FROM MEMBER WHERE MEMBER_ID = ?";
// SQL 실행
ResultSet rs = stmt.executeQuery(sql);
// ResultSet을 자바 객체로 변환
Member member = new Member();
member.setMemberId(rs.getLong("MEMBER_ID"));
member.setName(rs.getString("NAME"));
member.setTeamId(rs.getLong("TEAM_ID"));
member.setOrderId(rs.getLong("ORDER_ID"));
// 팀 정보를 가져오기 위한 또 다른 SQL
String teamSql = "SELECT * FROM TEAM WHERE TEAM_ID = ?";
...
"왜 객체 지향 언어인 JAVA로 개발을 하면서, 대부분의 시간을 SQL 작성에 쏟고 있는 것인가.."

개발자들은 비즈니스 로직을 작성하는 대신, SQL과 자바 객체 사이의 변환 작업에 많은 시간을 쏟고 있는 시대였을 것이다...
반복적인 코드 작성과 SQL 의존적인 개발...
모든 테이블마다 CRUD SQL을 작성해야하고, 필드가 추가될 때마다 SQL문을 수정해야 한다는 매우매우 번거러운 상황이 벌어진다.
물론 이후에 이야기할 자바 객체와 DB 데이터는 서로 패러다임이 다르기 때문에 계속해서 둘을 변환하는 과정이 필요하고,
그러한 코드를 작성하다보면 개발자의 피로도가 너무나 올라가게 된다.
SQL문을 쓰는 과정에서 생기는 충돌도 있다.
DAO 계층에서 SQL을 직접 다루게 되고, 결국에는 객체지향적인 설계가 아닌 데이터 중심의 설계가 될 수 밖에 없다.
이러한 점에서 서버 개발자는 어쩔 수 없는 SQL 의존적인 개발을 하게 된다는 것이다.
객체지향과 관계형 데이터 베이스의 패러다임 불일치
가장 큰 문제는 객체지향과 RDBMS가 지향하는 목표가 다르다는 점이다.
가장 먼저 우리가 [JAVA]라는 언어와 [데이터베이스 SQL]간에 어떠한 차이가 있는지를 알아보자.
차이를 알아야, 극복을 할 수 있으니.
4가지로 정리할 수 있다.
- 상속
- 연관관계
- 데이터 타입
- 데이터 식별 방법
1. 상속관계의 표현 문제
사실 객체지향 언어의 가장 큰 장점이자, 특징은 "상속"이 가능하다는 것이다.
자바에서는 이러한 자연스러운 상속 관계를 테이블에서 표현한다는 것부터가 매우 큰 도전이다.
public abstract class Item {
Long id;
String name;
int price;
}
public class Album extends Item {
String artist;
}
public class Movie extends Item {
String director;
String actor;
}
//////////////////////////////////////////////////////////////
// 실제 사용
List<Item> items = new ArrayList<>();
items.add(new Album("앨범1", "가수A"));
items.add(new Movie("영화1", "감독B"));
// 조회시
Album album = (Album) items.get(albumId); // 깔끔하게 조회 가능
하지만 이를 데이터베이스 저장하는 SQL문을 짜야한다면...?
-- 객체 하나를 저장하기 위해 두 번의 INSERT
INSERT INTO ITEM (ITEM_ID, NAME, PRICE) VALUES (1, '앨범1', 10000);
INSERT INTO ALBUM (ITEM_ID, ARTIST) VALUES (1, '가수A');
-- 조회시에는 복잡한 JOIN 필요
SELECT I.*, A.ARTIST
FROM ITEM I
JOIN ALBUM A ON I.ITEM_ID = A.ITEM_ID
저장시에는 여러번의 Insert 문과
조회시에는 모든 케이스에 따른 조인 SQL을 작성해야 하는 번거로움이 발생한다...
만약에 자바 컬렌션를 사용한다면 어떻게 될까?
list.add(album);
Album album=list.get(albumId);
// 부모 타입으로 조회하는, 다형성을 활용하는 방법도 있다.
Item item=list.get(albumId);
객체 세상이기 때문에, 위와처럼 쉽게 사용할 수 있다.
(컬렉션을 활용하는 방법은 이후에 다시 다루겠다.)
2. 연관 관계의 표현 차이
가장 큰 차이는 객체는 참조를 사용하고, 테이블은 외래키를 사용하다는 점이다.
// 객체 연관 관계 (단방향)
class Member {
private Team team; // 참조로 연관 관계를 표현
public Team getTeam() {
return team;
}
}
// 테이블 연관 관계 (양방향)
CREATE TABLE MEMBER (
MEMBER_ID BIGINT PRIMARY KEY,
TEAM_ID BIGINT REFERENCES TEAM,
...
);
일단 위를 보면 알겠지만, 객체지향 언어에서는 Team 자체를 참조하고 있지만,
테이블에서 Team_Id를 통해서 연관관계를 표현하고 있다.
그래도 객체에서 id를 가져오면 (member.getTeam().getId();) 어느정도 대응이 가능하다.
그치만... 문제는 조회이다.
멤버와 팀을 각각 죄회해서 연관 관계를 코드로 직접 짜줘야하는 번거로움이 있다.
이 또한, DB에 데이터로 저장하는 것이 아니라, 자바 컬렉션에 관리한다고 생각해보자.
list.add(member);
Member member = list.get(memberId);
Team tema = member.getTeam();
멤버를 저장하면 그와 연관관계가 있는 객체들도 딸려 들어오고, 조회에도 용이하게 된다.
(물론 이것은 자바 컬렉션에 저장하는 방식이고, DB에 데이터로 넣는 순간 망가져버린다.)
3. 객체 그래프 탐색의 한계
객체지향에서는 객체 그래프를 자유롭게 탐색할 수 있어야 한다.

자바에서 객체를 찾을 때, 객체간의 참조를 쭉쭉 따라갈 수 있어야 한다.
.get.get.get .....
그런데 다음과 같은 예시를 보자.
예를 들어 다음과 같은 도메인 구조가 있다고 생각해보자.
public class Member {
private Long id;
private String name;
private Team team;
private List<Order> orders = new ArrayList<>();
public Team getTeam() { return team; }
public List<Order> getOrders() { return orders; }
}
public class Order {
private Long id;
private String orderNumber;
private Delivery delivery;
public Delivery getDelivery() { return delivery; }
}
그리고 이제 회원을 조회하는 SQL을 작성해보자.
SELECT M.*, T.*
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
WHERE M.MEMBER_ID = ?
이 회원은 "회원과 팀 정보"만을 가져오게 된다.
이제 이 데이터들로 실제 객체를 생성한다면?
// SQL 실행 결과로 생성된 Member 객체
Member member = memberDAO.find(memberId);
// 팀 조회 가능 (SQL에 포함되어 있었기 때문)
Team team = member.getTeam(); // OK!
// 주문 조회 시도
List<Order> orders = member.getOrders(); // ???
// Order는 포함이 안되어 있다...ㅠㅠ
Order firstOrder = orders.get(0); // 예외 발생!
여기서 문제가 발생한다!
- 처음 실행한 SQL 문장에는 ORDER에 대한 정보가 포함되어 있지 않다.
[ 당연히 Team에 대한 조회는 가능하다. ] - 따라서 mebmer.getOrders()는 비어있거나 null일 수 있다.
- 개발자는 이 member 객체가 어디까지 조회가 가능한지 코드만 보고 확인할 수 없다.
-> 즉, "처음에 어디까지 세팅을 해두었냐에 따라 탐색 범위가 제한된다." 라는 단점이 있다.
즉, 다른 개발자가 이 코드를 보고 이 memberId로 찾은 member 객체가 어떤 객체까지 find 가능한지 바로 알 수 없다는 것이다.
이는 엔티티가 신회할 수 없는 상태가 되었다고 말한다.
그렇다면, 모든 객체를 미리 다 로딩을 해놓을 수 있을까..?
그럼 모든 조인을 해놓고, 어마어마한... 쿼리를 짜야하고 성능도 매우 안좋을 것이다..

그럼 다시 해결책을 생각해보자.
[야매로 상황별 메서드를 만들기]
class MemberDAO {
// 회원만 조회
public Member getMember(Long id) {
return "SELECT * FROM MEMBER ...";
}
// 회원과 팀을 함께 조회
public Member getMemberWithTeam(Long id) {
return "SELECT M.*, T.* FROM MEMBER M JOIN TEAM T ...";
}
// 회원과 주문을 함께 조회
public Member getMemberWithOrders(Long id) {
return "SELECT M.*, O.* FROM MEMBER M JOIN ORDERS O ...";
}
}
멤버 오더 딜리버리를 조회한다면...?
memberDAO.getMemberWithOrderWithDelivery();
이게 맞나?
즉, 진정한 의미의 계층 분할이 어렵다.
물리적으로는 분할이 되어있으나, 논리적으로는 엮여 있다.
(힌트 : JPA는 이런 문제를 지연 로딩(Lazy Loading)이라는 방식으로 해결한다.)
member.getTeam(); // OK
member.getOrder().getDelivery(); // ???
member.getOrder().getOrderItems().get(0).getItem(); // ???
4. 데이터 타입과 데이터 식별 방법의 차이
getMember()에 대해서 우리는 로직을 이렇게 짠다.
보통 데이터를 다 때려 넣은 새로운 객체를 만들어서 반환 한다.
즉, 매번 새로운 인스턴스를 생성하는 것.
public class MemberDAO {
public Member getMember(String memberId) {
// SQL 실행
String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID = ?";
...
// 매번 새로운 인스턴스 생성
return new Member(rs.getString("MEMBER_ID"),
rs.getString("NAME"));
}
}
그리고 다음과 같은 문제가 발생한다.
// 사용하는 코드
String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);
member1 == member2; // false
getMember()을 호출할 때마다 새로운 Member 인스턴스를 생성하는 것.
그래서 같은 데이터를 조회했지만, 각각 다른 메모리 주소를 가진 완전히 별개의 인스턴스로 취급되는 것이다.
그래서 "==" 비교 연산자로 비교하면 false가 반환되는 것이다.
이 또한 자바 컬렉션을 통해서 조회한 경우 어떻게 달라지는지 알아보자.
List<Member> list = new ArrayList<>();
... // 리스트에 Member 객체들을 저장
String memberId = "100";
Member member1 = list.get(memberId);
Member member2 = list.get(memberId);
member1 == member2; // true
컬렉션은 객체의 참조를 그대로 반환하기 때문에,
동일한 memberId로 조회하면, 컬랙션에 저장된 같은 객체의 참조를 반환한다.
그래서 true가 나오게 된다!
=> 즉, 객체의 동일성 비교가 불가능해지고, 객체 참조를 활용하는 객체지향적인 코드를 작성하기 어려워진다.
결론
이러한 문제들로 인해,
객체지향적인 설계가 어려워지고, 개발자의 생산성이 너무나 저하된다.
매핑 작업을 하는 데에만 너무 많은 에너지가 쓰이게 되는 것이다.
그래서 우리는 매핑 작업을 해줄 더 나은 방법이 필요했다.
결론은 [객체지향 프로그램 언어와 데이터베이스 간의 간극을 줄여줄 작업이 필요했다.]
이어서 드는 해결책에 대한 고민은 [객체를 자바 컬렉션에 저장하 듯, DB에 저장할 수 없을까?] 이다.
그리고 이러한 해결책이 바로 "Java Persistence API" JPA이다.
3. JPA 소개
JPA : 객체 지향 프로그래밍과 관계형 데이터베이스의 브릿지
먼저 JPA가 무엇인지 간단히 소개해보겠다.
JPA는 Java Persistence API의 약자로, 자바 진영에서의 ORM 기술 표준이다.
ORM이란?
JPA는 ORM 기술 표준이라고 했다.
그렇다면 과연 ORM 은 무엇일까?
개발자들이 이러한 고민을 해놨다고 했다.
"어떻게 하면 객체를 데이터베이스에 더 자연스럽게 저장할 수 있을까?"
더 나아가
"그냥, 자바 컬렉션에 객체를 저장하듯이 DB에 저장할 수는 없을까?"
이러한 고민 끝에 ORM이 탄생했다.

ORM은 두 가지 원칙을 따른다.
- 객체는 객체답게 설계한다.
- 관계형 데이터베이스는 관계형 데이터베이스답게 설게한다.
그리고! [내가 객체랑 관계형 데이터베이스 간의 MAPPING은 알아서 해줄게]
그것이 바로 ORM이다. (Object와 Realational DB의 Mapping)
그 중에서도 JAVA 언어에 국한된 ORM을 JPA라고 한다.
좀 더 자세한 그림으로 설명하자면, 다음과 같다.

이전에는 사람이 JDBC API 를 사용하여, JAVA 어플리케이션에서 DB와 통신을 했다.
그런데, 이러한 과정을 JPA는 대신해줄 수 있다.
근데 약간의 의문점이 생기는 점은 JAVA 어플리케이션과 DB 사이에서 도움을 주는 것은 마치 JDBC 템플릿 들과 별반 다른 역할이 없어 보인다.
이러한 프레임워크와 다른 점은 분명히 있다. 다음 그림을 보자.

예를 들어, 우리가 회원 객체를 저장한다고 생각해보자.
멤버 객체를 멤버 DAO에 넘기고, 멤버 DAO가 JPA에게 멤버 엔티티를 저장해달라고 전달한다.
그러면 JPA는 엔티티를 알아서 분석하고, 이에 맞는 INSERT SQL문을 생성해준다.
사실 가장 중요한 것은 "패러다임 불일치의 문제를 해결"해준다는 점이다.
조회할 때에도 마찬가지이다.
PK만 보내면 JPA가 적절한 쿼리를 만들어 보내고 결과를 매핑해준다.
참 신통방통한 녀석이고 빨리 이놈이 어떻게 돌아가는 지 알아보고 싶다.
그 전에! JPA 대한 간단한 소개를 하고 넘어가겠다.
JPA 소개
옛날 옛적 EJB라는 놈이 있었다...

Enterprise JavaBeans의 약자로 이 또한 매우 과거의 ORM이다.
EJB는 두가지 큰 기능이 있었다.
우리가 현재 사용하는 스프링(Spring)처럼 컨테이너 기술이 있었고,
다른 하나는 현재 JPA와 같은 역할을 하는 ORM 기능의 엔티티 빈이 있었다.
우리가 현재 스프링에서 사용하는 빈의 개념도 여기서 출발한 것이다.
그런데 이 엔티티 빈이라는 것이 직접 SQL을 짜는 것보다 성능도 안나오고, 배우기도 너무 어려웠기에 어떤 개발자가 화가 났다..
"내가 해도 그것보단 잘 만들겠다."

그래서 만든 오픈 소스가 바로 "하이버네이트"이다.
그리고 자바도 기회를 놓치지 않고, 하이버네이트 개발자를 회사에 잡아와서
자바 표준으로 ORM을 다듬고 만들어 달라고 요청한다...
=> JPA
그리고 JPA는 인터페이스의 모음이고, 결국에는 "인터페이스"이기 때문에 구현체가 필요하다.
그리고 그 인터페이스의 구현체가 Hibernate;하이버네이트 이다.
하이버네이트 말고도 EclipseLink, DataNucleus 등도 있긴 하지만 90퍼센트이상 하이버네이트로 쓰인다.
JPA를 사용하는 이유 1
간단하게 말하면,
생산성 향상, 유지보수성 향상, 패러다임 불일치의 해결이다.
이전에 CRUD에 관련된 SQL문을 개발자들이 직접 마구마구 썼다면,
한 두줄로 JPA가 모두 처리해준다.
//저장
jpa.persist(member)
// 조회
Member member = jpa.find(memberId)
// 수정
member.setName(“변경할 이름”)
// 삭제
jpa.remove(member)
필드가 추가되더라도 SQL 수정이 필요도 없다. JPA가 자동으로 처리해준다.
또한 상속관계와 같이 이전에 말했던 패러다임 불일치 문제도 알아서~ 해결해준다.
// JPA 사용 전
String sql1 = "INSERT INTO ITEM...";
String sql2 = "INSERT INTO ALBUM...";
// 복잡한 조인 쿼리 작성 필요
// JPA 사용 후
em.persist(album); // JPA가 알아서 처리
그 중에서도 "자유로운 그래프 탐색"도 가능하다.
class MemberService {
...
public void process() {
Member member = memberDAO.find(memberId);
member.getTeam();//자유로운 객체 그래프 탐색
member.getOrder().getDelivery();
}
}
그럼 아까 말했듯이 memberDAO.getMemberWithOrderWithDelivery();~~~ 와 같은 어마무시한 SQL문를 알아서 막 만들어서 돌아가게끔 해주는 걸까.?
(알아서 뒤에서 해준다고 해도 효율이 매우 별로일 것이다...)
이것은 JPA에서 [지연 로딩]을 지원하기 때문에 가능한 일이다. 이후에 자세히 설명하겠다.
JPA를 사용하는 이유 2 : 성능 최적화 기능
1. 1차 캐시와 동일성 보장
일단 같은 트랜잭션 내에서는 같은 엔티티를 반환해준다.
그렇기에 이전에 말했던 객체의 동일성 비교가 가능해지는 것이다.
추가적으로 같은 엔티티를 한 트랜잭션 내에서는 한 번만 호출하고 캐싱해두기 때문에, "성능상의 이점"도 있다.
2. 트랜잭션을 지원하는 쓰기 지연 (transactional write - behind)
똑같은 쿼리가 3개가 있다면 네트워크도 3번 타야 한다.
하지만 JPA는 일단 메모리에 쌓았다가 커밋되는 순간 같은 쿼리는 한 네트워크로 보낸다.
트랜잭션이란 기능이 있기 때문에 일단 메모리에 요청을 쌓았다가 커밋하는 순간에 네트워크로 보낸다.
3. 지연 로딩 (Lazy Loading)
만약에 Member랑 Team이 연관관계가 있다고 하자.
그런데 Member 객체를 찾을 때, Member 객체만 가져오고, 그 안에 속한 Team 데이터는 거의 쓰지 않는 비지니스 로직이 있다고 하자.
그렇다면, Member 객체를 찾을 땐 Member 객체만 가져오고, 그 안에 속한 Team 데이터는 나중에 필요할 때 쿼리를 날려서 가져오는 것이 훨씬 이득이다.
이것이 지연 로딩이다.
하지만 그만큼 쿼리가 여러 번 나가는 단점이 있다.
즉시 로딩은 join을 통해 한 번에 가져오는 기능이다.
Member를 가져올 때 Team을 같이 가져온다.
Team을 실제 가져오는 시점에는 이미 로딩된 데이터(Member를 가져올 때 가져온 데이터)를 사용한다.
// 지연 로딩
@Entity
class Member {
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
Member member = memberDAO.find(memberId);
// -> SELECT * FROM MEMBER
Team team = member.getTeam();
String teamName = team.getName();
// -> SELECT * FROM TEAM
// 즉시 로딩
@Entity
class Member {
@ManyToOne(fetch = FetchType.EAGER)
private Team team;
}
Member member = memberDAO.find(memberId);
// -> SELECT M.*, T.* FROM MEMBER JOIN TEAM ...
Team team = member.getTeam();
String teamName = team.getName();
실무에서는 일단 지연 로딩으로 해놓고 최적화가 필요한 부분에 즉시 로딩을 사용한다.