Domain/Spring

[Spring] JPA (1)

by Donghwan 2021. 9. 27.

등장배경

자바로 애플리케이션을 개발하고 관계형 데이터베이스를 데이터저장소로 사용하면서 SQL을 주로 사용하게 됩니다. 초기에 JDBC API를 직접 사용해서 개발하기도 했는데, 애플리케이션의 비즈니스 로직보다 SQL과 JDBC API 작성에 더 많은 시간을 소비하는 경우가 많았습니다. 마이바티스나 JdbcTemplate 같은 SQL Mapper를 사용하면서 JDBC API 관련 코드를 많이 줄일 수 있었습니다.

하지만 여전히 CRUD용 SQL은 반복해서 작성해야 했고, 이런 과정은 너무 지루하고 비생산적이었습니다. 객체 모델링을 세밀하게 진행할수록 객체를 데이터베이스에 저장하거나 조회하기는 점점 더 어려워졌고, 객체와 관계형 데이터베이스의 차이를 메우기 위해 더 많은 SQL을 작성해야하는 경우가 생겼습니다. 결국, 객체 모델링을 SQL로 풀어내는데 너무 많은 비용이 필요했고, 객체 모델은 점점 데이터 중심의 모델로 변해갔습니다.

이런 문제를 해결하기 위해 객체와 관계형 데이터베이스 간의 차이를 중간에서 해결해주는 ORM 프레임워크가 등장했습니다. JPA는 자바 진영의 ORM 기술 표준입니다. JPA는 지루하고 반복적인 CRUD SQL을 알아서 처리해줄 뿐만 아니라 객체 모델링과 관계형 데이터베이스 사이의 차이점도 해결해주었습니다. JPA를 사용하는 개발자는 SQL을 직접 작성하는 것이 아니라 어떤 SQL이 실행될지 고민하면 됩니다. 

스프링에서는 Spring Data JPA가 존재하는데 JPA를 쉽게 사용할 수 있도록 지원하는 모듈입니다. EJB 기술인 인티티 빈이 가지는 여러 문제들을 대체하기 위해 하이버네이트가 등장하게 되었고, 하이버네이트 개발자들이 중심이 되어 만든 ORM 표준이 JPA (Java Persistence API)입니다.

ORM이란?
ORM은 객체와 관계형 데이터베이스를 매핑한다는 뜻입니다. ORM 프레임워크는 객체와 테이블을 매핑해서 패러다임의 불일치 문제를 개발자 대신 해결해줍니다.

ORM 프레임워크는 단순히 SQL을 개발자 대신 생성해서 데이터베이스에 전달해주는 것뿐만 아니라 앞서 이야기한 다양한 패러다임의 불일치 문제들도 해결해줍니다. 따라서 객체 측면에서는 정교한 객체 모델링을 할 수 있고 관계형 데이터베이스는 데이터베이스에 맞도록 모델링하면 됩니다. 그리고 둘을 어떻게 매핑해야 하는지 매핑 방법만 ORM 프레임워크에게 알려주면 됩니다. 덕분에 개발자는 데이터 중심인 관계형 데이터베이스를 사용해도 객체지향 애플리케이션 개발에 집중할 수 있습니다.

 

기존 JDBC의 문제점

SQL에 의존적인 개발을 하게 됩니다. 데이터베이스에 데이터를 관리하려면 SQL을 사용해야 합니다. 자바로 작성한 애
플리케이션은 JDBC API를 사용해서 SQL을 데이터베이스에 전달합니다. 이때 JDBC API를 사용하게 되면 SQL 구문을 직접 작성하게 됩니다.
데이터베이스는 객체 구조와 달리 데이터 중심의 구조를 가지므로 객체를 데이터베이스에 직접 저장하거나 조회할 수는 없습니다. 따라서 개발자가 객체 지향 애플리케이션과 데이터베이스 중간에서 SQL과 JDBC API를 사용해서 변환 작업을 직접 해주어야 합니다. 결국 SQL을 작성하고 JDBC API를 사용하는 비슷한 일을 반복하는 상황이 발생합니다.

