본문 바로가기

CS

[JPA] JPA는 무엇이고 사용하는 이유는 무엇일까?

 

 

 

JPA란 무엇인가?

 

JPA는 Java Persistence API의 약자로, 객체-관계형 매핑(ORM, Object-Relational Mapping)을 위한 자바 진영의 기술 표준입니다. 이는 자바 애플리케이션에서 관계형 데이터베이스를 사용하는 방식을 정의한 "인터페이스"입니다.

 

JPA는 단순히 데이터베이스와의 연동을 돕는 라이브러리가 아닙니다. 자바 애플리케이션이 관계형 데이터베이스와 상호작용하는 방식을 객체지향적으로 정의하며, 개발자가 SQL에 의존하지 않고 데이터베이스를 다룰 수 있도록 도와줍니다. 다만 JPA는 앞서 말했듯, 어느 구현체가 아니라 인터페이스이기에 이를 구현한 다양한 구현체가 존재하고 이를 사용해야 합니다.

 

Hibernate: 가장 널리 사용되는 JPA 구현체로, 강력한 기능과 확장성을 제공합니다.
EclipseLink: Oracle에서 지원하는 구현체로, JPA의 참조 구현입니다.
OpenJPA: Apache 프로젝트의 JPA 구현체입니다.
ToLink: 간단한 기능을 제공하는 경량화된 구현체입니다.

 

 

이 중 Hibernate 가 가장 많이 사용되고 다른 구현체들은 개발하면서 만나볼 일은 잘 없으실 거기에, JPA 관련 포스팅은 하이버네이트 기준으로 하려고 합니다.

 


 

ORM은 무엇이고, JPA는 왜 사용하는가?

 

ORM은 객체지향 프로그래밍과 관계형 데이터베이스 간의 불일치 문제를 해결하기 위한 기술입니다. 객체지향은 말 그대로 객체를 중심으로 하며, 데이터베이스는 데이터 중심이기에 이를 매핑하는데 있어서 불일치 패러다임이 존재하고 이러한 패러다임을 해결하기 위해 추가적인 로직과 SQL 작성 시, 그만큼 비용이 많이 발생하게 되죠.

 

또한 JPA를 사용하기 이전에는 개발자들이 직접 SQL 쿼리를 작성하여서 개발을 진행하였는데요, 우선 개발 도중 SQL 쿼리를 사용하여서 DB와 커넥션을 연결하고 상호작용과 롤백 기능을 사용하려면 JDBC API를 사용하여야 하는데요, 우선 이러한 개발 방식은 직접 SQL을 작성하여야 하기에 오타가 일단 많이 납니다.

 

그리고 정말 간단한 CRUD를 구현하는데 있어, 무수히 많은 SQL을 추가해야 하며 SQL에 너무 의존적인 개발이 되게 됩니다. 그런데 여기까지 들어보면 단순히 SQL만 많이 작성하는 것만이 문제인가? 라는 생각이 드실텐데요.

 

물론 그러한 문제도 있지만 앞의 객체지향 불일치 패러다임을 극복하는데 SQL 쿼리를 작성하게 되어, 단순히 많은 SQL 뿐만이 아니라, 패러다임 불일치 해결을 위해 작은 기능에도 많은 비용이 생기게 되는 것이죠. 이러한 것을 극복하기 위해 ORM이 나온 것입니다.

 

ORM은 데이터베이스의 테이블을 객체로 매핑하여, 객체지향적인 개발 방식을 데이터베이스와의 상호작용에서도 활용할 수 있도록 합니다. 사실 이렇게만 말하면 간단하지만, 제대로 알아보기 위해서 좀 더 깊게 파보겠습니다.

 


 

1. JPA의 주요 기능으로 수많은 SQL 쿼리 작업 부담 감소

 

JPA를 사용하면 객체를 데이터베이스에 저장하고, 관리할 떄 개발자가 직접 SQL을 작성하는 것이 아니라 JPA가 제공하는 API를 사용하면 됩니다.


 

저장 - Insert

 

JPA는 객체를 데이터베이스에 저장하는 작업을 간단하게 처리할 수 있습니다. persist() 메서드를 호출하면 객체가 데이터베이스에 삽입됩니다.

jpa.persist(Object); // 객체 저장

조회 - Select

 

JPA는 객체를 데이터베이스에 저장하는 작업을 간단하게 처리할 수 있습니다. persist() 메서드를 호출하면 객체가 데이터베이스에 삽입됩니다.

jpa.find(Member.class, memberId); // ID로 객체 조회

