JPA 1차 캐시, 같은 트랜잭션 안에서 끝까지 따라가봤다
“안 나간 SELECT”가 단서였던 디버깅 기록
개인 프로젝트(Shopping Mall API)에서 옵션을 저장했는데 응답엔 빈 배열이 내려오는 버그를 만났다. 원인은 JPA 영속성 컨텍스트(1차 캐시)였고, 책으로만 알던 동작을 SQL 로그로 처음 손에 잡은 기록이다.
어떤 버그였나
ADMIN 계정으로 옵션이 있는 상품을 하나 등록했다. 요청은 200 OK로 잘 떨어졌다. 그런데 응답이 이상했다.
{
"id": 14,
"name": "오버사이즈 후드",
"price": 49000,
"options": []
}
분명히 옵션 두 개(S, M)를 같이 등록했는데 options가 빈 배열로 내려왔다. DB를 까보니 옵션은 멀쩡히 저장되어 있었다.
저장은 됐는데, 응답이 비어 있다.
이 한 문장이 글 전체의 출발점이다.
처음 의심한 건 매핑이었다
가장 먼저 의심한 건 JPA 매핑. Item에 @OneToMany(mappedBy = "item")가 있고, ItemOption에 @ManyToOne이 있는 양방향 구조였다.
@Entity
public class Item {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "item", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ItemOption> options = new ArrayList<>();
}
매핑은 멀쩡했다. 그런데 왜 응답에는 옵션이 없을까. 서비스 코드를 다시 봤다.
@Transactional
public ItemResponse createItem(ItemCreateRequest request) {
Item item = itemRepository.save(request.toEntity());
for (var optionRequest : request.getOptions()) {
ItemOption option = ItemOption.of(item, optionRequest);
itemOptionRepository.save(option);
}
Item saved = itemRepository.findById(item.getId())
.orElseThrow();
return ItemResponse.from(saved); // 여기서 options가 비어있음
}
“분명히 옵션을 save 했고, 다시 findById로 꺼냈는데?”
이 시점에 처음으로 1차 캐시가 떠올랐다.
가설 — 캐시가 빈 컬렉션을 돌려주고 있다
트랜잭션 안에서 영속성 컨텍스트는 (엔티티 타입, ID) → 객체 맵처럼 동작한다. 한 번 save()나 find()로 올라간 객체는, 같은 트랜잭션에서 같은 ID로 다시 조회하면 DB를 보지 않고 그 객체를 그대로 돌려준다.
이 한 줄을 놓고 코드를 다시 보니 의심이 한 곳에 모였다.
itemRepository.save(item)을 한 시점에 캐시에 들어간item은options가 빈 컬렉션이다.- 그 뒤에 옵션을 따로
save했지만, 그 옵션들이 캐시 안item.options리스트에 자동으로 들어가지는 않는다. - 그러면
findById(item.getId())는 캐시에서 빈 옵션을 가진 그 item을 그대로 돌려준다.
가설은 섰다. 이제 확인할 차례.
진짜로 그런지 SQL 로그로 확인
application.yml에서 SQL 로그를 켰다.
spring:
jpa:
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
다시 요청을 보냈다. 로그를 보고 깜짝 놀랐다.
insert into item ...
insert into item_option ...
insert into item_option ...
-- findById 호출했는데 SELECT가 안 나감
findById 호출 시점에 SELECT 쿼리가 아예 발생하지 않았다. 캐시에 이미 같은 ID의 Item이 있으니 JPA는 DB를 조회하지 않고 캐시 객체를 그대로 돌려준 것이다.
가설이 맞았다.
이전까지 나는 SQL 로그를 “쿼리가 잘 나가는지 확인하는 용도”로만 썼다. 이 사건 이후로 “안 나간 쿼리”가 더 중요한 단서일 수 있다는 걸 알았다.
해결책 세 가지를 비교했다
entityManager.flush() + clear()로 강제 재조회
em.flush();
em.clear();
Item saved = itemRepository.findById(item.getId()).orElseThrow();
1차 캐시를 비우니 findById가 진짜 SELECT를 날린다.
- 단점: 컨텍스트를 통째로 비워서 다른 영속 객체까지 영향을 받는다. 일반 비즈니스 로직에서
clear()를 쓰는 건 코드 냄새가 강하다. 테스트 환경에서나 가끔 쓰지, 운영 서비스 코드에 박아두기엔 위험하다.
item.getOptions().add(option) — 양방향 관계 동기화
for (var optionRequest : request.getOptions()) {
ItemOption option = ItemOption.of(item, optionRequest);
item.getOptions().add(option); // 양쪽 다 채워준다
itemOptionRepository.save(option);
}
캐시에 있는 그 item 객체의 컬렉션에 직접 옵션을 넣어주는 방법. JPA 양방향 관계에서 흔히 쓰는 패턴이고, 사실 정공법에 가깝다.
- 단점:
save()와add()를 둘 다 호출하는 게 일관성이 떨어진다고 느꼈다.cascade를 쓰면 깔끔해지긴 하지만, 그건 또 다른 설계 결정이 필요했다.
DTO를 직접 만든다 — 재조회 자체를 없앤다 (선택)
@Transactional
public ItemResponse createItem(ItemCreateRequest request) {
Item item = itemRepository.save(request.toEntity());
List<ItemOption> savedOptions = request.getOptions().stream()
.map(req -> ItemOption.of(item, req))
.map(itemOptionRepository::save)
.toList();
return ItemResponse.of(item, savedOptions);
}
어차피 응답에 필요한 데이터는 방금 내가 만든 것이다. 그걸 그대로 DTO에 담으면 재조회 자체가 없어진다. 1차 캐시 문제는 발생할 일이 없다.
세 번째를 골랐다.
“재조회를 해서 최신 상태를 얻는다”는 발상 자체가 어색했다. 내가 방금
save한 객체와 옵션 리스트가 손에 있는데 굳이 다시 DB(혹은 캐시)에서 꺼낼 이유가 없었다.
진짜로 배운 것
처음에는 단순한 “응답 DTO 버그”로 보였다. 끝까지 따라가보니 세 가지가 남았다.
- 같은 트랜잭션 안에서 같은 ID를 조회하면 JPA는 DB가 아니라 영속성 컨텍스트를 본다. “저장 후 재조회는 최신 상태를 보장한다”는 직관은 JPA에서는 틀린다.
- SQL 로그를 켜야 진짜로 무슨 일이 벌어지는지 보인다. 그리고 “안 나간 쿼리”가 더 중요한 단서일 때가 있다.
- 가장 좋은 코드는 문제 자체가 생기지 않는 코드다. 재조회를 영리하게 우회하는 방법보다, 재조회를 하지 않는 흐름이 더 명확하고 안전했다.
2026-09 추기. “부재가 단서”라는 감각은 이후 인프라 일에서 계속 반복됐다 — 로그에 안 찍힌 Slack 요청(Sentry Slack 통합 편), 타임아웃이 아니라 403이라는 차이(Sentry ingest 노출 편). 이 글이 그 첫 번째였다.
만약 지금 다시 본다면
지금 이 코드를 다시 본다면 한 단계 더 갈 것 같다.
- 옵션 ID 생성을 DB에 위임하지 않고 도메인에서 부여하는 구조
다만 그건 “옵션이 Item에 종속적인 값인가, 아니면 독립 도메인인가”를 먼저 정리한 다음이다. JPA의 편의 기능보다 도메인 경계가 먼저라는 점은, 이 버그 이후로 계속 의식하고 있는 부분이다.
참고
- 이 글의 코드와 추적 과정은 레포
docs/에 — docs/jpa-first-level-cache.md - 프로젝트: Shopping Mall API (Spring Boot 3.5 · JPA · MySQL 8) — github.com/std-yong/yong-mall
- 관련 코드:
ItemService#createItem,ItemRepository#findById