특히 요구사항이 추가되어 데이터 테이블에 변화가 생길 때, 직접 작성한 SQL문과 관련 메서드를 전부 수정해야 합니다. 진정한 의미의 계층 분할이 어렵고 엔티티를 신뢰할 수 없습니다. 그리고 SQL에 의존적인 개발을 피하기 어렵습니다.

 

JDBC의 문제를 해결할 수 있는 JPA

JPA 저장 과정 / JPA 조회 과정

JDBC의 가장 큰 문제점은 SQL에 대한 의존과 패러다임 불일치라고 할 수 있습니다.

하지만 JPA를 사용해서 얻은 가장 큰 성과는 애플리케이션을 SQL이 아닌 객체 중심으로 개발하여 생산성과 유지보수가 JDBC를 사용할 때보다 유리하고 테스트를 작성하기도 편리해진 점입니다. 또한 코드를 거의 수정하지 않고 데이터베이스를 쉽게 변경할 수 있습니다. 

또 JPA는 객체와 관계형 데이터베이스 사이의 패러다임 불일치 문제를 해결해주는 역할을 합니다. 상속, 연관관계, 데이터 타입, 데이터 식별 방법과 같은 패러다임 불일치 문제가 존재하고 JPA를 사용할 경우 패러다임의 불일치를 해결할 수 있습니다.

 

1. 상속

객체는 상속이라는 기능을 가지고 있지만 테이블은 상속이라는 기능이 없습니다. 데이터베이스 모델링에서 이야기하는 슈퍼타입 서브타입 관계를 사용하면 객체 상속과 가장 유사한 형태로 테이블을 설계할 수 있습니다.

하지만 JDBC API를 사용하여 상속에 관련된 코드를 완성하려면 부모 객체에서 부모 데이터만 꺼내서 INSERT SQL을 작성하고 자식 객체에서 자식 데이터만 꺼내서 INSERT SQL을 작성해야 하는데, 작성해야 할 코드량이 만만치 않습니다. 그리고 자식 타입에 따라서 DTYPE도 저장해야 합니다. 이런 과정이 모두 패러다임의 불일치를 해결하려고 소모하는 비용입니다.

JPA에서는 persist() 메서드를 사용하여 객체를 저장하면 JPA가 Entity를 분석하고 알맞은 INSERT SQL을 자동으로 생성하여 줍니다. 그 다음 JDBC API를 통해 데이터를 저장합니다. 따라서 JDBC에서 복잡하던 과정을 줄일 수 있습니다.

 

2. 연관 관계

앞선 상속의 문제에서 저장뿐만 아니라 조회하는 것도 굉장히 어렵습니다. 예를 들어 자식 데이터를 조회 한다면 부모 데이터와 자식 데이터 테이블을 조인해서 조회한 다음 그 결과로 자식 데이터 객체를 생성해야 합니다.

객체는 참조를 사용해서 다른 객체와 연관관계를 가지고 참조에 접근해서 연관된 객체를 조회하지만 테이블은 외래 키를 사용해서 다른 테이블과 연관관계를 가지고 조인을 사용해서 연관된 테이블을 조회합니다.

참조를 사용하는 객체와 외래 키를 사용하는 관계형 데이터베이스 사이의 패러 다임 불일치는 객체지향 모델링을 거의 포기하게 만들 정도로 극복하기 어렵습니다.

객체는 참조가 있는 방향으로만 조회할 수 있습니다. 예를 들어 Member의 멤버인 Team이라면 member.getTeam()는 가능하지만 반대는 참조가 없으므로 불가능합니다. 반면에 테이블은 외래 키 하나로 양방향 모두 조회가 가능합니다.

객체를 테이블을 기반으로 모델링
public class Member {
    private String id;
    private String name;
    private Long teamId;
    
