
피드 API의 N+1을 점검하다가, "코드상 명백한 N+1"이 실행해보니 N+1이 아니었던 경험. 왜 안 터졌는지(영속성 컨텍스트 1차 캐시), 그런데도 왜 고쳐야 했는지, 그리고 이걸 자동으로 검증하는 테스트까지 정리한다.
게시물 피드를 조회하면 각 게시물의 첨부 미디어(사진)마다 다운로드 URL(presigned URL)을 발급해서 내려준다. 대략 이런 흐름이다.
피드 조회
└ 게시물 N개
└ 게시물마다 미디어 M개
└ 미디어마다 다운로드 URL 발급
N+1 쿼리 문제는 목록 1번 조회 + 각 항목마다 추가 쿼리 N번 = 총 N+1번이 나가는 현상이다. 위 구조는 딱 N+1이 나기 좋은 모양이다.
먼저 유저를 만듭시다.

테스트 계정으로 Authorization 해주고

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

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"이라고 단정했다.
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번만 호출한다
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번. 왜 그럴까요?
같은 트랜잭션 안에서는, 한 번 조회한 엔티티를 Hibernate가 메모리(1차 캐시)에 들고 있다. 그 안에서
findById(같은 id)를 또 부르면 DB로 가지 않고 캐시에서 바로 꺼내준다.
피드 조회 메서드는 클래스에 @Transactional(readOnly = true)가 걸려 전체가 하나의 트랜잭션이다. 그 안에서:
findById(그 10개 중 하나)를 호출 → 이미 캐시에 있으니 DB 안 감 → SQL 0번그래서 "코드만 보면 N+1인데, 실행하면 N+1이 아닌" 상황이 됐다.
findById가 0번인 건 배치 조회와 findById가 같은 트랜잭션이라 우연히 그런 것이다. 지금은 1차 캐시에 의존하고 있는 불안정한 상태다.
LazyInitializationException이 터진다.Objects.requireNonNull(resolveDownloadUrl(...)) — resolveDownloadUrl은 실패 시 일부러 null을 반환(미디어 한 건 실패로 게시물 전체가 안 깨지게)하는데, requireNonNull으로 감싸면 그 방어가 무력화돼 미디어 한 건 실패 → 게시물 조회 전체가 500이 된다.그래서 "라이브 N+1"은 아니지만, 잠재 위험 제거 + 버그 수정 차원에서 리팩터링했다.
핵심은 PresignedDownloadService에 PostMedia를 통째로 받는 오버로드를 만들어 findById를 없애는 것.
// 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);
}
}
// 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" 복구
로그를 매번 손으로 세는 건 재현이 안 된다. 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가지:
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과 관계없다.
N+1은 코드를 노려봐서 단정하면 안 되고, 반드시 쿼리 로그로 실측해야 한다.
앞으로 댓글·팔로우 목록처럼 "연관 엔티티의 필드를 응답에 넣는" API를 만들 때:
FetchType.LAZY (기본 방어)getUser().getId()는 프록시라 쿼리 X). 하지만 다른 필드(getNickname() 등)를 건드리는 순간 N+1이 난다.IN 배치 조회 + groupingBy로 묶는다.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=100PostFeedQueryCountTest 패턴을 복사해서 "데이터 늘려도 쿼리 flat"을 테스트로 고정한다.