이 포스트는 김영한님의 "자바 ORM 표준 JPA 프로그래밍"을 참조하였음을 알립니다.
이론 공부로 공부를 시작하는 것을 선호하지 않는 타입이라 무지성으로 Spring Boot로 블로그를 만드는 미니 프로젝트를 해보는 중이었는데, 점점 JPA와 영속성 컨텍스트에 대한 이해도 부족으로 인한 이슈 해결 능력이 부족함을 느끼게 되었다.
하지만 그런 시도가 있었기 때문에 이번에 다룰 내용을 훨씬 쉽게 이해할 수 있었던 것도 사실이다.
만약, 이 포스트를 참고할 사람이 있다면 이론 공부도 중요하지만 기본적인 CRUD 기능을 한 번 구현해보는 게 좋다고 생각한다.
목차
1. 객체와 테이블의 패러다임 불일치
2. What is JPA(Java Persistence API)?
3. Entity Manager
4. 영속성 컨텍스트의 동작
5. Flush
1. 객체와 테이블의 패러다임 불일치
'JPA와 EntityManager가 무엇인가?'를 다루기 이전에 이해해야 할 내용이 있다.
아마 이 내용을 검색해볼 사람들이라면 JPA가 자바와 데이터 베이스 사이에서 무언가를 해주는 역할이라는 것 정도는 알 것이다. (몰랐어도 지금 알면 된다.)
'그런데 이게 대체 왜 필요한가?' 라는 원초적인 질문을 짚고 넘어가지 않으면 공부를 해도 이해가 잘 되지 않을 것이다.
Spring으로 API를 개발했을 때, Application 메모리에 모든 정보를 올려두는 건 부적절한 방법이다.
따라서 이 정보들을 어딘가에 영구적으로 저장할 곳이 필요한데, 그것이 바로 데이터 베이스이다.
문제는 여기서 발생한다.
자바는 객체 지향 언어지만 데이터 베이스는 말 그대로 데이터 중심으로 구조화가 되어 있다.
대용량 트래픽이 발생했을 때, 객체 지향 프로그래밍의 상속, 다형성 등의 특징으로 이 문제를 해결할 수 있지만
애플리케이션에 모두 저장해둘 수는 없으니 데이터 베이스에 저장해야 하는데, 정작 DB에는 상속이나 다형성이라는 개념이 존재하지 않는다.
반대로 DB에서 사용하는 외래키의 개념이 객체에는 존재하지 않는다.
즉, 프레임 워크에서 아무리 데이터들을 잘 관리하더라도 DB에 저장하고 조회하는 과정에서 난관에 봉착한다.
물론 방법이 없는 것은 아니지만, 객체 지향 모델링을 포기하고 SQL에 의존적인 개발을 하게 됨으로써 코드의 유지·보수 측면으로 보나, 효율성 측면으로 보나 모든 성능들이 뒤떨어지게 된다.
조금 더 구체적으로 문제들을 파헤쳐 보자.
1. 상속 문제
Item이라는 클래스를 상속받는 Album, Movie, Book 객체가 존재한다고 가정해보자.
이걸 그나마 유사하게 DB에서 구현하고자 한다면 Item객체의 pk값을 참조하고 연관관계를 이어주면 될 것이다.
그리고 Item에서 Type Column을 이용하면 부모 자식간의 관계를 DB가 알 수 있게끔 만들 수 있다.
그럼 이 객체를 실제로 저장할 때는 두 가지 방법이 있을 것이다.
1. Item 정보와 자식 정보를 한 테이블에 넣고 Album, Movie, Book 테이블 생성 (좋지 않은 생각이다.)
2. Item 테이블과 자식 테이블의 데이터를 분리하여 두 번 INSERT 하기
이렇게 되면 하나의 객체에서 정보를 분리하고 SQL문을 작성한 다음 DB에 요청을 보내야 하는데, 작성할 양도 방대하지만 수정 사항이 생겼을 때도 어지간히 골치 아파진다. (심지어 자식 타입도 판단해서 기입해주어야 한다.)
조회할 때도 2번 SELECT SQL을 보내 객체를 조합해야하는데 구현이 불가능하진 않지만 기획자가 수정안을 들고 오면 눈물을 흘리며 쓰러지지 않을 자신이 없다. 그것도 피눈물 좔좔
JPA는 이 문제를 해결하기 위해 객체를 저장해둔다.
(Application에 데이터를 모두 저장하는 개념과는 다르다. 그때그때 필요한 정보들만 뽑아서 관리해주는 것)
em.persist(album);
그리고 SQL문을 실행해 두 테이블에 나누어 저장해준다.
INSERT INTO ITEM
INSERT INTO ALBUM
조회의 경우엔 두 테이블을 조인하여 알아서 처리해준다.
여기서 개발자가 jpa에 등록만 해주면 이후는 jpa가 알아서 관리해준다.
2. 연관 문제
객체는 연관관계에 놓여 있는 객체 정보를 가져오기 위해서는 참조를 이용한다.
class Member {
Team team;
(...)
}
하지만 이 코드는 Member에서 team 객체를 가져올 수 있지만, Team에서 member 정보를 가져오진 못 한다.
즉, member.getTeam()은 가능해도 team.getMember()는 다른 수단을 취해야 한다.
반면, 테이블은 FK만 설정해준다면 양방향으로 조회가 가능하다.
SELECT M.*, T.* FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
그렇다면 객체를 table처럼 저장한다고 치고 teamId값을 저장하면 되지 않을까 싶지만,
이건 객체 지향 모델링을 포기하고 SQL에 종속된 프로그래밍을 하게 되는 지름길이다.
결국 참조를 쓰긴 써야하는데 테이블에 외래키를 지정해주기 위해서는 복잡한 방법이 쓰여야 한다.
// save
Long team_id = member.getTeam().getId();
member.setId(team_id);
(...SQL로 member 객체 저장)
// search
Member member = new Member();
(...SQL로 불러온 데이터 member에 저장)
Team team = new Team();
(...SQL로 불러온 데이터 team에 저장)
member.setTeam(team);
저장도 문제지만 조회를 위해서 참조하고 있는 객체를 매번 전부 불러와야 하는 소모 비용이 든다.
이 문제도 아까처럼 객체를 자바 컬렉션에 저장하여 관리한다면 비용이 들지 않는다.
persist를 이용하여 객체 정보를 저장해주기만 하면 관계를 매핑하는 건 JPA가 알아서 처리해준다.
// save
member.setTeam(team);
em.persist(member);
// search
Member member = em.find(Member.class, member_id);
Team team = team.getTeam();
하지만 아이디어만 놓고 봤을 때, 아직까진 JPA 덕분에 편한 건 사실이지만 굳이 JPA가 아니어도 직접 구현할 수 있는 수준이다.
SQL에 의존적인 개발을 하든, JPA의 아이디어를 딴 특정 로직을 구현하든 귀찮다는 레벨이 좀 더 큰 정도다.
하지만 JPA의 강력한 기능은 아직 더 많이 남아있다.
3. 그래프 탐색 문제
객체에 똑같은 정보가 담겨있다고 가정할 때, 참조를 이용해서 자유롭게 탐색이 가능해야 한다.
user.getPet().getPrescription()...
이게 가능하려면 조회하려는 모든 객체가 Application에 올라와 있어야 한다는 말인데, 이는 DB를 쓰는 목적에 부합하지 않는다.
그 말은 즉슨, user.getPet() 까지는 DAO에 SELECT SQL문을 작성하여 조회가 가능할 수 있어도 getPrescription()은 보장하지 않는 말이 된다.
물론 이것도 해결할 수 있는 방법이 있긴 하다. 모든 경우에 대해 SQL문을 작성하면 된다.
userDAO.getPet()
userDAO.getPetWithPrescription()
userDAO.getPetWithPrescriptionWith...()
문제가 발생한 원인을 살펴보면 해결할 수 있다.
결국 객체 그래프를 신뢰할 수 없기 때문에 하드 코딩이 필요해진 것인데 JPA는 이를 보장해준다.
User user = em.find(User.class, user_id); // SELECT USER SQL
// 설정에 따라 USER를 호출했을 때, 관련 PET을 함께 조회할지 말지 정할 수 있다.
Pet pet = user.getPet();
pet.getPrescription(); // 이 시점에 SELECT PRESCRIPTION SQL (조정가능)
만약 user를 호출할 때 pet을 동시에 호출하겠다고 한다면 JPA는 JOIN을 사용하여 한 번에 호출할 것이다.
하지만, prescription은 이후에 필요하다고 판단될 때 호출한다고 한다면 JPA는 prescription 호출을 미룬다.
이걸 바로 지연 로딩이라고 한다.
그러다 pet에서 get 함수를 써서 호출하면 그 때 SQL문을 호출하여 데이터를 조회한다.
따라서 객체 그래프를 신뢰하고 마음껏 탐색이 가능해진다.
4. 비교 문제
테이블은 PK 값으로 데이터들을 구분하지만, 객체의 경우에는 동일성(==) 혹은 동등성(equals())으로 구분한다.
다음의 코드를 고려해보자.
User user1 = userDAO.getUser(user_id);
User user2 = userDAO.getUser(user_id);
user1 == user2 // False
같은 user_id 값으로 객체를 조회했을 때, user1과 user2는 같아야 하지만 동일성 비교에서 실패한다.
왜냐하면 user1과 user2는 같은 값을 가지지만 다른 주소를 참조하는 별개의 인스턴스이기 때문이다.
재밌게도 이 문제를 해결하는 방법도 객체를 컬렉션에 보관하면 다음처럼 해결할 수 있다.
User user1 = list.get(0);
User user2 = list.get(0);
user1 == user2 // true
여러 트랜잭션이 동시에 작동하는 과정에서 데이터 베이스의 같은 레코드를 조회하는 같은 인스턴스를 리턴하도록 구현하는 것은 아이디어가 있다고 쉽게 해결할 수 있는 문제가 아니다.
하지만 JPA는 위의 동작을 성공적으로 수행한다고 보장한다.
지금까지 내용을 요약하자면 JPA는 어딘가에 애플리케이션 단에서 유지되는 어떤 컬렉션에 객체 정보를 유지하면서 데이터 베이스와 상호작용하는 모습을 보인다는 것을 알 수 있을 것이다.
그렇다면 JPA란 무엇이고, 어떻게 이런 것들이 가능하게 할 수 있는 것일까?
2. What is JPA(Java Persistence API)?
Spring Boot를 처음 공부할 때 제일 힘들었던 점은 API 개발을 Django로 가장 처음 해봤기 때문에 공부를 하는 전략을 잘못 세웠기 때문이다.
Django는 별다른 설정을 해주지 않아도 아주 손쉽게 ORM 기능을 사용할 수 있었고, 권한과 같은 기능들이 제공되었기에 비슷한 방식으로 접근했다가 한참을 고생했었다.
Spring Boot와 Spring Security와 JPA는 서로 별개의 프레임워크 내지 라이브러리가 협력하고 있다고 보는 것이 맞다.
그렇기 때문에 하나하나가 상당히 난이도 있고 숙련이 필요한 것이다.
JPA는 자바 진영의 ORM(Object-Relationship Mapping) 기술 표준이다.
JPA는 인터페이스 역할만 하고 실제 동작은 다른 ORM 프레임 워크를 선택해서 사용한다.
그 중에서 가장 많이 사용하는 ORM 프레임 워크가 바로 Hibernate이다.
ORM은 영속성이라고 하며, 객체와 테이블을 매핑하여 개발자가 코드에만 집중할 수 있도록 도와주는 기술이다.
즉, 객체의 매핑관계와 어노테이션으로 적절한 정보만 기입해준다면 개발자는 객체 지향 모델링에 신경 쓰기만 하면 되고 DB에 관리하는 것은 JPA가 알아서 해준다.
자세한 동작은 밑에서 알아보도록 하고, 간략하게 먼저 정리해보면 jpa에게 객체를 persist하여 등록을 해주면 JPA가 적절한 SQL문을 생성하여 DB에게 요청하는 방식이다.
그렇다면 왜 굳이 JPA인 것일까?
- 생산성 : CRUD SQL문, DDL문 자동 생성
- 유지·보수 : SQL문 작성을 JPA가 알아서 작성하므로 코드 수정 용이
- 패러다임 불일치 해결
- 성능 : 같은 트랜잭션 내에서 같은 레코드를 두 번 조회해도 DB를 한 번만 조회할 수 있고, SQL 힌트 작성 가능
- 데이터 접근 추상화와 벤더 독립성 : DB마다 추상화된 접근 계층을 제공. 사용할 SQL을 알려주기만 하면 됨.
물론, 어느정도 한계점도 존재하긴 한다.
보통 이런 인터페이스나 프레임 워크들은 정형화된 부분에 대해서 개발자들이 신경쓰지 않도록 도와주는 것이 목표다 보니
복잡한 쿼리문과 같은 경우에는 JPA를 사용하는 것이 더 효율성이 떨어질 수도 있다.
그럴 때는 JPA 네이티브 SQL이나, 마이타비스를 사용하여 보다 로우 레벨에서 비지니스 로직을 처리해야 하는 경우도 있지만
대부분의 경우에는 해당되지 않는 내용이다.
3. Entity Manager
Entity Manager는 JPA가 제공하는 가상의 DB로써 위에서 설명했던 패러다임 불일치 문제를 해결하기 위해 "자바 컬렉션에 객체를 저장하듯" 다루는 방법이라고 보면 된다.
EntityManagerFactory: 생성 비용이 크다. 한 개만 만들어서 Connection Pool을 Application 전체에서 공유한다.
EntityManager: 비용이 적으며 여러 스레드가 동시에 접근하면 동시성 문제가 발생하므로 공유되어서는 안 된다.
그림으로 그려보면 위와 같이 설명할 수 있다.
request가 들어오면 각각의 요청에 대해 EntityManagerFactory가 EntityManager을 생성하여 정보를 담는다.
EntityManager는 DB 작업이 필요하지 않다면 커넥션 풀을 얻지 않는다. (애초에 그럴 필요가 없으니까)
트랜잭션을 시작하는 경우에 커넥션을 획득하여 DB에 접근한다. (DB 작업이 필요하면 커넥션 획득)
더 정확히 말하자면 커넥션 풀을 만드는 방식은 J2SE 환경에서고 J2EE환경에서는 또 다른 방법을 사용한다.
📌 Persistence Context (영속성 컨텍스트)
Entity를 영구 저장하는 환경이다.
EntityManager에 HTTP 요청을 보냈을 때, Entity를 조회 및 수정을 하면 EntityManager는 영속성 컨텍스트에 엔티티를 보관하여 관리한다.
em.persist(domain)을 이용하여 특정 도메인 객체를 어딘가에 저장하고 있다고 보면 된다.
em.find()를 통해 DB에서 레코드를 참조해올 때도 마찬가지다.
📌 Entity LifeCycle
엔티티는 4가지 생명주기를 갖는다.
- 비영속(new/transient): 영속성 컨텍스트와 무관한 상태
- 영속(managed): 영속성 컨텍스트에 저장된 상태
- 준영속(detached): 영속성 컨텍스트에 저장되어 있다가 분리된 상태
- 삭제(remove): 삭제된 상태
준영속과 삭제를 개발자가 직접 컨트롤할 일은 잘 없다.
우선 flow를 설명해보자면 다음과 같다.
처음 Member member = new Member(); 을 이용하여 객체를 생성해도 EntityManager는 이 객체를 관리하지 않는다.
EntityManager에 등록하기 위해서는 반드시 다음 메서드가 호출되어야 한다.
public class MemberRepo implements MemberRepoInterface {
private final EntityManager em;
public MemberRepo(EntityManager em) {
this.em = em;
}
@Override
public Member save(Member member) {
em.persist(member);
return member;
}
}
persist 메서드의 인자로 도메인 객체를 넘겨주면 EntityManager가 member을 영속성 컨텍스트에 등록하여 관리한다.
이 상태가 되면 member 객체가 영속 상태에 들어가있다고 판단한다.
혹은 DB에서 값을 꺼내왔을 때도 마찬가지로 EntityManager가 영속성 컨텍스트에 등록한다.
여기서 em.detach(member)로 member 엔티티를 분리시키거나 em.clear()를 호출하여 영속성 컨텍스트를 비워버리면 준영속 상태이고
em.remove(member)를 호출하여 member를 영속성 컨텍스트와 데이터베이스에서 삭제할 수도 있다.
4. 영속성 컨텍스트의 동작
그렇다면 EntityManager는 어떤 원리로 위의 패러다임을 해결하고 이런 기능들을 구현할 수 있는 걸까?
📌 Persistence Context (영속성 컨텍스트)의 특징
EntityManager에게 관리해야할 도메인을 알려주면 EntityManger는 도메인에 @Id로 매핑되어 있는 정보를 키값으로 영속성 컨텍스트에 등록한다.
@Entity(name="user")
@Table(name="USER")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
@Column(name="USER_ID")
private Long id;
(...)
}
1차 캐시라는 곳에 저장을 하는데, 이게 바로 자바 컬렉션에 저장하여 객체를 관리하는 것이다.
persist 메서드나 find로 등록되어 영속화된 객체에 대해 적절한 SQL문을 작성해놓고 대기하다가 transaction을 commit할 때 DB에 요청을 보내 반영한다. (이를 flush라고 한다.)
이렇게 되면 JPA는 언제나 동일성을 보장하며, Transaction을 지원하는 쓰기 지원, 지연 로딩, 변경 감지 등을 수행할 수 있다.
📌 조회
생성자로 비영속 상태의 객체를 만들고 EntityManger에 등록하면 오른쪽처럼 1차 캐시에 저장이 된다.
만약, 만약 DB에 같은 정보의 member 정보가 있다고 가정하고 find를 수행하면 DB를 조사하지 않는다.
EntityManger는 우선 1차 캐시를 조회한 후에 값이 없는 경우에만 DB를 조회한다.
이러한 이유로 같은 트랜잭션 내에서 서로 다른 인스턴스에 동일한 row의 값을 가져오게 해도 같은 인스턴스임을 보장할 수 있다. (동일성 보장)
반대로 1차 캐시에 없는 값을 조회하면 DB를 탐색하고 1차 캐시에 등록한 다음 값을 리턴하게 된다.
📌 등록
위에서는 persist에 도메인을 넘기면 1차 캐시에 등록하는 과정에 대해서만 언급했지만, 사실 다른 작업이 더 존재한다.
바로 적절한 SQL문을 작성하는 과정이다.
EntityManager가 member1의 정보를 알게되면 1차 캐시에 등록하고 INSERT SQL문을 작성하여 기억해둔다.
이걸 쓰기 지연이라 부르는데, 등록할 때마다 바로바로 DB에 저장하는 것이 아니라
커넥션을 얻고 트랜잭션이 commit 될 때 flush를 통해 한 번에 요청을 보낸다.
여기서 주의해야 할 것은 transaction에서 commit 메서드를 호출한 시점과 실제 트랜잭션이 commit되는 시점이 다르다.
EntityManager에 member1과 member2를 등록하고 트랜잭션을 커밋하면 우선 flush를 통해 DB에 SQL문을 보낸다.
아직은 INSERT SQL문밖에 보여주지 않았지만, 이 단계에서 조회, 등록, 삭제한 엔티티에 대한 쿼리를 통해 데이터를 동기화한다.
transaction.commit() 호출 → flush() → transaction commit
📌 수정
SQL문으로 작성하면 코드 유지, 보수가 굉장히 힘든데, JPA는 이를 변경 감지(Dirty Check)를 통해 해결한다.
예를 들어, em.find(Member.class, "member1")로 member1이라는 엔티티 정보를 얻어서 정보를 수정했다고 치자.
transaction.begin();
Member member1 = em.find(Member.class, "member1");
member1.setName("변경");
transaction.commit()
중간에 마치 'em.update()라는 기능이 들어가야 하지 않을까?' 라는 의문이 들 것이다.
실제로 내가 처음 무지성으로 Spring을 공부할 때 그랬었지만 EntityManager에 update란 메서드는 존재하지 않는다.
놀랍게도 업데이트 기능은 다음의 코드로 작성할 수 있다.
public class UserRepo implements UserRepoInterface {
private final EntityManager em;
public UserRepo(EntityManager em) {
this.em = em;
}
@Override
public User save(User user) {
if (user.getId() == null) {
em.persist(user);
return user;
} else {
return em.merge(user);
}
}
(...)
}
현재 인자로 받은 user entity에 id값이 있다면 업데이트라고 판단하여 merge하고, id 값이 없다면 새로운 정보를 추가한다고 판단하여 persist를 진행하는 로직이다.
참고로 update기능의 이름만 다를 뿐 결국 merge가 그 역할을 하고 있는 게 아니냐고 할 수 있지만 원리가 다르다.
merge로 인해 영속성 컨텍스트 내부의 Entity 값이 변경되면 최초 상태의 스냅샷에 정보를 따로 저장한다.
이후 transaction.commit()이 호출되면 flush() 함수가 호출되는데 이 과정에서 캐시를 dirty check하여 UPDATE SQL 작성 여부를 판단한다.
만약, 변경 내역이 존재한다면 쓰기 지연 SQL 저장소에 UPDATE 쿼리를 추가하여 DB에 전달한다.
동작을 이해한다면 UPDATE SQL은 영속 상태의 엔티티만 적용된다는 것또한 쉽게 이해할 수 있을 것이다.
💡 JPA는 UPDATE SQL을 어떻게 작성할까?
결론부터 언급하면 모든 필드를 업데이트 한다.
데이터 전송량은 다소 증가할 수 있으나, 모든 필드를 업데이트 하도록 설정한다면 훨씬 정형화된 기능을 지원하고 수정 쿼리를 재사용할 수 있다는 장점이 있다.
(db에 동일한 쿼리를 보내면 이전에 파싱된 쿼리를 재사용 가능하다.)
물론, 하나의 엔티티에 속성이 너무 많다면 동적으로 SQL문을 작성하도록 어노테이션을 걸 수 있지만
이 경우엔 애초에 모델링 단계에서 역할 분담이 제대로 이루어지지 않은 것이 원인일 가능성이 크다.
💡 merge(병합)에 대하여
merge는 정확히 따지자면 준영속이나 비영속 상태에서 영속 상태로 변경하는 것이다.
영속상태가 아닌 엔티티는 정보를 수정해도 DB에 반영되지 않으므로 merge 메서드의 인자값으로 넘겨줄 경우
가지고 있는 Id 값과 매칭되는 정보를 1차 캐시 혹은 DB에서 탐색한 후, 변경 사항이 반영된 영속 상태의 새로운 인스턴스를 리턴시켜준다.
즉, merge를 시키기 위해 인자로 넘겨줬던 엔티티 또한 여전히 준영속 혹은 비영속 상태로 남아있다.
따라서 가장 안전한 방법은 다음 코드를 사용하는 것이 될 수 있음을 알 수 있다.
member = em.merge(member);
📌 삭제
업데이트 과정을 이해했다면 삭제도 쉽게 이해할 수 있다.
find로 해당 엔티티 정보를 컨텍스트에 올려놓고 remove를 수행하면 컨텍스트에서 삭제하고, flush 단계에서 DB에서도 삭제한다.
public class UserRepo implements UserRepo {
private final EntityManager em;
public UserRepo(EntityManager em) {
this.em = em;
}
@Override
public void delete(User user) {
Assert.notNull(user, "ID must not null");
em.remove(user);
}
}
5. Flush
위의 동작을 모두 이해했다면 flush가 어떤 역할을 하는지 알 수 있다.
영속성 컨텍스트와 DB는 동기화가 되기 전까지 서로 다른 정보를 가지고 있지만,
flush가 호출되었을 때 쌓아놨던 SQL문을 던져줌으로써 데이터 바인딩을 함으로써 동기화가 된다.
Django 덕분에 flush의 처음 용도를 잘못 이해해서 상당히 난해했었는데 절대 영속성 컨텍스트의 내용을 지우는 역할이 아님을 기억하자.
flush는 보통 트랜잭션 커밋 혹은 JPQL 쿼리 시행 시에 자동 호출되지만 직접 호출이 가능하긴 하다.
하지만 실무에서 잘 활용하지 않는데다 라이프 사이클을 온전히 이해하고 있지 않고서야 사용이 권고되지 않는다.
flush 모드에는 2가지가 존재한다.
- FlushModeType.AUTO: 기본값이다. 커밋이나 쿼리를 실행할 때 플러시가 동작한다.
- FlushModeType.COMMIT: 커밋할 때만 플러시가 동작한다.
Commit 모드를 사용하면 경우에 따라 성능 최적화가 가능하다고는 하는데, 이 다음부터는 다시 Spring 개발을 하면서 알아봐야겠다. ^^