    public Team getTeam() {
        return team;
    }
    ...
}

public class Team {
    private Long id;
    private String name;
    ...
}​

MEMBER 테이블의 컬럼을 그대로 가져와서 Member 클래스를 만든다면 위와 같습니다. 이렇게 객체를 테이블에 맞추어 모델링하면 객체를 테이블에 저장하거나 조회할 때는 편리합니다. 그런데 여기서 TEAM_ID 외래 키의 값을 그대로 보관하는 teamld 필드에는 문제가 있습니다. 관계형 데이터베이스는 JOIN이라는 기능이 있으므로 외래 키의 값을 그대로 보관해도 문제가 없지만 객체는 연관된 객체의 참조를 보관해야 참조를 통해 member.getTeam()과 같은 연관된 객체를 찾을 수 있습니다.

특정 회원이 소속된 팀을 조회하는 가장 객체지향적인 방법은 이처럼 참조를 사용하는 것입니다. Member.teamld 필드처럼 TEAM_ID 외래 키까지 관계형 데이터베이스가 사용하는 방식에 맞추면 Member 객체와 연관된 Team 객체를 참조를 통해서 조회할 수 없습니다. 이런 방식을 따르면 좋은 객체 모델링은 기대하기 어렵고 결국 객체지향의 특징을 잃어버리게 됩니다.

객체다운 모델링
public class Member {
    private String id;
    private String name;
    private Team team;
    
    public Team getTeam() {
        return team;
    }
    ...
}

public class Team {
    private Long id;
    private String name;
    ...
}​

객체는 참조를 통해서 관계를 맺습니다. 따라서 Member.team 필드를 보면 외래 키의 값을 그대로 보관하는 것이 아니라 연관된 Team의 참조를 보관하면 member.getTeam()을 할 수 있습니다.
그런데 이처럼 객체지향 모델링을 사용하면 객체를 테이블에 저장하거나 조회 하기가 쉽지 않습니다. Member 객체는 team 필드로 연관관계를 맺고 MEMBER 테이블은 TEAM_ID 외래 키로 연관관계를 맺기 때문인데, 객체 모델은 외래 키가 필요 없고 단지 참조만 있으면 됩니다. 반면에 테이블은 참조가 필요 없고 외래 키만 있으면 됩니다. 결국, 개발자가 중간에서 변환 역할을 해줘야 합니다.

객체를 데이터베이스에 저장하려면 team 필드를 TEAM_ID 외래 키 값으로 변환 해야 한다. 다음처럼 외래 키 값을 찾아서 INSERT SQL을 만들어야 하는데 MEMBER 테이블에 저장해야 할 TEAM_ID 외래 키는 TEAM 테이블의 기본 키이므로 member.getTeam().getId()로 구할 수 있습니다. 반대로 조회할 때는 TEAM_ID 외래 키 값을 Member 객체의 team 참조로 변환해서 객체에 보관해야 합니다.

JPA는 연관관계와 관련된 패러다임의 불일치 문제를 해결해줍니다. JPA는 참조를 외래 키로 변환해서 적절한 INSERT SQL을 데이터베이스에 전달합니다. 객체를 조회할 때 외래 키를 참조로 변환하는 일도 JPA가 처리해줍니다. 지금까지 설명한 문제들은 SQL을 직접 다루어도 열심히 코드만 작성하면 어느정도 극복할 수 있는 문제지만 더 어려운 문제들도 존재합니다.

 

3. 객체 그래프 탐색

객체에서 회원이 소속된 팀을 조회할 때는 참조를 사용해서 연관된 팀을 찾으면 되는데, 이것을 객체 그래프 탐색이라 합니다. 

public class Member {
    private String id;
    private String name;
    private Order order;

    public Order getOrder() {
        return order;
    }
    ...
}

public class Order {
    private Long id;
    private String name;
    private Delivery delivery;

