Coderoad

Java의 ORM 표준 기술, JPA

2023-07-03 at JPA category

JPA?

제가 주력으로 사용하는 프로그래밍 언어는 Java입니다. 대표적인 객체지향 언어이며, 우리나라 뿐만 아니라 전세계적으로 많이 사용되고 있습니다. 또한, Spring 프레임워크의 등장으로 정말 객체지향다운 객체지향 프로그래밍을 하면서도 쉽고 빠르게 웹 어플리케이션 개발이 가능해졌습니다. 백엔드 개발자를 꿈꾸는 저 역시, Spring 프레임워크와 Spring의 여러 하위 프로젝트들을 공부하면서 객체지향 프로그래밍에 대해 더 깊게 공부할 수 있었습니다.

그런데 첫 팀 프로젝트를 진행하며, 한가지 난관에 봉착했었습니다. 객체를 다루며 프로그래밍하는 것은 어느 정도 익숙해졌는데, 가공한 객체(데이터)를 영구히 저장하는 방법은 잘 알지 못했습니다. 어렴풋이 데이터베이스에 저장해야겠다는 생각은 있었고, 동아리 스터디 시간에 서비스 구현에 사용할 수는 있도록 정말 얉게 학습한 Spring Data JPA를 열심히 구글링해가며 프로젝트를 완성했었습니다. 익숙하지 않은 기술을 잘 모르는 채로 사용하다보니 예상대로 웹 어플리케이션의 성능은 썩 좋지 못했습니다.

팀 프로젝트가 끝난 후, Spring Data JPA의 근간인 JPA(Java Persistence API)를 본격적으로 공부하고자 했습니다. JPA가 ORM(Object Relational Mapping) 기술의 Java 진영 표준이라는 설명을 읽고 처음 든 생각은 ORM 기술이 도대체 무엇인가였습니다.

SQL 중심 개발의 문제점

ORM 기술에 대해 공부하기 위해서는 먼저 객체와 관계형 데이터베이스의 차이에 대해서 알아야 했습니다. NoSQL 데이터베이스를 사용하는게 아닌 이상, 우리는 Java와 같은 객체지향 프로그래밍 언어를 통해 객체를 생성하면, 이를 관계형 데이터베이스에 저장해야 합니다. 어떠한 객체도 데이터베이스에 저장하지 못한다면 사용자들이 어플리케이션을 사용할 수도 없고 굳이 사용할 필요도 없기 때문이죠.

그런데, 객체를 관계형 데이터베이스에 바로 저장할 수는 없습니다. 객체 안에는 많은 속성들이 있고 상속관계, 연관관계 등, 다른 객체와의 복잡한 관계가 있을 수 있습니다. 심지어는 여러 객체를 가지고 하나의 객체로 묶어두는 컬렉션 객체도 있습니다.

ORM 기술이 없었다면, CRUD라고 불리는 등록, 조회, 수정, 삭제 연산들을 위한 SQL을 작성하기 위해서 먼저 Java 객체를 SQL로, SQL을 Java 객체로 전환하는 코드를 직접 작성해야 했습니다. 거기다 객체에 속성이라도 추가된다면 기껏 작성해놓은 SQL을 몽땅 수정해야하는 불상사가 일어나기도 했습니다.

// 만약 객체에 속성을 하나 추가한다면?
public class Member {
    private String memberId;
    private String name;
    ...
}
# 이 SQL 쿼리도 수정해야 한다.
INSERT INTO MEMBER(MEMBER_ID, NAME) VALUES SELECT MEMBER_ID, NAME FROM MEMBER M
UPDATE MEMBER SET ...

즉, SQL에 의존적인 개발이 피하기 어려웠습니다. 다형성을 통한 유연한 확장이 장점인 객체지향 프로그래밍인데, 객체 데이터를 저장하려면 SQL에 의존적인 개발이 불가피한, 가장 중요한 객체지향의 원칙이 깨지는 문제가 발생한 것입니다.

객체와 관계형 데이터베이스의 차이

이렇게 치명적인 문제를 가지고 있는 SQL 중심의 개발을 할 수 밖에 없었던 이유는 객체와 관계형 데이터베이스의 패러다임 불일치에 있습니다. 객체는 어쨌든 관계형 데이터베이스에 저장하기 위해서 SQL로 변환해야 하는데, 이 작업을 모두 개발자가 해야 했기 때문에 SQL에 의존적일 수 밖에 없었습니다. 그럼, 객체와 관계형 데이터베이스 사이에 얼마나 큰 차이가 있길래 개발자가 개발은 뒷전이고 SQL 쿼리를 작성하느라 바빠진 것일까요?

