🍃SpringBoot

1. Spring Data JPA 이론: SQL 중심적인 개발의 문제점과 JPA 등장

limdaeil 2026. 1. 10. 12:09

1. Spring Data JPA: SQL 중심적인 개발의 문제점

1.1 SQL에 의존하는 개발의 한계

자바, C# 처럼 객체 지향 언어로 애플리케이션을 개발하면서, 데이터 저장소로는 대부분 관계형 데이터베이스(RDB)를 사용합니다. 이 조합은 너무나 보편적이어서 자연스럽게 느껴지지만, 실제 개발을 깊이 해보면 근본적인 불편함을 계속 마주하게 됩니다. 불편함이라는 문제의 출발점은 단순합니다. 객체를 데이터베이스에 저장하려면 반드시 SQL이 필요하다는 점입니다.

객체를 한 개 저장한다고 해서 save() 버튼 하나로 끝나지 않습니다. INSERT, SELECT, UPDATE, DELETE 같은 모든 CRUD 작업을 위해 개발자는 SQL을 직접 작성해야만 합니다. 이 순간부터 애플리케이션은 점점 객체 중심이 아니라 SQL 중심으로 흘러가게 됩니다.

 

반복되는 SQL과 유지보수 비용

처음에는 큰 문제가 전혀 없어 보입니다. 하지만 엔티티에 필드 한 개가 추가되는 순간, 상황이 달라집니다.

  • INSERT SQL 수정
  • SELECT SQL 수정
  • UPDATE SQL 수정
  • JOIN 쿼리 전부 점검

객체 구조가 바뀔 때마다 관련된 SQL을 모두 찾아서 수정해야 합니다. 쿼리 수가 많아질수록, 이 작업은 단순한 수정이 아니라 유지보수 리스크가 됩니다. 이 시점부터 개발자는 자연스럽게 "객체 구조를 바꾸는 게 귀찮으니까, DB 구조에 맞춰 객체를 설계하자." 이렇게 생각하게 됩니다. 객체가 아니라 테이블이 설계의 기준이 되기 시작하게 됩니다.

객체 지향과 관계형 DB의 근본적인 차이로 인한 문제는 도대체 무엇일까요? 문제는 단순히 SQL 쿼리문 작성이 귀찮다는 데 있지 않습니다. 더 근본적인 문제는 객체 지향과 관계형 데이터베이스의 철학 자체가 다르다는 점입니다.

 

객체 지향(OOP) vs 관계형 DB(RDB) 비교

구분 객체 지향
(Object-Oriented)
관계형 DB
(Relational DB)
설명 및 차이점
기본 단위 객체
(Object)
테이블
(Table)
OOP는 객체 중심
RDB는 데이터를 담는 표(Table) 중심
구조 정의 클래스
(Class)
스키마,
테이블 정의
객체의 설계도는 클래스
DB의 데이터 구조는 스키마로 정의
데이터 구성 필드
(Field)
컬럼 (Column),
행 (Row)
객체는 상태를 필드에 저장
DB는 컬럼(속성)과 로우(데이터)로 저장
핵심 원칙 캡슐화
(Encapsulation)
데이터 무결성 (Integrity) 객체는 정보 은닉을 중시
DB는 정규화와 제약 조건을 통한 데이터 정확성을 중시
계층 구조 상속
(Inheritance)
(없음) OOP는 상속을 지원
RDB는 상속 개념이 없어 테이블 간 관계로 흉내만 가능
연관 관계 참조
(Reference)
외래 키
(Foreign Key)
객체는 참조를 통해 방향성을 표시
DB는 외래 키를 이용해 양방향 조인이 가능
데이터 탐색 객체 그래프 탐색 조인
(Join)
객체는 점(.)을 찍어 연결된 객체를 자유롭게 탐색
DB는 여러 테이블을 합치는 조인 연산이 필요
지향점 추상화 및
기능 중심
데이터의 효율적 관리 및
무결성
OOP는 유연한 설계
RDB는 데이터의 일관성과 집합적 사고를 지향

객체 지향과 관계형 DB 이 둘은 출발점부터 다릅니다. 그래서 객체를 테이블에 저장하려는 순간, 우리는 항상 변환 작업을 해야 합니다. 이 변환 과정에서 발생하는 모든 문제를 통틀어 객체-관계 패러다임 불일치(ORM Impedance Mismatch)라고 부릅니다. 그리고 이 패러다임 불일치는 다음과 같은 형태로 개발자 앞에 나타납니다.

  1. 상속 구조를 테이블로 표현하기 어렵다.
  2. 객체는 참조하지만, DB는 외래 키로 연결한다.
  3. 객체 그래프 탐색이 SQL JOIN 범위에 묶인다.
  4. 같은 데이터를 조회해도 객체 동일성이 보장되지 않는다.

이 문제들은 단편적인 불편함이 아니라, 객체 지향 설계를 유지하려 할수록 더 크게 체감되는 구조적인 문제입니다. 따라서 SQL 중심적인 개발의 가장 큰 문제는 객체가 아니라 테이블이 설계의 기준이 되기 시작되면서 개발자가 점점 객체를 포기하게 된다는 점입니다.

  • 객체 모델링을 해도 결국 SQL에 끌려간다.
  • 참조 대신 ID를 사용하게 된다.
  • 계층 간 신뢰가 깨진다.
  • 객체는 데이터 덩어리로 전략한다.

이 문제를 해결하기 위해 등장한 것이 바로 JPA를 포함한 ORM 기술입니다. 하지만 JPA를 이해하려면, 먼저 이 불일치가 어디서, 어떻게 발생하는지를 정확하게 짚고 넘어가야 합니다. 가장 대표적인 문제인 상속, 연관관계, 객체 그래프 탐색, 동일성 문제에 대해 학습합니다.

1.2 상속(Inheritance) – 객체 지향의 핵심이 RDB에서 무너지는 지점

객체 지향 언어에서 상속은 매우 자연스러운 개념입니다. 공통 속성과 행위를 부모 클래스에 정의하고, 자식 클래스는 이를 확장하면서 자신의 책임만 추가합니다. 이를 통해 코드 재사용성과 모델의 표현력이 크게 향상됩니다. 하지만 이 구조를 그대로 관계형 데이터베이스에 옮기려고 하는 순간, 문제가 시작됩니다.

 

객체 지향에서의 상속

객체 세계에서는 상속이 너무나 직관적입니다.

class Item {
    Long id;
    String name;
}

class Book extends Item {
    String author;
}
  • BookItem이다
  • Item의 모든 속성을 그대로 가진다
  • 공통 개념과 확장 개념이 명확히 분리된다

객체 지향 관점에서는 아주 이상적인 모델입니다. 하지만 관계형 데이터베이스에는 상속이라는 개념 자체가 존재하지 않습니다. 테이블은 오직 행과 컬럼만을 가질 뿐입니다. 그래서 개발자는 선택을 강요받습니다. “이 상속 구조를 테이블로 어떻게 표현할 것인가?” 보통 다음과 같은 방식이 사용됩니다.

  • 부모 테이블 + 자식 테이블 분리
  • 부모를 슈퍼타입, 자식을 서브타입으로 설계
  • 공통 컬럼은 부모 테이블에, 확장 컬럼은 자식 테이블에 저장