수정 - Update

 

JPA를 사용하면 객체를 수정할 때 SQL을 작성할 필요가 없습니다. 객체의 값을 변경하면 JPA가 이를 감지하여 데이터베이스에 반영합니다.

Member member = jpa.find(Member.class, memberId);
member.setName("변경할 값"); // 값 수정

연관된 객체 조회 (Join)

 

객체 간 연관관계가 설정되어 있다면, JPA는 이를 통해 관련된 데이터를 쉽게 조회할 수 있습니다.

Member member = jpa.find(Member.class, memberId);
Team team = member.getTeam(); // 연관된 객체 조회

 


 

2. 페라다임 불일치 문제

 

객체지향 언어와 관게형 데이터베이스는 다음과 같은 차이로 인해 패러다임 불일치 문제가 발생합니다.

 

  • 상속: 객체지향 언어에서는 상속을 통해 공통 기능을 재사용할 수 있지만, RDB에서는 이를 지원하지 않습니다.
  • 다형성: 객체지향 언어에서는 인터페이스와 다형성을 활용할 수 있지만, RDB는 이러한 개념이 없습니다.
  • 참조: 객체는 참조를 통해 연관된 객체를 다루지만, RDB는 외래 키를 사용하여 연관 테이블을 연결합니다.

 

문제가 발생하는 이유는 관계형 데이터베이스는 데이터 중심이고, 객체지향은 객체를 중심으로 구조화 되어 있기에 자바 언어로 만든 다양한 데이터들을, SQL 언어를 이용한 데이터베이스랑은 지향하는 목적 자체가 다르므로 기능과 표현 방법이 아예 다르기 때문에 문제가 발생하게 되는 것입니다.

 

객체 구조를 테이블에 저장할 수 없기에 생기는 이러한 불일치 패러다임을 어떻게 극복해왔고, JPA는 어떻게 극복할지를 알아보겠습니다


 

2.1.1 상속

 

 

객체는 상속이라는 기능을 가지고 있지만 테이블은 상속이라는 기능이 없다. 상속은 그림처럼 부모의 것을 자식이 몰려받아 사용하는 것으로 객체지향 관점에서 좀 더 서비스의 본질에 집중하고 공통적인 부분을 따로 처리하여 코드의 단순화와 유지보수성을 높이는 객체지향적인 방법이기에 당연하다.

 

 

데이터베이스 모델링에서 슈퍼타입-서브타입 관계를 활용하면 객체 지향의 상속 구조와 유사한 방식으로 테이블을 설계할 수 있습니다. 이는 확장된 ER 모델의 일종으로, 데이터 구조를 더 명확하고 유연하게 만들어줍니다. 이 방법에서는 공통 속성을 슈퍼타입 테이블에 정의하고, 서브타입별 고유 속성은 별도의 테이블로 분리합니다.

 

예를 들어, Item이라는 슈퍼타입이 있고, 이를 상속받는 Album, Movie, Book과 같은 서브타입 테이블이 있다고 가정해 보겠습니다. 데이터 삽입 과정은 먼저 부모 테이블(Item)에 공통 데이터를 삽입한 후, 자식 테이블(Album, Movie, Book)에 각 서브타입의 고유 데이터를 삽입하며, 자식 테이블이 부모 테이블을 참조하도록 외래 키 관계를 유지합니다.

 

이러한 설계 방식은 데이터베이스 구조를 체계적으로 관리할 수 있게 하지만, 구현 과정에서 SQL 로직이 복잡해질 수 있습니다. 특히 JDBC API를 활용할 경우, 데이터 삽입 시, 부모와 자식 데이터를 각각 삽입하기 위한 SQL 작성, 삽입 결과를 ResultSet으로 매핑, 데이터 조합 및 객체화 등의 추가 작업이 필요합니다. 이로 인해 코드가 복잡해지고 유지보수가 어려워질 수 있습니다. 이는 서브클래스를 조회한다고 하더라고 부모 클래스인 ITem과 서브 클래스 중 하나의 테이블을 조인해서 조회한 다음 그 결과로 조회하고자 하는 서브 클래스의 객체를 생성하는 등 복잡해지게 됩니다.


 

2.1.2  JPA와 상속

 

JPA를 사용해서 Item을 상속한 Album 객체를 저장한다고 가정 시, persist 메서드를 사용해서 객체를 저장하면 된다.