객체 VS 관계형데이터베이스 - 상속

패러다임 불일치 중 첫 번째는 상속입니다. 객체지향 프로그래밍에서 너무나 중요하고 핵심적인 개념인 상속은 관계형 데이터베이스에 존재하지 않습니다. 분명히 객체는 상속관계를 가지고 있는데 관계형 데이터베이스에는 상속이라는 개념이 없다보니 상속관계의 객체를 저장하기 위해서는 특수한 방법을 이용해야 합니다.

objectrdbdiff
관계형 데이터베이스에서는 상속관계 대신 슈퍼타입 서브타입 관계를 사용한다.

대표적으로 슈퍼타입 서브타입 관계라는 설계 기법을 통해 상속관계를 유사하게 표현하지만, 이것을 객체의 상속과 동일하다고 볼 수 없습니다. 슈퍼타입과 서브타입 테이블은 엄밀히 말하자면 서로 다른 테이블이고 상속관계의 객체가 이 테이블을 조회할 때 JOIN 연산으로 하나로 묶어서 결과를 반환하는 것일 뿐입니다.

부모 객체에 대한 자식 객체가 하나라면 JOIN 연산 쿼리 작성은 큰 문제는 아니겠지만, 자식 객체를 통해 유연하게 확장(다형성)하는 것이 객체지향 프로그래밍임을 생각한다면 어림도 없는 이야기입니다. 당연히 이 연산을 위한 SQL 쿼리 작성과 객체와 SQL의 변환 코드는 모두 개발자가 직접 해야 합니다.

객체를 저장하는 것 역시 쉽지 않은 작업입니다. 객체 데이터를 저장할 때는 슈퍼타입과 서브타입 테이블 두 곳 모두에 자식 객체를 분해해서 각 테이블에 맞는 속성을 저장합니다. 즉, 하나의 객체를 저장하는데 두 테이블에 SQL 쿼리가 날아가는 것입니다.

# 부모 객체로 부터 상속 받은 속성은 슈퍼타입 테이블에
INSERT INTO ITEM ...
# 자식만의 속성은 서브타입 테이블에
INSERT INTO ALBUM ...

상속이 자식 객체가 부모 객체의 속성을 물려받아서 자유자재로 다룰 수 있는 것과는 분명 차이가 존재합니다. 그래서 DB에 저장할 객체에는 상속 관계를 사용하지 않습니다.

객체 VS 관계형 데이터베이스 - 연관관계

객체와 관계형 데이터베이스의 두 번째 불일치는 연관관계입니다. 연관관계는 서로 다른 두 객체가 참조를 통해 연결되는 것을 말합니다. 다행히도 데이터베이스 테이블 역시, 외래 키를 통한 JOIN 연산을 활용하면 연관관계를 가질 수 있습니다. 그런데, 참조와 외래 키는 엄연히 다른 개념입니다. 참조는 다른 객체의 주소를 갖는 것과 마찬가지입니다. 따라서, 객체의 참조를 가져오는 것은 객체 그 자체를 가져오는 것이라고 볼 수 있습니다.

// Team 객체를 향한 참조 존재. Team 객체와 연관관계를 맺음.
class Member {
    String id;
    Team team;
    String username;
    ...
}
// team은 member와 연관관계를 맺는 Team 객체 그 자체를 가리킨다.
Team team = member.getTeam();

문제는 관계형 데이터베이스가 객체를 객체 그 자체로 저장할 수 없다는 것입니다. DB 테이블은 연관관계를 위해 외래 키를 가지고 있어야 하고, 이 외래 키를 통해 다른 테이블과 JOIN한 후, 데이터를 가져와야 합니다. 외래 키가 없다면 연관관계를 절대 표현할 수 없고, 연관관계를 가진 객체를 저장할 방법도 없습니다. 결국, 이러한 관계형 데이터베이스 테이블의 한계 때문에 객체를 테이블에 맞춰서 모델링해야 합니다.

class Member {
    String id;
    Long teamId; // 외래 키
    String username;
}

이렇게 테이블에 맞춰서 객체를 모델링하면 데이터베이스에 객체를 저장하는 것은 간단해지지만, 이런 모델링은 전혀 객제지향스럽지 않은 모델링입니다. 객체는 참조로 연관관계를 맺어야 하는데, 객체의 특정 속성을 통해 연관관계를 맺어야 하는 상황이 벌어졌습니다. 물론, 참조를 이용해 객체를 모델링하고 테이블에 저장할 때만 team.getId()와 같은 과정을 추가로 거쳐 SQL을 잘 작성하면 객체다운 모델링이 가능하지만, 이런 과정 자체가 번거롭고 실수하기 쉬운 작업입니다!

