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 두 기둥 위에 있는 기술이다.

객체와 테이블을 제대로 설계하고 매핑하는 것이 가장 중요하다.