즉, 객체 하나를 저장하기 위해 테이블이 두 개 이상 필요해집니다. 상속 구조를 DB에 저장할 때 발생하는 작업들을 살펴보겠습니다. Book 객체 하나를 저장한다고 가정해봅시다. 이 경우 개발자가 해야 할 일은 다음과 같습니다.

  1. ITEM 테이블에 INSERT
  2. 생성된 ITEM의 PK를 가져옴
  3. BOOK 테이블에 INSERT
  4. 두 테이블 간의 관계를 정확히 맞춤

조회할 때는 더 복잡합니다.

  1. ITEMBOOK을 JOIN
  2. ResultSet을 분석
  3. Item + Book 객체로 다시 조립

즉, 객체를 분해해서 저장하고, 다시 조립해서 사용하는 구조가 됩니다. 이 모든 과정을 개발자가 직접 SQL과 매핑 코드로 관리해야 합니다. SQL 중심 개발의 실무에서 상속을 피하게 되는 가장 큰 이유에요. 이 구조는 이론적으로는 가능하지만, 실무에서는 거의 사용되지 않습니다. 이유는 명확합니다.

  • 테이블 설계가 복잡해진다
  • INSERT, SELECT 로직이 항상 두 배 이상이 된다
  • JOIN 누락, 매핑 실수 가능성이 높아진다
  • 객체 조립/분해 코드가 계속 늘어난다

결국 개발자는 “차라리 상속을 쓰지 말자.” 이렇게 판단하게 됩니다. 그래서 실제 프로젝트에서는 다음과 같은 선택이 흔해집니다.

  • 상속 대신 단일 테이블 사용
  • 모든 필드를 한 테이블에 몰아넣음
  • 객체 모델이 아니라 테이블 구조가 중심이 됨

즉, 객체 지향 설계를 포기하고 데이터 중심 설계로 후퇴하게 됩니다. 문제의 본질을 살펴보자면, 이 문제의 핵심은 단순히 “상속이 불편하다”가 아닙니다. 더 근본적인 문제는 다음과 같습니다.

  1. 객체는 개념과 책임 중심으로 설계된다
  2. RDB는 데이터 구조 중심으로 설계된다
  3. 상속은 객체의 핵심 표현 수단인데, RDB는 이를 표현하지 못한다

그래서 상속을 사용하려는 순간, 개발자는 SQL과 매핑 코드에 끌려다니게 됩니다. 이것이 바로 객체-관계 패러다임 불일치의 대표적인 사례입니다. 지금까지의 결론을 정리하면 다음과 같습니다.

  • 객체 지향에서는 상속이 자연스럽고 강력하다
  • RDB에는 상속 개념이 없다
  • 상속 구조를 테이블로 표현하려면 복잡한 분해·조립이 필요하다
  • 그 책임은 전부 개발자에게 있다
  • 결국 실무에서는 상속을 회피하게 된다

이렇게 해서 객체 모델링의 중요한 축 하나가 무너집니다. 그리고 이 문제를 해결하기 위해 ORM, 그중에서도 JPA는 상속 매핑 전략이라는 개념을 도입합니다. 하지만 그 이야기는 아직 이릅니다.

1.3 연관관계(Association) – 참조를 포기하게 되는 이유

객체 지향 설계에서 연관관계는 핵심 중의 핵심입니다. 객체는 다른 객체를 참조(reference) 하며, 이 참조를 통해 자신의 책임을 수행합니다. 하지만 이 자연스러운 객체 모델은 관계형 데이터베이스와 만나는 순간, 점점 형태가 바뀌기 시작합니다.

 

객체 세계에서의 연관관계

객체 모델에서 연관관계는 매우 직관적입니다.

class Member {
    Long id;
    Team team;   // 객체 참조
    String name;
}

class Team {
    Long id;
    String name;
}

이 모델의 의미는 명확합니다.

  • MemberTeam을 알고 있다
  • member.getTeam()을 통해 팀에 접근한다
  • 객체 간의 관계는 참조 자체로 표현된다

객체 지향 관점에서는 이보다 자연스러운 모델이 없습니다.
그러나 관계형 DB에서의 연관관계는 전혀 다른 방식으로 관계를 표현합니다.

  • 테이블 간의 관계는 외래 키(Foreign Key) 로 표현
  • 연관된 데이터를 조회하려면 반드시 JOIN 이 필요
SELECT M.*, T.*
FROM MEMBER M JOIN TEAM T ON M.TEAM_ID = T.ID;

DB 세계에서는 MEMBER 테이블 안에 TEAM_ID 컬럼이 존재할 뿐, “참조”라는 개념은 없습니다. 결국 객체와 테이블 사이에서 발생한 간극은 이 두 세계를 모두 동시에 만족시키기 어렵다는 데 있습니다. 객체답게 설계하려면,

  1. MemberTeam team을 가져야 한다
  2. 필요한 시점에 member.getTeam()을 호출하면 된다

하지만 SQL 중심 개발에서는 다음과 같은 부담이 생깁니다.

  • Team을 사용하려면 항상 JOIN을 직접 작성해야 한다
  • 조회 쿼리마다 어떤 연관관계를 가져올지 결정해야 한다
  • JOIN을 빠뜨리면 객체가 불완전해진다

이 부담은 곧 설계의 방향을 SQL 중심 개발로 바꿔버립니다.
그래서 현실적인 선택은 참조 대신 ID를 추가합니다. 일반적으로 많은 프로젝트에서 이런 선택을 하게 됩니다.

class Member {
    Long id;
    Long teamId;   // 참조 대신 외래 키 값
    String name;
}

이 구조는 다음과 같은 특징을 가집니다.

  • SQL 작성이 단순해진다
  • JOIN을 직접 통제할 수 있다
  • 성능 예측이 쉬워진다

하지만 대가는 큽니다. 참조 대신 ID로 인해 객체 모델링이 무너지는 순간입니다.
teamId를 사용하기 시작하면, 객체는 더 이상 객체답지 않습니다.

  • member.getTeam()은 존재하지 않는다
  • 팀 정보를 얻으려면 다시 조회 로직이 필요하다
  • 객체 간 책임과 관계가 코드에서 사라진다

즉, 객체는 데이터 구조로 전락하고, 비즈니스 로직은 점점 서비스 계층과 SQL 쪽으로 의존성이 쏠리게 됩니다. 이 시점부터 애플리케이션은 사실상 “객체를 쓰는 SQL 프로그램” 이 됩니다. 문제의 핵심은 다음과 같습니다.

  • 객체는 참조를 통해 관계를 표현하고, DB는 외래 키와 JOIN으로 관계를 표현한다
  • SQL 중심 개발에서는 이 차이를 개발자가 직접 메워야 한다
  • 이 부담 때문에 객체 모델링을 포기하게 된다

즉, 연관관계는 객체와 RDB 간 불일치가 가장 직접적으로 드러나는 지점입니다.
연관관계 불일치 문제를 정리하자면 다음과 같습니다.

  • 객체는 참조를 통해 자연스럽게 연결되고, SQL 기반 개발에서는 JOIN 부담이 계속 발생한다
  • 결국 참조 대신 ID를 쓰게 되면서 객체 지향 설계가 점점 사라진다

이렇게 해서 객체 지향의 또 하나의 축이 무너집니다.

1.4 객체 그래프 탐색(Navigation) – 객체가 완전하지 않게 느껴지는 이유