객체 VS 관계형 데이터베이스 - 객체 그래프 탐색

객체와 관계형 데이터베이스의 세 번째 불일치는 객체 그래프 탐색입니다. 객체는 자유롭게 객체 그래프를 탐색할 수 있어야 합니다. 즉, 객체는 참조를 통해서 여러 객체를 넘나들며 원하는 작업을 수행할 수 있어야 합니다. 문제는 데이터베이스의 경우, 처음 실행하는 SQL에 따라 탐색 범위가 결정되어버린다는 것입니다.

objectgraph
객체는 이런 객체 그래프를 자유롭게 탐색할 수 있다.

예를 들어, 다음과 같은 SQL을 통해 객체를 조회했을 때는 객체 그래프 탐색이 제한됩니다.

SELECT M.*, T.*
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
member.getTeam();
member.getOrder(); // NULL

분명히 위에서 본 객체 그래프에 의하면, MemberOrder가 연관관계를 갖고 있어 Member에서 Order로 출발하는 객체 그래프 탐색이 가능해야 합니다. 그러나, SQL에서는 오직 Team 테이블과 JOIN해서 Team 말고는 어떠한 객체도 탐색이 불가능합니다.

이런 객체 그래프 탐색의 제한은 엔티티 신뢰 문제를 일으킵니다. 객체 입장에서는 연관관계를 맺은 다른 객체를 전적으로 신뢰하고 사용할 수 있어야 하는데, 작성한 SQL에 따라 결과가 완전히 달라질 수 있다는 큰 위험이 존재하는 것입니다. 즉, 다음과 같은 코드의 결과를 전혀 예측할 수 없다는 것입니다.

class MemberService {
    ...
    public void process() {
        Member member = memberDAO.find(memberId);
        member.getTeam(); // 결과를 예측할 수 없음
        member.getOrder().getDelivery(); //결과를 예측할 수 없음
    }
}

이런 신뢰 불가능한 코드는 결국 서비스 로직을 구현하다 말고 memberDAO 같이 데이터베이스에 접근하는 객체의 코드를 직접 확인해야 하는 비효율적인 작업이 추가로 필요해집니다. 그렇다고 미리 모든 데이터를 전부 불러와서 준비해두는 것도 문제가 있습니다. 당장 사용하지 않을 객체의 테이블까지 모조리 JOIN해 SQL 쿼리 자체가 비대해지고 성능 저하가 발생하면 개선하기 어려워질 수도 있습니다.

이런 문제를 피하기 위해 모든 상황에 대비해 필요한 객체만 가져오는 메소드와 SQL 쿼리를 작성할 수도 있겠지만 이 방법 역시 여전히 효율적인 방법은 아닙니다.

객체 VS 관계형 데이터베이스 - 비교

마지막 객체와 관계형 데이터베이스 간의 불일치는 비교입니다. 어쩌면 가장 치명적일 수 있는 차이입니다.

String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);

member1 == member2; // false

데이터베이스에서 같은 memberId를 기준으로 조회해도 비교 시에 두 객체가 서로 다르다는 결과를 반환합니다. 우리는 위의 코드가 데이터베이스에서 같은 데이터를 가져왔다고 생각했지만, 객체 변환 과정에서 SQL의 결과를 새로운 인스턴스로 생성해서 반환하기 때문에 객체 입장에서는 전혀 다른 객체인 것입니다. 결론적으로, 우리의 예상과는 전혀 다르게 동작하기 때문에 예기치 못한 오류가 발생할 수도 있는 것입니다.

지금까지 여러 불일치들을 살펴보면 객체답게 객체를 모델링할수록 SQL 매핑 작업만 늘어나는 것을 볼 수 있습니다. 여기서 한 가지 고민을 해볼 수 있습니다. 객체를 Java 컬렉션에 저장하는 것처럼 DB에 저장할 수는 없을까요? 이런 고민을 해결하기 위해 등장한 기술이 바로 ORM 기술, 그 중에서도 Java 진영의 ORM 기술 표준인 JPA입니다.

ORM과 JPA

ORMObject-relational Mapping(객체 관계 매핑)의 약자입니다. ORM 기술을 활용하면 개발자가 객체는 객체대로 설계하고 관계형 데이터베이스는 데이터베이스대로 설계할 수 있습니다. ORM 프레임워크가 각자의 패러다임에 맞게 설계된 객체와 관계형 데이터베이스를 중간에서 매핑해주기 때문에 개발자는 더 이상 객체를 데이터베이스 테이블에 맞춰서 설계할 이유도, SQL 매핑에 시간을 쏟을 이유도 없어진 것입니다!

