김영한 님의 "자바 ORM 표준 JPA 프로그래밍" 책을 정리한 포스팅 입니다.
1. 트랜잭션과 락
NON-REPEATABLE READ
- 사용자 A, B가 동시에 같은 데이터를 수정 → 나중에 저장한 B의 값만 반영되어 A의 수정사항이 유실됨.
- 트랜잭션만으로는 해결 불가능 (트랜잭션 범위를 넘어선 문제)
해결 방법 | 설명 | 구현 방식 |
마지막 커밋만 인정 | 마지막에 커밋한 트랜잭션의 값이 저장됨 (이전 변경 내용이 덮어씌워짐) |
버전 관리 없이 단순히 UPDATE 수행
|
최초 커밋만 인정 | 먼저 커밋한 트랜잭션만 인정 이후 커밋은 버전 불일치로 예외 발생 |
JPA 낙관적 락 적용 (@Version)
|
충돌 내용 병합 | 동시에 수정된 데이터를 병합하여 둘 다 반영 |
OptimisticLockException 발생 시 직접 예외 처리
|
낙관적 락과 비관적 락
구분 | 낙관적 락 (Optimistic Lock) |
비관적 락 (Pessimistic Lock)
|
가정 | 충돌이 거의 없다고 가정 |
충돌이 자주 발생한다고 가정
|
데이터베이스 락 사용 | ❌ |
✅ (SELECT ... FOR UPDATE)
|
구현 방법 | JPA의 @Version 애노테이션으로 버전 관리 |
JPA의 @Lock(LockModeType.PESSIMISTIC_*) 사용
|
충돌 감지 시점 | 트랜잭션 커밋 시점에 버전 비교하여 충돌 감지 |
트랜잭션 시작 시점에 락 걸어 충돌 방지
|
처리 방식 | 충돌 발생 시 예외 발생 |
충돌 자체를 사전에 차단
|
장점 | 성능 부담이 적고 낙관적인 상황에 적합 |
충돌 가능성이 높은 환경에서도 데이터 정합성 보장 가능
|
단점 | 충돌 시점이 늦어 예외 처리 필요 |
락으로 인해 성능 저하 및 데드락 위험 존재
|
JPA 추천 전략
- READ COMMITTED 트랜잭션 격리 수준 + 낙관적 버전 관리 (JPA는 기본적으로 READ COMMITTED 수준을 가정함)
- 영속성 컨텍스트를 활용하면 애플리케이션 수준에서 REPEATABLE READ 가능
- NON-REPEATABLE READ 문제 해결 가능
JPA 낙관적 락
- @Version 필드를 이용해서 동작한다. (필수)
- 별도의 락 설정 없이 @Version만 있어도 낙관적 락이 기본 적용
- 트랜잭션 커밋 시점에 엔티티의 버전 정보를 비교하여 충돌 여부 판단
옵션 | 설명 | 특징 |
@Version | 갱신 시에만 엔티티 일관성 보장 | 엔티티당 하나의 필드만 적용 가능 VERSION 컬럼 필요 (INT 타입) |
OPTIMISTIC (LockModeType) |
조회한 후 트랜잭션 끝날 때까지 다른 트랜잭션 수정 불가 | 트랜잭션 커밋 시 SELECT로 버전 체크 |
OPTIMISTIC_FORCE_INCREMENT (LockModeType) |
논리적 단위(묶음) 버전 관리 필요 시 사용 - 하위 엔티티 수정으로 인한, 부모 엔티티 버전 증가 |
엔티티 수정 없이도 버전 강제 증가 수정하면 2번 증가 가능 |
예시) @Version
더보기
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT에 해당
private Integer id;
private String name;
private String nickname;
@Version
private Integer age;
}
- 버전 관리하고 싶은 필드에 @Version 붙이기
@Transactional
void crud() {
User user = userRepository.findById(1).get();
user.setAge(30);
}
UPDATE user
SET age = ?, version = version + 1
WHERE id = ? AND version = ?
- 실제 쿼리는 최초 읽은 엔티티의 버전값 갱신을 처리함
- 최초 읽은 엔티티의 버전 정보가, 커밋 직전 시점에 현재 DB에 저장된 버전과 다르면 OptimisticLockException 예외 발생
예시) OPTIMISTIC
더보기
흐름
- 트랜잭션 1: 재고 ID=1 조회 (버전=5) + OPTIMISTIC 락 설정
- 트랜잭션 2: 같은 재고 ID=1 조회
- 트랜잭션 2: 재고를 수정하고 커밋 → 버전 5 → 6
- 트랜잭션 1: 결제 시도 (커밋하려고 함) → 버전 5 != 현재 버전 6 → OptimisticLockException 발생
@Transactional
void optimistic() {
Stock stock = em.find(Stock.class, stockId, LockModeType.OPTIMISTIC);
// 커밋 시, 버전 달라질 경우 예외 발생
}
SELECT version
FROM stock
WHERE id = :stockId
- 트랜잭션 커밋 시 추가로 나가는 쿼리
- 내가 처음에 읽은 버전과 같은지 비교하고, 다를 경우 OptimisticLockException 예외 발생
예시) OPTIMISTIC_FORCE_INCREMENT
더보기
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Version
private int version;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<Attachment> attachments = new ArrayList<>();
}
@Entity
public class Attachment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String fileName;
@ManyToOne
@JoinColumn(name = "post_id")
private Post post;
}
@Transactional
public void updatePostWithAttachment(Long postId) {
// 게시물 조회
Post post = em.find(Post.class, postId, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
// 첨부파일 추가
Attachment attachment = new Attachment();
attachment.setFileName("new_attachment.jpg");
attachment.setPost(post);
post.getAttachments().add(attachment);
// 부모 엔티티(게시물)의 버전이 증가되고, 트랜잭션 커밋 시 저장됩니다.
}
UPDATE post SET version = version + 1 WHERE id = ?;
INSERT INTO attachment (file_name, post_id) VALUES ('new_attachment.jpg', ?);
- 트랜잭션 커밋할 때 무조건 버전 하나 올리는 쿼리가 날라감 (수정 안해도 강제로 version 올림)
- version 조건 안맞으면 OptimisticLockException 예외 발생
JPA 비관적 락
- 데이터 일관성 보장: 데이터베이스의 트랜잭션 락 매커니즘을 활용 여러 트랜잭션간 동시성 문제를 해결
- 타임아웃: 락을 획득할 때까지 대기 (무한정 기다릴 수는 없으므로 타임아웃 시간을 줄 수 있음)
락 모드 | 설명 | 사용 예시 |
PESSIMISTIC_WRITE | 데이터베이스에 쓰기 락을 걺 데이터를 수정할 때 사용되는 락 |
데이터 수정 시, 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 함.
|
PESSIMISTIC_READ | 데이터베이스에 읽기 락을 걺 데이터를 읽기만 하고 수정하지 않는 경우 |
읽기 전용 작업에서 데이터를 다른 트랜잭션에서 수정하지 않도록 락을 건다.
|
PESSIMISTIC_FORCE_INCREMENT | 버전 정보를 강제로 증가시킴 |
락을 걸 때마다 데이터의 버전이 증가
nowait 옵션을 통해 즉시 락을 획득하거나 실패 |
예제) PESSIMISTIC_WRITE
더보기
@Test
@Transactional
void pessimistic_write() {
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.lock.timeout", 10 * 1000);
User user = entityManager.find(User.class, 1, LockModeType.PESSIMISTIC_WRITE, properties);
}
SELECT * FROM user WHERE id = ? FOR UPDATE;
- User 테이블의 해당 행에 쓰기 락 걸기
예제) PESSIMISTIC_READ
더보기
@Test
@Transactional
void pessimistic_read() {
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.lock.timeout", 10 * 1000);
User user = entityManager.find(User.class, 1, LockModeType.PESSIMISTIC_READ, properties);
}
SELECT * FROM user WHERE id = ? FOR SHARE;
- User 테이블의 해당 행에 읽기 락 걸기
예제) PESSIMISTIC_FORCE_INCREMENT
더보기
@Test
@Transactional
void pessimistic_nowait() {
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.lock.timeout", 10 * 1000);
User user = entityManager.find(User.class, 1, LockModeType.PESSIMISTIC_FORCE_INCREMENT, properties);
}
SELECT * FROM user WHERE id = ? FOR UPDATE NOWAIT;
UPDATE user SET age=? WHERE id=? and age=?;
- nowait 옵션: 락을 걸려고 시도할 때, 락이 이미 다른 트랜잭션에 의해 걸려있으면 기다리지 않고 바로 실패
2. 2차 캐시
vs 1차 캐시
항목 | 1차 캐시 | 2차 캐시 |
위치 | 영속성 컨텍스트 내부 |
애플리케이션 범위 (공유 캐시)
|
유효 범위 | 트랜잭션 단위 (요청~응답) |
애플리케이션 전체 (서버 전체 공유)
|
동작 방식 | 조회 시 영속성 컨텍스트에서 먼저 탐색 |
조회 시 2차 캐시에서 먼저 탐색 후 없으면 DB 조회
|
저장 방식 | 조회하거나 변경한 엔티티를 저장 |
DB 기본 키 기준으로 복사본 저장
|
동일성 보장 | 보장함 (같은 영속성 컨텍스트 안) |
보장하지 않음 (복사본 반환)
|
설정 여부 | 항상 활성 (옵션 없음) |
설정 필요 (@Cacheable 등)
|
주 용도 | 트랜잭션 내 엔티티 관리 최적화 |
DB 접근 최소화 및 전체 성능 향상
|
특징 | 트랜잭션 종료 시 사라짐 |
명시적으로 제거하거나 서버 종료 시 사라짐
|
2차 캐시 필요성
- DB 부하를 줄이기 위함
- 1차 캐시: 트랜잭션 내에서의 재사용이므로 근본적인 DB 부하 감소 ❌
- 2차 캐시: 여러 요청이 DB를 거치지 않고 메모리에 조회하므로 DB 부하 감소 ✅
- 애플리케이션 성능 개선
- 조회한 데이터를 메모리에 캐시해서 데이터베이스 접근 횟수 줄이기
- 네트워크를 통해 데이터베이스에 접근하는 비용 ⋙⋙⋙ 내부 메모리에 접근하는 비용 (수만~수십만 배 이상 차이)
사용
예시) 캐시 모드 설정
더보기
1. @Cacheable 설정
@Entity
@Cacheable
public class User { ... }
- 2차 캐시를 적용할 엔티티 붙이면, DB 조회 전에 2차 캐시를 참조함
예시) 캐시 적용 끄기
더보기
@Test
@Transactional
void cache_disable() {
// 앤티티 매니저 범위
entityManager.setProperty("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
// find() 호출시만
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
properties.put("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS);
entityManager.find(User.class, 1, properties);
// JPQL 사용
User user = entityManager.createQuery("select u from User u where u.id = 1", User.class)
.setHint("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS)
.setHint("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS)
.getSingleResult();
}
예시) 캐시 관리
더보기
@Test
@Transactional
void cache_manage() {
EntityManagerFactory entityManagerFactory = entityManager.getEntityManagerFactory();
Cache cache = entityManagerFactory.getCache();
// 캐시 존재 확인
boolean existsInCache = cache.contains(User.class, 1);
System.out.println("캐시에 존재? = " + existsInCache);
// 캐시 제거
cache.evict(User.class, 1); // 특정 객체
cache.evict(User.class); // 타입 전체
cache.evictAll(); // 전체 캐시
}
주의사항
- 쿼리 캐시/컬렉션 캐시: 식별자 값만 캐시하고, 실제 엔티티는 엔티티 캐시에서 조회.
- 엔티티 캐시 미적용 시 성능 문제: 쿼리 캐시나 컬렉션 캐시만 사용하고 엔티티 캐시를 사용하지 않으면 성능 문제가 발생할 수 있음.
3. 하이버네이트 EHCACHE
- 하이버네이트에서 캐시를 구현하는 캐시 제공 라이브러리 중 하나
- 하이버네이트의 1차 캐시, 2차 캐시 등을 실제로 구현하고 관리
종류
캐시 종류 | 설명 | 캐시 영역 |
엔티티 캐시 |
엔티티 단위로 캐시
- 식별자로 엔티티 조회 시 사용 - 연관된 엔티티 로딩 시에도 사용 |
[패키지명 + 클래스명] |
컬렉션 캐시 |
엔티티와 연관된 컬렉션을 캐시
- 컬렉션이 엔티티를 담고 있을 경우 사용 - 식별자 값만 캐시 |
[엔티티명 + 컬렉션 필드명] |
쿼리 캐시 |
쿼리 실행 결과를 캐시하는 방식
- 쿼리와 파라미터를 키로 결과를 캐시 - 결과가 엔티티라면 식별자 값만 캐시 |
StandardQueryCache
- 쿼리 캐시를 저장하는 영역 - 쿼리, 결과 집합, 실행 타임스탬프 등 보관 UpdateTimestampsCache - 쿼리 대상 테이블의 최근 변경 타임스탬프를 저장 - 쿼리 캐시 유효성 검사 |
예제) 엔티티 캐시
더보기
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // 캐시 동시성 전략
public class User { ... }
예제) 컬렉션 캐시
더보기
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT에 해당
private Integer id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<User> members = new ArrayList<>();
}
예제) 쿼리 캐시
더보기
- 캐시 조회: 쿼리 실행 시 StandardQueryCache에서 캐시된 타임스탬프 조회.
- 타임스탬프 비교: UpdateTimestampsCache에서 테이블의 최신 변경 타임스탬프 조회.
- 캐시 유효성 검사: 캐시 타임스탬프가 더 오래되면, DB에서 데이터를 다시 읽어와 캐시 갱신.
@Test
@Transactional
void cache_query() {
List<User> users = entityManager.createQuery("SELECT u FROM User u", User.class)
.setHint("org.hibernate.cacheable", true)
.getResultList();
}
캐시 동시성 전략(CacheConcurrencyStrategy)
전략 | 설명 |
NONE |
캐시 설정을 사용하지 않음.
|
READ_ONLY |
읽기 전용 캐시
- 수정 ❌ - 캐시 조회 시 원본 객체 반환 (불변 객체에 적합) |
NONSTRICT_READ_WRITE |
읽고 쓰기 전략 (엄격 ❌)
- 읽은 캐시 값을 다른 트랜잭션에서 데이터 수정 허용 - 일관성 문제 방지 ❌ |
READ_WRITE | 읽고 쓰기 전략 (엄격 ✅) - 읽은 캐시 값을 다른 트랜잭션에서 데이터 수정 허용 - 일관성 문제 방지 ✅ - 수정된 데이터가 캐시에서도 갱신됨 |
TRANSACTIONAL |
트랜잭션 기반 캐시
- REPEATABLE READ 수준의 격리 제공. |
'Spring > Spring Data JPA' 카테고리의 다른 글
[자바 ORM 표준 JPA 프로그래밍] 15. 고급 주제와 성능 최적화 (1) | 2025.04.26 |
---|---|
[자바 ORM 표준 JPA 프로그래밍] 13. 웹 애플리케이션과 영속성 관리 (1) | 2025.04.26 |
[자바 ORM 표준 JPA 프로그래밍] 12. 스프링 데이터 JPA (0) | 2025.04.25 |
[자바 ORM 표준 JPA 프로그래밍] 10-5. 객체지향 쿼리 언어: 객체지향 쿼리 심화 (0) | 2025.04.24 |
[자바 ORM 표준 JPA 프로그래밍] 10-4. 객체지향 쿼리 언어: Native SQL (0) | 2025.04.24 |