1. 개요


Java를 이용해 DB 작업을 할때 최초에는 가장 네이티브한 JDBC API를 이용했습니다. JDBC API를 사용해보신 분들은 아시겠지만, 항상 Connection 객체를 받아와서.. SQL 코드 적고, 실행한 후 마지막에는 .close() 로 자원을 해제해줘야 합니다. 중복 및 불필요한 코드 때문에 생산성이 너~무나 떨어졌습니다. 그래서 자원 관리는 누가좀 알아서 해주고 개발자는 SQL 코드만 신경쓸 수 있게하자 해서 나온게 MyBatis, Spring JDBC와 같은 SQL 매퍼입니다. 

 

SQL 매퍼를 사용하면 자원 관리를 알아서 해주니 너무 편리했습니다.
하지만 개발자가 SQL을 직접 작성한다는 것으로 인해 여전히 몇 가지 문제가 남아있었습니다.

// 개발자가 SQL을 직접 작성해야한다면..
// 조회 방식이 아주 살~짝만 달라져도 SQL을 새로 다 작성해줘야합니다.
memberDao.getMember();
memberDao.getMemberWithTeam();
memberDao.getMemberWithOrderWithDelivery();

위 코드처럼 조회 방식이 살짝만 달라져도 SQL을 다 작성해줘야 합니다. 얼핏봐도 SQL 코드량이 심상치않게 나올 것 같지 않나요..?
그리고 위와 같은 코드를 Service에서 호출해서 사용할텐데 이때, 가장 큰 문제는 코드를 신뢰할 수 없다는 것입니다.

  • 여러 Entity를 조회해오는 과정에서 null 체크를 다 해줘야 한다.
  • SQL을 개발자가 직접 짜다보니 실수는 하지 않았을까, 어떻게 짰을까 직접 다 확인한 다음에야 비로소 마음놓고 쓸 수 있다.

 

SQL 매퍼로 조회 vs 컬렉션에서의 조회

SQL 매퍼로 객체를 조회해오게 되면 아래와 같이 .getMember() 메서드에서 new 키워드로 객체를 새로 생성해 반환하기 때문에 
같은 키 값으로 불러온 객체들의 참조가 다릅니다.

String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);
member1 == member2; // 다르다!!!

class MemberDAO {
    public Member getMember(String memberId) {
        String sql = "SELECT * FROM member WHERE member_id = ?";
        // ... 중략 
        return new Member(...);
    }
}

그런데 Java 컬렉션에서 같은 키 값으로 객체를 불러오면 항상 같은 객체를 반환합니다.

String memberId = "100";
Member member1 = list.get(memberId);
Member member2 = list.get(memberId);
member1 == member2; // 같다!!!

같은 키 값으로 객체를 조회해오면 항상 동일한 객체가 반환되어야하는게 상식적으로 맞습니다.
객체지향스럽게 객체를 모델링하면 할수록 SQL 쿼리가 많이 복잡해진다거나, 같은 키 값으로 조회하는데 다른 참조를 가진 객체를 반환한다거나 하는 문제가 발생하죠 🤔

 

객체를 Java 컬렉션에 저장하듯이 DB에 저장할 수는 없을까? 라는 물음의 답으로 JPA가 탄생합니다.

2. JPA란?


JPA는 Java ORM 표준 스펙입니다. 여기서 ORM은 Object-Relational Mapping 을 의미합니다.
ORM이 탄생하게된 배경이 객체는 객체대로 설계하고 RDB는 RDB 에 맞게 설계하는걸 추구했기 때문인데요, 이를 위해 ORM 프레임워크가 객체와 RDB 를 중간에서 서로 매핑해주는 역할을 담당합니다. 참고로 대중적인 언어에는 대부분 ORM 기술이 존재한다고 합니다.

 

JPA의 작동 방식은 다음과 같습니다.

[그림 1] JPA의 작동 방식

보시다시피, JPA도 내부적으로는 JDBC API를 이용해 DB와 통신합니다. (당연하겠죠? 🙂 )

JPA 탄생배경

과거에도 ORM이라는 개념이 있었습니다. EJB 시절 만들어놓은 ORM이 있었지만, 사용하기도 까다롭고, 무엇보다 성능이 너~무 별로였습니다. 그래서 그걸 사용하던 한 개발자가 "내가 만들어도 이거보단 낫겠다" 싶어서 만든게 Hibernate 입니다. Hibernate 오픈 소스 프로젝트에 여러 개발자들이 참여하면서 표준처럼 사용되기 시작했고, Java 진영에서는 결국 Hibernate 창시자를 불러 Java ORM 표준 스펙인 JPA를 만들게 되었습니다.
(오픈 소스는 아무래도 거친 면이 있는데, 표준 스펙으로 만들면 용어 정리도 하고 문장도 다듬고 해서 좀 더 부드럽습니다.)

 

