김영한 님의 "자바 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 |