객체 지향의 가장 큰 장점 중 하나는 객체 그래프를 따라 자유롭게 탐색할 수 있다는 점입니다. 객체는 다른 객체를 참조하고, 그 참조를 통해 또 다른 객체로 자연스럽게 이동할 수 있습니다. 하지만 SQL 중심 개발 환경에서는 이 장점이 쉽게 무너집니다.

 

객체 세계에서의 탐색

객체 모델에서는 탐색이 매우 자연스럽습니다.

member.getTeam().getCompany().getLocation();

이 코드를 작성할 때 개발자는 거의 고민하지 않습니다.

  • MemberTeam을 알고 있고
  • TeamCompany를 알고 있으며
  • CompanyLocation을 알고 있다

따라서 객체 간 관계가 보장되어 있다는 신뢰를 전제로 코드를 작성합니다. 하지만 RDB에서는 상황이 완전히 다릅니다. SQL 기반 환경에서의 탐색은 JOIN한 테이블까지만 데이터를 제공합니다.

SELECT M.*, T.*
FROM MEMBER M JOIN TEAM T ON M.TEAM_ID = T.ID;

이 쿼리의 결과로 만들어진 객체는 다음과 같은 상태입니다.

  • Member 객체는 존재
  • Team 객체도 존재
  • 하지만 Company, Order, Delivery는 없다

위 쿼리를 통해 Member, Team 객체까지만 객체 그래프 탐색이 가능합니다. 따라서 객체 그래프가 중간에서 끊긴 상태입니다. 결국 SQL 기반 환경에서의 탐색으로 인해 불완전한 객체가 만들어지게 됩니다. 이 상태에서 다음 코드를 실행하면 어떻게 될까요?

member.getOrder();

SQL에서 ORDER 테이블을 JOIN하지 않았습니다. 결국 해당 값은 null이거나 아예 조회 자체가 불가능합니다. 문제는 이 사실을 컴파일 타임에는 알 수 없다는 점입니다. 결국 오직 개발자의 기억과 주의에만 의존하게 됩니다. 이 구조에서는 개발자가 항상 다음을 고민해야 합니다.

  • 이 객체는 어디까지 조회된 상태인가?
  • 어떤 JOIN이 이미 포함되어 있는가?
  • 이 메서드에서 다른 연관 객체를 써도 되는가?

즉, 객체를 다루면서도 항상 SQL을 머릿속에 떠올려야 합니다. 이 순간부터 객체는 이상 “자율적인 존재”가 아닙니다. 그저 SQL 결과를 담은 그릇일 뿐입니다. 이 문제는 단순히 불편한 수준을 넘어섭니다. 서비스 계층은 리포지토리 계층의 SQL 내용을 알아야 하고 어떤 JOIN이 있는지까지 인지해야 합니다. 결국 계층 간 책임 분리가 깨지고, 계층 간 신뢰가 무너지게 됩니다. 그리고 하위 계층(SQL)의 구현 방식이 상위 계층(비즈니스 로직)에 그대로 새어 나옵니다.

객체 그래프 탐색 문제의 본질은 다음과 같습니다.

  • 객체는 “필요하면 언제든 탐색 가능”해야 하고, SQL 기반 개발에서는 “미리 JOIN한 것만 탐색 가능”하다
  • 이 차이를 개발자가 직접 관리해야 하는데, 그 결과는 객체에 대한 신뢰가 사라진다

그래서 많은 개발자가 이런 습관을 갖게 됩니다. “이 객체는 그냥 데이터 덩어리라고 생각하자.” 이 생각이 드는 순간, 객체 지향 설계는 사실상 종료됩니다. 객체 그래프 탐색은 N+1 문제를 발생하는 가장 흔한 원인 중의 한 가지라는 것 또한 미리 알고 있으면 좋습니다. 객체 그래프 탐색과 SQL 기반 개발에서의 차이점으로 인한 문제를 정리하면 다음과 같습니다.

  • 객체 그래프 탐색은 객체 지향의 핵심 장점이고, SQL 중심 개발에서는 JOIN 범위에 탐색이 묶인다
  • 객체가 불완전한 상태로 전달되고, 계층 간 신뢰가 깨진다
  • 규모가 커질수록 유지보수가 점점 어려워진다

1.5 동일성(Identity) – 같은 데이터인데, 왜 다른 객체인가

객체 지향에서 동일성(identity) 은 매우 중요한 개념입니다. 객체는 단순히 값이 같다는 것(equals)을 넘어서, “같은 존재인가?” 를 판단할 수 있어야 합니다. 하지만 SQL 중심 개발 환경에서는 이 기본적인 전제가 쉽게 깨집니다.

 

객체 세계에서의 동일성

객체 지향에서 동일성은 아주 직관적입니다.

Member m1 = new Member(1L);
Member m2 = m1;

System.out.println(m1 == m2); // true
  • m1m2는 같은 인스턴스를 가리킴
  • 두 참조는 같은 객체를 의미함
  • == 비교는 곧 “같은 존재인가?”의 판단

객체 세계에서는 이 전제가 너무나 당연합니다. 그러나 DB에서 객체를 조회하는 순간, 상황이 달라집니다.

Member m1 = memberRepository.find("id100");
Member m2 = memberRepository.find("id100");

System.out.println(m1 == m2); // false
  • 같은 ID를 조회했음에도
  • 매번 새로운 객체 인스턴스가 생성됨
  • 결과적으로 == 비교는 false

즉, 같은 데이터를 의미하지만, 다른 객체가 됩니다.

 

동일성 문제가 왜 중요한가?

처음 보면 “equals로 비교하면 되잖아?” 이렇게 생각하기 쉽습니다. 하지만 이 문제는 단순한 비교 연산의 문제가 아닙니다. 객체의 정체성 자체가 보장되지 않는다는 점이 핵심입니다. 이로 인해 다음과 같은 문제가 발생합니다.

  1. 컬렉션(Set, Map)에서 예기치 않은 동작
  2. 동일 객체임을 전제로 한 로직 오류
  3. 참조 기반 로직 붕괴
  4. 캐시, 중복 처리, 상태 관리가 어려워짐

객체가 “같은 존재”라는 전제가 깨지는 순간, 비즈니스 로직은 점점 방어 코드로 가득 차게 됩니다.

객체 지향 설계에서는 종종 이런 전제를 둡니다. “같은 엔티티라면, 같은 객체일 것이다.” 하지만 SQL 기반 개발에서는 이 전제가 성립하지 않습니다. 그래서 참조 무결성의 붕괴가 발생하게 됩니다.

  • A 서비스에서 조회한 Member
  • B 서비스에서 다시 조회한 Member

이 둘은 논리적으로는 같은 회원이지만, 메모리 상에서는 전혀 다른 객체입니다. 결국 개발자는 이런 사고방식으로 바뀌게 됩니다. “객체는 믿지 말고, ID로만 판단하자.” 이 순간부터 객체는 더 이상 도메인 모델이 아니라 그저 데이터 전달 수단이 됩니다. 따라서 동일성 문제의 본질은 명확합니다.

  • 객체는 동일성을 통해 자신의 정체성을 유지한다
  • SQL 기반 조회는 매번 새로운 객체를 생성한다
  • 동일성이 보장되지 않는다
  • 객체 지향 설계의 전제가 무너진다

지금까지 살펴본 문제들을 다시 떠올려보면, 이 동일성 문제는 모든 문제의 종착지에 가깝습니다.

