N+1인 줄 알았는데 아니었다 — 영속성 컨텍스트

Do Young·2026년 7월 22일

refactor

목록 보기
3/4
post-thumbnail

피드 API의 N+1을 점검하다가, "코드상 명백한 N+1"이 실행해보니 N+1이 아니었던 경험. 왜 안 터졌는지(영속성 컨텍스트 1차 캐시), 그런데도 왜 고쳐야 했는지, 그리고 이걸 자동으로 검증하는 테스트까지 정리한다.

게시물 피드를 조회하면 각 게시물의 첨부 미디어(사진)마다 다운로드 URL(presigned URL)을 발급해서 내려준다. 대략 이런 흐름이다.

피드 조회
 └ 게시물 N개
     └ 게시물마다 미디어 M개
         └ 미디어마다 다운로드 URL 발급

N+1 쿼리 문제는 목록 1번 조회 + 각 항목마다 추가 쿼리 N번 = 총 N+1번이 나가는 현상이다. 위 구조는 딱 N+1이 나기 좋은 모양이다.

0.swagger 준비물

먼저 유저를 만듭시다.

테스트 계정으로 Authorization 해주고

테스트 게시물들을 만들어줍니다.

1. 예상 — 라이브 N+1

URL 발급 로직을 따라가 보면 이렇게 되어 있었다.

// PostService
private List<PostMediaResponse> toMediaResponses(List<PostMedia> media) {
    return media.stream()
            .map(m -> PostMediaResponse.from(m,
                    Objects.requireNonNull(resolveDownloadUrl(m.getId())))) // ← 미디어 1건당 호출
            .toList();
}

private String resolveDownloadUrl(UUID postMediaId) {
    return presignedDownloadService.createDownloadUrl(postMediaId).downloadUrl();
}
// PresignedDownloadService
public PresignedDownloadResponse createDownloadUrl(UUID postMediaId) {
    PostMedia postMedia = postMediaRepository.findById(postMediaId) // ← SELECT * FROM post_media WHERE id=?
            .orElseThrow(() -> new PostMediaNotFoundException(postMediaId));
    // ...presigned URL 생성...
}

이미 손에 든 미디어 객체를, id만 뽑아서 findById로 다시 조회하고 있다. 게시물 20개 × 미디어 3장이면 findById가 60번. 누가 봐도 N+1이다.

그래서 나는 "게시물 20개면 60번 쿼리가 나가는 라이브 N+1"이라고 단정했다.

2. 실측 — 로그로 쿼리 수를 센다

N+1은 코드 모양이 아니라 실제로 나간 SQL 개수로 판단해야 한다. 그래서 직접 세봤다.

로그 설정 (application.properties, 로컬 점검용):

spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=debug

측정 절차 — "딱 1번 호출"을 격리해서 그 이후 로그만 센다.

# (1) 기준 시각 찍기
SINCE=$(date -u +%Y-%m-%dT%H:%M:%S)

# (2) 여기서 API를 딱 한 번 호출 (Swagger Execute 등)

# (3) 그 이후 나간 쿼리 개수 (show-sql은 쿼리당 'Hibernate:' 1줄)
docker compose logs backend --since "${SINCE}Z" | grep -c "Hibernate:"

# (4) post_media를 'id로 단건' 조회한 게 반복되면 그게 N+1
docker compose logs backend --since "${SINCE}Z" | grep -iE "post_media pm1_0" | grep "pm1_0.id=?"

⚠️ 삽질 포인트 2개...

  • 로그 이중 출력: show-sql(Hibernate:)과 logging.level.org.hibernate.SQL(org.hibernate.SQL DEBUG)이 같은 쿼리를 각각 찍는다. 셀 때는 Hibernate: 한 줄만 세야 쿼리당 1개다.
  • 여러 번 호출 금지: 호출마다 로그가 섞인다. SINCE 찍고 딱 1번만 호출한다