    public Delivery getDelivery() {
        return delivery;
    }
    ...
}

public class Delivery {
    private Long id;
    ...
}

객체의 경우 member.getOrder().getDelivery()와 같은 코드로 Delivery를 찾을 수 있습니다. 객체는 마음껏 객체 그래프를 탐색할 수 있어야 합니다. 하지만 데이터 접근 게층(DAO 또는 Repository)에서 member 객체를 조회할 때 이런 SQL을 실행해서 회원과 주문에 대한 데이터만 조회 했다면 member.getOrder()는 성공하지만 추가적으로 getDelivery()는 데이터가 없으므로 탐색할 수 없습니다. SQL을 직접 다루면 처음 실행하는 SQL에 따라 객체 그래프를 어디까지 탐색할 수 있는지 정해집니다. 이것은 객체지향 개발자에겐 너무 큰 제약입니다. 왜냐하면 비즈니스 로직에 따라 사용하는 객체 그래프가 다른데 언제 끊어질지 모를 객체 그래프를 함부로 탐색할 수는 없기 때문입니다. 어디까지 객체 그래프 탐색이 가능한지 알아보려면 데이터 접근 계층을 열어서 SQL을 직접 확인해야 합니다.

하지만 JPA를 사용하면 객체 그래프를 마음껏 탐색할 수 있습니다. JPA는 연관된 객체를 사용하는 시점에 적절한 SELECT SQL을 실행합니다. 따라서 JPA를 사용하면 연관된 객체를 신뢰하고 마음껏 조회할 수 있습니다. 이 기능은 실제 객체를 사용하는 시점까지 데이터베이스 조회를 미룬다고 해서 지연 로딩(Lazy-Loading)이라 한다.

Member를 사용할 때마다 Order를 함께 사용하면, 이렇게 한 테이블씩 조회하는 것보다는 Member를 조회하는 시점에 SQL JOIN을 사용해서 Member와 Order를 함께 조회하는 것이 효과적입니다. JPA는 연관된 객체를 즉시 함께 조회할지 아니면 실제 사용되는 시점에 지연해서 조회할지를 간단한 설정으로 정의할 수 있습니다. 만약 Member와 Order를 즉시 함께 조회하겠다고 설정하면 JPA는 Member를 조회할 때 다음 SQL을 실행해서 연관된 Order도 함께 조회합니다.

 

4. 비교

데이터베이스는 기본 키의 값으로 각 row를 구분합니다. 반면에 객체는 동일성 비교와 동등성 비교라는 두 가지 비교 방법이 있습니다. 동일성 비교는 == 비교로 객체 인스터스의 주소 값을 비교합니다. 동등성 비교는 equals() 메소드를 사용해서 객체 내부의 값을 비교합니다. 테이블의 로우를 구분하는 방법과 객체를 구분하는 방법에는 차이가 있습니다.

기본 키 값이 같은 회원 객체를 두 번 조회했을 때 둘을 동일성(==) 비교를 하면 false가 반환됩니다. 같은 데이터베이스 로우에서 조회했지만, 객체 측면에서 볼 때 둘은 다른 인스턴스기 때문입니다. 따라서 데이터베이스의 같은 로우를 조회했지만 객체의 동일성 비교에는 실패합니다. 

이런 패러다임의 불일치 문제를 해결하기 위해 데이터베이스의 같은 row를 조회할 때마다 같은 인스턴스를 반환하도록 구현하는 것은 쉽지 않습니다. 여기에 여러 트랜잭션이 동시에 실행되는 상황까지 고려하면 문제는 더 어려워집니다.

하지만 JPA는 같은 트랜잭션일 때 같은 객체가 조회되는 것을 보장합니다. 

 


참고자료

  • 자바 ORM 표준 JPA 프로그래밍
  • 인프런 자바 ORM 표준 JPA 프로그래밍 (기본편)

 

728x90
반응형

댓글