정리하자면 SQL 중심적인 개발은 결국 다음과 같은 결과를 낳습니다.

  • 상속은 복잡해서 피하게 된다
  • 연관관계는 참조 대신 ID로 바뀐다
  • 객체 그래프 탐색은 SQL에 종속된다
  • 객체 동일성은 보장되지 않는다

그 결과, 객체를 쓰고 있지만, 객체 지향 개발은 아니다라는 모순적인 상태에 도달하게 됩니다.

2. JPA란 무엇인가

앞에서 우리는 SQL 중심적인 개발이 어떤 문제를 만들어내는지 충분히 살펴봤습니다. 중요한 점은, 이 문제들이 개발자의 실수나 SQL 숙련도의 문제가 아니라는 사실입니다. 객체 지향 언어로 설계하려고 할수록 관계형 데이터베이스와의 간극은 더 크게 느껴졌고, 그 간극을 메우는 책임은 언제나 개발자 개인에게 돌아왔습니다.

  • 상속을 쓰고 싶지만 테이블 설계가 복잡해지고
  • 참조를 쓰고 싶지만 JOIN이 부담이 되고
  • 객체를 믿고 탐색하고 싶지만 SQL 범위를 항상 기억해야 했습니다

이 지점에서 자연스럽게 하나의 질문이 등장합니다. “이 간극을 매번 사람이 직접 메워야 할까?” ORM, 그리고 JPA는 바로 이 질문에 대한 답으로 등장했습니다. JPA는 지금까지 우리가 겪어온 문제를 전혀 다른 관점에서 바라봅니다. 이 문제들은 개발자가 SQL을 못 써서 생긴 것도 아니고, 설계를 잘못해서 생긴 것도 아닙니다 JPA는 다음과 같이 정의합니다.

  • 상속이 불편한 이유는 → RDB에는 상속이라는 개념이 없기 때문
  • 연관관계를 유지하기 어려운 이유는 → 객체는 참조를, DB는 외래 키를 사용하기 때문
  • 객체 탐색이 끊기는 이유는 → SQL은 미리 JOIN한 범위만 알 수 있기 때문
  • 동일성이 깨지는 이유는 → 조회할 때마다 새로운 객체를 만들기 때문

즉, 이 모든 문제는 객체와 관계형 데이터베이스의 패러다임이 다르기 때문에 발생하는 구조적인 문제 라고 보는 것입니다. 그래서 JPA는 문제를 이렇게 재정의합니다. “이건 개발자가 해결할 문제가 아니라, 프레임워크가 책임져야 할 문제다.”

 

JPA의 접근 방식

JPA의 접근 방식은 의외로 단순합니다."객체 지향은 객체 지향답게 두고, 관계형 데이터베이스는 관계형 데이터베이스답게 두자. 그리고 그 사이의 변환은 프레임워크가 책임지자"

  • 객체를 테이블처럼 설계하지도 말고
  • 테이블을 객체처럼 억지로 다루지도 말자
  • 각자의 세계를 존중하되, 연결은 자동화하자

이것이 JPA의 출발점입니다.

 

“SQL을 숨긴다”는 말의 진짜 의미

JPA를 설명할 때 자주 나오는 말이 있습니다. “JPA는 SQL을 숨겨준다” 이 표현은 반만 맞고, 반은 오해를 낳기 쉽습니다. JPA의 목표는 SQL을 없애는 것이 아닙니다. JPA의 진짜 의도는 이것에 가깝습니다. 개발자가 객체를 다룰 때 매번 SQL을 떠올리지 않아도 되게 하자 즉, 비즈니스 로직을 작성하는 시점에서,

  • 어떤 테이블에 INSERT 해야 하는지
  • 어떤 컬럼이 외래 키인지
  • JOIN을 해야 하는지 말아야 하는지

이런 고민을 의식하지 않게 만드는 것이 목적입니다. 개발자는 다음과 같은 코드만 생각합니다.

member.setTeam(team);
em.persist(member);

이 순간 개발자는 “외래 키를 어떻게 넣지?”, “INSERT를 두 번 날려야 하나?” 같은 고민을 하지 않습니다. 그 책임은 JPA가 가져갑니다.

 

JPA가 제공하는 새로운 사고 방식

JPA를 도입하면 개발자의 사고 흐름이 달라집니다.

 

SQL 중심 사고

  • 이 데이터를 넣으려면 어떤 INSERT가 필요하지?
  • 이 관계를 가져오려면 JOIN을 어떻게 하지?
  • 이 객체는 어디까지 조회된 상태지?

JPA 중심 사고

  • 이 객체는 어떤 책임을 가지는가?
  • 이 객체는 누구를 알고 있어야 하는가?
  • 지금 이 객체를 영속 상태로 만들면 된다

즉, “어떻게 저장할까”에서 “무엇을 표현할까”로 사고의 축이 이동합니다. 이 변화가 JPA의 가장 큰 가치입니다.

2.1 EntityManager

EntityManagerJPA(Java Persistence API)에 정의된 핵심 인터페이스입니다. JPA는 자바 애플리케이션에서 관계형 데이터베이스를 다루기 위한 표준 명세이고, EntityManager는 그 명세 안에서 엔티티를 관리하는 중심 역할을 담당합니다.

하이버네이트(Hibernate)는 이 JPA 표준을 실제로 구현한 구현체 중 하나입니다. 우리가 하이버네이트를 사용하더라도 애플리케이션 코드는 JPA 표준 인터페이스인 EntityManager를 통해 데이터베이스와 상호작용하고, 하이버네이트는 내부에서 그 동작을 처리합니다. 따라서 EntityManager 자체는 표준 인터페이스이며, 하이버네이트는 해당 인터페이스의 특정 구현 버전을 제공하고 있어요.

 

EntityManager에 대해 오해하는 부분이 있어요. EntityManager의 역할은 단순히 SQL을 대신 실행해 주는 것이 아닙니다. 이 객체는 엔티티(객체)의 생명주기 전체를 관리하며, 애플리케이션과 데이터베이스 사이에서 객체 중심의 관점을 유지하도록 돕습니다. 개발자는 EntityManager에게 다음과 같이 요청합니다.

  • “이 객체를 DB에 저장해 줘” → persist
  • “이 객체를 조회해 줘” → find
  • “이 객체의 변경을 관리해 줘”

그러면 EntityManager는 내부적으로 다음과 같은 책임을 수행합니다.

  • 엔티티의 상태 관리
  • 동일성 보장
  • SQL 생성 및 실행 시점 제어
  • 쓰기 지연과 변경 감지
  • 캐시 관리

이 모든 역할의 핵심은 하나입니다. EntityManager객체를 데이터베이스 테이블이 아니라, 애플리케이션의 객체로서 관리하기 위해 등장한 개념이라는 점입니다. 즉, 개발자가 더 이상 “언제 INSERT를 날려야 하지?”, “UPDATE SQL을 직접 작성해야 하나?” 같은 DB 중심의 고민을 하지 않도록, 객체 중심 개발의 책임을 EntityManager에게 위임하기 위해 만들어진 인터페이스가 바로 EntityManager입니다.

 

무너졌던 객체 지향 요소는 어떻게 회복되는가