3. 반전 — findById가 DB로 0번 나갔다

(4)번 명령의 결과가 0줄이었다. post_media를 id로 단건 조회한 기록이 아예 없다. 실제로 나간 쿼리는 3개뿐이었다.

-- 1) 피드 조회
SELECT * FROM posts p WHERE ...

-- 2) 미디어 배치 조회 (IN 절로 한 방에)
... FROM post_media pm1_0 LEFT JOIN posts p1_0 ... WHERE p1_0.id IN (?, ?, ?, ?, ?)

-- 3) 인증용 유저 조회
... FROM users u1_0 WHERE u1_0.id = ?

코드상 60번 나가야 할 findById가 0번. 왜 그럴까요?

이유: 영속성 컨텍스트(1차 캐시)

같은 트랜잭션 안에서는, 한 번 조회한 엔티티를 Hibernate가 메모리(1차 캐시)에 들고 있다. 그 안에서 findById(같은 id)를 또 부르면 DB로 가지 않고 캐시에서 바로 꺼내준다.

피드 조회 메서드는 클래스에 @Transactional(readOnly = true)가 걸려 전체가 하나의 트랜잭션이다. 그 안에서:

  1. 미디어 10개를 배치 쿼리 1번으로 다 읽음 → 10개 전부 1차 캐시에 올라감
  2. 이어서 URL 발급이 findById(그 10개 중 하나)를 호출 → 이미 캐시에 있으니 DB 안 감 → SQL 0번

그래서 "코드만 보면 N+1인데, 실행하면 N+1이 아닌" 상황이 됐다.

4. 그런데 왜 고쳐야 하나 — "우연히 안전한" 코드

findById가 0번인 건 배치 조회와 findById가 같은 트랜잭션이라 우연히 그런 것이다. 지금은 1차 캐시에 의존하고 있는 불안정한 상태다.

  • 취약함: 나중에 트랜잭션을 쪼개거나, 미디어 응답 변환을 트랜잭션 밖에서 호출하면 → 그 순간 진짜 N+1로 돌변하거나 LazyInitializationException이 터진다.
  • 의미상 이상함: 이미 손에 든 객체를 id만 뽑아 다시 찾는 코드다.
  • 숨은 버그: Objects.requireNonNull(resolveDownloadUrl(...)) — resolveDownloadUrl은 실패 시 일부러 null을 반환(미디어 한 건 실패로 게시물 전체가 안 깨지게)하는데, requireNonNull으로 감싸면 그 방어가 무력화돼 미디어 한 건 실패 → 게시물 조회 전체가 500이 된다.

그래서 "라이브 N+1"은 아니지만, 잠재 위험 제거 + 버그 수정 차원에서 리팩터링했다.

5. 개선 — 이미 든 객체를 그대로 넘긴다

핵심은 PresignedDownloadService에 PostMedia를 통째로 받는 오버로드를 만들어 findById를 없애는 것.

Before

// PresignedDownloadService
public PresignedDownloadResponse createDownloadUrl(UUID postMediaId) {
    PostMedia postMedia = postMediaRepository.findById(postMediaId) // ← 손에 든 걸 다시 조회
            .orElseThrow(() -> new PostMediaNotFoundException(postMediaId));

    String objectKey = postMedia.getFileKey();
    try {
        String downloadUrl = presignedUrlMinioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .method(Method.GET)
                        .bucket(properties.bucketName())
                        .object(objectKey)
                        .expiry(properties.presignedDownloadExpirySeconds())
                        .build()
        );
        return new PresignedDownloadResponse(
                downloadUrl, objectKey,
                Instant.now(clock).plusSeconds(properties.presignedDownloadExpirySeconds())
        );
    } catch (Exception e) {
        log.error("Failed to create presigned download URL. bucket={}, objectKey={}",
                properties.bucketName(), objectKey, e);
        throw new MediaStorageException("Presigned download URL creation failed.", e);
    }
}

After

// PresignedDownloadService