대부분의 대중적인 프로그래밍 언어에는 ORM 기술이 존재하고, 그 중 Java의 ORM 기술 표준이 JPA(Java Persistence API)입니다. 즉, ORM 기술을 Java 프로그래밍에서 잘 사용할 수 있도록 해주는 것이 JPA인 것입니다. 물론 JPA 자체만 가지고 ORM 기술을 바로 사용할 수 있는 것은 아닙니다.

JPA는 이름 그대로 API이기 때문에 구현 클래스 없이 인터페이스만 모아둔 표준 명세일 뿐이고, Hibernate(하이버네이트)라는 JPA의 인터페이스들을 실제로 구현한 프레임워크를 사용해야 합니다. 하이버네이트 외에도 여러 다른 구현체들도 있지만 주로 하이버네이트를 사용합니다.

JPA의 동작

JPA는 어플리케이션과 JDBC(Java Database Connectivity) API 사이에서 동작합니다. 결국 JPA도 따로 데이터베이스를 다루는 기술이 있는 것이 아니기 때문에 Java의 데이터베이스 접근 기술 API인 JDBC를 사용합니다. JPA는 단지 Java 객체와 데이터베이스 테이블 사이의 패러다임 불일치를 해결하고 번거로운 SQL 매핑 작업을 대신해주는 기술일 뿐입니다.

예를 들어 객체를 데이터베이스에 저장하고 조회하는 과정은 다음과 같습니다.

persist
JPA는 엔티티를 분석해서 SQL을 작성하고 JDBC를 통해 DB에 엔티티를 저장한다.

find
DB에서 엔티티를 조회할 때도 Java 메소드에 맞게 SQL을 작성하고 JDBC를 활용한다.

얼핏 보기에도 JPA가 개발자 대신 해주는 작업의 양이 상당하다는 것을 알 수 있습니다. 개발자 대신 객체와 SQL을 매핑해주고 DB에 접근해서 저장하거나 DB로부터 데이터를 조회합니다. 이 모든 작업을 개발자가 직접 한다면 많은 시간을 DB와의 사투에 쏟아야 한다는 것이 너무나 명확하게 보입니다. 반대로, JPA를 사용하면 이런 작업들을 신경쓰지 않아도 된다는 이야기입니다!

JPA를 사용해야 하는 이유

위에서 언급한 것처럼 JPA를 사용하면 SQL 매핑, DB 연결, 패러다임 불일치 해결 등, 개발자가 수많은 부수적인 작업을 신경쓰지 않아도 됩니다. 즉, SQL 중심적인 개발에서 객체 중심 개발이 가능해지는 것입니다. 이는 높은 생산성과도 연결되는 장점입니다.

// JPA를 활용한 예시 코드입니다. 실제로 작동하지는 않습니다.
// CREATE
jpa.persist(member);

// READ
Member member = jpa.find(memberId);

// UPDATE
member.setName("NewName");

// DELETE
jpa.remove(member);

우리가 주로 작성하게 될 CRUD 코드가 너무나 간단하게 작성됩니다. 실제로 작동하는 코드는 아니지만 JPA를 활용하면 정말 위와 같이 코드를 작성합니다. 코드 어디에도 객체를 SQL과 매핑하고 데이터베이스로 SQL 쿼리를 보내는 부분은 찾아볼 수 없습니다. Member 객체를 중심으로 데이터베이스에 저장, 조회, 수정, 삭제하는 기능을 구현할 수 있는 것입니다.

당연히 SQL을 JPA가 자동으로 작성해주니 개발자는 객체의 수정을 자유롭게 할 수 있습니다. 예를 들어, Member 객체의 요구사항이 변경되어 필드를 하나 추가해야 하는 상황을 가정해보겠습니다.

public class Member {
    private String memberId;
    private String name;
    private String tel; // 새로 추가한 필드
    ...
}

만약 JPA를 사용하지 않았다면 당연히 Member에 새로운 필드가 추가되었으니 데이터베이스의 Member 테이블도 수정해야하며 Member와 관련된 모든 SQL을 수정해야 하는지 확인해야 합니다.

# 객체를 수정했더니 SQL까지 신경써야 하는 상황...
INSERT INTO MEMBER(MEMBER_ID, NAME, TEL) VALUES
SELECT MEMBER_ID, NAME, TEL FROM MEMBER M
UPDATE MEMBER SET ... TEL = ?