이제 앞에서 무너졌던 요소들을 다시 떠올려보면, JPA가 무엇을 회복하려 했는지가 선명해집니다.

  • 상속 → 객체 상속 구조를 유지한 채 테이블로 매핑 가능
  • 연관관계 → 참조를 유지해도 외래 키는 자동 관리
  • 객체 그래프 탐색 → 필요한 시점에 연관 객체를 조회
  • 동일성 → 같은 트랜잭션 안에서는 같은 객체 보장

중요한 점은, 이 모든 것이 개발자의 코드가 아니라 JPA 내부 메커니즘으로 해결된다는 사실입니다. JPA는 생산성을 높이기 위한 도구이기 이전에, 객체 지향 설계를 끝까지 밀고 갈 수 있게 해주는 장치입니다. 이어서 설명할 EntityManager는 객체를 “어떻게” 관리하는가? 그 답은 바로 영속성 컨텍스트와 엔티티 생명주기입니다.

3. 영속성 컨텍스트와 엔티티 생명주기

JPA를 처음 접할 때 많은 사람이 “JPA는 그냥 SQL을 자동으로 만들어주는 도구 아닌가요?” 이렇게 생각합니다. 하지만 실제로 JPA의 핵심은 SQL 생성이 아니라 객체 상태 관리에 있습니다. 그리고 그 상태 관리를 가능하게 만드는 개념이 바로 영속성 컨텍스트(Persistence Context) 입니다.

3.1 영속성 컨텍스트

영속성 컨텍스트는 흔히 엔티티를 영구 저장하는 환경으로 정의됩니다. 하지만 이 정의만으로는 감이 잘 오지 않습니다. 조금 더 애플리케이션 관점에서 풀어보면 이렇습니다. 영속성 컨텍스트는 “지금 이 트랜잭션 안에서 내가 책임지고 관리할 객체들의 집합”이다. 즉, DB에 저장되었는지 여부와 상관없이 JPA가 “관리 중”이라고 선언한 객체들이 트랜잭션이 끝날 때까지 머무는 공간. 이 공간이 바로 영속성 컨텍스트입니다.

SQL 중심 개발에서는 객체의 상태를 따질 필요가 없었습니다.

  • SQL 실행 → 결과를 객체로 변환
  • 객체 사용 → 버림

객체는 그저 DB 결과를 담는 임시 그릇이었기 때문입니다.하지만 JPA는 다릅니다. “객체를 한 번 가져왔으면, 그 이후의 변경도 내가 책임지겠다.” 이 철학 때문에 엔티티에는 상태(state) 라는 개념이 생깁니다.

3.2 엔티티의 생명주기

JPA는 엔티티를 크게 네 가지 상태로 나눕니다. 이 개념은 JPA를 깊이 이해하기 위한 기본 언어라고 생각하면 됩니다.

 

1. 비영속(Transient)

Member member = new Member();
  • 단순히 new로 생성된 객체
  • 영속성 컨텍스트와 아무 관계 없음
  • JPA는 이 객체의 존재를 모름

이 상태의 객체는 JPA 입장에서는 완전히 평범한 자바 객체입니다.

 

2. 영속(Managed)

em.persist(member);
// 또는 
Member member = em.find(Member.class, 1L);

이 순간부터 객체는 영속 상태가 됩니다.
영속 상태의 의미는 “이 객체는 이제 내가 관리한다.”으로 단순합니다. 영속 상태가 되면 다음 일이 자동으로 일어납니다.

  • 동일성 보장
  • 1차 캐시에 저장
  • 변경 감지 대상이 됨
  • 트랜잭션 커밋 시 DB 반영 대상이 됨

JPA의 거의 모든 핵심 기능은 영속 상태에서만 동작합니다. 자세한 설명은 뒤에서 합니다.

 

3. 준영속(Detached)

em.detach(member);
// 또는
em.clear();

영속 상태였던 객체가 영속성 컨텍스트의 관리 대상에서 빠진 상태입니다.

  • 객체는 여전히 메모리에 존재
  • 하지만 JPA는 더 이상 추적하지 않음
  • 변경해도 DB에 반영되지 않음

준영속 상태는 “객체는 살아 있지만, JPA의 관심 밖”인 상태라고 보면 됩니다.

 

4. 삭제(Removed)

em.remove(member);
  • 삭제가 예약된 상태
  • 즉시 DELETE SQL이 실행되지 않음
  • 트랜잭션 커밋 시 DELETE 실행

이 역시 상태 관리의 일부입니다.

엔티티의 생명주기 네 가지 상태를 이해하면 앞에서 보았던 JPA의 특징들이 갑자기 연결되기 시작합니다. 그래서 중요합니다.

 

동일성 보장

Member m1 = em.find(Member.class, 1L);
Member m2 = em.find(Member.class, 1L);

m1 == m2 // true

이게 가능한 이유는 영속성 컨텍스트는 같은 ID의 엔티티를 한 번만 만든다는 규칙 단 하나 때문입니다.
“같은 엔티티 = 같은 객체”라는 규칙을 프레임워크 차원에서 강제합니다.

 

변경 감지(Dirty Checking)

Member member = em.find(Member.class, 1L);
member.setName("newName");

이 코드에 UPDATE는 없습니다. 하지만 트랜잭션이 커밋될 때 JPA는 다음을 수행합니다.

  • 영속 상태 엔티티의 최초 스냅샷과 현재 상태 비교
  • 변경된 필드 자동 감지
  • UPDATE SQL 생성

이것이 가능한 이유도 영속성 컨텍스트가 객체의 상태를 계속 추적하고 있기 때문입니다.

 

쓰기 지연(Transaction Write-Behind)

em.persist(m1);
em.persist(m2);

이 시점에는 SQL이 실행되지 않습니다. JPA는 이렇게 생각합니다.

“아직 트랜잭션이 끝나지 않았다. 변경 의도만 모아두자.”

그리고 커밋 시점에 INSERT, UPDATE, DELETE SQL을 한 번에 실행합니다.
이 역시 영속성 컨텍스트가 변경 내역을 알고 있기 때문에 가능한 방식입니다.

 

객체 그래프 탐색이 다시 가능해지는 이유

앞에서 SQL 중심 개발의 문제로 객체 그래프 탐색이 끊긴다는 이야기를 했습니다.
JPA는 이를 지연 로딩(Lazy Loading) 으로 해결합니다.

  • 연관 객체는 프록시로 대기
  • 실제 접근 시점에 SQL 실행
  • 객체 그래프 탐색의 자유 회복

중요한 점은, 이 판단을 개발자가 아니라 영속성 컨텍스트가 한다는 것입니다.

영속성 컨텍스트는 단순한 내부 구현이 아닙니다. 객체를 중심으로 애플리케이션을 동작하게 만들고, 객체의 생명주기와 상태를 관리하며, SQL 실행 시점을 통제하고, 객체 지향의 전제를 다시 성립하게 만듭니다. 그래서 JPA를 이해할 때는 항상 이렇게 생각하는 것이 좋습니다. “지금 이 객체는 영속성 컨텍스트가 관리하고 있는가?” 이 질문에 답할 수 있으면, JPA의 동작은 대부분 설명됩니다.

4. 지연 로딩(Lazy Loading)과 즉시 로딩(Eager Loading)

JPA는 왜 ‘기본이 Lazy’일까

JPA를 처음 쓰면 이런 코드가 자연스럽게 나옵니다.

Member member = em.find(Member.class, memberId);
Team team = member.getTeam();