// 단건 API용 — id만 아는 경우. 하위호환 유지 (findById 한 번만).
public PresignedDownloadResponse createDownloadUrl(UUID postMediaId) {
    PostMedia postMedia = postMediaRepository.findById(postMediaId)
            .orElseThrow(() -> new PostMediaNotFoundException(postMediaId));
    return createDownloadUrl(postMedia);
}

// 목록용 — 이미 엔티티를 손에 든 경우. DB 재조회 없음.
public PresignedDownloadResponse createDownloadUrl(PostMedia postMedia) {
    String objectKey = postMedia.getFileKey();
    try {
        String downloadUrl = presignedUrlMinioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .method(Method.GET)
                        .bucket(properties.bucketName())
                        .object(objectKey)
                        .expiry(properties.presignedDownloadExpirySeconds())
                        .build()
        );
        return new PresignedDownloadResponse(
                downloadUrl, objectKey,
                Instant.now(clock).plusSeconds(properties.presignedDownloadExpirySeconds())
        );
    } catch (Exception e) {
        log.error("Failed to create presigned download URL. bucket={}, objectKey={}",
                properties.bucketName(), objectKey, e);
        throw new MediaStorageException("Presigned download URL creation failed.", e);
    }
}
// PostService
// URL 발급에 실패한 미디어는 url이 null로 내려간다.
// requireNonNull로 감싸면 미디어 한 건의 실패가 게시물 조회 전체를 500으로 만든다.
private List<PostMediaResponse> toMediaResponses(List<PostMedia> media) {
    return media.stream()
            .map(m -> PostMediaResponse.from(m, resolveDownloadUrl(m))) // id 대신 엔티티 전달
            .toList();
}

private String resolveDownloadUrl(PostMedia media) {
    try {
        return presignedDownloadService.createDownloadUrl(media).downloadUrl(); // 오버로드 호출
    } catch (Exception e) {
        // 미디어 하나의 URL 발급 실패로 게시물 조회 전체가 실패하지 않도록 null 처리.
        return null;
    }
}

바뀐 점
1. createDownloadUrl(UUID)를 둘로 분리 → 단건 API는 그대로, 목록은 엔티티를 그대로 넘겨 findById 제거
2. Objects.requireNonNull(...) 삭제 → 원래 의도한 "실패 시 null로 graceful degradation" 복구

6. 자동 검증 — 쿼리 개수를 테스트로 못 박는다

로그를 매번 손으로 세는 건 재현이 안 된다. Hibernate Statistics로 실제 나간 SQL 개수를 세는 테스트를 두면, 이후 누가 코드를 바꿔 N+1을 유발해도 CI에서 잡힌다.

@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class PostFeedQueryCountTest extends PostgresTestSupport {

    @DynamicPropertySource
    static void enableStatistics(DynamicPropertyRegistry registry) {
        registry.add("spring.jpa.properties.hibernate.generate_statistics", () -> "true");
    }

    @Autowired private PostService postService;
    @Autowired private TransactionTemplate tx;
    @PersistenceContext private EntityManager em;

    private Statistics statistics() {
        return em.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics();
    }

    // 데이터를 심는 트랜잭션 (측정 트랜잭션과 반드시 분리)
    private UUID seed(String tag, int posts, int mediaPerPost) {
        return tx.execute(status -> {
            User author = new User(tag + "@memorin.test", "hash", tag, tag, null);
            em.persist(author);
            for (int p = 0; p < posts; p++) {
                Post post = Post.create(author, "[]", VisibilityType.PUBLIC,
                        TimeslotType.AM, Date.valueOf(LocalDate.of(2026, 7, 1).plusDays(p)));
                em.persist(post);
                for (int m = 0; m < mediaPerPost; m++) {
                    em.persist(PostMedia.of(post, "uploads/%s-%d-%d.png".formatted(tag, p, m),
                            "image/png", 1000L, (short) m, 100, 100));
                }
            }
            em.flush();
            return author.getId();
        });
    }

    private long countQueriesForFeed(UUID authorId, int expectedPosts) {
        Statistics stats = statistics();
        stats.clear();
        PostListResponse response = postService.list(authorId, authorId, null, 20);
        assertThat(response.items()).hasSize(expectedPosts);
        return stats.getPrepareStatementCount(); // 실제 나간 PreparedStatement 수
    }

    @Test
    void 미디어_장수를_늘리면_쿼리도_늘어나는지_본다() {
        UUID few  = seed("few",  5, 1);  // 미디어 5장
        UUID many = seed("many", 5, 3);  // 미디어 15장

        long fewQueries  = countQueriesForFeed(few, 5);
        long manyQueries = countQueriesForFeed(many, 5);

        // 미디어가 3배 늘었는데 쿼리도 따라 늘면 N+1이다.
        assertThat(manyQueries)
                .as("미디어 장수에 비례해 쿼리가 늘어나면 N+1")
                .isEqualTo(fewQueries);
    }
}