그러나, JPA를 사용한다면 위와 같은 SQL은 전혀 신경쓰지 않아도 됩니다. JPA가 자동으로 새로 추가된 필드를 SQL에 알아서 추가해주기 때문입니다. JPA의 이러한 SQL 작성 기능은 개발자가 객체만 신경쓰면 되도록 돕습니다!

또한, 객체와 관계형 데이터베이스의 차이 절에서 정리한 여러 객체와 관계형 데이터베이스의 패러다임 불일치를 해결해줍니다. 상속관계, 연관관계, 객체 그래프 탐색, 객체 비교 등에서 JPA를 사용하지 않았을 때 발생하는 여러 불일치 문제를 JPA는 적절한 SQL 작성이나 동일한 트랙잭션 내에서 조회한 엔티티의 동일을 보장하는 방식을 통해 모두 해결해줍니다.

JPA는 객체 중심 개발을 통한 생산성 향상과 패러다임 불일치 해결 뿐만 아니라 성능 최적화 기능까지 제공합니다. 먼저, 1차 캐시를 활용하는 방식입니다. JPA는 동일 트랜잭션 내에서 한 번 조회한 엔티티는 저장해뒀다가 다시 조회할 때 1차 캐시에 저장해둔 엔티티를 반환해 DB에 직접 조회 쿼리가 날아가지 않도록 조회 성능을 향상시킵니다. 즉, 같은 엔티티를 두 번 호출 했을 때, 두 객체의 동일성(identity)을 보장합니다. 이는 패러다임 불일치를 해결하는 것이기도 합니다.

트랜잭션(Transaction), 또는 데이터베이스 트랜잭션(Database Transaction)은 데이터베이스에서 데이터에 대한 작업의 논리적 실행단계를 의미합니다. ACID라는 성질을 가지는데, 이는 원자성(Atomicity), 일관성(Consistency), 독립성(Isolation), 영구성(Durability)의 앞글자를 따서 만든 것입니다. 쉽게 설명해서 데이터베이스의 상태를 변화시키는 여러 SQL을 묶어둔 하나의 작업 단위입니다.

다음으로 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)을 통해 Java 메소드가 실행될 때마다 매번 SQL을 작성하고 DB로 보내는 것이 아니라, 하나의 트랜잭션을 커밋(Commit)하기 전까지 SQL을 모아뒀다가 JDBC의 BATCH SQL 기능을 통해 한 번에 DB로 전송합니다.

트랜잭션 지원 쓰기 지연 덕분에 UPDATEDELETE처럼 서로 다른 두 사용자가 동시에 진행하면 안되는 작업이 동시에 수행되는 것을 방지하기 위해 걸리는 로우 락(Row Lock) 시간도 최소화됩니다. 수정이나 삭제 메소드가 중간에 작성되어 있어도 여러 비즈니스 로직을 수행하면서 데이터를 가공하다가 트랜잭션의 커밋 순간에만 UPDATEDELETE 쿼리가 실행되기 때문에 로우 락 시간이 짧습니다.

마지막으로, 지연 로딩이라는 기능 덕분에 객체 그래프에서 당장 사용하는 객체만 조회하고, 모든 객체를 한 번에 조회하지 않아도 되기 때문에 객체 조회에 소요되는 시간이 줄어듭니다.

loading
지연 로딩을 사용하면 실제로 객체가 필요한 순간에 조회한다.

가장 많은 비용이 들어가는 SQL 연산이 조회(SELECT)연산이기 때문에 JPA는 조회 연산을 최대한 적게할 수 있도록 위와 같은 여러 최적화 기능을 제공합니다. 결론적으로 개발자가 객체에 집중해서 개발할 수 있게 도와주고, 객체와 관계형 데이터베이스의 패러다임 불일치를 해결해주며, 성능 최적화까지 해주는 JPA를 사용하지 않을 이유가 없는 것 같습니다.

요약

  • 객체와 관계형 데이터베이스 사이에는 여러 패러다임의 차이가 있다.
  • SQL 중심의 개발과 패러다임 차이를 해결하기 위해 ORM 기술이 등장했다.
  • Java 진영의 ORM 기술 표준은 JPA이며, 구현체로는 Hibernate가 있다.
  • JPA는 어플리케이션과 JDBC API 사이에서 동작한다.
  • JPA는 객체 중심 개발, 패러다임 불일치 해결, 성능 최적화를 위해 꼭 사용해야 한다.
hangillee

Personal blog by hangillee.

Road to good developer.