JPA를 왜 사용해야 하는가?

  • SQL 중심적인 개발에서 객체 중심으로 개발
  • 생산성과 유지보수
  • 성능
  • 패러다임 불일치 해결
  • 데이터 접근 추상화와 벤더 독립성
  • 표준

JPA의 CRUE

jpa.persist(member);                // 저장
Member member = jpa.find(memberId); // 조회
member.setName("변경할 이름");         // 수정
jpa.remove(member);                 // 삭제

위 CRUD에서 수정을 보면 .setName()만 하면 DB에 UPDATE 쿼리가 나갑니다.
JPA 사상 자체가 객체를 Java 컬렉션에 저장하는 것처럼 하자인데 우리가 컬렉션에서 조회해온 데이터를 .setName() 으로 수정한 다음 다시 컬렉션에 .add() 하지 않는다는걸 생각해보세요!

 

신뢰할 수 있는 엔티티, 계층

다음과 같이 null 체크를 하거나 SQL이 어떻게 짜여져있나 확인할 필요없이 신뢰성을 가지고 객체 그래프를 탐색할 수 있습니다.
만약 연관 관계가 아주 많은 객체라면 .find() 에 성능이 문제가 되지 않을까 하는 걱정은 하지않으셔도 됩니다!
JPA에는 지연 로딩이란 기능이 있는데 .getTeam() 메서드를 호출하면 이 시점에 SQL을 날려 Team 객체를 채워 반환합니다.

class MemberService {
    public void process() {
        Member member = memberDAO.find(memberId);
        member.getTeam(); // 자유로운 객체 그래프 탐색 
        member.getOrder().getDelivery();
    }
}

 

 

동일한 트랜잭션에서 조회한 엔티티는 같음을 보장

JPA의 이 특징 덕분에 트랜잭션 Isolation Level이 Read Commit이어도 Repeatable Read를 보장합니다.

String memberId = "100";
Member member1 = jpa.find(Member.class, memberId);
Member member2 = jpa.find(Member.class, memberId);
member1 == member2; // 같다!!!

 

JPA의 성능 최적화를 위한 기능

JPA를 사용하면 성능상 문제가 되지는 않을까라는 생각이 들 수 있는데, 한 번 생각해봅시다.
계층 사이에 중간 계층이 있으면 항상 할 수 있는게 있습니다.

  1. 모아서 쏘는 버퍼링
  2. 읽을 때 캐싱하기

위 두 개를 할 수 있기 때문에 JPA를 잘 다루면 오히려 성능을 더 끌어올릴 수 있습니다.
다음은 JPA에서 성능 최적화를 위해 지원하는 기능입니다.

  • 1차 캐시와 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
  • 지연 로딩(Lazy Loading)

 

위 세 가지 기능이 무엇인지 하나씩 살펴보겠습니다.

첫 번째, 1차 캐시와 동일성 보장은 아래와 같이 동일한 키 값으로 조회를 하면 항상 동일함을 보장하고, 여러 번 읽을 때는 캐시에 있는 것을 바로 읽는다는 것입니다. 재밌는건 위에서 얘기했듯 JPA 자체적으로 지원하는 동일함의 특징 덕분에 트랜잭션 Isolation Level이 Read Commit이어도 애플리케이션에서 Repeatable Read를 보장한다는 것입니다. 

(여기에서의 캐시는 한 트랜잭션 내에서의 캐시이기 때문에 실제 성능 상의 이점은 미미한 정도입니다.)

String memberId = "100";
Member m1 = jpa.find(Member.class, memberId); // SQL
Member m2 = jpa.find(Member.class, memberId); // 캐시
println(m1 == m2); // true
// SQL을 1번만 실행합니다.

 

두 번째, 트랜잭션을 지원하는 쓰기 지연을 살펴보겠습니다.
계층 사이에 중간 계층을 두면 모아서 쏘는 버퍼링이 가능하다고 했는데, 이러한 특징을 얘기합니다.
트랜잭션을 커밋할 때까지 INSERT SQL을 모았다가, 커밋하면 한 번에 SQL을 DB로 전송합니다.
(내부적으로 JDBC Batch 기능을 사용합니다.)

transaction.begin(); // 트랜잭션 시작 
entityManager.persist(memberA);
entityManager.persist(memberB);
entityManager.persist(memberC);
// 여기까지 INSERT SQL을 DB에 보내지 않습니다.

// 커밋하는 순간 DB에 모아놨던 INSERT SQL을 보냅니다. (시간이 오래걸리는 네트워크 작업을 최소화)
transaction.commit(); // 커밋

세 번째, 지연 로딩을 살펴보겠습니다.

Member member = memberDAO.find(memberId);
Team team = member.getTeam(); // 이 시점에 member의 Team 객체를 SELECT로 채웁니다.