JPA와 모던 자바 데이터 저장 기술
SQL 중심 개발의 한계
애플리케이션은 객체지향 언어(Java, Kotlin, Scala)로 개발하지만, 데이터는 관계형 데이터베이스(Oracle, MySQL, PostgreSQL)에 저장한다. 이 구조에서 개발자는 필연적으로 SQL 매퍼 역할을 하게 된다.
// 반복되는 CRUD 코드
public class MemberDAO {
public void save(Member member) {
String sql = "INSERT INTO member(id, name, email) VALUES(?, ?, ?)";
// PreparedStatement 설정...
}
public Member findById(Long id) {
String sql = "SELECT * FROM member WHERE id = ?";
// ResultSet -> Member 변환...
}
public void update(Member member) {
String sql = "UPDATE member SET name = ?, email = ? WHERE id = ?";
// ...
}
}
문제점:
- 필드 추가 시 모든 SQL 수정 필요
- SQL에 의존적인 개발
- 진정한 계층 분리가 어려움
패러다임의 불일치
‘객체 지향 프로그래밍은 추상화, 캡슐화, 정보은닉, 상속, 다형성 등 시스템의 복잡성을 제어할 수 있는 다양한 장치들을 제공한다.’
객체와 관계형 데이터베이스의 차이
| 구분 | 객체 | RDB |
|---|---|---|
| 상속 | 있음 | 없음 (슈퍼타입-서브타입 관계로 유사 구현) |
| 연관관계 | 참조 (단방향) | FK (양방향) |
| 데이터 타입 | 다양한 객체 타입 | 제한된 데이터 타입 |
| 식별 | == 비교, equals() | PK |
상속 관계의 불일치
// 객체 모델
abstract class Item { Long id; String name; int price; }
class Album extends Item { String artist; }
class Movie extends Item { String director; String actor; }
-- 테이블 모델 (슈퍼타입-서브타입)
CREATE TABLE item (id BIGINT, name VARCHAR, price INT, dtype VARCHAR);
CREATE TABLE album (id BIGINT, artist VARCHAR);
CREATE TABLE movie (id BIGINT, director VARCHAR, actor VARCHAR);
Album 저장 시 INSERT 2번, 조회 시 JOIN 필요 → 객체답게 모델링할수록 매핑 작업만 늘어난다.
연관관계의 불일치
// 객체는 참조로 연관관계
class Member {
Long id;
Team team; // 참조
}
// 테이블은 FK로 연관관계
// MEMBER 테이블: ID, TEAM_ID(FK)
객체 그래프 탐색의 문제:
member.getTeam().getOrders().get(0).getProduct();
// SQL에 따라 탐색 범위가 결정됨 → 엔티티 신뢰 문제
JPA란?
Java Persistence API - 자바 진영의 ORM 기술 표준
ORM (Object-Relational Mapping)
객체 ←→ ORM 프레임워크 ←→ RDB
- 객체는 객체대로 설계
- 관계형 데이터베이스는 관계형 데이터베이스대로 설계
- ORM 프레임워크가 중간에서 매핑
JPA 표준과 구현체
JPA는 인터페이스의 모음이다. 실제 구현체:
- Hibernate (가장 많이 사용)
- EclipseLink
- DataNucleus
JPA (표준 인터페이스)
↓ 구현
Hibernate, EclipseLink, DataNucleus
JPA 버전 히스토리
| 버전 | JSR | 연도 | 주요 기능 |
|---|---|---|---|
| JPA 1.0 | JSR 220 | 2006 | 초기 버전 |
| JPA 2.0 | JSR 317 | 2009 | Criteria API 추가 |
| JPA 2.1 | JSR 338 | 2013 | 스토어드 프로시저, 컨버터, 엔티티 그래프 |
| JPA 2.2 | JSR 338 | 2017 | Java 8 Date/Time 지원, Stream 결과 |
| JPA 3.0 | Jakarta | 2020 | Jakarta EE로 이관, 패키지명 변경 |
JPA 동작 원리
영속성 컨텍스트 (Persistence Context)
엔티티를 영구 저장하는 환경. 1차 캐시 역할을 한다.
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
// 비영속 상태
Member member = new Member();
member.setId(1L);
member.setName("회원1");
// 영속 상태 (1차 캐시에 저장)
em.persist(member);
// 1차 캐시에서 조회 (DB 접근 X)
Member findMember = em.find(Member.class, 1L);
tx.commit(); // 이 시점에 INSERT SQL 실행
엔티티 생명주기
비영속 (new)
↓ persist()
영속 (managed) ←→ 1차 캐시
↓ detach(), clear(), close()
준영속 (detached)
↓ remove()
삭제 (removed)
쓰기 지연 (Write-Behind)
em.persist(memberA); // 1차 캐시 저장, SQL 쓰기 지연 저장소
em.persist(memberB); // 1차 캐시 저장, SQL 쓰기 지연 저장소
tx.commit(); // flush → DB에 INSERT SQL 2개 전송
변경 감지 (Dirty Checking)
Member member = em.find(Member.class, 1L);
member.setName("변경된 이름"); // UPDATE SQL 자동 생성
// em.update(member) 같은 코드 불필요!
tx.commit(); // 스냅샷과 비교 후 변경된 엔티티 UPDATE
JPA CRUD
// 저장
em.persist(member);
// 조회
Member member = em.find(Member.class, id);
// 수정 (변경 감지)
member.setName("새이름");
// 삭제
em.remove(member);
// JPQL 조회
List<Member> members = em.createQuery(
"SELECT m FROM Member m WHERE m.age > :age", Member.class)
.setParameter("age", 18)
.getResultList();
연관관계 매핑
단방향 연관관계
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
}
양방향 연관관계
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team") // 읽기 전용
private List<Member> members = new ArrayList<>();
}
연관관계의 주인:
- FK가 있는 곳이 주인 (Member.team)
- 주인만 외래 키를 관리 (등록, 수정)
- 주인이 아닌 쪽은 읽기만 가능
연관관계 편의 메서드
public class Member {
public void changeTeam(Team team) {
this.team = team;
team.getMembers().add(this); // 양쪽 모두 설정
}
}
JPA 성능 최적화
1. 1차 캐시와 동일성 보장
Member a = em.find(Member.class, 1L); // DB 조회
Member b = em.find(Member.class, 1L); // 1차 캐시에서 조회
System.out.println(a == b); // true (동일성 보장)
2. 쓰기 지연으로 배치 처리
<!-- persistence.xml -->
<property name="hibernate.jdbc.batch_size" value="50"/>
for (int i = 0; i < 100; i++) {
em.persist(new Member("member" + i));
if (i % 50 == 0) {
em.flush();
em.clear();
}
}
3. 지연 로딩 (Lazy Loading)
@ManyToOne(fetch = FetchType.LAZY) // 프록시 객체 로딩
@JoinColumn(name = "team_id")
private Team team;
// member 조회 시 team은 프록시
Member member = em.find(Member.class, 1L);
// team 실제 사용 시점에 SELECT 쿼리 실행
String teamName = member.getTeam().getName();
N+1 문제와 해결
// N+1 문제 발생
List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
.getResultList();
// Member 조회 1번 + 각 Member의 Team 조회 N번
// 해결: Fetch Join
List<Member> members = em.createQuery(
"SELECT m FROM Member m JOIN FETCH m.team", Member.class)
.getResultList();
// 한 번의 쿼리로 Member와 Team 함께 조회
실무 권장 설정
기본 설정
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@ManyToOne(fetch = FetchType.LAZY) // 모든 연관관계는 LAZY
@JoinColumn(name = "team_id")
private Team team;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
}
Spring Data JPA와 함께 사용
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByName(String name);
@Query("SELECT m FROM Member m JOIN FETCH m.team WHERE m.age > :age")
List<Member> findMembersWithTeam(@Param("age") int age);
}
정리
| 장점 | 설명 |
|---|---|
| 생산성 | SQL 작성 없이 객체 중심 개발 |
| 유지보수 | 필드 변경 시 SQL 수정 불필요 |
| 패러다임 불일치 해결 | 상속, 연관관계, 객체 그래프 탐색 |
| 성능 | 1차 캐시, 쓰기 지연, 지연 로딩 |
| 벤더 독립성 | Dialect 설정으로 DB 변경 용이 |
ORM은 객체와 RDB 두 기둥 위에 있는 기술이다.
객체와 테이블을 제대로 설계하고 매핑하는 것이 가장 중요하다.
댓글