요구사항에 맞는 소프트웨어를 개발하기 위해서는 현실세계의 데이터를 필요에 맞게 데이터베이스에 저장하는 작업이 필수적이다. 이를 위한 과정에서 우리는 JAVA
와 같은 언어를 사용해 객체 지향 프로그래밍(OOP)
을 사용하며 이러한 과정을 거쳐 관계형 데이터베이스(RDB)
에 필요한 정보를 저장한다.
여기서 문제 아닌 문제가 발생한다. 객체 지향 프로그래밍은 클래스를 이용하고 관계형 데이터베이스는 테이블을 이용하는데 우리가 생성한 객체 모델과 관계형 모델 간의 불일치가 존재한다. 이러한 문제를 해결하기 위해 나온 개념이 바로 ORM
이다.
이렇듯 위와 같이 데이터를 매핑하여 데이터베이스에 저장하는 방법을 떠올려 보면 다양한 키워드가 존재하는데 이번 포스팅에서는 하이라이트 된 부분을 중점적으로 설명하였다.
ORM은 Object Relational Mapping 즉, 객체-관계 매핑의 줄임말이다.
쉽게 말하자면, 우리가 OOP에서 사용하는 객체와 RDB에서 사용하는 테이블을 자동으로 연결하는 방법을 말한다.
위와 같이 ORM
은 프로그래밍 언어의 객체
와, 관계형 데이터베이스의 테이블
사이에서 데이터를 자동으로 매핑(연결)
해주는 중계자(통역자)
역할을 하는 녀석이다. Spring의 핵심인 MVC 패턴에서 Model을 기술하는 도구로 객체와 모델 사이의 관계를 기술한다.
앞서 설명했듯 ORM 사용의 주된 이유는 객체와 테이블의 불일치이다.
ORM 사용의 세부적인 이유와 목적은 아래와 같다.
👉 직관적인 코드 (가독성) + 비지니스 로직 집중 가능 (생산성)
👉 재사용 및 유지보수 편리성 증가
👉 DBMS의 종속성 저하
ORM을 사용하는 것은 매우 편리하지만 그만큼 신중하게 설계해야한다. 프로젝트의 복잡성이 커질 수록 난이도도 올라가고 부족한 설계로 잘못 구현되었을 경우 속도 저하 및 일관성을 무너뜨리는 문제점이 생길 수 있다. 또한 일부 자주 사용되는 대형 SQL문은 속도를 위해 별도의 튜닝이 필요하기 때문에 결국 SQL문을 써야할 수도 있다. 아래에는 객체와 관계 간의 불일치가 생기는 여러가지 특성(상황)에 대해 설명하였다.
👉 세분성(Granularity)
경우에 따라서 데이터베이스에 있는 테이블 수보다 더 많은 클래스를 가진 모델이 생길 수 있다.
👉 상속성(Inheritance)
RDBMS는 객체지향 프로그래밍 언어의 특징인 상속 개념이 없다.
👉 일치(Identity)
RDBMS는 기본키(primary key)를 이용하여 동일성을 정의한다. 그러나 자바는 객체 식별(a==b)과 객체 동일성(a.equals(b))을 모두 정의한다.
👉 연관성(Associations)
객체지향 언어는 방향성이 있는 객체의 참조(reference)를 사용하여 연관성을 나타내지만 RDBMS는 방향성이 없는 외래키(foreign key)를 이용해서 나타낸다.
👉 탐색(Navigation)
자바와 RDBMS에서 객체를 접근하는 방법이 근본적으로 다르다. 자바는 그래프형태로 하나의 연결에서 다른 연결로 이동하며 탐색한다. 그러나 RDBMS에서는 일반적으로 SQL문을 최소화하고 JOIN을 통해 여러 엔티티를 로드하고 원하는 대상 엔티티를 선택하는 방식으로 탐색한다.
그렇다면 JPA란 무엇일까?
JPA란 Java Persistence API의 약자로 자바 진영의 ORM 표준을 말한다.
쉽게 말해 자바 진영에서 ORM 기술 표준으로 사용하는 인터페이스의 모음을 말하며, 이는 곧 자바 어플리케이션에서 관계형 데이터베이스를 사용하는 방식을 정의한 인터페이스를 뜻한다. 인터페이스이기 때문에 구현체를 가지고 있으며 이는 아래에서 설명하였다.
위와 같이 JPA는 여러 구현체를 두고 있다. 그 중 대표적으로 자바에서 사용하고 있는 구현체는 Hibernate 이다.
나아가 Spring Data JPA란 Spring에서 Hibernate를 조금 더 간편하게 사용하기 위해 추상 객체를 한번 더 감싸 놓은 것을 말한다.
정리하자면, 어플리케이션과 데이터베이스를 연결해주는 부분이 ORM
이며 자바 진영에서는 ORM 표준으로 JPA
라는 인터페이스를 만들어 제공하고 있다. 또한 JPA의 실제 구현 클래스들을 모아놓은 것이 Hibernate
이며 그 중에 자주 쓰이는 기능들을 좀 더 사용하기 쉽도록 Spring Framework 차원에서 묶어놓은 것을 Spring Data JPA
라 한다.
위와 같이 JPA는 JAVA 어플리케이션과 JDBC 사이에서 동작한다. 개발자가 JPA를 사용하면 JPA 내부에서 JDPC API를 사용해 SQL을 호출하여 DB와 통신한다. 다시 말해, 개발자가 JDBC를 직접적으로 사용하는게 아니라 JPA를 통해 사용한다. 이해를 돕기 위해 JPA를 사용하여 데이터를 저장하고 조회하는 과정을 나누어 예시를 통해 설명하였다.
👉 저장 과정
👉 조회 과정
두 경우 마찬가지로 개발자가 직접 SQL 쿼리문을 작성하는게 아니라 JPA를 사용하면 JPA 내부적으로 적절한 SQL 쿼리를 생성한다. 따라서 우리가 사용하는 Object
와 RDB 테이블
의 패러다임 불일치를 해결할 수 있다.
JPA를 사용해야 하는 이유는 앞서 살펴본 ORM의 사용 이유와 유사하다. 핵심은 SQL에 의존적인 개발에서 벗어나 조금더 자바스러운 개발을 하기 위함이다.
👉 1. SQL 중심적인 개발에서 객체 중심으로 개발
JPA를 사용하지 않으면 개발자는 JDBC API를 사용하여 직접 SQL 쿼리문을 작성해 DB에 날려야 한다. 프로젝트가 커지고 질의의 복잡도가 증가하게 되면 이러한 과정이 매우 번거롭다. JPA의 사용은 이러한 문제를 해결하고 SQL에 의존적인 개발에서 벗어나 객체 중심의 개발을 가능케 한다.
👉 2. 생산성 증대
JPA를 사용하는 것은 마치 Java Collection에 데이터를 넣었다 빼는 것처럼 사용할 수 있게 만든 것이다. 이처럼 CRUD를 간단한 메서드를 통해 사용할 수 있다.
특히, 수정이 굉장히 간단한데 객체를 변경하기만 하면 JPA가 알아서 DB에 UPDATE 쿼리를 날려준다.
👉 3. 유지보수 용이
위와 같이 JPA를 사용하지 않았다면 필드가 변경되었을 경우 모든 SQL을 수정해야 한다. 하지만 JPA를 사용하면 필드를 그냥 추가하기만 하면 된다. 관련된 SQL은 JPA가 알아서 처리하기 때문에 손 댈 것이 없다.
👉 4. Object와 RDB 간의 패러다임 불일치 해결
예를 들어 Album이라는 객체가 Item 객체를 상속 받아 생성되었으며 우리는 album이라는 객체를 저장하고 조회할 일이 있다고 가정해보자.
* 저장
개발자가 할 일
-> jpa.persist(album);
나머진 JPA가 처리
-> INSERT INTO ITEM ...
-> INSERT INTO ALBUM ...
* 조회
개발자가 할 일
-> Album album = jpa.find(Album.class, albumId);
나머진 JPA가 처리
SELECT I., A. FROM ITEM I JOIN ALBUM A ON I.ITEM_ID = A.ITEM_ID
JPA는 JDBC API와 DB 사이에 존재하기 때문에 캐싱 및 버퍼렁 기능이 존재하며 이를 사용하여 JPA를 통해 성능을 개선할 수 있다. 아래에는 캐싱 / 버퍼링을 사용한 성능 개선의 예시를 설명했다.
👉 1차 캐시와 동일성(identity) 보장 - 캐싱 기능
String memberId = "100";
Member m1 = jpa.find(Member.class, memberId); // SQL
Member m2 = jpa.find(Member.class, memberId); // 캐시 (SQL 1번만 실행, m1을 가져옴)
println(m1 == m2) // true
코드를 살펴보면 같은 트랜잭션 안에서는 같은 엔티티를 반환하고 있다. memberId를 통해 조회한 Member 정보를 m1, m2에 동일하게 저장하는 메서드를 총 2번 호출했지만 m2의 경우에는 SQL 쿼리를 실행하는 것이 아니라 캐시 메모리에서 값을 가져온다.
이처럼 JPA를 사용하면 1차 캐시와 동일성을 보장해주며 이를 통해 약간의 조회 성능이 향상된다.
👉 트랜잭션을 지원하는 쓰기 지연(transactional write-behind) - 버퍼링 기능
INSERT
/** 1. 트랜잭션을 커밋할 때까지 INSERT SQL을 모음 */
transaction.begin(); // [트랜잭션] 시작
em.persist(memberA);
em.persist(memberB);
em.persist(memberC);
// -- 여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
// 커밋하는 순간 데이터베이스에 INSERT SQL을 모아서 보낸다. --
/** 2. JDBC BATCH SQL 기능을 사용해서 한번에 SQL 전송 */
transaction.commit(); // [트랜잭션] 커밋
코드를 살펴보면 transaction.begin() 메서드를 호출하는 순간부터 트랜잭션이 시작하고 이 시점부터 INSERT SQL을 DB로 날리지 않고 모으기 시작해 INSERT SQL을 메모리에 차곡차곡 쌓는다. transaction.commit() 메서드가 호출되어 트랜잭션이 종료되고 commit이 실행되면 메모리에 쌓아두었던 SQL 쿼리를 한번에 DB로 전송한다. 이 때 SQL을 한번에 전송하기 위해 JDBC Batch SQL 기능을 사용하며 지연 로딩 전략(Lazy Loading) 옵션을 사용한다.
이렇게 트랜잭션을 통해 SQL 쿼리를 한번에 보내는 이유가 무엇일까? 트랜잭션을 사용하지 않고 INSERT SQL 쿼리가 필요할 때마다 DB에 쿼리를 날리게 되면 해당 수요 만큼 네트워크를 통해 DB에 쿼리가 전달된다. 하지만 트랜잭션을 통해 쿼리를 모아서 한번에 날리게 되면 단 한번의 네트워킹을 통해 작업을 처리할 수 있다.
UPDATE
/** 1. UPDATE, DELETE로 인한 로우(ROW)락 시간 최소화 */
transaction.begin(); // [트랜잭션] 시작
changeMember(memberA);
deleteMember(memberB);
비즈니스_로직_수행(); // 비즈니스 로직 수행 동안 DB 로우 락이 걸리지 않는다.
// 커밋하는 순간 데이터베이스에 UPDATE, DELETE SQL을 보낸다.
/** 2. 트랜잭션 커밋 시 UPDATE, DELETE SQL 실행하고, 바로 커밋 */
transaction.commit(); // [트랜잭션] 커밋
코드를 살펴보면 트랜잭션이 시작되고 update 또는 delete 연산을 수행하고 비즈니스 로직을 수행한다. 이 때 DB에서 로우 락이 발생하는 것을 막기 위해 쿼리의 실행을 트랜잭션으로 감싸고 쿼리문을 DB로 보내지 않고 있다가 트랜잭션이 종료되어 커밋이 되는 순간 UPDATE 및 DELETE 쿼리를 실행하게 된다.