0. 도입
이번 포스팅에서는 SQL 중심적인 개발의 문제점을 살펴보고, JPA가 필요한 이유에 대해 정리하겠습니다.
이 시리즈를 접하기 전, 알아야 할 선수지식은 다음과 같습니다.
- 객체 지향과 상속
- DB 테이블의 연관 관계
- 기본키, 외래키에 대한 개념
- Java와 SpringBoot 대한 기본 개념
위의 키워드를 처음 들어봤다면 기본적인 게시판을 만들어 보며 스프링부트를 접하고 와야 합니다.
또한 JPA에 대해 다룰 때, 1차 캐시, 쓰기 지연 저장소, 트랜잭션 등 다소 어려운 내용도 소개합니다. 이해하기 쉽게 소개했지만 처음 접하는 분들은 이해가 안될 수 있습니다. 지금 당장은 어렵더라도 뒤에 글들을 이해하고 보면 한번에 이해될 것입니다. 따라서 이해가 안되는 부분들은 일단 읽어보며 넘어가는 것을 추천합니다.
그럼 시작하겠습니다.
1. SQL 중심적인 개발의 문제점
아래와 같은 Member와 TEAM 객체가 있다고 해봅시다.
class Member {
String memberId; //MEMBER_ID 컬럼 사용
Long teamId; //TEAM_ID FK 컬럼 사용 //**
String username;//USERNAME 컬럼 사용
String email;
...
}
class Team {
Long id; //TEAM_ID PK 사용
String name; //NAME 컬럼 사용
}
위의 Member과 Team은 Java로 작성했지만 객체가 아닌 DB에 맞춰서 설계되어 있습니다.
SQL 중심적인 개발에서 이 객체를 관리하려면 매우 불편한 점이 많습니다. 기본적으로 객체와 DB의 패러다임이 다르기 때문에 문제점이 발생하죠.
먼저 첫 번째 문제점을 살펴보면,
1) 반복적이고 지루한 코드 작성
- CRUD 작업마다 SQL 쿼리를 반복해서 작성해야 함
- 객체 필드가 추가될 때마다 관련된 모든 SQL 쿼리를 수정해야 함
예를 들어, 회원(Member) 엔티티의 CURD 기능을 구현할 때, 다음과 같이 직접 SQL 쿼리를 반복해서 작성해야 합니다.
// 회원 등록
String sql = "INSERT INTO MEMBER(ID, USERNAME, EMAIL) VALUES(?, ?, ?)";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, member.getId());
pstmt.setString(2, member.getName());
pstmt.setString(3, member.getEmail());
pstmt.executeUpdate();
// 회원 조회
String sql = "SELECT * FROM MEMBER WHERE ID = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, memberId);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setId(rs.getLong("ID"));
member.setName(rs.getString("USERNAME"));
member.setEmail(rs.getString("EMAIL"));
}
또한 Member 필드에 private String tel
이라는 변수가 추가되면, 위에서 작성했던 모든 CRUD에 대해 tel 변수를 추가해야 합니다.
//Create
INSERT INTO MEMBER(ID, USERNAME, EMAIL, TEL) VALUES(?, ?, ?, ?)
//Read
SELECT MEMBER_ID, USERNAME, EMAIL, TEL FROM MEMBER M
두 번째 문제점을 살펴보면,
2) 객체-관계형 데이터베이스 간 패러다임 불일치
객체와 관계형 데이터베이스는 상속, 연관 관계, 데이터 타입, 데이터 식별 방법이 다릅니다. 이 때문에 다음과 같은 불편한 점이 있습니다.
- 객체의 상속 관계를 데이터베이스에 구현하기 어려움
- 객체는 참조로, 데이터베이스는 외래 키로 연관 관계를 표현하여 불일치 발생
- 데이터 접근할 때 객체 그래프 탐색의 한계
예를 들어, Item(상품)을 상속받는 Album, Movie, Book 클래스가 있을 때, 객체의 상속 관계를 DB에서는 슈퍼타입 서브타입 관계로 나타냅니다.
슈퍼타입 서브타입 관계란 간단히, 다른 테이블을 만들어 데이터를 분리하고 필요할 때 외래키(FK)를 기준으로 JOIN하여 가져오는 관계입니다. 여기서 문제가 생깁니다.
- Album 객체를 조회할 때마다 연관관계를 나타내기 위해 ALBUM테이블을 ITEM에 JOIN 하는 쿼리문을 작성해야 한다.
- Album 객체를 저장할 때 ITEM과 ALBUM에 각각 INSERT 쿼리를 작성해야 한다.
- DB에서 단일 테이블 전략을 사용하더라도 SQL이 매우 복잡하다.
위의 예시에서 Album 객체를 생성하거나 조회할 때 쿼리는 다음과 같습니다.
// 생성 쿼리
INSERT INTO ITEM (item_id, name, price, item_type) VALUES (?, ?, ?, 'ALBUM');
INSERT INTO ALBUM (item_id, artist) VALUES (?, ?);
Album과 Item 테이블에 따로 INSERT 쿼리를 총 두 번 실행됩니다.
// 조회 쿼리
SELECT A.*, I.*
FROM ALBUM A
JOIN ITEM I ON A.ITEM_ID = I.ITEM_ID;
객체를 조회할 때에도 SELECT 쿼리를 총 두 번 실행하고 JOIN 쿼리까지 작성해야 합니다.
INSERT, JOIN 쿼리는 접근하는 테이블이 추가될 때마다 추가적으로 작성해야 합니다.
세 번째 문제를 살펴보면,
3) 데이터 접근의 범위의 제한
처음 실행하는 SQL에 따라 데이터 탐색의 범위가 결정됩니다.
- SQL 범위에 따라 불러오는 객체가 한정된다.
- 결국 객체들의 개수에 따라 생성되는 관계를 모두 매핑해야 한다.
- 이렇게 반복적으로 JON 쿼리로 조회한 객체는 엔티티 동일성을 보장하지 않는다.
예를 들어 위의 그림처럼 객체들의 연관관계가 존재할 때,
다음처럼 Member을 조회할 때 TEAM 객체를 가져오도록 SQL이 작성되어 있다면
SELECT M.*, T.*
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID;
member.getTeam()메서드로 Team 객체만 불러올 수 있습니다.
member.getTeam(); //OK
member.getOrder(); //null!
위처럼 유저의 주문 정보가 필요하여 유저를 통해 주문을 가져오면 Member.getOrder()의 결과는 null입니다.
앞에서 작성된 SQL에서는 MEMBER, TEAM만 조회했기 때문에 개발자는 Member객체와 함께 조회된 객체들의 범위를 확인한 후 없다면 Order 객체는 다시 조회해서 가져와야 합니다. 결국 엔티티를 믿고 쓸 수 없는 문제가 생깁니다. 이를 엔티티 신뢰 문제라고 합니다.
SQL 중심적인 개발에서 현실적인 대안은 상황별로 메서드를 따로 만드는 것입니다.
//Member만 조회하는 메서드
Member memberDao.getMember();
//Member와 Team을 조회하는 메서드
List<Object[]> memberDao.getMemberWithTeamWithOrder();
//Member, Team, Order을 조회하는 메서드
List<Object[]> memberDao.getMemberWithTeamWithOrder();
...
하지만 간단히 생각해봐도, N개의 객체가 있을 때 만들어질 수 있는 조합은 2ⁿ가지입니다.
이 방식을 사용하면 개발자는 비지니스적 문제 해결 이전에 SQL 매퍼역할을 먼저 해야 하죠.
마지막 문제를 보면,
4) 엔티티 동일성 문제
또한 위처럼 여러 번 SELECT 쿼리로 Member 객체를 조회하면 각 메서드마다 별도의 쿼리를 실행하여 다른 인스턴스가 생성됩니다.
예를 들어 위에서 정의한 getMember
과 getMemberAndTeam
메서드로 동일한 member객체를 조회하여 객체가 같은지 비교해 보면 실제로는 다른 값이 나옵니다.
Long memberId = 10L;
// id = 10으로 member 조회
Member member1 = memberDao.getMember(memberId);
// id = 10으로 member와 team 조회 후 member만 추출
List<Object[]> memberAndTeam = memberDao.getMemberWithTeam(memberId);
// memberAndTeam에서 member만 추출 (첫 번째 요소가 Member라고 가정)
Member member2 = (Member) memberAndTeam.get(0);
// 두 엔티티의 동일성 비교
boolean isIdentical = member1.equals(member2);
위의 코드에서 member1과 member2는 10라는 동일한 id를 가진 member입니다. 상식적으로 같은 멤버라고 생각할 수 있지만 java에서는 다른 인스턴스여서 실행 결과 isIdentical은 false입니다.
따라서 Java에서는 인스턴스마다 메모리를 사용하여 서버 성능 문제가 발생합니다.
앞에서 살펴본 문제를 정리해봅시다.
정리
결국 객체를 DB 테이블에 맞춰 설계하면 여러 문제점이 있습니다.
- 객체지향적 설계에서 벗어 개발 생산성이 떨어진다.
- 코드에 SQL이 섞여 가독성이 떨어진다.
여기서 다음의 해결책을 생각할 수 있습니다.
그럼 JAVA의 객체처럼 관계형 DB를 설계할 수는 없을까?
연관 관계를 객체처럼 저장한다는 것은 DB의 외래키(FK)가 아니라, 객체를 직접 저장한다는 것입니다.
연관 관계를 Java의 객체처럼 저장할 수 있으면 거의 모든 문제를 해결할 수 있습니다.
// 객체 중심 설계
class Member {
String memberId;
Team team; // TEAM_ID 대신 Team 객체 사용
String username;
String email;
...
}
위처럼 Member 필드에서 Team에 대한 연관관계를 teamId 대신 Team 객체로 직접 저장하면 많은 장점이 있습니다.
- 객체를 Java의 컬랙션에서 관리할 수 있다.
- 엔티티 동일성이 보장된다.
첫 번째 장점에 대해 살펴 보면, 객체를 컬렉션에서 관리하기 때문에 객체 저장은 복잡한 INSERT 쿼리가 아니라 단순한 list.add(member) 명령어로 끝낼 수 있습니다.
두 번째 장점에 대해 살펴 보면,
String memberId = "10";
Member member1 = list.get(memberId);
Member member2 = list.get(memberId);
member1 == member2; //같다.
SQL 중심 개발에서는 DB에서 SELECT 쿼리로 조회했지만 Java에서는 컬렉션에서 조회합니다. 동일한 컬렉션에서 조회하므로 엔티티 동일성을 보장할 수 있죠.
따라서 객체를 컬렉션으로 관리할 수만 있다면 간단한 명령어로 객체를 CRUD할 수 있습니다.
하지만, 아직 해결하지 못한 문제가 있습니다.
그럼 SQL은 누가 작성하지?
save(member);
으로 간단히 저장을 수행하려면 결국 누군가는 MEMBER 테이블에 맞게 SQL을 작성하고 실행해야 합니다. 다음처럼 말이죠.
//save(member)
INSERT INTO MEMBER(member_id, team_id, username, email) VALUES (?, ?, ?, ?, ?);
여기서 가장 중요한 점은 Member 필드의 team
변수가 team_id
로인식되어 SQL 쿼리가 실행되야 한다는 점입니다.
마찬가지로 member 객체의 team이 변경되었을 때,
update(member);
한 줄로 객체의 CRUD를 수행한다면 결국 누군가는 다음처럼 SQL 매핑 과정을 수행해야 합니다.
//update(member)
UPDATE member
SET name = 'Updated Name', team_id = 2
WHERE id = 1;
여기서도 중요한 점은 member객체의 team
변수가 SQL에서는 team_id
로 인식되어 값이 2로 변경되어야 한다는 점이죠. 이렇게 객체와 DB 테이블 사이에서 이 매핑 작업을 대신 해주는 것이 JPA입니다.
JPA를 사용하면 위에서 살펴봤던 패러다임 불일치 문제를 모두 해결할 수 있습니다. 즉, SQL 중심 개발에서 객체를 중심적으로 개발할 수 있게 됩니다.
JPA에 대해 자세히 알아봅시다.
2. JPA
JPA란?
객체 관계를 알아서 매핑해주는 자바 영속성 관리 API
쉽게 말하면, JPA는 객체와 DB 사이에서 알맞게 SQL을 생성하여 “SQL매퍼” 역할 수행해주는 표준 명세 API입니다.
참고로 객체-RDB매핑 프레임워크는 대부분의 언어에서 지원하며 Java 진영의 ORM 프레임워크가 JPA입니다.
ORM이란 객체와 관계형 DB를 매핑해주는 기술입니다.
JPA와 하이버네이트
JPA는 인터페이스의 모음입니다. JPA는 여러 구현체가 있지만 거의 대부분 Hibernate를 사용합니다.
- Hibernate는 JPA의 모든 기능을 지원한다.
- 추가적인 기능(캐싱, 다양한 쿼리 언어)도 지원한다.
- 이를 통해 DB 독립성을 가져갈 수 있다.
Hibernate는 설정 파일을 통해 다양한 DB에 쉽게 연결할 수 있도록 지원합니다. 이는 어플리케이션이 특정 기술에 종속되지 않고 확장성을 가져갈 수 있습니다.
특히 Hibernate의 캐싱 매커니즘을 이용해 어플리캐이션의 성능을 향상시킬 수 있죠.
JPA의 동작 원리
JPA는 내부적으로 JDBC(Java Database Connectivity)를 사용하여 데이터베이스와 통신합니다.
먼저, JDBC에 대해 간단히 살펴보면,
JDBC란 자바 애플리케이션이 DB와 연결하여 데이터를 주고받을 수 있도록 해주는 API입니다. JDBC는 기본적으로 DB 연결 지원, SQL 실행, 결과 처리, 트랜잭션 관리 기능을 지원합니다.
- JDBC는 JDBC 드라이버를 통해 자바 애플리케이션이 다양한 데이터베이스 시스템에 연결할 수 있도록 지원한다.
- JDBC를 사용하면 SQL 쿼리를 작성하고 실행히여 CRUD를 수행할 수 있다.
- SQL 쿼리 실행 후 결과를 처리할 수 있는 기능을 제공합니다. 따라서 SELECT 쿼리의 결과는
ResultSet
객체를 통해 접근할 수 있다. - JDBC는 트랜잭션을 관리하는 기능을 이용하여 트랜잭션을 시작하고, 커밋하거나 롤백할 수 있다.
JPA는 애플리케이션과 JDBC 사이에서 동작합니다.
만약 개발자가 JPA를 이용하여 CRUD를 수행하면, JPA가 알아서 다음 과정을 수행합니다.
- JDBC 드라이버 로드
- EntityManager 생성
- 트랜잭션 시작
- SQL 쿼리 생성 및 실행
- 결과 매핑
- 트랜잭션 커밋
- 자원 해제
JPA는 미리 설정한 정보을 바탕으로 동작하므로 의존성과 DB 설정은 필수적으로 존재해야 합니다.
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'mysql:mysql-connector-java' // MySQL 데이터베이스를 사용하는 경우
}
application.yml
// 기본 DB 설정
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC
username: root
password: 1234
JPA의 특징
JPA가 어떤 식으로 ORM을 지원하는지 살펴봅시다.
생산성
JPA는 다음과 같은 명령어로 객체의 CRUD를 지원한다.
- 저장: jpa.persist(member)
- 조회: Member member = jpa.find(memberId)
- 수정: member.setName(“변경할 이름”)
- 삭제: jpa.remove(member)
유지 보수
필드 추가시 변수만 추가하면 된다.
예를 들어 Member 객체에 tel필드가 추가된 경우,
public class Member {
private String memberId;
private String name;
private String tel;
...
}
위처럼 tel 변수만 추가하면 아래의 SQL은 JPA가 알아서 처리합니다.
INSERT INTO MEMBER(MEMBER_ID, NAME, TEL) VALUES
SELECT MEMBER_ID, NAME, TEL FROM MEMBER M
UPDATE MEMBER SET … TEL = ?
상속
위에서 객체의 상속은 DB의 FK로 표현했습니다.
JPA를 사용하면 아래처럼 한줄로 바뀝니다.
jpa.persist(album);
위의 코드처럼 영속성 컨텍스트에 추가하기만 하면
나머지 매핑 작업은 JPA가 알아서 처리해 줍니다.
// 저장
INSERT INTO ITEM (item_id, name, price, item_type) VALUES (?, ?, ?, 'ALBUM');
INSERT INTO ALBUM (item_id, artist) VALUES (?, ?);
// 조회
SELECT I.*, A.*
FROM ITEM I
JOIN ALBUM A ON I.ITEM_ID = A.ITEM_ID
연관 관계로 객체 그래프 탐색
member.setTeam(team);
jpa.persist(member);
위처럼 member 객체에 team을 설정한 후 영속성 컨텍스트에 추가하면 객체 그래프를 탐색할 때 다음의 명령어로 관련된 객체를 모두 찾을 수 있습니다.
Member member = jpa.find(Member.class, memberId);
Team team = member.getTeam();
성능 최적화
사실 이 부분이 JPA의 꽃입니다.
먼저 간단히 트랜잭션에 대해 짚고 넘어가면,
"데이터베이스에서 상태를 변화시키기 위해 수행하는 작업"의 단위입니다.
예를 들면 은행 계좌의 경우 트랜잭션은 데이터베이스에서의 "거래"를 안전하게 처리하도록 보장하는 작업 단위라고 이해하면 됩니다.
이제 JPA에서 어떻게 성능 최적화가 이루어 지는지 살펴봅시다.
1차 캐시와 동일성 보장
1차 캐시란 JPA에서 영속성 컨텍스트(Persistence Context) 내에 존재하는 캐시로, 특정 트랜잭션과 함께 작동합니다. 1차 캐시를 사용하여 JPA는 데이터베이스에 대한 접근 횟수를 줄이고 성능을 최적화합니다.
- JPA는 같은 트랙잭션 안에서는 같은 엔티티를 반환한다.
- DB Isolation Level이 Read Commit이어도 애플리케이션에서 Repeatable Read을 보장한다.
1번 특징을 보면, JPA는 DB에 커밋하기 전까지는 한 트랜잭션의 영속성 컨텍스트에서 엔티티를 관리합니다.
String memberId = "10";
Member m1 = jpa.find(Member.class, memberId); //SQL에서 조회
Member m2 = jpa.find(Member.class, memberId); //1차 캐시에서 조회
println(m1 == m2) //true
이 코드에서는 첫 번째 조회는 DB에서 하며, 두 번째 조회시 영속성 컨텍스트에서 조회한다. 따라서 두 엔티티는 동일성이 보장됩니다.
2번 특징을 보면, 동일한 트랜잭션 내에서 같은 데이터를 반복적으로 조회할 때, 이렇게 영속성 컨텍스트에 저장된 1차 캐시의 객체를 사용하여 항상 동일한 결과를 제공합니다.
트랜잭션을 지원하는 쓰기 지연 저장소
쓰기 지연 저장소란 트랜잭션이 커밋될 때까지 변경된 엔티티의 상태를 임시로 저장하는 곳입니다. 이 변경 내용은 커밋 시점에 한 번에 데이터베이스에 반영합니다.
- 트랜잭션을 커밋할 때까지 INSERT SQL을 모음
- JDBC BATCH SQL 기능을 사용해서 한번에 SQL 전송
// [트랜잭션] 시작
transaction.begin();
//INSERT: 쓰기 지연 저장소 저장
em.persist(memberA);
// UPDATE: 쓰기 지연 저장소 저장
Member member = em.find(Member.class, memberId);
member.setName("새로운 이름");
// DELETE: 쓰기 지연 저장소 저장
em.persist(memberC);
// 여기까지 INSERT, UPDATE, DELETE SQL을 데이터베이스에 보내지 않는다.
// 커밋하는 순간 데이터베이스에 INSERT SQL을 모아서 보낸다.
transaction.commit(); // [트랜잭션] 커밋
위 코드에서 보면 커밋 시점 전까지 DB의 변경 사항을 쓰기 지연 저장소에 저장한다. 이후 트랜잭션 커밋 시점에 한 번에 DB에 반영됩니다.
지연 로딩과 즉시 로딩
JPA는 객체를 로딩할 때 두 가지 옵션을 지원합니다.
- 지연 로딩: 객체가 실제 사용될 때 로딩
- 즉시 로딩: JOIN SQL로 한번에 연관된 객체까지 미리 조회
예를 들어 Member와 Team객체가 존재할 때 Member객체와 Team 객체를 순차적으로 조회하는 코드에서
Member member = memberDAO.find(memberId); // 1
Team team = member.getTeam(); // 2
String teamName = team.getName(); // 3
지연 로딩의 경우,
먼저 JPA는 1번 라인에서 SELECT * FROM MEMBER
쿼리로 먼저 Member객체를 조회하고 이후 2번 라인에서 Team 객체가 필요할 때, SELECT * FROM TEAM
쿼리로 Team 객체를 조회합니다.
즉시 로딩의 경우에는,
1번 라인에서 Member 객체와 함께 다음의 쿼리로 Team 객체도 함께 조회합니다.
SELECT M.*, T.*
FROM MEMBER
JOIN TEAM
이러한 즉시로딩/지연로딩 기능은 상황에 따라 유연하게 사용하여 DB의 조회 성능을 최적화할 수 있습니다.
3. 마무리
요약
JPA를 사용함으로써 객체에 집중하며 비지니스 로직에 더 집중할 수 있습니다. 이는 결과적으로 SQL 중심 개발에 비해 생산성 향상과 유지보수 용이성이라는 엄청난 장점을 가져옵니다.
하지만, JPA를 사용하더라도 객체와 관계형 데이터베이스에서 연관 관계를 설계하는 방법을 모두 알고 있어야 제대로 JPA를 사용할 수 있습니다.
참고 자료
[(강의)자바 ORM 표준 JPA 프로그래밍 - 기본편 강의]
다음 글에서는 JPA 시작을 위한 프로젝트를 생성해보고 JPA를 실행해볼 예정입니다.
긴 글 읽어주셔서 감사합니다. 잘못된 정보나 궁금한 점이 있다면 댓글을 남겨주세요. 도움이 되었다면 좋아요 부탁드립니다.
'SpringBoot > JPA' 카테고리의 다른 글
[JPA ORM 기본] (2) 프로젝트 생성과 기본 실습 (0) | 2024.12.31 |
---|---|
[JPA ORM 기본] (0) 시리즈 소개 (0) | 2024.12.27 |
경이로운 BE 개발자가 되기 위한 프로그래밍 공부 기록장
도움이 되었다면 "❤️" 또는 "👍🏻" 해주세요! 문의는 아래 이메일로 보내주세요.