jpa.persist(album;

 

JPA는 이렇게 album 클래스를 저장 시, SQL 들이 자체적으로 실행되는데 ITEM 즉 부모 클래스의 테이블과 서브 클래스의 테이블을 나누어서 저장하는 SQL을 자체적으로 실행합니다.

 

만일 Album 객체를 조회를 한다고 하더라도, 위에서 말했던 것처럼 조인해서 조회를 해야하는데 마찬가지로 JPA가 자체적으로 두 테이블을 조회해서 필요한 데이터를 조회하고 그 결과를 반환하게 됩니다.


 

2.2. 연관관계

 

객체는 다른 엔티티를 연관관계를 맺고 참조를 사용할 수 있게 합니다. 다른 객체와 연관관계를 가지고 참조에 접근해서 연관된 객체를 조회하는 반면에 테이블은 외래키를 사용해서 다른 테이블과 연관관계를 가지고 조인을 사용해서연관된 테이블을 조회하게 됩니다.

 

객체와 테이블의 차이를 매꾸기 위해, 객체를 단순히 테이블에 맞추어 모델링 해서 필드로 Long teamId를 추가한다고 해봅시다. 그렇다면 객체는 연관된 객체의 참조를 보관해야 참조를 통해 연관된 객체를 찾을 수 있기에 단순히 외래키 필드를 추가한다고 해서 연관관계를 맺었다고 할 수 없으며 객체 탐색이 불가하겠죠.

 

 

JPA는 이를 다음과 같은 방식으로 매핑합니다

member.setTeam(team); // 객체 간 연관관계 설정
jpa.persist(member); // 연관된 객체 함께 저장

 

PA는 객체의 참조를 외래 키로 변환하고, 반대로 외래 키를 객체 참조로 변환합니다. 이를 통해 객체지향적인 방식으로 데이터베이스를 다룰 수 있습니다.

 


 

2.3.1 객체 그래프 탐색

 

 

그림으로 보면 각 개체의 연관관계가 복잡하게 맺혀져 있는 것을 알 수 있습니다. 사실 해당 그림이 중요한 게 아니라, SQL 쿼리가 중요한 것입니다.

 

만일 그림처럼 관계가 맺어져 있는 객체들이 있다고 하고, 데이터베이스에 접근하는 메서드를 설정한다고 하면 처음 실행하는 SQL에 따라 객체 그래프를 어디까지 탐색할 수 있을지가 정해집니다.

 

무슨 소리인가 하겠지만, 만일 회원과 팀을 조인해서 회원과 팀에 대한 데이터만 조회하는 SQL 쿼리를 사용하는 DAO 메서드를 사용한다면 해당 메서드로 찾게되는 회원 객체는 Team 방향으로 탐색을 이어갈 순 있어도 Order 방향으로는 못한다.

 

member.getTeam // O
memger.getOrder // x

 

그렇다면 이 말은, 개발자는 서비스 로직만을 보고 해당 메서드가 어디까지의 객체 그래프 탐색을 하는지를 예측할 수 없기에 온전히 서비스 로직에 집중할 수 없게 되고 SQL에 의존적인 개발방식을 이어가게 됩니다.

 

그리고 SQL 쿼리를 쓸 때 마다 해당 객체의 그래프 범위가 정해지므로 각각의 상황에 맞추어서 계속하여서 필요한 객체그래프 탐색 메서드를 추가로 공장 찍듯이 팡팡 찍어내야 한다는 커다란 문제도 생기게 됩니다.


 

2.3.2 JPA와 객체 그래프 탐색

 

// 처음 조회 시점에는 SELECT MEMBER SQL
Member member = jpa.find(Member.class, memberId);

Order order = member.getOrder();
order.getOrderDate(); // Order를 사용하는 시점에 JPA가 알아서 SELECT Order SQL을 날려서 조회해온다. - 지연로딩

 

JPA는 연관된 객체를  "사용"하는 시점에서 SELECT SQL을 실행하기에 해당 코드를 보게 된다면, member를 찾는 SQL 쿼리와 Order를 찾는 SQL을 직접 사용할 때, 즉 member.getOrder 시점이 아닌 그렇게 불려오진 order가 지닌 메서드를 실행할 때 해당 객체를 찾는 SELCET SQL 을 실행하게 됩니다.

 

이 기능은 실제 객체를 사용하는 시점까지 DB 조회를 미룬다고 하여서 지연로딩 이라고 합니다. 이는 매우매우 중요한 개념이고 추후 개발 시 꼭 마주치게 되는 N + 1 문제를 이해할 때 알아야 되는 개념입니다.

 

하지만 코드를 보면 SQL 쿼리를 2번 날린다라는 것은, 어찌됐던 그만한 비용이 발생한다는 것인데 딱 봐도 쿼리를 2번 날리는 것보다는 Join하는 SQl 쿼리를 이용하여서 한 번의 쿼리로 member와 order를 가지고 오는 것이 더 효율적으로 보입니다. 

 

JPA는 이러한 것도 고려하여 앞서 설명한 것처럼 사용하는 때 마다 쿼리를 날리는 지연 로딩 방식과 Join을 사용해서 쿼리 한 번에 다 가져오는 즉시 로딩 방법을 간단하게 설정 할 수 있습니다.

 

간단하게 추가 설명을 해보자면 n + 1 문제는 지연 로딩을 사용할 경우, 연관된 데이터를 반복적으로 조회하면 예상치 못한 다수의 추가 SQL 쿼리가 발생할 수 있습니다. 이를 N+1 문제라고 하며, 해결 방법으로는 @EntityGraph 또는 JPQL에서 fetch join을 사용해 필요한 데이터를 한 번의 쿼리로 가져올 수 있습니다.

 


 

그렇다면 JPQL로 조인 쿼리를 작성하는 것과 FetchType으로 Eager로 설정하는 것, 이 둘의 차이는 뭘까?

 

 

FetchType.EAGER를 사용하는 것과 JPQL로 JOIN FETCH를 작성하는 것은 JPA에서 데이터를 로드하는 방식과 제어권의 차이에서 큰 차이를 보입니다. FetchType.EAGER는 JPA가 엔터티를 로드할 때 연관된 데이터를 자동으로 함께 로드하는 방식으로, 설정된 관계를 따라 JPA가 알아서 LEFT OUTER JOIN 쿼리를 실행합니다.

 

이는 간단한 사용 방식으로, 별도의 쿼리 작성 없이 데이터 로드가 가능하다는 장점이 있습니다. 하지만 항상 연관 데이터를 즉시 로드하기 때문에 필요하지 않은 데이터까지 가져올 수 있어 성능 저하가 발생할 가능성이 있습니다. 특히 여러 연관 관계가 얽혀 있는 경우 과도한 데이터 로드가 발생할 수 있어 주의가 필요합니다.

 

반면, JPQL로 JOIN FETCH를 작성하면 연관 데이터를 명시적으로 가져올 수 있어 더 유연하고 세밀한 제어가 가능합니다. 개발자가 직접 쿼리를 작성하기 때문에 특정 조건을 추가하거나 필요한 필드만 가져오는 등 최적화가 가능합니다. 또한, JOIN FETCH를 활용하면 N+1 문제를 효과적으로 방지할 수 있으며, 불필요한 데이터를 로드하지 않아 성능 면에서도 효율적입니다.

 

다만, 반복적인 작업에서는 코드 중복이 발생할 수 있으며, 관계가 단순한 경우에도 직접 쿼리를 작성해야 하는 번거로움이 있을 수 있습니다.

 

정리하자면, FetchType.EAGER는 단순한 관계 로드에는 간편하게 사용할 수 있지만, 복잡한 조건을 처리하거나 성능 최적화가 필요한 상황에서는 JPQL로 JOIN FETCH를 작성하는 것이 더 적합합니다. 실무에서는 기본적으로 FetchType.LAZY를 설정한 뒤, 필요에 따라 JPQL로 JOIN FETCH를 사용하는 방식이 더 유연하고 성능 관리에도 유리합니다. EAGER는 남용할 경우 성능 문제를 초래할 수 있으므로 신중히 사용해야 합니다.

 


 

2.4 동등성 비교

 

데이터베이스는 각 행을 고유하게 식별하기 위해 기본 키(PK)를 사용합니다. 반면, 객체는 비교 시 동일성과 동등성이라는 두 가지 개념을 사용합니다. 동일성 비교는 == 연산자를 사용하여 두 객체가 같은 메모리 주소를 참조하는지 확인합니다.

 

반면, 동등성 비교는 equals() 메서드를 통해 객체 내부의 값이 동일한지를 판단합니다.기본적으로, Object 클래스 equals() 메서드는 ==와 동일하게 주소를 비교합니다. 만약 객체 내부의 값을 기준으로 동등성을 비교하려면 equals() 메서드를 오버라이드해야 합니다.

 

Member member1 = list.get(0);
Member member2 = list.get(0);
member1 == member2; // true

 

위 코드에서는 list 컬렉션이 동일한 객체의 참조를 반환하므로 == 비교에서도 true를 반환합니다. 이는 컬렉션이 객체의 참조를 공유하기 때문입니다. 하지만 DAO와 같은 전통적인 방식으로 동일한 PK를 기준으로 조회된 두 객체는 서로 다른 인스턴스로 생성되므로 동일성 비교(==)는 실패합니다.

 

Member member1 = dao.findMember(1L);
Member member2 = dao.findMember(1L);
member1 == member2; // false

 

 

JPA는 이러한 비교 방식에서 차별점을 제공합니다. JPA는 같은 트랜잭션 내에서 동일한 엔터티를 조회할 경우, 항상 같은 인스턴스를 반환하도록 보장합니다. 이는 JPA의 영속성 컨텍스트와 관련이 있습니다. 영속성 컨텍스트는 동일한 트랜잭션 동안 엔터티를 관리하며, 동일한 기본 키 값으로 조회된 객체가 항상 동일한 인스턴스임을 보장합니다.

Member member1 = jpa.find(Member.class, 1L);
Member member2 = jpa.find(Member.class, 1L);
member1 == member2; // true

 

위 코드에서 JPA는 동일한 PK로 조회된 두 객체를 영속성 컨텍스트에 캐싱하고, 동일한 인스턴스를 반환합니다. 따라서 동일성 비교(==)가 성공합니다. 이로 인해 Object 클래스의 기본 equals() 메서드를 사용하더라도 true가 반환됩니다.


 

JPA를 왜 사용하는가?

 

 

생산성
JPA는 반복적인 코드를 줄이고 생산성을 높여줍니다. 간단한 CRUD SQL을 개발자가 직접 작성하지 않아도 프레임워크가 자동으로 처리해 주며, 개발자는 비즈니스 로직에 집중할 수 있습니다. 이를 통해 코드의 중복을 줄이고 개발 속도를 높일 수 있습니다.

 

유지보수성
JDBC API를 사용할 경우, SQL 쿼리나 API 코드의 수정이 필요할 때 모든 관련 부분을 직접 수정해야 합니다. 반면, JPA는 데이터베이스 스키마 변경 사항이나 컬럼 수정이 발생해도 이를 자동으로 반영하므로 유지보수 비용을 줄여줍니다.

 

패러다임의 불일치 해결
JPA는 객체 지향 프로그래밍의 특징인 상속, 연관관계, 객체 그래프 탐색 등을 자연스럽게 지원하여 관계형 데이터베이스와 객체 간의 패러다임 불일치 문제를 해결합니다. 또한, 동등성 비교와 같은 객체 지향적 비교 방식을 데이터베이스와의 연동 과정에서도 일관되게 사용할 수 있습니다.

 

성능 최적화
JPA는 영속성 컨텍스트를 활용하여 동일한 트랜잭션 내에서 동일한 쿼리를 한 번만 실행하도록 최적화합니다. 이로 인해 불필요한 데이터베이스 호출을 줄이고 성능을 개선할 수 있습니다. 또한, 특정 성능 최적화를 위해 힌트를 추가하거나 필요한 기능을 활용할 수도 있습니다.

 

데이터 접근 추상화와 벤더 독립성
관계형 데이터베이스는 벤더(Oracle, MySQL, PostgreSQL 등)에 따라 동일한 기능도 구현 방식이 다릅니다. JPA는 데이터 접근 계층을 추상화하여 특정 DBMS 벤더에 종속되지 않도록 설계되었습니다. 이를 통해 로컬에서는 H2를 사용하고 운영 환경에서는 MySQL로 전환하는 등 데이터베이스 교체가 용이해집니다. JPA에서는 이를 Dialec로 표현하며, 다양한 데이터베이스를 유연하게 지원합니다.


 

마무리

 

JPA는 객체 지향 프로그래밍과 관계형 데이터베이스의 간극을 메우는 강력한 도구지만, 이를 제대로 활용하기 위해 데이터베이스 설계 및 SQL 최적화에 대한 기본적인 이해가 필요합니다. 효율적인 코드 작성과 함께, 적절한 데이터베이스 활용 방식을 고민하는 것이 성공적인 애플리케이션 개발로 이어질 것입니다.

'CS' 카테고리의 다른 글

[JPA] 다양한 연관관계 매핑  (1) 2024.12.25
[JPA] 연관관계 매핑 기초  (0) 2024.12.23
TCP/IP 4계층 모델  (1) 2024.10.07
도커란 무엇일까?  (4) 2024.09.26
네트워크 기초 개념 및 성능 분석 명령어  (1) 2024.09.19