이때 많은 사람이 “find를 했으니까 Member랑 Team은 이미 다 로딩된 거 아닌가요?” 이렇게 생각합니다. 하지만 JPA의 대답은 다릅니다. “아직 Team이 필요하다는 말은 안 들었는데?” 이 차이를 이해하는 것이 지연 로딩과 즉시 로딩을 이해하는 출발점입니다.

 

객체 그래프와 데이터 로딩은 다르다

객체 모델에서 연관관계는 항상 연결되어 있습니다.

Member → Team → Company

하지만 JPA는 이 사실을 이렇게 해석합니다.

“연결되어 있다는 것과 지금 당장 데이터를 가져와야 한다는 것은 다르다.”

  • 객체 그래프는 구조
  • 데이터 로딩은 시점의 문제

JPA는 이 둘을 의도적으로 분리합니다.

4.1 지연 로딩(Lazy Loading)

지연 로딩은 말 그대로, “실제로 사용할 때 가져온다” 라는 전략입니다.

@ManyToOne(fetch = FetchType.LAZY)
private Team team;

이 설정이 의미하는 바는 다음과 같습니다.

  • Member를 조회할 때
  • Team은 아직 조회하지 않는다
  • 대신 프록시 객체를 넣어둔다
Member member = em.find(Member.class, id);
// 아직 TEAM 테이블 조회 X

Team team = member.getTeam();
// 이 시점에 SELECT * FROM TEAM 실행

중요한 점은, member.getTeam() 자체는 SQL을 실행하지 않는다. team의 실제 데이터를 접근하는 순간 SQL이 실행된다는 것입니다.

 

프록시 객체가 등장하는 이유

JPA는 지연 로딩을 위해 프록시 객체를 사용합니다. 프록시는 쉽게 말해, “진짜 객체인 척하는 가짜 객체”입니다. 예를 들어 타입은 Team으로 실제 데이터는 없습니다. 접근 시점에 영속성 컨텍스트에게 요청하는 경우, 프록시 덕분에 JPA는 객체 그래프 구조는 유지하면서 데이터 로딩은 미룰 수 있습니다 이것이 바로 객체 지향과 성능 사이의 타협점입니다.

4.2 즉시 로딩(Eager Loading)

즉시 로딩은 정반대입니다.

@ManyToOne(fetch = FetchType.EAGER)
private Team team;

의미는 단순합니다. “Member를 조회할 때 Team도 함께 가져와라” 이 경우 JPA는 보통 다음과 같은 SQL을 생성합니다.

SELECT M.*, T.*
FROM MEMBER M JOIN TEAM T ON M.TEAM_ID = T.ID

즉시 로딩은 처음 보면 매우 편해 보입니다.

  1. 바로 사용할 수 있다
  2. 추가 SQL이 없다
  3. 코드가 단순해 보인다

하지만 여기에는 치명적인 함정이 있습니다. JPA가 연관관계의 기본을 Lazy로 둔 이유는 명확합니다.
즉시 로딩은 예측 불가능한 SQL을 만들어내기 때문입니다. 예를 들어,

Member → Team → Company → Location

모두가 EAGER라면, Member 하나 조회했을 뿐인데 JOIN이 4개, 5개씩 붙고, 어떤 SQL이 나갈지 코드만 보고 알 수 없습니다.
특히 문제는 컬렉션 연관관계입니다.

@OneToMany(fetch = FetchType.EAGER)
private List<Order> orders;

이 경우,

  • Member 100명 조회
  • Order는 각자 여러 개
  • 결과적으로 데이터 폭증 + Cartesian Product

그래서 JPA 스펙은 아예 이렇게 권장합니다. 모든 연관관계는 기본을 Lazy로 두고, 필요한 경우에만 가져와라

4.3 N+1 문제 원인

지연 로딩을 이야기하면 반드시 따라오는 단골 문제가 있습니다. 바로 N+1 문제입니다.

 

상황 예시

List<Member> members = em.createQuery(
    "select m from Member m", Member.class
).getResultList();

for (Member member : members) {
    System.out.println(member.getTeam().getName());
}

이 코드에서 발생하는 일은 다음과 같습니다.

  1. Member 조회 → 1번 SQL
  2. Member 수만큼 Team 조회 → N번 SQL

1 + N 번의 SQL이 실행됨. 이것이 N+1 문제입니다.

중요한 오해가 있습니다. N+1 문제는 Lazy의 문제처럼 보이지만, 사실은 그렇지 않습니다.
“설계 의도 없이 연관 객체를 접근한 결과”가 정확한 원인입니다.

  • 지연 로딩 자체는 문제가 아니다
  • 언제 연관 객체를 사용할지 명확하지 않은 상태에서
  • 반복 접근이 발생하면 문제가 된다

4.4 해결책: Fetch Join

N+1 문제의 대표적인 해결책은 Fetch Join입니다.

select m from Member m
join fetch m.team

이 쿼리는 이렇게 해석됩니다. “Member를 조회할 때 Team도 함께 가져오되, 이건 즉시 로딩 설정이 아니라 이번 쿼리에서만 그렇게 하겠다” 입니다. 기본은 Lazy 유지하고 필요한 조회에서만 명시적으로 함께 로딩됩니다. 이 방식이 JPA에서 가장 권장되는 패턴입니다. 여기까지의 핵심 정리하자면,

  • 객체 그래프와 데이터 로딩 시점은 다르다
  • JPA는 기본을 Lazy로 둔다
  • 즉시 로딩은 편해 보이지만 위험하다
  • N+1은 지연 로딩의 본질 문제가 아니다
  • Fetch Join은 “필요할 때만 함께 가져오기” 전략이다

5. 영속성 컨텍스트와 트랜잭션 경계

왜 지연 로딩은 “트랜잭션”을 벗어나면 실패하는지 살펴보겠습니다. 지연 로딩을 사용하다 보면, 어느 순간 LazyInitializationException 예외를 반드시 만나게 됩니다. 처음 보면 당황스럽습니다. “분명 객체는 있는데… 왜 갑자기 로딩을 못 한다는 거지? 이 문제는 지연 로딩 자체의 문제가 아닙니다. 영속성 컨텍스트와 트랜잭션의 관계에 있습니다.

영속성 컨텍스트는 트랜잭션과 생명주기를 함께한다. 이 한 문장을 정확히 이해하는 것이 출발점입니다.

  • 트랜잭션 시작 → 영속성 컨텍스트 생성
  • 트랜잭션 종료 → 영속성 컨텍스트 종료

영속성 컨텍스트는 트랜잭션과 생명주기를 함께합니다. 따라서 트랜잭션이 시작되면 영속성 컨텍스트가 만들어지고, 트랜잭션이 끝나면 영속성 컨텍스트도 종료됩니다. 이 말은 곧, 영속성 컨텍스트가 사라지면 지연 로딩도 더 이상 동작할 수 없습니다.

 

지연 로딩은 “영속성 컨텍스트”에게 요청한다

앞에서 지연 로딩을 이렇게 설명했습니다.

  • 연관 객체는 프록시로 대기
  • 실제 접근 시점에 SQL 실행

여기서 중요한 포인트는 누가 SQL을 실행하느냐입니다.

member.getTeam().getName();

