-
[JPA] 기본적인 Spring Data Jpa 활용Data 2026. 1. 24. 19:06

이번 글에서는 Spring Data JPA를 활용하는 기본적인 방법을 코드를 통해서 알아보겠습니다. Entity, Repository, 데이터 삽입 부분을 직접 작성하고 각 코드의 특성을 기술합니다.
Person.java
package jpabasic.jpaexample.Entity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Person { @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "person_id") private Long id; @Column(nullable = false) private String name; public static Person createPerson(String name){ Person person = new Person(); person.name = name; return person; } }1. @NoArgsConstructor(access = AccessLevel.PROTECTED)
기본 생성자
JPA 스펙상 엔티티는 반드시 기본 생성자를 가져야 합니다. 왜 그럴까요?
가장 주요한 이유는 프록시(Proxy) 객체 생성해야 하기 때문입니다.
JPA는 성능 최적화를 위해 객체를 데이터베이스에서 즉시 조회하지 않고, 가짜 객체인 '프록시'를 먼저 만들어 둡니다. 다음 과정을 통해 동작합니다.
- JPA는 우리가 만든 엔티티 클래스를 상속받아 이 프록시 객체를 만듭니다.
- 이때 자바의 상속 구조상, 자식 객체(프록시)를 생성하려면 부모 객체(우리가 만든 엔티티)의 생성자를 호출해야 합니다.
- JPA 표준 명세상 이 과정에서 인자가 없는 기본 생성자를 사용하도록 설계되어 있습니다.
이렇게 프록시 객체를 생성하면 다음과 같은 효과를 얻습니다.
성능 최적화: 지연 로딩 (Lazy Loading)
만약 Member 엔티티와 그 멤버가 속한 Team 엔티티가 연관되어 있다고 가정해 보겠습니다.
- 화면에 단순히 '멤버의 이름'만 출력하면 되는데, DB에서 멤버를 조회할 때마다 매번 팀의 정보(팀 이름, 팀 설명 등)까지 통째로 다 긁어온다면 네트워크 비용과 메모리 낭비가 심해집니다.
- 이때 JPA는 Team 자리에 실제 데이터 대신 가짜 객체(프록시)를 꽂아둡니다.
- 실제로 member.getTeam().getName() 처럼 팀의 데이터가 진짜로 필요한 시점에만 DB에 쿼리를 날려 데이터를 가져옵니다.
객체 그래프 탐색의 자유도
객체지향 설계에서는 Member를 통해 Team에 접근할 수 있어야 합니다. 하지만 DB 입장에서는 무조건 조인(Join)을 해야 하죠.
- 프록시 객체가 있으면, 실제 DB에 데이터가 있든 없든 일단 객체 구조를 유지할 수 있습니다.
- 덕분에 개발자는 "이 데이터를 조회할 때 연관된 데이터까지 한꺼번에 다 가져올지(Eager), 나중에 가져올지(Lazy)"를 엔티티 설정만으로 유연하게 결정할 수 있습니다.
두 번째 이유는 데이터 조회 시 객체를 복원하기 위함입니다.
데이터베이스에서 데이터를 조회한 후, JPA는 그 결과값을 자바 객체로 변환해야 합니다.
- JPA는 내부적으로 Reflection API라는 기술을 사용하여 엔티티 객체를 생성합니다.
- Reflection은 클래스의 이름만 알면 동적으로 객체를 생성할 수 있는데, 이때 가장 간편하고 표준적인 방법이 기본 생성자를 호출한 뒤 필드 값을 채워 넣는 방식입니다.
기본 생성자에 대한 접근 제어
- PUBLIC으로 열어두면 아무 곳에서나 new Person()을 호출해 필드 값이 누락된 불완전한 객체가 생성될 위험이 있습니다.
- PROTECTED로 제한함으로써 "무분별한 객체 생성을 막으면서도 JPA가 객체를 관리할 수 있게" 설계되었습니다.
2. 정적 팩토리 메서드 (createPerson)
생성자를 PROTECTED로 막았기 때문에, 외부에서는 이 createPerson 메서드를 통해서만 객체를 만들 수 있습니다.
- 이는 생성 로직을 캡슐화하고, 나중에 객체 생성 시 유효성 검사나 복잡한 초기화 로직이 필요할 때 한 곳에서 관리할 수 있다는 장점이 있습니다.
3. @GeneratedValue(strategy = GenerationType.AUTO)
기본키(PK) 생성 전략을 데이터베이스 방언(Dialect)에 맞춰 자동으로 선택하도록 설정하셨습니다.
- 학습용으로는 편리하지만, 실무에서 MySQL을 쓰신다면 IDENTITY를, Oracle/PostgreSQL을 쓰신다면 SEQUENCE를 명시적으로 사용하는 경우가 많습니다. AUTO는 DB를 바꿀 때 유연하다는 장점이 있습니다.
GenerationType에는 어떤 것들이 있는 알아보겠습니다.
- IDENTITY (데이터베이스에 위임)
- 핵심: MySQL의 AUTO_INCREMENT처럼 DB가 기본키를 자동으로 생성하도록 합니다.
- 특이사항: 데이터베이스에 값을 실제로 INSERT 해야만 ID 값을 알 수 있습니다.
- 주의점: JPA의 '쓰기 지연'이 작동하지 않습니다. em.persist()를 호출하는 즉시 DB에 쿼리가 날아갑니다. (ID를 알아야 영속성 컨텍스트에서 관리할 수 있기 때문입니다.)
- SEQUENCE (데이터베이스 시퀀스 사용)
- 핵심: Oracle, PostgreSQL 등에서 사용하는 전용 시퀀스 객체를 이용합니다.
- 동작 방식: em.persist() 호출 시 먼저 DB 시퀀스에서 다음 값을 받아온 뒤, 그 값을 가지고 영속성 컨텍스트에 저장합니다.
- 장점: '쓰기 지연'이 가능합니다. 성능 최적화를 위해 시퀀스 값을 미리 할당받는 allocationSize 설정을 주로 사용합니다.
Database Sequence
1. 정의
- 데이터베이스 시퀀스(Database Sequence)는 유일한 정수 값을 생성하기 위해 설계된 데이터베이스 내의 독립적인 객체입니다.
2. 핵심 메커니즘
- 원자성 보장: 여러 세션이 동시에 접근해도 중복되지 않는 순차적인 값을 보장합니다.
- 비연결적 생성: 특정 테이블의 컬럼에 종속되지 않고, NEXT VALUE FOR (또는 NEXTVAL) 호출을 통해 값을 생성합니다.
- 속성:
INCREMENT BY: 한 번 호출 시 증가할 수치
START WITH: 시작 값
MAXVALUE / MINVALUE: 최대/최소 범위
CACHE: 성능 향상을 위해 메모리에 미리 할당해둘 값의 개수
3. JPA와의 상호작용 (SEQUENCE 전략)
- 식별자 선취 (Pre-allocation): em.persist() 호출 시, JPA는 DB 시퀀스로부터 식별자 값을 먼저 조회합니다.
- 영속성 컨텍스트 관리: 조회한 ID 값을 엔티티에 할당하여 영속성 컨텍스트에 저장합니다. 이 시점에는 아직 DB에 INSERT가 발생하지 않습니다.
- 쓰기 지연 (Transactional Write-behind): 트랜잭션 커밋 시점에 모아둔 INSERT 쿼리를 한꺼번에 실행합니다.
4. 핵심 장점: 성능 최적화
- Batch Insert: ID를 미리 알고 있기 때문에 여러 개의 INSERT 문을 하나의 네트워크 태스크로 묶어 처리하는 배치가 가능합니다. (IDENTITY 전략은 실제 삽입 전까지 ID를 알 수 없어 이것이 불가능합니다.)
- Allocation Size: @SequenceGenerator의 allocationSize를 설정하면, 한 번의 DB 호출로 여러 개의 식별자를 메모리에 캐싱하여 DB 왕복(Round-trip) 횟수를 획기적으로 줄일 수 있습니다.- TABLE (키 생성 전용 테이블 사용)
- 핵심: 키 생성 전용 테이블을 하나 만들고, 여기서 이름과 값을 관리하며 시퀀스를 흉내 내는 방식입니다.
- 장점: 모든 데이터베이스에서 사용할 수 있습니다.
- 단점: 별도의 테이블을 조회하고 값을 업데이트해야 하므로 성능이 떨어집니다. 실무에서는 잘 사용하지 않습니다.
- UUID (범용 고유 식별자)
- 핵심: RFC 4122 표준에 따른 128비트 고유 ID를 생성합니다.
- 용도: 분산 시스템에서 ID 중복을 피해야 하거나, DB 성능보다는 ID의 고유성과 보안이 중요할 때 사용합니다.
- 데이터 타입: java.util.UUID나 String 타입을 사용해야 합니다.
- AUTO (기본 전략)
- 핵심: 사용하는 데이터베이스의 방언(Dialect)에 따라 JPA가 위의 전략 중 하나를 자동으로 선택합니다.
- 선택 기준: ID 타입이 UUID나 String이면 UUID 전략 선택.
- 숫자 타입이면 DB 종류에 따라 IDENTITY, SEQUENCE, TABLE 중 하나를 선택.
- 참고: DB를 교체해도 코드를 수정할 필요가 없어 편리하지만, 예측 가능성을 위해 실무에서는 명시적인 전략을 선호하기도 합니다.
4. @Column(name = "person_id")
DB 테이블의 컬럼명을 별도로 지정하셨습니다.
- 단순히 id라고 이름 짓는 것보다 person_id처럼 서비스의 도메인 이름을 붙여주는 것이 나중에 테이블 간 조인(Join) 쿼리를 작성할 때 가독성이 훨씬 좋습니다.
Repository
package jpabasic.jpaexample.repository; import jpabasic.jpaexample.Entity.Person; import org.springframework.data.repository.Repository; import java.util.Optional; public interface PersonRepository extends Repository<Person, Long> { Person save(Person person); Optional<Person>findById(Long id); }1. Repository<T, ID> 상속 (최상위 인터페이스)
보통 JpaRepository나 CrudRepository 등을 상속받아서 사용하며, 가장 기본인 Repository를 사용하였습니다.
- 선택적 노출: JpaRepository를 상속받으면 수십 개의 메서드가 자동으로 노출됩니다. 반면 이렇게 직접 상속받으면 딱 정의한 메서드(save, findById)만 외부에서 호출할 수 있게 되어, 인터페이스를 엄격하게 관리할 수 있습니다.
- 마커 인터페이스: Repository 인터페이스는 실제 기능이 없는 마커 인터페이스이며, Spring Data JPA가 해당 인터페이스를 찾아 구현체를 동적으로 생성(Proxy)하게 만드는 신호 역할을 합니다.
2. 쿼리 메서드 메커니즘
save와 findById는 Spring Data JPA가 미리 약속된 규칙에 따라 자동으로 구현체를 만들어주는 메서드입니다.
- save(Person person): 엔티티가 새로운 객체(ID가 null)라면 em.persist()를, 이미 존재하는 객체라면 em.merge()를 호출하도록 내부적으로 설계되어 있습니다.
- findById(Long id): 메서드 이름 자체가 쿼리가 됩니다. SELECT * FROM person WHERE person_id = ? 쿼리를 생성하여 실행합니다.
3. Optional<T>의 사용
findById의 반환 타입으로 Optional을 사용한 것은 매우 현대적이고 안전한 방식입니다.
- NPE(NullPointerException) 방지: 데이터베이스에 해당 ID를 가진 데이터가 없을 경우 null을 반환하는 대신, 비어있는 Optional 객체를 반환합니다.
- 가독성 향상: 이 메서드를 호출하는 클라이언트 코드는 "값이 없을 수도 있다"는 것을 명시적으로 인지하고, .orElseThrow()나 .ifPresent() 등을 사용하여 예외 처리를 할 수 있습니다.
4. 제네릭 타입 설정 (<Person, Long>)
인터페이스 선언부의 제네릭은 다음과 같은 의미를 가집니다.
- Person: 이 리포지토리가 관리할 엔티티 타입입니다.
- Long: 해당 엔티티의 기본키(@Id) 타입입니다. 엔티티 정의 시 private Long id;로 선언했으므로 타입을 맞춰주어야 합니다.
데이터 넣어보기
package jpabasic.jpaexample; import jpabasic.jpaexample.Entity.Person; import jpabasic.jpaexample.repository.PersonRepository; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import java.util.NoSuchElementException; @SpringBootApplication public class JpaExampleApplication { public static void main(String[] args) { SpringApplication.run(JpaExampleApplication.class, args); } @Bean CommandLineRunner runner(PersonRepository repository) { return args -> { Person person = repository.save(Person.createPerson("John")); Person saved = repository.findById(person.getId()).orElseThrow(NoSuchElementException::new); }; } }1. CommandLineRunner를 통한 초기 실행 로직
@Bean으로 등록된 CommandLineRunner는 애플리케이션 컨텍스트가 로딩된 직후 run() 메서드를 실행합니다.
- 용도: 주로 애플리케이션 시작 시 필요한 초기 데이터를 DB에 넣거나(Seed Data), 작성한 리포지토리 로직이 잘 작동하는지 테스트하는 용도로 사용됩니다.
- 의존성 주입: runner(PersonRepository repository)처럼 파라미터에 리포지토리를 선언하면, 스프링이 자동으로 구현체를 주입(DI)해 줍니다.
2. Optional을 활용한 안전한 조회
findById()의 반환 타입인 Optional을 처리하는 정석적인 방법을 보여주고 있습니다.
- orElseThrow(): 데이터가 존재하면 값을 반환하고, 없으면 지정된 예외(NoSuchElementException)를 던집니다.
- null 체크 조건문 없이 단 한 줄로 성공과 실패 케이스를 모두 명확히 정의한 현대적인 자바 스타일입니다.
'Data' 카테고리의 다른 글
[JPA] Spring Data JPA의 핵심 개념 (1) 2026.01.24 [Data] JPA - 나오게 된 배경, 사용 이유 (0) 2026.01.03 PL/SQL 실습 (2) 2025.05.01 PL/SQL이란? 오라클의 절차형 SQL 언어 정리 (0) 2025.05.01 데이터베이스 종류 (3) 2025.03.10