Stan 기술블로그

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 버그”로 보였다. 끝까지 따라가보니 세 가지가 남았다.

  1. 같은 트랜잭션 안에서 같은 ID를 조회하면 JPA는 DB가 아니라 영속성 컨텍스트를 본다. “저장 후 재조회는 최신 상태를 보장한다”는 직관은 JPA에서는 틀린다.
  2. SQL 로그를 켜야 진짜로 무슨 일이 벌어지는지 보인다. 그리고 “안 나간 쿼리”가 더 중요한 단서일 때가 있다.
  3. 가장 좋은 코드는 문제 자체가 생기지 않는 코드다. 재조회를 영리하게 우회하는 방법보다, 재조회를 하지 않는 흐름이 더 명확하고 안전했다.

2026-09 추기. “부재가 단서”라는 감각은 이후 인프라 일에서 계속 반복됐다 — 로그에 안 찍힌 Slack 요청(Sentry Slack 통합 편), 타임아웃이 아니라 403이라는 차이(Sentry ingest 노출 편). 이 글이 그 첫 번째였다.


만약 지금 다시 본다면

지금 이 코드를 다시 본다면 한 단계 더 갈 것 같다.

  • 옵션 ID 생성을 DB에 위임하지 않고 도메인에서 부여하는 구조

다만 그건 “옵션이 Item에 종속적인 값인가, 아니면 독립 도메인인가”를 먼저 정리한 다음이다. JPA의 편의 기능보다 도메인 경계가 먼저라는 점은, 이 버그 이후로 계속 의식하고 있는 부분이다.


참고

백엔드·ORM 카테고리의 글