이 순간 프록시 객체는 이렇게 요청합니다. “나 아직 데이터 없어요. 영속성 컨텍스트님, Team 좀 가져와 주세요.” 즉, 지연 로딩은 영속성 컨텍스트에게 위임된 작업입니다. 그런데 만약 이 시점에, 트랜잭션이 이미 끝났고 영속성 컨텍스트가 종료된 상태라면 요청을 받을 대상이 없습니다. 그래서 LazyInitializationException 예외가 발생합니다. 이 예외의 진짜 의미는 단순합니다. “지연 로딩을 시도했지만, 이미 영속성 컨텍스트가 사라졌다.” 즉, JPA가 잘못한 것도 아니고, Lazy 전략이 문제인 것도 아니고, 프록시가 이상한 것도 아닙니다. 트랜잭션 경계 밖에서 영속 상태가 아닌 객체를 탐색하려 했기 때문입니다.

전형적인 문제 상황이자 가장 흔한 패턴은 이렇습니다.

@Service
@Transactional
public Member findMember(Long id) {
    return em.find(Member.class, id);
}

// Controller
Member member = memberService.findMember(id);
member.getTeam().getName(); // 💥LazyInitializationException
  • 서비스 메서드가 끝나면서 트랜잭션 종료
  • 영속성 컨텍스트 종료
  • 컨트롤러에서 연관 객체 접근
  • 지연 로딩 실패

이 구조는 굉장히 흔하고, 그래서 더 많은 혼란을 낳습니다. 이 문제를 해결하기 위해 등장한 개념이 OSIV(Open Session In View) 입니다. OSIV의 아이디어는 “영속성 컨텍스트를 뷰(View)까지 열어두자.”로 단순합니다. 요청 시작 시 영속성 컨텍스트 생성하고, 컨트롤러, 뷰 렌더링까지 유지하고, 응답 완료 시 종료합니다. 이렇게 하면, 컨트롤러에서도, 뷰에서도, 지연 로딩이 가능해집니다

5.1 OSIV는 왜 논쟁적인가

OSIV는 분명 편리합니다. 하지만 그만큼 명확한 단점도 가지고 있습니다.

 

1. 트랜잭션 범위가 불명확해진다

서비스 계층이 아닌 컨트롤러, 뷰, 직렬화 과정에서도 DB 접근이 가능해집니다.
그 결과, “어디까지가 비즈니스 로직인지” 경계가 흐려집니다

 

2. 성능 문제를 숨긴다

뷰 렌더링 중 연관 객체 접근하면 의도치 않은 추가 SQL 실행됩니다. N+1 문제가 화면에서 터지는 원인입니다.
문제는 이 SQL들이 코드상으로 잘 보이지 않는다는 점입니다.

 

3. API 서버에는 어울리지 않는다

특히 REST API 환경에서는 뷰 렌더링이 없고, JSON 직렬화 과정에서 연관 객체 접근이 발생합니다.
이때 OSIV가 켜져 있으면, “직렬화 도중 DB를 계속 조회하는 상황”이 벌어질 수 있습니다.

 

4. 실무 권장 방안

spring.jpa.open-in-view=false

최근의 권장 흐름은 비교적 명확합니다. OSIV는 끄고,트랜잭션 안에서 필요한 데이터를 명확히 가져옵니다. 구체적으로, 서비스 계층에서 필요한 연관 객체를 결정하고 Fetch Join이나 DTO 조회로 명시적으로 로딩하는 이 방식의 장점은 분명합니다. 트랜잭션 경계가 명확해지고, SQL 실행 시점이 예측 가능하며, 성능 문제가 코드 레벨에서 드러납니다. 그래서 JPA를 사용할 때는 항상 “이 접근은 아직 트랜잭션 안에서 이루어지고 있는가?” 질문을 던져야 합니다. 정리하면 다음과 같습니다.

  • 지연 로딩은 영속성 컨텍스트에 의존한다
  • 영속성 컨텍스트는 트랜잭션과 함께한다
  • 트랜잭션 밖에서는 지연 로딩이 불가능하다
  • OSIV는 문제를 “편하게” 해결하지만, 대가가 있다
  • 실무에서는 조회 전략을 서비스 계층에서 명확히 해야 한다

OSIV를 끄면 좋은 점

  • 리소스 절약: 트랜잭션 종료와 함께 영속성 컨텍스트와 DB 커넥션이 반환됩니다.
  • 성능 예측 가능성: 프레젠테이션 계층에서 발생하는 숨은 N+1 문제를 방지할 수 있습니다.

OSIV를 끌 때 주의할 점

  • 지연 로딩은 반드시 트랜잭션 내부에서 처리해야 한다
  • 트랜잭션이 끝난 뒤 엔티티는 Detached 상태가 된다
  • View/Controller에서 Lazy 접근은 예외로 이어진다
  • 복잡한 시스템에서는 Command / Query 분리 구조가 도움이 된다

6. 핵심 정리

Spring Data JPA: SQL 중심적인 개발의 문제점

이 챕터의 결론은 하나입니다. 객체 지향 언어로 설계하려 할수록 SQL 중심 개발은 구조적으로 객체를 포기하게 만든다. 단순히 “SQL이 귀찮다”가 아니라, 객체와 RDB의 철학 차이 때문에 개발자가 매번 변환 책임을 떠안고, 그 결과 설계 기준이 테이블로 이동합니다.

 

1.1 SQL에 의존하는 개발의 한계

SQL 중심 개발은 CRUD마다 SQL이 필수라서, 애플리케이션이 자연스럽게 객체 중심이 아니라 SQL 중심으로 흐릅니다. 필드 하나가 추가돼도 INSERT, SELECT, UPDATE, JOIN 쿼리들을 모두 찾아 수정해야 하므로 유지보수 리스크가 커지고, 결국 “DB 구조에 맞춰 객체를 설계하자”로 사고가 바뀝니다. 이 전체 현상이 객체-관계 패러다임 불일치(ORM Impedance Mismatch)이며, 대표 증상은 상속, 연관관계, 그래프 탐색, 동일성 붕괴로 나타납니다.

  • 객체 설계가 바뀔 때마다 SQL이 연쇄 수정된다
  • 객체 모델이 테이블 구조에 종속되며 설계의 기준이 바뀐다
  • 문제는 개발자 역량이 아니라 패러다임 차이에서 발생한다

1.2 상속(Inheritance)

객체 세계의 상속은 자연스럽지만, RDB에는 상속이 없습니다. 그래서 상속을 테이블로 표현하려면 분해(부모/자식 테이블에 나눠 저장)와 조립(JOIN 후 객체 재구성)이 필요하고, 그 모든 비용은 개발자에게 전가됩니다. 결과적으로 실무에서는 복잡도를 피하려고 상속을 회피하고, 데이터 중심(단일 테이블) 설계로 후퇴하기 쉽습니다.

  • 객체의 상속 = 개념/책임 확장
  • DB는 상속 개념이 없어서 JOIN 기반의 분해·조립이 강제된다
  • 결국 상속을 포기하거나 모델이 테이블 중심으로 변형된다

1.3 연관관계(Association)

객체는 참조로 관계를 표현하지만, DB는 외래 키와 JOIN으로 관계를 표현합니다. SQL 중심 개발에서는 “참조를 유지하려면 항상 JOIN을 직접 관리해야 하는 부담”이 생기고, 이 부담 때문에 많은 팀이 결국 참조 대신 ID(teamId)를 넣습니다. 그 순간 객체는 객체답지 않게 되고, 비즈니스 로직은 서비스/SQL에 의존하며 객체는 데이터 덩어리로 전락합니다.

  • 객체: member.getTeam() 같은 참조 중심
  • DB: TEAM_ID + JOIN 중심
  • JOIN 부담이 누적되면 참조가 사라지고 ID 중심 설계로 변한다

