# JPA 실무 애노테이션 & 설계 지식 정리
# 연관관계의 주인 (가장 중요)
양방향 연관관계에서 주인은 외래 키(FK)가 있는 쪽으로 정한다.
- 주인 = FK를 관리(등록·수정)하는 쪽. 비즈니스상 우위로 정하면 안 된다
- 일대다 관계에서 FK는 항상 '다(N)' 쪽에 위치한다 → 따라서 '다' 쪽이 주인
- 주인이 아닌 쪽은
mappedBy로 주인 필드를 지정하며, 읽기 전용이 된다
FK 없는 쪽을 주인으로 정하면
주인이 관리하지 않는 테이블의 FK가 업데이트되어, 의도와 다른 별도 UPDATE 쿼리가 발생한다. 유지보수가 어렵고 성능 문제가 생긴다.
# 연관관계 애노테이션
| 애노테이션 | 기본 fetch | 실무 설정 | 비고 |
|---|---|---|---|
@ManyToOne | EAGER | LAZY로 직접 변경 필수 | FK 보유, 주인 쪽 |
@OneToOne | EAGER | LAZY로 직접 변경 필수 | FK 가진 쪽이 주인 |
@OneToMany | LAZY | 그대로 사용 | mappedBy 필수(주인 아님) |
@ManyToMany | LAZY | 사용 금지 | 중간 엔티티로 풀어낼 것 |
@XToOne은 기본이 EAGER
@ManyToOne, @OneToOne은 기본값이 즉시 로딩이다. 반드시 fetch = FetchType.LAZY를 명시해야 한다. EAGER는 예측 불가능하고 JPQL 실행 시 N+1 문제를 유발한다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id") // FK 컬럼명 지정
private Member member;
@JoinColumn(name = "..."): 주인 쪽에서 FK 컬럼명을 지정- 주인이라는 용어는 FK를 보유하는 주체라는 것을 표현하기 위해 사용된다.
- Order 테이블이 Member 테이블의 PK를 FK로 사용하기 위해
@JoinColumn애노테이션을 사용한다. name파라미터를 통해 FK로 사용할 컬럼명을 명시한다.- 단방향 OneToMany + JoinColumn보다는, FK를 사용하는 쪽 엔티티에 ManyToOne + JoinColumn으로 정의하는 것이 일반적이다.
- 내 테이블에 없는 FK를 관리한다는 의미가 되기 때문이다.
mappedBy = "필드명": 주인이 아닌 쪽에서, 주인 엔티티의 필드명을 지정- 양방향 관계 정의 시에만 필요한 속성
- 부모를 통해 자식을 자주 조회하거나, CASCADE를 통해 자식 생명주기를 함께 관리하는 경우 양방향 관계로 정의한다.
fetch 파라미터
fetch 파라미터는 DB에서 데이터를 언제 가져올지를 결정한다.
FetchType.EAGER는 (즉시 로딩) 엔티티를 조회할 때 연관된 엔티티도 즉시 함께 가져온다.
Order를 조회하면 연결된 Member도 곧바로 같이 SELECT 한다.
FetchType.LAZY는 (지연 로딩) 연관 엔티티를 실제로 사용하는 시점에 가져온다. Order만 조회할 때는 Member를 건드리지 않다가, order.getMember().getName()처럼 실제로 접근하는 순간 그제서야 SELECT 쿼리가 나간다. 이때 JPA는 진짜 엔티티 대신 프록시 객체를 넣어두고, 접근 시점에 실제 데이터를 채운다.
EAGER로 지정 시 여러 건 조회 시 행마다 연관 엔티티를 가져오는 쿼리가 수행되어 N+1 문제가 발생할 수 있다.
# 즉시/지연 로딩 & N+1 대응
실무 원칙
- 모든 연관관계는 LAZY로 설정한다
- 함께 조회가 필요하면 그때 fetch join 또는 **엔티티 그래프(
@EntityGraph)**를 사용한다 - EAGER를 섞어 쓰면 어떤 SQL이 나갈지 추적이 불가능해진다
# Cascade (영속성 전이)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
cascade = CascadeType.ALL: 부모 저장/삭제 시 자식도 함께 처리 → 부모만 persist하면 자식까지 자동 저장- 적용 기준: 라이프사이클이 동일하고, 단일 소유자(다른 엔티티가 참조하지 않는 private owner)일 때만 사용. 여러 곳에서 참조하는 엔티티에는 쓰지 말 것
- 기본 동작은 영속성 전이 적용이 안 되어 있고, cascade + 타입 지정 시 어떤 동작에서 (삭제, refresh 등) 영속성 전이를 수행할지 지정할 수 있다.
# 상속 매핑
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
public abstract class Item { ... }
@Entity
@DiscriminatorValue("B")
public class Book extends Item { ... }
@Inheritance(strategy = ...): 상속 전략 지정SINGLE_TABLE: 한 테이블에 통합(기본값, 성능 유리, 자식 컬럼 nullable)JOINED: 정규화, 조인 발생TABLE_PER_CLASS: 권장하지 않음
@DiscriminatorColumn(name = "dtype"): 부모에서 타입 구분 컬럼 정의@DiscriminatorValue("..."): 자식에서 구분 값 지정- 기본값 클래스명 (엔티티명)
DiscriminatorColumnname으로 정의한 컬럼이 테이블에 추가됨DiscriminatorValue에서 정의한 값이 row 삽입 시 함께 들어감 (자식 엔티티별 구분)- 조인 전략의 경우 필수적이지는 않지만 부모 테이블만으로 데이터 타입 판별이 가능하다는 편의로 사용하기도 함.
# 값 타입 (임베디드)
@Embeddable
@Getter
public class Address {
private String city, street, zipcode;
protected Address() {} // JPA 스펙상 기본 생성자 필수
public Address(String city, String street, String zipcode) { ... }
}
// 사용하는 엔티티 쪽
@Embedded
private Address address;
@Embeddable은 값 타입(value type)을 정의하는 애노테이션이다. 여러 필드를 하나의 의미 있는 묶음으로 만들어서, 엔티티 안에 끼워 넣을 수 있게 해준다.- DB에 실제로 저장될 때는 Address 내의 속성들이 품고 있는 엔티티의 테이블에 컬럼으로 펼쳐져 들어간다.
값 타입은 불변(immutable)으로 설계
@Embeddable: 값 타입 클래스에 선언 /@Embedded: 사용하는 엔티티 필드에 선언- Setter를 두지 말고 생성자로만 초기화한다 (공유 시 부작용 방지)
- JPA가 리플렉션으로 객체를 생성하므로 기본 생성자 필수,
public보다protected가 안전 - 리플렉션은 자바에서 런타임에 클래스 정보를 보고 객체를 생성하거나 필드에 접근하는 기술이다.
- JPA 구현체 (하이버네이트)는 DB 정보를 읽고 객체로 만들 때 생성자를 직접 호출하는 것이 아닌 리플렉션을 통해 빈 객체를 만들고 값을 하나씩 집어넣는다.
- 리플렉션을 통해 빈객체를 만들고 값을 집어넣기 위해서는 파라미터가 없는 생성자가 필수적이다.
- 이때 protected로 선언하여 같은 패키지 + 다른 패키지더라도 상속받는 자식 클래스에서 접근이 가능하게 한다.
- private으로 선언하면 JPA 하이버네이트가 런타임에 접근이 어려운 경우가 생기고, public으로 선언하는 경우 불필요한 생성자가 외부에 노출된다.
# Enum 매핑
@Enumerated(EnumType.STRING)
private OrderStatus status;
반드시 STRING
@Enumerated의 기본값은 ORDINAL(숫자 0,1,2...)이다. enum 중간에 값이 추가되면 기존 데이터가 밀려 치명적 버그가 발생한다. 항상 EnumType.STRING을 사용한다.
# 기본 키 매핑
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
@GeneratedValue: 기본 전략은AUTO. DB 방언에 따라 결정됨- PK 컬럼명은 관례상
테이블명 + _id(예:member_id) - 객체 식별자는 타입이 있으므로
id로 충분하나,memberId도 가능 — 일관성이 핵심
# @ManyToMany 회피 (실무 필수)
@ManyToMany 사용 금지
- 중간 테이블에 추가 컬럼을 넣을 수 없고, 세밀한 쿼리가 불가능하다
- 중간 엔티티(예: CategoryItem)를 직접 만들고
@ManyToOne+@OneToMany로 풀어서 매핑한다 - 즉, 다대다 → 일대다 + 다대일로 분해한다
# 컬렉션 필드 초기화
private List<Order> orders = new ArrayList<>(); // 필드에서 즉시 초기화
필드 레벨에서 초기화할 것
- null 문제에서 안전하다
- 하이버네이트는 영속화 시 컬렉션을 내장 타입(
PersistentBag등)으로 교체하는데, 임의 메서드에서 새로 할당하면 이 메커니즘이 깨진다
# 엔티티 설계 4대 원칙
실무 체크리스트
- Setter를 열지 마라 — 변경 지점이 분산되면 추적 불가. 변경은 의미 있는 비즈니스 메서드로만. (Getter는 부작용 없어 허용)
- 모든 연관관계는 LAZY
- 컬렉션은 필드에서 초기화
- 값 타입은 불변으로 설계
# 테이블/컬럼 네이밍 전략
spring.jpa.hibernate.naming.implicit-strategy # 논리명: 명시 안 한 컬럼/테이블명 생성
spring.jpa.hibernate.naming.physical-strategy # 물리명: 모든 논리명을 실제 DB명으로 변환
- 스프링 부트 기본 변환 규칙: 카멜케이스 → 언더스코어(
memberPoint→member_point),.→_, 대문자 → 소문자 - 테이블명은 예약어 충돌 방지를 위해
@Table(name = "orders")처럼 명시 (order는order by예약어 충돌) physical-strategy를 회사 룰(username→usernm등)로 커스터마이징 가능
# @JoinColumn
- 연관관계에서 FK 컬럼을 지정하는 애노테이션.
@ManyToOne,@OneToOne등 관계 애노테이션과 함께 쓴다. name은 FK를 보유한 테이블(주인 쪽)에 생성될 FK 컬럼명이다. 참조 대상의 속성명이 아니다.- 참조 대상은 기본적으로 상대 엔티티의 PK다. (PK 외 컬럼 참조 시
referencedColumnName로 별도 지정) - 생략하면 기본값
필드명_참조PK명(예:member_id)으로 자동 생성. - 부가 속성:
nullable,unique,updatable등으로 FK 컬럼 세부 제어 가능.
# cascade (영속성 전이)
- 기본값은 전이 없음이다. 명시하지 않으면 부모/자식을 각각 영속화해야 한다.
CascadeType.PERSIST(저장),REMOVE(삭제),MERGE,REFRESH,DETACH,ALL(전부) 중 선택·조합 가능.- 무조건 ALL이 아니다. 두 조건을 모두 만족할 때만 사용: ① 부모-자식의 라이프사이클이 동일하고, ② 자식을 그 부모 하나만 소유(단일 소유자)할 때.
- 여러 곳에서 공유되는 엔티티에 ALL(특히 REMOVE)을 걸면 의도치 않은 삭제 사고가 날 수 있다.
orphanRemoval = true는 cascade와 별개다. cascade REMOVE는 "부모 삭제 시 자식 삭제", orphanRemoval은 "컬렉션에서 자식을 빼냈을 때(고아) 삭제"다.
# 정적 팩토리 메서드 + 생성자 제어
엔티티 생성을 통제하는 패턴:
- 기본 생성자는
@NoArgsConstructor(access = AccessLevel.PROTECTED)로 막는다 (JPA용으로만 열어둠). - 값 받는 생성자는
private으로 두고,public static create(...)정적 팩토리로만 생성을 유도한다. - 장점: 생성 의도를 이름으로 표현, 생성 경로 통제, 필수 값을 갖춘 완전한 객체만 생성 강제.
- 빌더 패턴도 흔한 대안(필드 많을 때). 단순한 엔티티는 public 생성자로 끝내기도 한다.
# @Column 주요 속성
updatable = false: INSERT 후 UPDATE 대상에서 제외(DB 레벨 불변). 생성 시각, 변하지 않는 FK 등에 사용. (단, 자바 객체 필드 자체를 막는 건 아니다)@Id(PK)는 식별자라 별도 설정 없이도 수정 불가다.- FK(
@JoinColumn)의updatable = false는 "FK라서"가 아니라 관계가 생성 후 불변일 때 적용한다. 바뀔 수 있는 관계엔 적용하지 않는다. nullable = false,length,unique등으로 제약을 건다.- 복합 유니크 제약은
@Column(unique=true)로 불가하며,@Table(uniqueConstraints = @UniqueConstraint(...))로 지정한다. - DB 컬럼명(
@Column(name=...))과 자바 필드명을 다르게 둘 수 있다. (예: 필드refreshTokenHash, 컬럼refresh_token)
# TEXT 타입 매핑
- 자바 필드는
String그대로 쓴다. 그냥 두면 보통 VARCHAR(255). - TEXT로 하려면
@Lob(DB 방언에 맞춰 대용량 타입 매핑, 이식성 좋음) 또는@Column(columnDefinition = "TEXT")(직접 지정, 이식성 낮음). - 단순히 길이만 늘리면 되는 경우
@Column(length = ...)로 충분.
# JPA Auditing (생성/수정 시각 자동 관리)
@CreatedDate,@LastModifiedDate는 기본값을 갖는 게 아니라, Auditing이 저장/수정 이벤트 시점의 현재 시각을 필드 타입에 맞춰 채운다.- INSERT 시 두 필드 모두 채워짐(최초 생성도 "수정"에 포함), UPDATE 시
@LastModifiedDate만 갱신. - 동작 조건 두 가지 (둘 다 필요):
- 설정 클래스에
@EnableJpaAuditing(앱 전체에 한 번) - Auditing을 쓰는 엔티티마다
@EntityListeners(AuditingEntityListener.class)
- 설정 클래스에
- 둘 중 하나라도 빠지면 시각이 null →
nullable = false와 충돌해 저장 에러. (공통 필드를@MappedSuperclassBaseTimeEntity로 빼면 반복을 줄일 수 있다) - Auditing을 쓸 거면 필드에
= Instant.now()같은 초기값을 주지 않는다.
# 리포지토리·서비스·트랜잭션·테스트 실무 정리
JPA 기반 도메인 개발에서 리포지토리, 서비스, 트랜잭션 전략, 테스트를 다룰 때 알아야 할 실무 지식을 정리한다.
# EntityManager 기반 리포지토리 작성
영속성 작업은 EntityManager를 통해 수행한다.
em.persist(): 엔티티 저장(영속화)em.find(타입, PK): PK 기반 단건 조회- JPQL(
em.createQuery(...)) : 조건·목록 조회. 테이블이 아니라 엔티티 객체를 대상으로 쿼리한다. - 파라미터 바인딩은
:name형태의 named parameter +setParameter()로 한다.
핵심 애노테이션:
@Repository: 스프링 빈으로 등록하고, JPA 예외를 스프링 표준 예외(DataAccessException)로 변환해준다. 이 변환 덕분에 서비스 계층이 특정 영속성 기술(JPA/JDBC 등)에 종속되지 않는다.@PersistenceContext:EntityManager주입. (EntityManagerFactory가 필요하면@PersistenceUnit)- 스프링 데이터 JPA 환경에서는
EntityManager도 생성자 주입으로 받을 수 있다.
EntityManagerFactory
- EntityManagerFactory는 이름 그대로 EntityManager를 만들어내는 공장(factory)이다. JPA의 핵심 객체 중 하나로, EntityManager와 짝을 이루지만 역할과 생명주기가 완전히 다르다.
- EntityManagerFactory는 애플리케이션 전체에서 딱 하나만 생성되고, 거기서 필요할 때마다 EntityManager를 찍어낸다.
- EntityManagerFactory는 생성 비용이 매우 비싸다. 만들 때 DB 커넥션 풀 설정, 엔티티 메타데이터 분석, 매핑 정보 구성 등 무거운 초기화를 한다.
- 이로 인해 애플리케이션 시작 시 한 번만 만들고 종료까지 공유한다.
- 여러 스레드가 동시에 써도 안전하도록(thread-safe) 설계돼 있다.
- 반면 EntityManager는 가볍고, 요청이나 트랜잭션 단위로 짧게 생성했다가 버린다.
- thread-safe하지 않아서 여러 스레드가 공유하면 안 된다.
- 스프링에서는 EntityManagerFactory를 직접 쓸 일이 없다.
- 스프링이 EntityManagerFactory를 빈으로 자동 생성·관리해주고, EntityManager도 트랜잭션에 맞춰 알아서 주입·정리해준다.
# 서비스 계층의 트랜잭션 전략
서비스 계층은 비즈니스 로직과 트랜잭션 경계를 담당한다.
- 클래스 레벨에
@Transactional(readOnly = true)를 기본으로 걸고, 쓰기(등록·수정·삭제) 메서드에만@Transactional을 따로 걸어 readOnly를 해제하는 패턴이 일반적이다. 메서드 레벨 설정이 클래스 레벨보다 우선 적용된다. readOnly = true는 영속성 컨텍스트를 플러시하지 않아 더티 체킹 비용이 줄고, DB 드라이버가 지원하면 읽기 전용 최적화가 적용된다.- 조회가 대부분인 서비스에서 이 전략은 성능과 의도 명확성을 모두 확보한다.
# 영속성 컨텍스트와 readOnly 트랜잭션이 비용을 줄이는 원리
@Transactional(readOnly = true)가 왜 비용을 줄이는지 이해하려면 영속성 컨텍스트, 더티 체킹, 플러시가 어떻게 맞물리는지 봐야 한다.
영속성 컨텍스트(Persistence Context)는 EntityManager가 엔티티를 보관·관리하는 공간으로, 보통 트랜잭션 단위로 생성되고 트랜잭션이 끝나면 닫힌다. 스프링에서는 @Transactional 범위가 곧 영속성 컨텍스트가 살아있는 범위다. 조회하거나 저장한 엔티티는 이 안에서 영속 상태(managed)로 관리되며, 1차 캐시, 동일성 보장, 변경 감지, 쓰기 지연, 지연 로딩 같은 기능이 모두 이 영속 상태 엔티티를 대상으로 동작한다.
EntityManager 객체의 persist 메서드는 INSERT 쿼리, remove는 DELETE, find는 SELECT 전용 메서드이다. 업데이트 쿼리는 별도의 메서드가 없다.
UPDATE를 위한 더티 체킹(변경 감지)은 다음과 같이 작동한다. 영속성 컨텍스트는 엔티티를 처음 읽어온 시점의 상태를 스냅샷으로 보관해두고, 이후 개발자가 엔티티 값을 바꾸면 별도의 update 호출 없이도 현재 상태와 스냅샷을 비교해 변경된 부분을 감지하여 자동으로 UPDATE 쿼리를 만들어 반영한다. 관리 중인 엔티티가 많을수록 이 비교 연산과 스냅샷 보관 비용이 늘어난다.
단건 수정은 JPA를 통해 SELECT + UPDATE를 수행하고, 벌크 UPDATE는 createQuery를 통해 직접 UPDATE 쿼리를 수행한다.
플러시(flush)는 영속성 컨텍스트의 변경 내용을 DB에 동기화하는 작업이며, 플러시가 일어나는 시점에 더티 체킹이 수행된다. 즉 "플러시 → 변경 감지 → DB 반영"이 한 흐름으로 이어지고, 보통 트랜잭션 커밋 직전에 자동으로 발생한다. 플러시는 트랜잭션 커밋 시 수행되며, 트랜잭션 커밋은 @Transactional 메서드 호출이 종료될 때 이루어진다.
@Transactional(readOnly = true)를 걸면 하이버네이트는 트랜잭션이 끝날 때 자동 플러시를 수행하지 않는다. 플러시를 하지 않는다는 것은 곧 트랜잭션 종료 시점에 더티 체킹(스냅샷 비교)을 건너뛴다는 의미다. 조회 전용 작업은 어차피 반영할 변경이 없으므로 변경 감지가 불필요하기 때문이다. 이로 인해 관리 중인 엔티티를 스냅샷과 비교하는 연산이 생략되고, 변경 감지용 스냅샷도 가볍게 처리되어 CPU·메모리 비용이 절감된다.
실무 관점
절감 폭 자체는 보통 크지 않다. readOnly의 더 큰 가치는 성능보다 "이 메서드는 데이터를 변경하지 않는다"는 의도를 코드에 명확히 드러내는 것, 그리고 DB 드라이버가 지원할 경우 읽기 전용 최적화가 적용될 수 있다는 점에 있다. 그래서 조회 전용 메서드에는 readOnly를 기본으로 거는 것이 권장된다.
# 애플리케이션 검증과 동시성 방어
애플리케이션 레벨의 중복 검증(예: 이름으로 조회 후 존재하면 예외)은 멀티 스레드 동시 요청을 완전히 막지 못한다. 두 요청이 거의 동시에 검증을 통과한 뒤 둘 다 INSERT할 수 있기 때문이다.
무결성의 최종 방어선은 DB 제약
애플리케이션 검증은 사용자 경험(빠른 피드백, 친절한 메시지)을 위한 것이고, 데이터 무결성의 최종 방어선은 DB 유니크 제약이어야 한다. 중복을 막아야 하는 컬럼에는 반드시 유니크 제약을 함께 건다.
# 생성자 주입을 사용하는 이유
필드 주입(@Autowired를 필드에 직접)보다 생성자 주입을 사용한다.
- 의존성을
final로 선언할 수 있어 불변 객체가 되고, 주입 누락을 컴파일 시점에 잡는다. - 객체 생성 시점에 의존성이 모두 확정되므로 테스트(특히 수동 주입 단위 테스트)가 쉽다.
- 순환 참조를 애플리케이션 기동 시점에 발견할 수 있다.
- 생성자가 하나면
@Autowired를 생략할 수 있고, Lombok@RequiredArgsConstructor가final필드 생성자를 자동 생성한다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
}
# 통합 테스트 구성 (스프링 부트 3.x / JUnit5)
통합 테스트에서 자주 쓰는 애노테이션과 동작:
@SpringBootTest: 스프링 컨테이너를 띄워 빈 주입(@Autowired)이 동작하게 한다.- 테스트 클래스에 붙은
@Transactional: 각 테스트마다 트랜잭션을 시작하고 종료 시 자동 롤백한다. 테스트 간 데이터 격리를 보장한다. 운영 코드의@Transactional과 달리 여기서는 롤백이 목적이다. - 예외 검증은
assertThrows(예외클래스, () -> 실행코드)로 한다. - 테스트 구조는 Given / When / Then 패턴으로 의도를 드러내는 것이 권장된다.
JUnit4에서 JUnit5로 전환 시 달라진 점:
org.junit.jupiter.api.Test사용,import static org.junit.jupiter.api.Assertions.*@RunWith(SpringRunner.class)불필요(제거) — 스프링과 자동 통합된다.@Test(expected = ...)방식 폐기 →assertThrows사용.
# 테스트 환경과 설정 분리
테스트는 격리되고 반복 가능해야 하므로, 운영과 설정을 분리하고 메모리 DB를 쓰는 것이 이상적이다.
test/resources/application.yml을 두면 테스트 실행 시 이 파일을 우선 읽는다. (없으면 메인 설정을 읽음)- 스프링 부트는
datasource설정이 없으면 자동으로 메모리 DB(H2)를 사용하고, 등록된 라이브러리로 드라이버를 찾으며,ddl-auto를create-drop으로 동작시킨다. 따라서 테스트용 별도 데이터소스 설정 없이도 동작한다.create-drop옵션은 애플리케이션 실행마다 테이블을 drop 후 create / 테스트가 종료되면 다시 drop하는 옵션이다.- 별도의 datasource 설정이 없으면 H2 DB 드라이버를 테스트 환경에 적용한다.
- 운영 환경에서는
ddl-auto를none또는validate로 두고 스키마를 별도로 관리하는 것과 대비된다.
# 도메인 & 웹 계층 개발
# 도메인 모델 패턴 (핵심 설계 원칙)
비즈니스 로직을 엔티티 안에 두고 서비스는 위임만 하는 방식을 도메인 모델 패턴이라 한다. 반대로 엔티티는 데이터만 갖고 서비스가 로직을 다 처리하면 트랜잭션 스크립트 패턴이다.
핵심
상태를 변경하는 로직(재고 증감, 주문 취소 등)은 데이터를 가진 엔티티 내부에 두는 것이 응집도 측면에서 유리하다. 서비스에서 getter/setter로 값을 꺼내 계산하지 않는다.
# 상품(Item) 엔티티 — 비즈니스 로직 내장
상속 관계 매핑(SINGLE_TABLE)을 쓰는 추상 엔티티이며, 재고 로직을 엔티티가 직접 가진다.
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {
@Id @GeneratedValue
@Column(name = "item_id")
private Long id;
private String name;
private int price;
private int stockQuantity;
@ManyToMany(mappedBy = "items")
private List<Category> categories = new ArrayList<>();
//==비즈니스 로직==//
public void addStock(int quantity) { // 재고 증가 (주문 취소 시 복구)
this.stockQuantity += quantity;
}
public void removeStock(int quantity) { // 재고 감소 (주문 시)
int restStock = this.stockQuantity - quantity;
if (restStock < 0) {
throw new NotEnoughStockException("need more stock");
}
this.stockQuantity = restStock;
}
}
사용자 정의 예외는 RuntimeException을 상속해 생성자 4종(기본/message/message+cause/cause)을 표준 패턴으로 정의한다.
public class NotEnoughStockException extends RuntimeException {
public NotEnoughStockException() { // ①
}
public NotEnoughStockException(String message) { // ②
super(message);
}
public NotEnoughStockException(String message, Throwable cause) { // ③
super(message, cause);
}
public NotEnoughStockException(Throwable cause) { // ④
super(cause);
}
}
- 에러 4종 메서드를 구현하는 것은 자바 표준 라이브러리 관례이다.
- IDE가 자동완성 해준다.
# 주문(Order) 엔티티 — 연관관계와 생성/취소 로직
연관관계: 회원(N:1 LAZY), 주문상품(1:N cascade ALL), 배송(1:1 cascade ALL LAZY). 테이블명은 예약어 회피로 orders.
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status; // ORDER, CANCEL
//==연관관계 편의 메서드==//
public void setMember(Member member) {
this.member = member;
member.getOrders().add(this);
}
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
public void setDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this);
}
//==생성 메서드==//
public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
Order order = new Order();
order.setMember(member);
order.setDelivery(delivery);
for (OrderItem orderItem : orderItems) {
order.addOrderItem(orderItem);
}
order.setStatus(OrderStatus.ORDER);
order.setOrderDate(LocalDateTime.now());
return order;
}
//==비즈니스 로직==//
public void cancel() {
if (delivery.getStatus() == DeliveryStatus.COMP) {
throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
}
this.setStatus(OrderStatus.CANCEL);
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
//==조회 로직==//
public int getTotalPrice() {
return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
}
}
핵심 포인트
- 연관관계 편의 메서드: 양방향 관계 양쪽을 한 번에 세팅해 정합성을 보장한다.
- 생성 메서드(
createOrder): 생성 시점에 모든 연관관계·초기 상태를 한 곳에서 채워, 변경 시 한 곳만 수정하면 되게 한다.@NoArgsConstructor애노테이션까지 활용하여 빈 생성자를 외부에서 호출하지 못하도록 막고, 필요한 값 없이는 객체를 만들 수 없는 상태로 만들 수 있다.- 불완전 객체의 존재 가능성을 완전히 지우는 방식이다.
- setter 호출도 열어둘 필요가 없기 때문에 안정적인 방식이다.
실무 관점 - 전체 주문 가격
강의는 매번 합산하지만, 실무에서는 보통 Order에 totalPrice 필드를 두고 역정규화하는 경우가 많다.
# Repository 계층
엔티티 매니저를 주입받아 영속성 컨텍스트를 다룬다.
@Repository
@RequiredArgsConstructor
public class ItemRepository {
private final EntityManager em;
public void save(Item item) {
if (item.getId() == null) {
em.persist(item); // 신규 → 영속화
} else {
em.merge(item); // 기존 → 병합 (수정, 아래 주의사항 참고)
}
}
public Item findOne(Long id) {
return em.find(Item.class, id);
}
public List<Item> findAll() {
return em.createQuery("select i from Item i", Item.class).getResultList();
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public void save(Order order) { em.persist(order); }
public Order findOne(Long id) { return em.find(Order.class, id); }
public List<Order> findAll(OrderSearch orderSearch) { /* 동적 쿼리 */ }
}
# Service 계층 — 트랜잭션 전략
클래스 레벨에 @Transactional(readOnly = true)를 두고, 쓰기 메서드에만 @Transactional을 따로 붙인다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
private final MemberRepository memberRepository;
private final OrderRepository orderRepository;
private final ItemRepository itemRepository;
@Transactional
public Long order(Long memberId, Long itemId, int count) {
Member member = memberRepository.findOne(memberId);
Item item = itemRepository.findOne(itemId);
Delivery delivery = new Delivery();
delivery.setAddress(member.getAddress());
delivery.setStatus(DeliveryStatus.READY);
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
Order order = Order.createOrder(member, delivery, orderItem);
orderRepository.save(order); // cascade로 delivery, orderItem 함께 저장
return order.getId();
}
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderRepository.findOne(orderId);
order.cancel(); // 비즈니스 로직은 엔티티에 위임
}
}
readOnly = true
조회 위주 클래스에 기본으로 두면 JPA가 플러시를 생략하는 등 성능 최적화가 된다. 쓰기 메서드에서만 별도 @Transactional로 오버라이드한다.
cascade 활용
Order에 cascade = ALL이 있어 save(order) 한 번으로 delivery, orderItem이 함께 persist된다. cascade는 해당 엔티티를 한 군데에서만 참조하고 라이프사이클이 동일할 때 안전하다.
# 동적 쿼리 — 주문 검색
검색 조건 객체(OrderSearch)로 동적 쿼리를 만든다. 강의는 두 가지 방식을 보여준다.
1) JPQL 문자열 조립 — 조건 유무에 따라 where/and를 직접 붙인다. 동작하지만 문자열 연결이 번거롭고 버그 가능성이 크다.
2) JPA Criteria — JPA 표준 스펙이지만 코드가 직관적이지 않고 유지보수가 어렵다.
결론
JPQL 문자열 조립과 Criteria 둘 다 실무에 부적합하다. 실무 동적 쿼리 표준은 Querydsl이다 (타입 안전 + 컴파일 시점 오류 검출 + 가독성).
# 웹 계층 - Thymeleaf 뷰 동작 원리
컨트롤러가 반환한 문자열을 prefix/suffix와 조합해 실제 뷰 파일을 찾는다.
return "home" → classpath:/templates/ + home + .html
spring:
thymeleaf:
prefix: classpath:/templates/
suffix: .html
공통 레이아웃은 fragment로 분리하며, 스프링 부트 3.x 최신 Thymeleaf는 ~{...} 문법을 쓴다.
<head th:replace="~{fragments/header :: header}">
<!-- 사용하는 쪽 -->
<head th:fragment="header">
<!-- 정의하는 쪽 -->
</head>
</head>
개발 편의
spring-boot-devtools 추가 시 서버 재시작 없이 뷰 변경 반영 (HTML build → Recompile).
# 폼 객체(DTO) vs 엔티티 직접 사용 — 매우 중요
화면/API 계층과 도메인 계층을 분리하기 위해 별도의 폼 객체·DTO를 사용한다.
@Getter @Setter
public class MemberForm {
@NotEmpty(message = "회원 이름은 필수 입니다")
private String name;
private String city;
private String street;
private String zipcode;
}
실무 원칙
- 단순하면 엔티티를 화면에 직접 써도 동작은 하지만, 요구사항이 복잡해질수록 엔티티에 화면용 코드가 쌓여 화면에 종속되고 유지보수가 어려워진다.
- 엔티티는 핵심 비즈니스 로직만 갖고, 화면/API용은 폼 객체·DTO로 처리해 엔티티를 순수하게 유지한다.
- API 응답에 엔티티를 직접 노출하는 것도 같은 이유로 금지 (스펙 변경 취약 + 불필요한 필드 노출).
클라이언트 JSON
↓ (컨트롤러가 받음)
TrackCreateRequest ← 요청 DTO
↓ request.toEntity(genre)
Track (엔티티) ← 저장
↓ TrackResponse.from(track)
TrackResponse ← 응답 DTO
↓
클라이언트에게 JSON 반환
public record TrackCreateRequest(
@NotBlank String externalId,
@NotBlank String artistName,
@NotBlank String collectionName,
@NotBlank String trackName,
@NotBlank String previewUrl,
@NotBlank String trackViewUrl,
@NotBlank String artworkUrl,
@NotNull Instant releaseDate,
@NotNull Long genreId,
String country
) {
public Track toEntity(Genre genre) {
return Track.create(
externalId, artistName, collectionName, trackName,
previewUrl, trackViewUrl, artworkUrl, releaseDate,
genre, country, "ITUNES"
);
}
}
public record TrackResponse(
Long id,
String artistName,
String collectionName,
String trackName,
String artworkUrl,
String trackViewUrl,
String previewUrl
) {
public static TrackResponse from(Track track) {
return new TrackResponse(
track.getId(),
track.getArtistName(),
track.getCollectionName(),
track.getTrackName(),
track.getArtworkUrl(),
track.getTrackViewUrl(),
track.getPreviewUrl()
);
}
}
검증은 @Valid + BindingResult로 처리한다.
@PostMapping("/members/new")
public String create(@Valid MemberForm form, BindingResult result) {
if (result.hasErrors()) {
return "members/createMemberForm";
}
// 엔티티 변환 후 저장
return "redirect:/";
}
스프링 부트 3.x 변경점
검증 어노테이션 import가 javax.validation → jakarta.validation으로 변경됨 (javax → jakarta 전반 적용).
# PRG 패턴 (Post-Redirect-Get)
폼 submit(POST) 처리 후 직접 렌더링하지 않고 redirect한다. 새로고침 시 POST 중복 전송을 막는 실무 표준.
return "redirect:/items";
# 준영속 엔티티 — 변경 감지 vs 병합
컨트롤러에서 new Book()으로 만들어 id를 채운 엔티티는 준영속 상태다 (식별자는 있지만 영속성 컨텍스트가 관리하지 않음). 준영속 엔티티는 변경 감지가 동작하지 않아 수정에 두 가지 방법이 있다. (id값도 할당되지 않은 상태면 비영속 상태다)
@Transactional
public Item findItem(Long id) {
return itemRepository.findOne(id); // 메서드 안에선 영속
} // 여기서 트랜잭션 종료 → 영속성 컨텍스트 닫힘
// 컨트롤러가 받은 item은 이미 준영속
Item item = itemService.findItem(1L);
item.setName("변경"); // 변경 감지 안 됨 (준영속이라)
영속성 컨텍스트는 트랜잭션 단위로 살아있기 때문에 트랜잭션이 종료된 이후 시점에 반환받은 객체는 준영속으로 관리된다.
1) 변경 감지 (Dirty Checking) — 권장
@Transactional
public void update(Long id, String name, int price) {
Item findItem = em.find(Item.class, id); // 영속 상태로 다시 조회
findItem.setName(name); // 원하는 값만 변경
findItem.setPrice(price);
// 커밋 시점에 자동 UPDATE
}
2) 병합 (merge) — 준영속 엔티티를 영속 상태로 만든다. 동작 순서: ①식별자로 1차 캐시(없으면 DB) 조회 → ②영속 엔티티의 모든 필드를 준영속 엔티티 값으로 교체 → ③커밋 시 변경 감지로 UPDATE.
@Transactional
public void update(Item itemParam) {
Item mergeItem = em.merge(itemParam);
}
병합의 위험 — 반드시 기억
- 변경 감지는 원하는 속성만 선택 변경 가능.
- 병합은 모든 필드를 통째로 교체한다. 폼에서 넘어오지 않은 필드가 있으면 그 값을
null로 덮어쓴다. - 실무 수정 폼은 보통 일부 필드만 노출하므로, 병합을 쓰면 나머지 필드가 null로 날아가는 사고가 난다.
실무 권장 방식 (결론)
- 엔티티 변경은 항상 변경 감지를 사용한다.
- 컨트롤러에서 어설프게 엔티티를 생성하지 않는다.
- 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확히 전달한다 (파라미터 또는 DTO).
- 서비스에서 영속 엔티티를 조회한 뒤 직접 변경 → 커밋 시 변경 감지 동작.
@Transactional
public void updateItem(Long id, String name, int price, int stockQuantity) {
Item item = itemRepository.findOne(id); // 영속 상태 조회
item.setName(name);
item.setPrice(price);
item.setStockQuantity(stockQuantity);
// 별도 save 불필요 — 변경 감지가 처리
}
save() 전제 조건
save()는 식별자 자동 생성(@GeneratedValue)이 전제다. @Id만으로 직접 할당하는 방식이면, 식별자 없이 persist()를 호출할 때 예외가 난다. 또한 영속 상태 엔티티는 변경 감지가 자동 동작하므로 별도 수정 메서드가 필요 없다.
# 컨트롤러 파라미터 바인딩
@RequestParam: 쿼리 파라미터/폼 필드 단건 바인딩@PathVariable: URL 경로 변수 바인딩 (/items/{itemId}/edit)@ModelAttribute: 객체 단위 바인딩 (폼 객체 ↔ 뷰)Model: 조회 결과를 뷰에 전달하는 컨테이너
# Thymeleaf 실무 표현식
<tr th:each="item : ${items}">
<!-- 반복 -->
<td th:text="${member.address?.city}"></td>
<!-- null 안전 접근 -->
<a th:if="${item.status.name() == 'ORDER'}" ...>CANCEL</a>
<!-- 조건부 -->
<option
th:each="status : ${T(jpabook.jpashop.domain.OrderStatus).values()}"
th:value="${status}"
th:text="${status}"
></option>
<!-- enum 전체 -->
<a th:href="@{/items/{id}/edit (id=${item.id})}">수정</a>
<!-- 경로 변수 -->
</tr>
# JPA 활용편2 - API 개발 기본 & 고급 정리
# 1. 회원 등록/수정/조회 API의 핵심 원칙
엔티티를 API 요청/응답에 직접 사용하지 않는다. 항상 별도의 DTO를 만들어 매핑한다.
// 등록 V2 - DTO를 RequestBody에 매핑
@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
Member member = new Member();
member.setName(request.getName());
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
실무 절대 원칙
엔티티를 API 스펙에 노출하면 안 된다. 엔티티가 변경되면 API 스펙이 함께 깨지고, 한 엔티티에 모든 API 요청/응답 요구사항을 담을 수 없다.
# 2. 회원 수정은 변경 감지(Dirty Checking) 사용
수정은 setter 호출 후 트랜잭션 커밋 시점의 변경 감지로 처리한다. 별도 update 쿼리를 직접 호출하지 않는다.
@Transactional
public void update(Long id, String name) {
Member member = memberRepository.findOne(id);
member.setName(name); // 변경 감지로 자동 UPDATE
}
REST 스타일
부분 업데이트에는 PUT이 아니라 PATCH 또는 POST가 맞다. PUT은 전체 업데이트용이다.
# 3. 컬렉션은 반드시 Result 클래스로 감싸기
배열/리스트를 그대로 반환하면 향후 API 스펙 확장이 불가능하다.
@Data @AllArgsConstructor
static class Result<T> {
private T data;
}
# 4. xToOne 관계 조회 최적화 (Order → Member, Delivery)
성능 최적화의 핵심은 지연 로딩으로 인한 N+1 문제 해결이다.
- V1 (엔티티 직접 노출): 사용 금지. 프록시 직렬화 문제, 양방향 무한 루프 위험
- V2 (DTO 변환, fetch join X): 쿼리 1 + N + N번 → N+1 문제 발생
- V3 (DTO 변환, fetch join O): 페치 조인으로 쿼리 1번. 실무 기본 권장
- V4 (JPA에서 DTO 직접 조회): SELECT 절 최적화. 단 리포지토리 재사용성 떨어짐
// V3 - 페치 조인으로 한 방에 조회 (실무 권장)
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.getResultList();
}
즉시 로딩(EAGER) 금지
N+1을 피하려고 EAGER로 바꾸면 안 된다. 연관관계가 필요 없는 경우에도 항상 조회되어 성능 튜닝이 매우 어려워진다. 항상 LAZY 기본 + 필요 시 페치 조인.
xToOne 조회 권장 순서
- 엔티티를 DTO로 변환
- 필요 시 페치 조인으로 최적화 (대부분 해결됨)
- 그래도 안 되면 DTO 직접 조회
- 최후엔 네이티브 SQL / JdbcTemplate
# 5. 컬렉션 조회 최적화 (Order → OneToMany OrderItem)
일대다(컬렉션) 조인은 row가 예측 불가하게 증가하여 toOne보다 훨씬 까다롭다.
- V3 (컬렉션 페치 조인):
distinct로 중복 제거. 쿼리 1번이지만 페이징 불가능 - V3.1 (페치 조인 + batch fetch): 실무 핵심 해법
- V4 (DTO 직접 조회): 루트 1번 + 컬렉션 N번
- V5 (DTO 직접 조회 최적화): 루트 1번 + 컬렉션 1번 (IN 절 + Map)
- V6 (플랫 데이터): 쿼리 1번이지만 페이징 불가 + 중복 전송
// V3 - 컬렉션 페치 조인 (distinct 필수, 페이징 불가)
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
컬렉션 페치 조인 한계
- 컬렉션 페치 조인 시 페이징 불가능. 하이버네이트가 모든 데이터를 메모리에 올려 페이징하므로 장애로 이어질 수 있다.
- 컬렉션 페치 조인은 1개만 가능. 둘 이상은 데이터 부정합 발생.
# 6. V3.1 페이징 한계 돌파 (실무 핵심)
ToOne 관계만 페치 조인하고, 컬렉션은 지연 로딩 + batch_fetch_size로 최적화한다. 이 방법이 페이징 + 컬렉션 조회 문제의 대부분을 해결한다.
// ToOne만 페치 조인, 컬렉션은 batch size로 IN 조회
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
개별 적용은 @BatchSize(컬렉션 필드 또는 엔티티 클래스에)를 사용한다. 쿼리가 1 + N에서 1 + 1로 최적화되고, 각각 조회하므로 중복 데이터 전송이 없으며 페이징도 가능하다.
batch_fetch_size 크기
100~1000 사이 권장. 애플리케이션 메모리 사용량은 어차피 동일하므로 1000이 성능상 가장 좋지만, DB 순간 부하를 어디까지 견딜지로 결정한다.
스프링 부트 3.1 (하이버네이트 6.2) 변경
where in 대신 array_contains를 사용한다. IN 절은 파라미터 개수에 따라 SQL 구문 자체가 달라져 캐싱이 분산되지만, array_contains는 배열 1개만 바인딩되어 데이터가 늘어도 SQL 구문이 동일 → SQL 파싱 캐시 재사용으로 성능 최적화.
# 7. V5 컬렉션 직접 조회 최적화 (다건 조회 시 필수)
루트(ToOne)를 먼저 조회해 orderId를 모은 뒤, OrderItem을 IN 절로 한 번에 조회하고 Map으로 매칭한다.
public List<OrderQueryDto> findAllByDto_optimization() {
List<OrderQueryDto> result = findOrders(); // 루트 1번
Map<Long, List<OrderItemQueryDto>> orderItemMap =
findOrderItemMap(toOrderIds(result)); // 컬렉션 1번 (IN)
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new ...OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi join oi.item i" +
" where oi.order.id in :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();
return orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId)); // Map O(1) 매칭
}
V4 vs V5 선택
- 단건 조회: V4(코드 단순)로 충분
- 다건 조회: V4는 1 + N번(예: Order 1000건이면 1 + 1000) → 반드시 V5(1 + 1번)로 최적화. 운영에서 100배 이상 성능 차이.
# 8. API 개발 고급 최종 권장 순서
1. 엔티티 조회 방식으로 우선 접근
- 페치 조인으로 쿼리 수 최적화
- 컬렉션은:
· 페이징 필요 → batch_fetch_size / @BatchSize
· 페이징 불필요 → 페치 조인
2. 안 되면 DTO 직접 조회 (V4 → V5)
3. 그래도 안 되면 NativeSQL / JdbcTemplate
엔티티 조회 방식은 옵션만 살짝 바꿔 다양한 최적화를 시도할 수 있어 코드가 단순하다. DTO 직접 조회는 SQL을 직접 다루는 것과 유사해 최적화 변경 시 코드 수정량이 크다.
# 9. OSIV와 성능 최적화 (실무 필수)
OSIV(Open Session In View)는 spring.jpa.open-in-view: true가 기본값이다. 최초 DB 커넥션 시점부터 API 응답이 끝날 때까지 영속성 컨텍스트와 커넥션을 유지해 컨트롤러/뷰에서도 지연 로딩이 가능하다.
spring:
jpa:
open-in-view: false # 실시간 트래픽 서비스 권장
OSIV ON의 위험
커넥션을 너무 오래 유지한다. 컨트롤러에서 외부 API를 호출하면 그 대기 시간만큼 커넥션을 반환하지 못한다. 실시간 트래픽이 많은 서비스는 커넥션 고갈 → 장애로 이어진다.
OSIV를 끄면 트랜잭션 종료 시 영속성 컨텍스트와 커넥션을 즉시 반환해 리소스 낭비가 없다. 단, 모든 지연 로딩을 트랜잭션 안에서 처리해야 하고 뷰 템플릿에서 지연 로딩이 동작하지 않는다.
Command와 Query 분리 (OSIV OFF 복잡성 관리)
OrderService: 핵심 비즈니스 로직OrderQueryService: 화면/API용 조회 (주로 읽기 전용 트랜잭션)
서비스 계층에서 트랜잭션을 유지하면 OSIV가 꺼져도 지연 로딩을 사용할 수 있다. 실무에서는 고객 실시간 API는 OSIV OFF, ADMIN처럼 커넥션을 적게 쓰는 곳은 OSIV ON으로 운영하기도 한다.