이 테스트의 핵심 설계 2가지:

  • seed 트랜잭션 ↔ 측정 트랜잭션 분리(tx.execute): 같은 트랜잭션에서 재면 1차 캐시가 조회를 가로채 실제 운영에서 나갈 쿼리가 안 보인다. (3번의 그 반전을 테스트에서 재현되게)
  • getPrepareStatementCount(): 1차 캐시로 안 나간 쿼리는 자동 제외되므로, "실제 DB로 간 SQL"만 정확히 센다.

결과

게시물 5개 / 미디어 5장  → SQL 2개
게시물 5개 / 미디어 15장 → SQL 2개

미디어를 3배로 늘려도 2개 그대로(피드 1 + 미디어 배치 1). N+1 없음이 기계로 증명됐고, 리팩터링 후에도 이 숫자는 그대로 = "고쳐도 안 깨졌다".

HTTP로 쟀을 땐 3개였는데 테스트는 2개인 이유: 테스트는 서비스를 직접 호출해 JWT 인증용 users 조회(1건)가 빠지기 때문. 이 쿼리는 게시물 수와 무관해 N+1과 관계없다.

7. 느낀점

N+1은 코드를 노려봐서 단정하면 안 되고, 반드시 쿼리 로그로 실측해야 한다.

  • 코드만 보면 "명백한 N+1"이 1차 캐시 덕에 실제로는 0번 나가기도 한다.
  • 반대로, 지금 안전한 게 트랜잭션 경계 하나 바뀌면 진짜 N+1로 돌변할 수도 있다.
  • 그러니 "쟀냐?"가 정답이다. 그리고 한 번 잰 걸 테스트로 못 박아 회귀를 막는다.

8. (부록) 목록 API 만들 때 N+1 예방 체크리스트

앞으로 댓글·팔로우 목록처럼 "연관 엔티티의 필드를 응답에 넣는" API를 만들 때:

  • 연관은 전부 FetchType.LAZY (기본 방어)
  • 응답에서 연관 엔티티의 PK만 쓰면 안전(getUser().getId()는 프록시라 쿼리 X). 하지만 다른 필드(getNickname() 등)를 건드리는 순간 N+1이 난다.
  • 1:N(게시물→미디어)은 IN 배치 조회 + groupingBy로 묶는다.
  • N:1(댓글→작성자)은 JOIN FETCH로 한 번에 가져온다.
    @Query("SELECT c FROM PostComments c JOIN FETCH c.user WHERE c.post.id = :postId")
    List<PostComments> findByPostIdWithAuthor(@Param("postId") UUID postId);
  • 보험으로 spring.jpa.properties.hibernate.default_batch_fetch_size=100
  • 그리고 PostFeedQueryCountTest 패턴을 복사해서 "데이터 늘려도 쿼리 flat"을 테스트로 고정한다.
profile
풀스택 개발자

0개의 댓글