1.4 객체 그래프 탐색(Navigation)

객체 그래프 탐색은 “필요하면 언제든 점(.)으로 따라갈 수 있다”는 신뢰 위에 서 있습니다. 하지만 SQL 중심 개발에서는 JOIN한 범위까지만 데이터가 존재하기 때문에 객체 그래프가 중간에서 끊긴 불완전 객체가 만들어집니다. 그 결과 상위 계층이 하위 SQL을 알아야 해서 계층 분리가 깨지고, 개발자는 항상 “어디까지 JOIN했지?”를 머릿속에 들고 다니게 됩니다.

  • 객체: 탐색 가능하다는 신뢰가 전제
  • SQL: JOIN 범위 밖은 없거나 null
  • 결국 계층 간 신뢰 붕괴 + SQL 의존적 설계로 이어진다

1.5 동일성(Identity)

같은 ID를 조회해도 매번 새 객체가 만들어지면 == 동일성이 깨집니다. 이 문제는 단순 비교가 아니라, “같은 존재”라는 전제가 붕괴하는 문제라서 컬렉션 동작, 중복 처리, 참조 기반 로직에서 오류가 늘어납니다. 결국 개발자는 객체를 믿지 못하고 “ID로만 판단하자”로 사고가 바뀌며, 도메인 모델이 약해집니다.

  • SQL 조회는 매번 새로운 인스턴스를 만든다
  • 동일성 붕괴는 참조 기반 설계를 무너뜨린다
  • 결국 객체는 도메인이 아니라 데이터 전달 수단이 된다

 

2. JPA란 무엇인가

JPA는 “SQL을 없애는 도구”가 아니라, 객체와 RDB 사이의 변환 책임을 프레임워크가 가져가서 개발자가 객체 중심으로 설계·개발할 수 있게 만드는 표준입니다. 핵심은 “어떻게 저장할까”가 아니라 “무엇을 표현할까”로 사고 축을 옮기는 것입니다.

  • 문제의 원인을 개발자 실수가 아니라 패러다임 차이로 재정의한다
  • 객체는 객체답게, DB는 DB답게 두고 연결(매핑)은 프레임워크가 담당한다
  • 결과적으로 상속/연관관계/탐색/동일성 같은 객체 지향 요소를 회복한다

2.1 EntityManager

EntityManager는 JPA 표준이 정의한 핵심 인터페이스이며, 하이버네이트 같은 구현체가 내부 동작을 수행합니다. EntityManager는 단순 DAO가 아니라 엔티티의 생명주기와 상태를 관리하는 경계 객체이고, SQL 생성/실행 시점 제어 같은 핵심 기능의 출발점입니다. 즉 “DB 작업”이 아니라 “객체를 관리”하기 위해 존재합니다.

  • JPA 표준 인터페이스(구현은 Hibernate 등)
  • 엔티티 상태/동일성/쓰기 지연/변경 감지/캐시 등 관리
  • “객체 중심 개발”을 가능하게 만드는 핵심 창구

 

3. 영속성 컨텍스트와 엔티티 생명주기

JPA의 핵심은 SQL 자동 생성이 아니라 객체 상태 관리입니다. 영속성 컨텍스트는 “지금 트랜잭션 안에서 JPA가 책임지고 관리할 엔티티들의 작업 공간”이고, 엔티티는 이 공간에 의해 비영속/영속/준영속/삭제 상태로 나뉘며 JPA의 대부분 기능은 영속 상태에서만 작동합니다.

 

3.1 영속성 컨텍스트 

영속성 컨텍스트는 DB 저장 여부와 무관하게, JPA가 “관리 중”이라고 선언한 엔티티들을 트랜잭션 동안 붙잡아 두는 공간입니다. 여기서 동일성 보장, 변경 감지, 쓰기 지연 같은 기능이 가능해집니다. 결국 JPA를 이해하는 질문은 하나로 수렴합니다.

  • “지금 이 객체는 영속성 컨텍스트가 관리하는가?”

3.2 엔티티 생명주기

엔티티 상태는 네 가지로 구분되며, 이 구분이 곧 JPA 기능의 ON/OFF 스위치입니다.

  • 비영속: 단순 new, JPA 관리 X
  • 영속: persist/find로 관리 시작, 핵심 기능 동작
  • 준영속: detach/clear로 관리 해제, 변경 반영 X
  • 삭제: remove로 삭제 예약, 커밋 시 반영

 

4. 지연 로딩과 즉시 로딩

객체 그래프의 “연결”과 데이터 로딩의 “시점”은 다릅니다. JPA는 예측 가능한 성능을 위해 기본을 LAZY로 두고, 필요할 때만 명시적으로 함께 로딩하는 방식을 권장합니다. 즉시 로딩은 편하지만 SQL이 예측 불가능해져 위험해집니다.

 

4.1 지연 로딩(LAZY) 

연관 객체는 프록시로 두고, 실제로 접근할 때 SQL이 실행됩니다.
객체 그래프는 유지하면서 로딩 시점을 늦추는 전략입니다.

 

4.2 즉시 로딩(EAGER)

조회 시 연관 객체를 함께 가져옵니다.
하지만 연관관계가 연쇄되면 JOIN이 폭발하고, 컬렉션 연관관계에서는 데이터 폭증으로 이어져 예측이 어려워집니다.


4.3 N+1 문제

N+1은 LAZY 자체의 문제가 아니라, 의도 없이 연관 객체를 반복 접근한 결과입니다.
1번 조회 후, 연관 로딩이 N번 추가로 발생해 성능이 급락합니다.

 

4.4 해결책: Fetch Join

기본 LAZY는 유지하면서, “이번 조회에서만” 연관 객체를 함께 가져오도록 명시하는 방식입니다.
JPA에서 가장 권장되는 해결 패턴 중 하나입니다.

 

5. 영속성 컨텍스트와 트랜잭션 경계

지연 로딩은 프록시가 영속성 컨텍스트에게 “실제 데이터를 로딩해 달라”고 요청하는 구조입니다. 그런데 영속성 컨텍스트는 트랜잭션과 생명주기를 함께하므로, 트랜잭션 밖에서 프록시를 초기화하려 하면 LazyInitializationException 이 발생합니다.

  • 지연 로딩은 영속성 컨텍스트 의존
  • 영속성 컨텍스트는 트랜잭션과 함께 생성/종료
  • 따라서 트랜잭션 밖에서 지연 로딩은 실패한다

5.1 OSIV는 왜 논쟁적인가

OSIV는 영속성 컨텍스트를 요청 끝까지 열어 지연 로딩을 가능하게 하지만, 그 대가로 트랜잭션 경계가 흐려지고 성능 문제가 숨겨지며(API에서는 직렬화 중 조회가 발생), 운영 리스크가 커집니다. 그래서 실무에서는 OSIV를 끄고 서비스 계층에서 필요한 데이터를 명시적으로 로딩하는 흐름이 권장됩니다.

  • 장점: 컨트롤러/뷰/직렬화에서도 Lazy 가능
  • 단점: 경계 흐림 + 숨은 N+1 + API에서 위험
  • 권장: spring.jpa.open-in-view=false + 서비스 계층에서 fetch join/DTO 조회