현대의 자바 기반 애플리케이션에서 데이터 접근 계층은 단순한 SQL 실행 그 이상을 요구받고 있습니다. 객체 지향 언어인 Java와 관계형 데이터베이스(RDB) 사이의 간극을 효율적으로 연결해주는 기술이 바로 JPA(Java Persistence API) 입니다.
이번 글에서는 실무 관점에서 JPA가 왜 필요한지, 어떤 구조로 동작하는지, 그리고 실질적으로 우리가 어떻게 다뤄야 하는지에 대해 깊이 있는 시선으로 정리해보려 합니다.
🔍 JPA란 무엇인가?
JPA는 Java 진영의 ORM(Object Relational Mapping) 표준 명세입니다. 즉, 자바 객체 ↔ 관계형 데이터베이스 사이의 매핑을 선언적으로 처리할 수 있도록 도와주는 인터페이스 모음이죠.
💡 Hibernate, EclipseLink, OpenJPA는 JPA의 구현체입니다. 스프링부트에서 주로 사용하는 건 Hibernate이며, JPA를 추상 레이어로 사용합니다.
📐 왜 JPA인가?
1. SQL 중심 개발의 한계
중복되는 SQL
비즈니스 로직에 쿼리 로직이 섞임
테이블 구조 변경 시 코드 유지보수 어려움
객체와 테이블 간의 불일치
2. 객체 지향적으로 설계된 애플리케이션의 요구
컬렉션 기반의 연관 관계 관리
캡슐화된 비즈니스 메서드
영속성 생명주기와 트랜잭션 추적
👉 이를 해결하기 위해 JPA는 다음과 같은 추상화를 제공합니다.
🧱 핵심 개념 정리
🗃️ Entity
@Entity
@Table(name = "users")
public class User {
@Id @GeneratedValue
private Long id;
private String name;
@Column(unique = true)
private String email;
}
클래스 자체가 데이터베이스 테이블과 매핑됩니다.
@Id, @Column, @Table 등은 모두 매핑 메타데이터입니다.
🔄 EntityManager
JPA의 핵심 동작을 담당하는 인터페이스. Hibernate의 Session과 유사한 개념으로, 영속성 컨텍스트를 관리합니다.
EntityManager em = entityManagerFactory.createEntityManager();
em.getTransaction().begin();
User user = em.find(User.class, 1L);
user.setName("변경된 이름");
em.getTransaction().commit();
📌 주의: Spring Data JPA에서는 직접 EntityManager를 다룰 일은 거의 없습니다. 대신, Repository가 이를 추상화합니다.
📦 영속성 컨텍스트 (Persistence Context)
엔티티 객체를 1차 캐시에 보관
같은 트랜잭션 내 동일 객체 반환 (== 동일성 보장)
변경 감지(Dirty Checking) → flush 시 자동 update
User user = em.find(User.class, 1L);
user.setName("홍길동"); // 별도 update 쿼리 없이도 자동 감지됨
🧠 Dirty Checking & Flush
Dirty Checking: 엔티티 객체의 변경 여부 추적
Flush: 변경된 내용을 SQL로 동기화 (트랜잭션 커밋 시 자동 수행)
em.flush(); // DB에 SQL 전송
🏗️ Spring Data JPA와의 관계
Spring Data JPA는 JPA의 Repository 패턴을 자동화한 구현체입니다. 즉, JpaRepository 인터페이스만 상속받으면 기본적인 CRUD는 따로 SQL 없이 구현됩니다.
🚀 커스텀 쿼리는 @Query 어노테이션으로 처리하거나, QueryDSL, Specification으로 확장 가능
🧬 연관관계 매핑
예시: 회원과 주문 (1:N)
@Entity
public class Member {
@Id
private Long id;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@Entity
public class Order {
@Id
private Long id;
@ManyToOne
private Member member;
}
양방향 매핑 시 mappedBy 설정 필수
지연 로딩(LAZY)이 기본값이므로 주의
⚠️ 실무에서 자주 하는 실수
항목
설명
양방향 매핑 남용
단방향으로도 충분한 경우가 많음
즉시 로딩(EAGER) 사용
성능 저하, N+1 문제 발생 가능
식별자 전략 잘못 사용
GenerationType.IDENTITY는 제약이 있음
트랜잭션 없이 EntityManager 사용
변경 감지 미동작
🎯 마무리
JPA는 단순히 SQL을 안 쓰기 위한 도구가 아닙니다. 도메인 모델을 중심으로 한 객체 지향 아키텍처를 구현하기 위한 전략이며, 이는 결국 유지보수성과 생산성을 극대화합니다.
Spring Boot 3와 함께 JPA를 적절히 활용하면, 비즈니스 로직에 집중할 수 있는 유연하고 강력한 백엔드 아키텍처를 구성할 수 있습니다.
/** dname(부서명) like 검색 + dname 내림차순 조회 */
public List<Dept> findAllByDnameContainingOrderByDnameDesc(String dname){
List<Dept> list
= deptRepository.findAllByDnameContainingOrderByDnameDesc(dname);
return list;
}
📂 DeptController.java 추가
/** 전체조회 + dname(부서명) like 검색 + dname 내림차순 조회 */
@GetMapping("/dept/dname/containing/desc/{dname}")
public ResponseEntity<Object> findAllByDnameContainingOrderByDnameDesc(
@PathVariable String dname
){
try {
// todo : 전체 조회 + 정렬(dno 내림차순)함수 호출
List<Dept> list
= deptService
.findAllByDnameContainingOrderByDnameDesc(dname);
if (list.isEmpty() == false) {
// 성공
return new ResponseEntity<>(list, HttpStatus.OK);
} else {
// 데이터 없음
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e) {
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
✔ 서버 재시작 후 API 테스트
1) 전체조회 - 부서명 like 검색 + dname 내림차순 조회
✅ TODO 4) 쿼리메소드 응용 연습문제
// todo) 응용연습
// todo: 응용 연습문제
// todo: 연습 4) EMP 테이블에서 Job 이 manager 이고
// 매개변수로 부서번호(dno)를 받는 함수를 작성하세요.
// todo: 연습 5) Emp 테이블에서 salary 가 1000 ~ 1500 사이의 값을 같는
// 사원을 조회하려고 합니다. 함수를 작성해 주세요
List<Emp> findAllByJobAndDno(String job, int dno);
List<Emp> findAllBySalaryBetween(int first, int last);
📂 EmpService.java
// todo: 연습 4) EMP 테이블에서 Job 이 manager 이고
// 매개변수로 부서번호(dno)를 받는 함수를 작성하세요.
public List<Emp> findAllByJobAndDno(String job, int dno){
List<Emp> list = empRepository.findAllByJobAndDno(job, dno);
return list;
}
// todo: 연습 5) Emp 테이블에서 salary 가 1000 ~ 1500 사이의 값을 같는
// 사원을 조회하려고 합니다. 함수를 작성해 주세요
public List<Emp> findAllBySalaryBetween(int first, int last){
List<Emp> list = empRepository.findAllBySalaryBetween(first, last);
return list;
}
📂 EmpController.java
/** 연습 4) : 쿼리메소드 */
@GetMapping("/emp/dno/{dno}")
public ResponseEntity<Object> findAllByEnameContaining(
@PathVariable int dno
) {
try {
// 전체 조회 + 정렬(dno 내림차순) 호출
List<Emp> list
= empService.findAllByJobAndDno("MANAGER", dno);
if (list.isEmpty() == false) {
// 성공
return new ResponseEntity<>(list, HttpStatus.OK);
} else {
// 데이터 없음
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e){
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/** 연습5 */
@GetMapping("emp/salary/{first}/{last}")
public ResponseEntity<Object> findAllBySalaryBetween(@PathVariable int first, @PathVariable int last){
try {
List<Emp> list
= empService.findAllBySalaryBetween(first, last);
if(list.isEmpty() == false){
return new ResponseEntity<>(list, HttpStatus.OK);
} else {
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e) {
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
데이터베이스에서 값을 가져올 때는 위의 코드처럼 메서드의 이름만으로 쿼리 메서드를 생성할 수 도 있습니다.
이번에는 @Query 어노테이션을 사용해 직접 JPQL을 작성해봅시다.
📂 DeptRepository.java
// ----------------------------------------
// @Query 예제
// 1) 오라클 쿼리
// 2) 객체 쿼리
// ----------------------------------------
// todo 1) ename like 검색
@Query(value = "SELECT TD.* FROM TB_EMP TD WHERE TD.ENAME LIKE '%' || :dname || '%'", nativeQuery = true)
List<Emp> selectByEname(@Param("ename") String ename);
위 코드를 다르게 작성하는 방법(JDK 버전에 따라 사용 불가능할 수도 있음)
// todo: 1-1) 위의 코드를 다르게 코딩
@Query(value = "SELECT TD.* FROM TB_DEPT TD WHERE TD.DNAME LIKE '%'||:dname||'%'",
nativeQuery = true)
List<Dept> selectByDname(String dname);
// todo: 1-2) 위의 코드를 다르게 코딩 : 참고
@Query(value = "SELECT TD.* FROM TB_DEPT TD WHERE TD.DNAME LIKE '%'|| ?1 ||'%'",
nativeQuery = true)
List<Dept> selectByDname(String dname);
✅ SQL에서 쿼리문 결과가 올바른지 확인
📂 DeptService.java
/** 전체조회 + dname like 검색 : @Query */
public List<Dept> selectByDname(String dname) {
List<Dept> list
= deptRepository.selectByDname(dname);
return list;
}
📂 DeptController.java
/** 전체조회 + dname like 검색 : @Query */
@GetMapping("/dept/dname/{dname}")
public ResponseEntity<Object> selectByDname(
@PathVariable String dname
) {
try {
// 전체 조회 + 정렬(dno 오름차순) 호출
List<Dept> list
= deptService.selectByDname(dname);
if (list.isEmpty() == false) {
// 성공
return new ResponseEntity<>(list, HttpStatus.OK);
} else {
// 데이터 없음
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e){
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
그리고 main > resources > application.properties 파일에 설정을 추가합니다.
# 서버 포트
server.port=8000
# todo : docker db 설정
spring.datasource.driver-class-name=net.sf.log4jdbc.sql.jdbcapi.DriverSpy
# todo : spring.datasource.url=jdbc:log4jdbc:oracle:thin:@ip주소:db포트번호/db이름
spring.datasource.url=jdbc:log4jdbc:oracle:thin:@localhost:1521/xepdb1
spring.datasource.username=scott
spring.datasource.password=!Ds1234567890
# todo : jpa 설정
# todo : ddl-auto - create(모든 테이블 재생성)/update(수정된 테이블만 생성)/none(안함)
spring.jpa.hibernate.ddl-auto=create
#spring.jpa.hibernate.ddl-auto=update
#spring.jpa.hibernate.ddl-auto=none
# todo : db 제품 연결 ( oracle 12 이상 : Oracle12cDialect )
spring.jpa.database-platform=org.hibernate.dialect.Oracle12cDialect
# todo : generate-ddl=true (테이블 자동 생성 옵션)
spring.jpa.generate-ddl=true
# todo : sql log 보기 (true/false)
spring.jpa.show-sql=true
# 1) resource/data.sql 자동 실행 ( DML 실행 )
# -> data.sql ( dml 실행 ), schema.sql ( ddl 실행 )
# todo : DML, DDL 스크립트 (실습용 샘플 테이블) 실행을 위한 옵션
spring.jpa.defer-datasource-initialization=true
# todo : sql log 좀 더 정리된 형태로 찍기
spring.jpa.properties.hibernate.format_sql=true
# todo : 로깅 레벨 : error < info < debug < trace (정보 많은 순서)
logging.level.org.hibernate=info
# 2) resource/data.sql 자동 실행 ( DML 실행 )
# -> data.sql ( dml 실행 ), schema.sql ( ddl 실행 )
spring.sql.init.mode=always
# sql 에러 무시하고 스프링 서버 로딩
spring.sql.init.continue-on-error=true
# 자바 소스 변경시 스프링 서버 자동 재시작
spring.devtools.restart.enabled=true
# PUT , DELETE 방식도 form 에서 사용할 수 있게 만들어줌 : jsp에서 사용함
spring.mvc.hiddenmethod.filter.enabled=true
DROP SEQUENCE SQ_DEPT;
CREATE SEQUENCE SQ_DEPT START WITH 50 INCREMENT BY 10;
DROP SEQUENCE SQ_EMP;
CREATE SEQUENCE SQ_EMP START WITH 8000 INCREMENT BY 1;
DROP SEQUENCE SQ_FAQ;
CREATE SEQUENCE SQ_FAQ START WITH 1 INCREMENT BY 1;
DROP TABLE TB_EMP CASCADE CONSTRAINT;
DROP TABLE TB_DEPT CASCADE CONSTRAINT;
DROP TABLE TB_FAQ CASCADE CONSTRAINT;
CREATE TABLE TB_DEPT (
DNO NUMBER NOT NULL PRIMARY KEY,
DNAME VARCHAR2(255),
LOC VARCHAR2(255),
INSERT_TIME VARCHAR2(255),
UPDATE_TIME VARCHAR2(255)
);
CREATE TABLE TB_EMP
(ENO NUMBER NOT NULL PRIMARY KEY,
ENAME VARCHAR2(255),
JOB VARCHAR2(255),
MANAGER NUMBER,
HIREDATE VARCHAR2(255),
SALARY NUMBER,
COMMISSION NUMBER,
DNO NUMBER,
INSERT_TIME VARCHAR2(255),
UPDATE_TIME VARCHAR2(255)
);
CREATE TABLE TB_FAQ (
NO NUMBER NOT NULL PRIMARY KEY,
TITLE VARCHAR2(255),
CONTENT VARCHAR2(255),
INSERT_TIME VARCHAR2(255),
UPDATE_TIME VARCHAR2(255)
);
그리고 java > com.example.jpaexam 폴더 아래에 다음과 같이 폴더를 생성합니다.
💡 model 폴더에 공통모델 파일 생성
model 폴더 아래에 생성일자/수정일자를 만들어주는 추상클래스를 생성합니다.
실습에서는 파일명을 BaseTimeEntity 로 생성하였습니다.
📂 model > BaseTimeEntity.java
1) 먼저 추상클래스 위에 어노테이션 3개를 추가합니다.
package com.example.jpaexam.model;
import lombok.Getter;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
/**
* packageName : com.example.jpaexam.model
* fileName : BaseTimeEntity
* author : GGG
* date : 2023-10-16
* description : JPA 에서 자동으로 생성일자/수정일자를 만들어주는 클래스
* 요약 :
* <p>
* ===========================================================
* DATE AUTHOR NOTE
* —————————————————————————————
* 2023-10-16 GGG 최초 생성
*/
@Getter
// todo : 자동으로 생성일자/수정일자 컬럼을 sql 문에 추가시키는 어노테이션 2개
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
// todo) 공통 속성
private String insertTime;
private String updateTime;
}
여기서 @MappedSuperclass 어노테이션의 의미는
생성일자/수정일자를 모든 엔티티에 공통으로 가져가야하는 상황에서 위의 코드처럼
BaseTimeEntity를 정의해서 활용할 때 사용합니다.
2) 공통 속성의 형태를 변경하는 함수 추가
위의 코드는 time의 형태가 yyyy-MM-dd HH:mm:ss 형태가 아닌 기본 형태로 보이게 됩니다.
가독성을 좋게하기 위해 공통속성 코드 아래에 함수를 추가합니다.
...
private String updateTime;
// todo) 해당 테이블에 데이터가 만들어 질 때(insert 문) 실행되는 이벤트 함수
@PrePersist
void OnPerPersist(){
this.insertTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
// todo) 해당 테이블에 데이터가 수정될 때(update 문) 실행되는 이벤트 함수
@PreUpdate
void OnPreUpdate(){
this.updateTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
this.insertTime = this.updateTime; // 생성일시 == 수정일시 동일하게 처리
}
Hibernate:
select
dept0_.dno as dno1_0_,
dept0_.insert_time as insert_time2_0_,
dept0_.update_time as update_time3_0_,
dept0_.dname as dname4_0_,
dept0_.loc as loc5_0_
from
tb_dept dept0_