본문 바로가기
엔지니어링

직접 쓴 논문 리뷰하기: JPA 성능에 대해 우리가 착각하던 세 가지

by Renechoi 2026. 7. 29.

1. Intro: 회의실의 질문이 논문이 되기까지

백엔드 실무를 하다 보면 종종 설계의 갈림길에 섭니다. 얼마 전 회사에서 새로운 기능을 설계할 때의 일입니다. 도메인 주도 설계의 원칙에 따라, 엔티티 간의 결합도를 낮추기 위해 연관 매핑 대신 'ID 참조' 방식으로 끊어내자는 의견을 냈습니다.

 

그때 돌아온 피드백은, 사실 우리 모두가 한 번쯤 해봤거나 들어봤을 법한 익숙한 우려였습니다.

 

"그렇게 매번 ID로 다시 조회하면 결국 N+1 문제가 생겨서
시스템이 많이 느려지지 않나요?

차라리 연관 매핑을 맺고 JOIN FETCH로
한 번에 가져오는 게 안전하지 않을까요?"

 

속으로는 '구현하기에 따라 다르고, 설계 방식 자체가 성능을 무조건 떨어뜨리는 건 아닐 텐데'라고 생각했습니다. 하지만 제게는 그 생각을 뒷받침할 명확한 '숫자'가 없었습니다. 데이터가 부족하다 보니, 결국 각자의 경험이나 막연한 추측을 바탕으로 논의가 이어질 수밖에 없었죠.

 

그때 이런 의문을 정량적으로 확인해보고 싶다는 생각이 들었습니다. 기술 블로그들을 찾아보면 "N+1은 안티 패턴이니 피하라"는 조언은 많지만, 정작 "데이터가 몇 건일 때, 네트워크 지연이 어느 정도일 때 시스템이 실제로 무너지는가?"에 대해 수치로 대답해주는 경우는 잘 찾기 힘들어 보였거든요. 막연한 추측 대신, 실제 데이터를 바탕으로 논의할 수 있는 기준점을 만들어보고 싶어 직접 실험을 준비하게 되었습니다.

 

실험을 설계하면서, 혹시라도 제게 유리한 쪽으로 결과를 해석하는 확증 편향을 방지하기 위해 조금 더 엄밀한 방식을 차용했습니다. 측정 전에 가설과 판정 공식을 깃허브 커밋 해시로 동결하는 '사전 등록(Pre-registration)' 방식을 도입했고, 가용 영역 간 네트워크 지연과 부하를 철저히 통제한 환경에서 1,240만 건의 데이터를 테스트했습니다.

 

결과를 정리하다 보니 내용이 꽤 방대해져서, 이 실험 결과는 현재 소프트웨어 공학 저널에서 동료 심사를 받고 있습니다. 하지만 심사 결과가 나오기 전이라도, 저처럼 회의실에서 비슷한 답답함을 느꼈을 동료 개발자분들을 위해 제가 쓴 논문을 직접 리뷰하는 형식으로 핵심만 먼저 공유해보려 합니다.

 

결론부터 미리 말씀드리자면, 성능을 결정하는 건 우리가 그토록 고민했던 '매핑 철학'이 아니었습니다. 오히려 저 스스로 당연하다고 믿었던 가설들이 보기 좋게 빗나간 예상외의 기록들도 포함되어 있습니다.

 

읽기 전에 알아두시면 좋은 것들

  • 이 글은 제 논문을 제가 다시 읽고 요약한 글입니다. 논문 자체가 아니라 논문 리뷰입니다.
  • 논문은 아직 동료 심사를 통과하지 않았습니다. 심사 과정에서 결론이나 수치가 바뀔 수 있습니다.
  • 본문의 모든 수치는 제가 직접 측정한 값입니다. 측정 코드, 데이터 생성 스크립트, 원시 측정 결과를 전부 공개해두었으니 직접 돌려보고 틀린 곳을 찾아내셔도 좋습니다.
  • 사전 등록한 가설은 일곱 개입니다. 맞은 것과 틀린 것을 모두 적습니다. 특히 제 예상이 빗나간 두 건이 이 글에서 제일 재미있는 부분입니다.
  • 일부 결과는 사전 등록 범위 밖에서 나중에 추가로 측정한 것입니다. 해당 절에 그렇다고 표시해두었습니다. 사전 등록 안에서 나온 결과와는 신뢰도가 다르게 취급하셔야 합니다.
  • 프리프린트 전문(국문·영문): Zenodo, DOI 10.5281/zenodo.21594741 (CC BY 4.0)
  • 측정 하네스와 원시 데이터: github.com/renechoi/rdb-join-lab


2. 실험실 들여다보기: 무엇을, 어떻게 쟀는가?

숫자로 이야기하겠다고 마음은 먹었지만, 막상 실험을 설계하려니 고민이 많았습니다. "N+1이 느리다" 혹은 "JOIN이 낫다" 같은 주장은 누구나 할 수 있지만, 그 주장이 진실이 되려면 실험 환경 자체가 누구나 수긍할 수 있을 만큼 공정하고 엄밀해야 했습니다.

 

특히 성능 테스트는 측정하는 사람의 의도나 환경 변수에 따라 결과가 쉽게 왜곡될 수 있습니다. 제 편견이나 확증 편향이 결과에 개입되는 것을 원천 차단하기 위해, 측정 코드를 돌리기 전에 가설과 판정 공식을 깃허브 커밋 해시로 완전히 동결하는 사전 등록 방식을 도입했습니다.

* 사전 등록(Pre-registration)이 뭔가요?

데이터를 모으기 전에 "나는 이런 결과를 예상한다"와 "무엇을 근거로 맞았다 틀렸다를 판정하겠다"를 공개된 곳에 먼저 고정해두는 방식입니다. 임상시험에서 시작해서 심리학의 재현성 위기 이후 여러 분야로 퍼졌습니다.


왜 필요하냐면, 데이터를 다 본 다음에는 사람이 무의식적으로 자기에게 유리한 해석을 고르기 때문입니다. 열 개를 재고 잘 나온 세 개만 골라 쓰거나, 예상과 다르게 나오면 "사실 내 예상은 이런 뜻이었다"고 판정 기준을 슬쩍 바꾸는 식입니다. 악의가 없어도 일어납니다.

그래서 저는 가설 일곱 개와 각각의 판정 공식을 문서로 적고, 측정을 한 줄도 돌리기 전에 깃 커밋으로 동결했습니다. 커밋 해시가 곧 "이 시점에 이렇게 예상했다"는 증거입니다. 동결 이후에는 가설을 고칠 수 없고, 프로토콜을 벗어난 일이 생기면 개정 이력에 사유와 함께 남기게 되어 있습니다.


이 방식의 대가는 분명합니다. 틀린 예상도 그대로 보고해야 합니다. 이 글 뒷부분에 제 예상이 빗나간 이야기가 두 번 나오는데, 그게 이 방식을 쓴 이유이기도 합니다.


실험은 다음과 같은 조건과 통제 하에 진행되었습니다.

  • 시스템 및 인프라 환경: MySQL 8과 Spring Boot 3(Hibernate 6, Spring Data JPA) 기반의 애플리케이션을 도커 컨테이너 환경에 올렸습니다. 데이터베이스 버퍼 풀(innodb_buffer_pool_size)은 3GB로 고정하고, 커넥션 풀은 실무에서 흔히 쓰이는 HikariCP maximumPoolSize = 10을 기준으로 삼았습니다.
  • 네트워크 지연(RTT) 통제: 로컬 개발 환경처럼 RTT가 0에 가까운 조건부터, 가용 영역 간 통신, 그리고 지리적으로 떨어진 원격 환경까지 모사하기 위해 Linux 트래픽 제어 도구(tc netem)를 사용해 MySQL 컨테이너의 egress 네트워크에 인위적인 지연(0 ~ 10ms)을 주입했습니다.
  • 데이터 규모: 100만 명의 Member, 10,000개의 Coupon Policy, 그리고 총 1,240만 건의 쿠폰 발급 이력 데이터를 실제 서비스와 유사한 Zipf 분포(상위 정책에 데이터가 몰리는 현상)로 적재했습니다.
  • 부하 생성: 오픈소스 부하 테스트 도구인 k6를 사용하여 일정한 도착률로 트래픽을 밀어 넣었습니다. 이 과정에서 응답 지연으로 인해 부하 생성기가 스스로 요청을 멈추는 왜곡(Coordinated omission)을 방지했습니다.

비교 대상은 총 아홉 가지 스타일입니다. 연관 매핑 진영에서 네 가지(LAZY 반복, 제한 없는 LAZY 반복, JOIN FETCH, 애플리케이션 배치 페치), ID 참조 진영에서 세 가지(단건 조회 byid, 명시적 in-batch, 중복을 제거하지 않은 in-batch), 그리고 ORM을 완전히 걷어낸 순수 JDBC 대조군 두 가지입니다. 마지막 둘은 "이 비용 중 어디까지가 ORM 때문인가"를 가르기 위한 기준선입니다.

 

환경이 준비되었으니, 이제 이 실험실에서 데이터를 돌렸을 때 첫 번째로 마주한 결과부터 짚어보겠습니다.

3. 핵심 리뷰 1: 매핑 철학은 죄가 없다 (쿼리 계층의 문제)

회의실에서 ID 참조 방식을 제안했을 때 가장 거세게 부딪혔던 우려는 "그렇게 하면 N+1이 터져서 결국 시스템이 느려진다"는 점이었습니다. 과연 그럴까요?

 

실험 데이터를 열어본 결과는 흥미로웠습니다. 결론부터 말씀드리면, 'ID 참조를 쓰면 느리다'는 말은 반은 맞고 반은 틀렸습니다.

먼저 두 진영의 코드를 나란히 놓고 보겠습니다

비교가 공정하려면 스키마가 같아야 합니다. 테이블이 다르면 무엇 때문에 빨라졌는지 알 수 없으니까요. 그래서 두 엔티티를 같은 테이블에 매핑했습니다.

// 연관 매핑 진영
@Entity
@Table(name = "coupon_issue")
public class CouponIssue {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "policy_id", insertable = false, updatable = false)
    private CouponPolicy policy;      // 객체를 타고 들어간다
    ...
}

// ID 참조 진영
@Entity @Immutable
@Table(name = "coupon_issue")         // 같은 테이블이다
public class CouponIssueRef {
    @Id
    private Long id;

    @Column(name = "policy_id", nullable = false)
    private Long policyId;            // 값만 들고 있다
    ...
}


app/src/main/java/lab/entity/ 아래 두 파일입니다.

 

차이는 딱 한 줄입니다. 한쪽은 CouponPolicy라는 객체를 들고 있고 다른 쪽은 Long이라는 을 들고 있습니다. 데이터베이스에 저장된 것은 완전히 같습니다. policy_id라는 컬럼 하나뿐입니다. 바라보는 방식만 둘로 갈린 것입니다.

 

CouponIssueRef에 붙은 @Immutable은 공정성을 위한 장치입니다. 읽기 전용 모델이라고 선언해서 하이버네이트가 더티 체킹을 하지 않게 만듭니다. 이걸 안 붙이면 ID 참조 쪽만 불필요한 변경 감지 비용을 지게 되어 비교가 기울어집니다.

조회 코드는 완전히 달라 보입니다

// 연관 매핑: 프록시를 건드리면 그때 쿼리가 나간다
public List<IssueListItemDto> lazy(long memberId, int limit) {
    List<CouponIssue> issues = issueRepo.findByMemberId(memberId, PageRequest.of(0, limit));
    return issues.stream().map(ci -> {
        var p = ci.getPolicy();          // 여기서 N번
        ...
    }).toList();
}

// ID 참조: 값을 꺼내서 직접 조회한다
public List<IssueListItemDto> byId(long memberId, int limit) {
    List<CouponIssueRef> refs = issueRefRepo.findByMemberId(memberId, PageRequest.of(0, limit));
    return refs.stream().map(ref -> {
        var p = policyRepo.findById(ref.getPolicyId()).orElseThrow();   // 여기서 N번
        ...
    }).toList();
}

 

app/src/main/java/lab/service/ScenarioBService.java입니다.

 

ci.getPolicy()policyRepo.findById(...). 한쪽은 객체 그래프를 타고 가고 한쪽은 리포지토리를 직접 호출합니다. 코드만 보면 전혀 다른 일을 하는 것 같습니다.

그런데 데이터베이스가 받는 것은 같습니다

MySQL의 general_log를 켜고 요청을 딱 한 번 던져봤습니다. ID 참조 쪽입니다.

set session transaction read only
SET autocommit=0
select cir1_0.id, cir1_0.issued_at, ... from coupon_issue cir1_0 where cir1_0.member_id=?
select cp1_0.id, cp1_0.name, ... from coupon_policy cp1_0 where cp1_0.id=?
select cp1_0.id, cp1_0.name, ... from coupon_policy cp1_0 where cp1_0.id=?
select cp1_0.id, cp1_0.name, ... from coupon_policy cp1_0 where cp1_0.id=?
                        ... 참조 수만큼 반복 ...
commit
SET autocommit=1
set session transaction read write

 

목록 조회 한 번, 그 다음 정책 조회가 참조 수만큼. 연관 매핑 쪽도 정확히 같은 모양입니다. 제가 요약한 게 아니라 데이터베이스가 뱉은 기록입니다.

 

여기서 알게 되는 사실이 있습니다. 하이버네이트 입장에서 두 코드는 결국 같은 일을 시킨다는 것입니다. 정책 하나를 알아내려면 정책 테이블에 물어봐야 하고, 그걸 참조마다 반복하면 참조 수만큼 왕복합니다. 객체를 타고 가든 값을 꺼내서 가든 네트워크는 그 사정을 모릅니다.

 

측정 결과도 그대로였습니다.

 

두 방식의 응답 지연 곡선이 포개진다

 

  • 지연을 명목 300마이크로초로 걸어둔 환경(실측 왕복 시간은 약 0.52밀리초)에서, 고유 참조 1개당 늘어나는 지연 시간(기울기)은 연관 매핑이 516마이크로초, ID 참조가 515마이크로초였습니다. 1마이크로초 차이입니다. 그리고 이 값은 실측 왕복 시간 0.52밀리초와 거의 정확히 같습니다. 참조 하나가 늘어날 때마다 네트워크를 정확히 한 번 더 왕복한다는 뜻입니다.
  • 회귀분석의 결정계수는 1.000이었습니다. 오차가 거의 없는 직선입니다. 이 정도로 깨끗하게 나오는 이유도 같습니다. 비용이 왕복 횟수로만 결정되기 때문입니다.

즉, 연관 매핑이든 ID 참조든 '행별로 단건 쿼리를 날리는 N+1' 구조를 방치하는 순간, 성능 저하는 피할 수 없습니다.

반대로 말하면, 성능을 갉아먹는 범인은 설계 철학(매핑 방식) 자체가 아니라, 그 아래에서 동작하는 '쿼리 패턴'인 셈입니다.

ID 참조의 구제책: 명시적 IN-batch의 명예 회복

그렇다면 ID 참조를 고수하면서도 성능을 지키려면 어떻게 해야 할까?

 

답은 간단했습니다. 단건 조회를 반복하는 대신, 애플리케이션 단에서 고유 ID들을 모아 WHERE id IN (?) 형태로 한 번에 찌르는 명시적 IN-batch 패턴을 적용해 보는 것입니다.

public List<IssueListItemDto> inBatch(long memberId, int limit) {
    List<CouponIssueRef> refs = issueRefRepo.findByMemberId(memberId, PageRequest.of(0, limit));
    if (refs.isEmpty()) return List.of();

    // 중복 제거가 핵심이다. 같은 정책이 여러 번 발급된 목록에서
    // 실제로 물어봐야 할 정책은 고유한 것들뿐이다
    List<Long> policyIds = refs.stream()
            .map(CouponIssueRef::getPolicyId)
            .distinct()
            .toList();

    List<PolicyDto> policies = policyRepo.findDtosByIdIn(policyIds);   // 쿼리 1번
    Map<Long, PolicyDto> policyMap = new HashMap<>(policies.size() * 2);
    for (PolicyDto p : policies) policyMap.put(p.id(), p);

    return refs.stream().map(ref -> {
        PolicyDto p = policyMap.get(ref.getPolicyId());
        return new IssueListItemDto(ref.getId(), ref.getStatus(), ref.getIssuedAt(), ...);
    }).toList();
}

 

.distinct()가 그냥 붙어 있는 게 아닙니다. 쿠폰 목록에는 같은 정책이 여러 번 나옵니다. 중복을 걷어내면 IN 리스트가 짧아지고, 짧아진 만큼 데이터베이스가 할 일이 줍니다. 3절 첫머리의 회귀분석에서 x축을 나열한 행 수가 아니라 고유 참조 수로 잡은 이유도 같습니다. 비용을 결정하는 건 화면에 보이는 개수가 아니라 실제로 물어본 개수입니다.

 

실측 데이터에서 명시적 IN-batch는 단일 JOIN 쿼리 대비 모든 측정 조건에서 2.5 라운드트립(RTT) 이내의 성능을 안정적으로 방어해 냈습니다. RTT가 커지는 원격 환경이라도 단 2번의 왕복(목록 조회 1번 + IN 쿼리 1번)으로 끝나기 때문에, JOIN 쿼리와 비교해도 성능 차이가 거의 나지 않는 플랫(Flat)한 구간에 안착했습니다.

 

왕복 횟수 비교

 

이 결과가 주는 실무적 시사점은 꽤나 명확하죠.

"성능이 걱정되니 Aggregate 경계를 무너뜨리고 연관 매핑을 맺어야 한다"는 주장은
기술적으로 타당하지 않습니다.


도메인 모델의 결합도를 낮추기 위해 ID 참조 방식을 선택하더라도,
쿼리 계층에서 IN-batch 처리를 구현하기만 하면
JOIN과 대등한 성능을 충분히 낼 수 있습니다.


연관 매핑을 쓰신다면 코드를 고칠 필요도 없습니다

설정 한 줄입니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100


app/src/main/resources/application.yml입니다.

 

이걸 켜두면 프록시를 해소할 때 하이버네이트가 알아서 IN-batch 쿼리로 묶어줍니다.코드는 그대로 ci.getPolicy()입니다.

이 설정만큼은 사전 등록 밖에서 나중에 잰 값입니다.

논문을 다듬는 과정에서, 제가 이 설정의 효과를 한 번도 실제로 측정하지 않은 채 본문에 적어두었다는 사실을 발견했습니다.

그래서 보조 라운드를 따로 돌려 측정했습니다.

다만 이 라운드는 반복 없는 단일 실행이고 조건도 두 가지(참조 100개, 1000개)뿐이라, 아래 수치는 앞선 결과들보다 느슨하게 읽어주셔야 합니다.


측정해보니 구제 효과는 분명했습니다. 참조 100개 기준으로 아무 설정도 없는 LAZY 반복이 198밀리초였는데, default_batch_fetch_size=100을 켜자 26밀리초로 떨어졌습니다. 7.6배입니다.

 

다만 손으로 짠 IN-batch(22밀리초)보다는 여전히 조금 느립니다. 그리고 배치 크기를 잘못 잡으면 효과가 절반 이하로 깎입니다. 참조가 800개쯤 되는 요청에 배치 크기를 100으로 두면 프레임워크는 IN 쿼리를 8번 나눠 던집니다. 이 경우 182밀리초가 나왔고, 배치 크기를 1000으로 올려 한 번에 묶자 91밀리초가 됐습니다. 왕복 횟수가 그대로 응답 시간이 되는 구조라서, 설정값이 '요청당 고유 참조 수'보다 작으면 그만큼 왕복이 남습니다.

 

설정 한 줄로 끝나는 문제처럼 보이지만, 그 한 줄의 숫자를 얼마로 적을지는 결국 내 쿼리가 참조를 몇 개 끌고 오는지를 알아야 정할 수 있습니다.

 

설계는 설계대로 원칙을 지키고, 성능은 쿼리 패턴으로 풀면 된다는 점이 데이터로 증명된 셈입니다.

 

하지만 성능 저하보다 더 무서운 진짜 리스크는 따로 있었습니다.

 

다음 절에서는 N+1이 마주하는 '시스템 붕괴의 좌표'를 짚어보겠습니다.

4. 핵심 리뷰 2: N+1의 진짜 공포는 '지연'이 아니라 '시스템 붕괴(Saturation)'다

개발자들이 N+1 문제를 마주할 때 흔히 하는 오해가 있습니다.

 

"조금 느리긴 해도 결국 응답은 오니까, 트래픽이 몰리지 않을 때 천천히 고치면 된다"는 생각입니다.

 

하지만 이번 실험에서 부하 축을 열어보고 가장 뼈저리게 느낀 점은, N+1은 단순한 '응답 지연'의 문제가 아니라 특정 임계점을 넘는 순간 시스템 전체를 멈추게 만드는 '안정성 붕괴(Saturation)'의 문제라는 사실이었습니다.

로컬(RTT 0)의 착시

로컬 개발 환경에서는 N+1이 발생해도 체감하기가 쉽지 않습니다. 데이터베이스와 애플리케이션이 동일한 장비나 loopback 네트워크에 있어 라운드트립 시간(RTT)이 수십 마이크로초 수준에 불과하기 때문입니다.

 

실험에서도 RTT가 0이거나 매우 짧은 환경에서는 쿼리가 수십 번 더 나가더라도 p50 응답 속도가 몇십 밀리초 선에서 그럭저럭 버텨주었습니다. 로컬 테스트나 간단한 코드 리뷰 과정에서 N+1 코드가 별다른 제재 없이 살아남아 프로덕션까지 무사히 진입하는 이유이기도 합니다.

부하를 어떻게 밀어 넣었는가

측정 이야기를 먼저 해야 합니다. 여기서 방법을 틀리면 무너지는 순간 자체를 관측할 수 없기 때문입니다.

scenarios: {
    main: {
        executor:        'constant-arrival-rate',   // 도착률 고정
        rate:            RATE,
        timeUnit:        '1s',
        duration:        DURATION,
        preAllocatedVUs: 100,
        maxVUs:          1000,
    },
},

 

k6/scenario.js입니다.

 

핵심은 constant-arrival-rate입니다. 흔히 쓰는 방식은 가상 사용자 수를 고정하는 것인데, 그렇게 하면 응답이 느려질 때 부하 생성기가 스스로 요청을 덜 보냅니다. 사용자 하나가 응답을 기다리느라 다음 요청을 못 보내니까요. 그러면 서버가 아무리 느려져도 부하는 알아서 줄고, 무너지는 지점이 측정기에 안 잡힙니다. 이걸 협조적 누락(coordinated omission)이라고 부릅니다.

 

실제 트래픽은 우리 사정을 봐주지 않습니다. 우리 서버가 느려졌다고 사용자들이 알아서 클릭을 자제하지 않습니다. 그래서 도착률을 고정했습니다. 초당 50건이면 서버가 어떤 상태이든 초당 50건을 밀어 넣습니다.

 

커넥션 풀 설정도 마찬가지로 의도가 있습니다.

hikari:
  maximum-pool-size: 10
  connection-timeout: 5000     # 5초 안에 못 얻으면 실패시킨다

 

5초를 걸어둔 이유는 포화를 '지연'이 아니라 '실패'로 보이게 만들기 위해서입니다. 타임아웃이 없으면 커넥션을 못 얻은 요청이 무한정 기다리고, 그 대기가 응답 시간에 섞여 들어가 애플리케이션이 느려진 것처럼 보입니다. 5초로 끊어두면 포화가 요청 실패라는 명확한 신호로 나타납니다.

커넥션 풀이 마르는 임계점의 좌표

하지만 인프라가 분리되어 네트워크 지연이 발생하는 환경으로 넘어가면 이야기가 완전히 달라집니다. 명목 RTT 1.5밀리초, 실측 왕복 약 2.4밀리초. 일반적인 클라우드 실무 환경입니다.

 

도착률별 p99 곡선

 

 

숫자로 보면 이렇습니다.

도착률 LAZY 반복 p50 LAZY 반복 p99 유실 요청 배치 페치 p99 JDBC JOIN p99
30 req/s 222ms 240ms 0 27.7ms 6.6ms
40 req/s 217ms 234ms 0 26.4ms 6.5ms
50 req/s 12,740ms 23,016ms 1,131 24.8ms 8.2ms
60 req/s 22,282ms 24,421ms 7,406 23.4ms 6.0ms
  • N+1 스타일의 붕괴 좌표: N+1 패턴(lazy, byid)은 40 req/s까지는 멀쩡했습니다. p50이 각각 217밀리초와 214밀리초, p99는 234밀리초와 232밀리초, 유실된 요청은 0건이었습니다. 느리긴 해도 예측 가능한 느림입니다. 그런데 한 칸 더 올린 50 req/s에서 무너지는 것을 볼 수 있죠. LAZY 반복은 p50이 217밀리초에서 12,740밀리초로 뛰었고, p99는 23,016밀리초를 찍었습니다. 부하 생성기가 밀어 넣지 못하고 버린 요청이 1,131건 발생했습니다. 40에서 50으로, 도착률을 25퍼센트 올린 것이 전부입니다.
  • 플랫 스타일의 생존: 반면 단일 쿼리 계열(JOIN FETCH, JDBC JOIN)과 배치 페치, 중복 미제거 IN-batch는 100 req/s까지 밀어 넣어도 포화 지점이 나타나지 않았습니다. p99가 19.4밀리초, 5.8밀리초, 19.9밀리초, 19.9밀리초였고 유실 요청은 전부 0건입니다. N+1이 무너진 50 req/s 지점에서 이들의 p99는 24밀리초 안팎이었습니다. 900배가 넘는 차이입니다.
  • 그 사이에 걸친 것 하나: ID 참조의 명시적 IN-batch는 80 req/s에서 p99가 8,760밀리초로 튀었고, 100 req/s에서도 1,686밀리초에 머물렀습니다. N+1처럼 완전히 주저앉지도, 나머지처럼 끝까지 평평하지도 않은 중간입니다.

이 좌표를 그대로 외우시면 안 되는 이유.

부하 축은 각 지점을 9분씩 한 번만 측정했습니다. 도착률을 촘촘히 훑어 곡선 모양을 보는 것이 목적이라 반복 대신 실행 시간을 길게 가져갔습니다. 그래서 단발 스파이크가 섞입니다. 실제로 JOIN FETCH는 60 req/s에서 p99가 177밀리초로 한 번 튀었다가 100 req/s에서 19밀리초로 돌아왔습니다. 나중에 자원이 더 넉넉한 다른 장비에서 같은 지점을 세 번씩 다시 재봤더니 아홉 번 모두 23에서 26밀리초로 평평했고 그 스파이크는 흔적도 없었습니다. 그 한 번은 잡음이었다는 뜻입니다.

위의 IN-batch도 같은 재측정에서는 평평하게 나왔습니다(p99 27에서 29밀리초). 그러니 80 req/s라는 숫자는 그 패턴의 성질이 아니라 그 장비에서의 좌표입니다. 장비가 바뀌면 좌표도 움직입니다.

반면 N+1의 붕괴는 성격이 다릅니다. 50 req/s에서 무너진 뒤 그 위의 모든 지점에서 계속 무너져 있었고, 유실 요청이 도착률에 비례해 쌓였습니다. 한 번 튄 것이 아니라 곡선 자체가 꺾인 것입니다.

따라서, 이 글에서 가져가실 것은 40이나 50이라는 숫자 보다는, 내 시스템에도 그런 꺾이는 지점이 있다는 것, 그 지점은 다를 수 있으나 중요한 건 꺾인다는 사실 그 자체입니다.


왜 하필 그 지점에서 꺾이는가

산수를 해보면 당연한 결과입니다.

 

커넥션 풀에는 커넥션이 10개 있습니다. 요청 하나가 커넥션을 붙잡고 있는 시간은 그 요청이 데이터베이스에 다녀오는 시간의 합입니다.

 

N+1 패턴에서 참조 100개짜리 요청은 왕복을 100번 넘게 합니다. 부하가 낮을 때 실측 p50이 약 220밀리초였으니, 요청 하나가 커넥션을 0.22초쯤 쥐고 있는 셈입니다.

 

커넥션 하나가 1초 동안 몇 번 회전하느냐로 바꿔 보면 그림이 선명해집니다.

 

 

커넥션 10개로 초당 처리할 수 있는 요청 수는 대략 10 나누기 0.22, 즉 초당 45건 언저리입니다. 논문에서는 이 계산을 조금 더 보수적으로 잡아 초당 37건을 하한으로 적었습니다. 실측 발현 지점은 50이었으니 하한 위에 있고, 예측과 어긋나지 않습니다.

 

중요한 건 소수점이 아니라 자릿수입니다. 초당 수천 건이 아니라 수십 건대에서 무너진다는 것. 40 req/s까지는 여유가 있으니 대기가 없습니다. 50에 닿는 순간 이용률이 1에 붙고, 대기행렬 이론이 말하는 대로 대기 시간이 발산합니다. 서서히 느려지다 멈추는 게 아니라 어느 지점을 넘으면 갑자기 무너지는 이유가 이것입니다.

 

반대로 IN-batch나 JOIN은 왕복이 두 번이라 요청 하나가 커넥션을 쥐는 시간이 20밀리초대입니다. 같은 계산으로 초당 500건 근처가 한계가 됩니다. 100 req/s 정도로는 근처에도 못 갑니다.

즉, N+1 코드는 "평소에는 조금 느리지만 돌아가는 코드"가 아니라, 트래픽이 튀는 순간 커넥션 풀을 말려 죽여 연쇄 장애를 일으키는 '시한폭탄'에 가깝습니다.

 

로컬 환경의 안일한 테스트만 믿고 N+1 코드를 방치했다가는, 가용 영역 간 통신이 일상적인 실무 프로덕션 환경에서 예기치 못한 장애와 마주하게 되는 이유가 바로 여기에 있습니다.

5. 핵심 리뷰 3: 연구자인 나조차 빗나갔던 예상 (기각된 가설 두 건과 유령 같은 오버헤드)

앞선 두 가지 발견이 실무의 설계와 부하 관리 측면에서의 인사이트였다면, 이번 섹션은 이번 실험을 진행하면서 연구자인 저조차도 완전히 허를 찔렸던, 그리고 가장 흥미로웠던 부끄러운(?) 고백에 가깝습니다. 사전 등록한 일곱 개 가설 중 두 개가 기각됐습니다. 하나는 제 예상보다 상황이 나빴고(H7), 하나는 제 예상보다 좋았습니다(H4). 먼저 나빴던 쪽부터 보겠습니다.

"JPA의 오버헤드는 고정 비용일 것이다"라는 착각

실험을 설계할 당시, 저는 JPA가 엔티티를 영속성 컨텍스트에 담고 객체를 매핑하는 과정에서 발생하는 인프라 오버헤드는 네트워크 RTT와 상관없는 '고정 비용'일 것이라 당연하게 예측했습니다. (사전 등록된 가설 H7입니다.) 즉, 순수 JDBC와 비교했을 때 JPA가 추가로 소모하는 시간은 네트워크 지연이 얼마가 되든 일정한 굵기로 나타날 줄 알았습니다.

 

하지만 측정 데이터를 모아서 회귀 분석을 돌려본 결과, 제 예측은 완전히 틀렸습니다.

 

RTT가 커질수록 벌어지는 격차

 

  • JPA 환경과 순수 JDBC 환경의 응답 지연 격차(JPA-JDBC gap)는 RTT가 커질수록 함께 선형으로 벌어졌습니다.
  • 겉보기에는 고정 값처럼 보였던 오버헤드가 네트워크 지연이 커지니 덩달아 불어난 것입니다. RTT가 0인 로컬 환경에서는 3~4ms 수준으로 거의 티가 나지 않던 격차가, 가용 영역 간 지연(1.5ms RTT) 환경에서는 10ms 이상으로 벌어졌고, 5ms RTT 환경에서는 수십 밀리초 단위로 불어났습니다.

도대체 단일 쿼리를 날리는데 이 보이지 않는 추가 비용은 어디서 불어나는 것일까? 의문을 품고 측정 종료 후 MySQL의 general_log를 직접 뜯어보기 시작했습니다.

범인의 정체: @Transactional(readOnly=true)가 숨겨둔 5번의 왕복

로그를 상세히 추적해 본 원인은 생각보다 가까운 곳에 있었습니다. 평소 성능 최적화를 위해 다들 무심코 붙이곤 하는 @Transactional(readOnly=true) 애노테이션이 트리거였습니다.

 

문제의 코드는 이렇게 생겼습니다. 어디서나 볼 수 있는 평범한 조회 메서드입니다.

@Transactional(readOnly = true)      // 읽기니까 붙였다
public List<IssueListItemDto> joinfetch(long memberId, int limit) {
    List<CouponIssue> issues = issueRepo.findByMemberIdWithPolicy(memberId, PageRequest.of(0, limit));
    return issues.stream().map(...).toList();
}

 

JOIN FETCH를 쓰니 SQL은 딱 한 번 나갑니다. N+1도 없습니다. 그런데 general_log를 켜고 이 요청을 한 번만 던져봤더니 이렇게 찍혔습니다.

set session transaction read only
SET autocommit=0
select ci1_0.id, ci1_0.issued_at, ... from coupon_issue ci1_0 join coupon_policy p1_0 on ...
commit
SET autocommit=1
set session transaction read write

 

조회는 세 번째 줄 하나뿐입니다. 나머지 다섯 줄은 트랜잭션 경계를 여닫는 데 쓰였습니다. 순수 JDBC로 같은 JOIN 쿼리를 오토커밋 모드에서 던지면 왕복은 한 번으로 끝납니다.

 

데이터 조회 쿼리 앞뒤로 트랜잭션 경계를 관리하기 위해 무려 5번의 네트워크 왕복이 눈에 보이지 않게 추가로 발생하고 있었습니다.

 

RTT가 0인 로컬 환경에서는 이 추가 왕복 5번의 비용이 0.2ms 조차 되지 않기 때문에 프로파일링 툴이나 로컬 테스트에서는 절대로 감지되지 않습니다. 하지만 클라우드 환경처럼 네트워크 지연이 존재하는 실무 배포 환경에서는 RTT가 커질수록 이 숨은 5번의 왕복 비용이 누적되면서 전체 성능을 갉아먹는 거대한 유령 비용으로 돌변하는 것입니다. N+1 문제를 완벽하게 피해 간 최적화된 쿼리라 할지라도 이 트랜잭션 래퍼 비용은 고스란히 살아남아 발목을 잡게 됩니다.

 

산수로 보면 이렇습니다. 왕복 다섯 번이 RTT 0.05밀리초에서는 0.25밀리초입니다. 아무도 눈치채지 못합니다. 그런데 실측 왕복 2.4밀리초인 클라우드 환경에서는 같은 다섯 번이 12밀리초가 됩니다. 코드는 한 줄도 안 바뀌었는데 배포하는 순간 50배가 됩니다.

설정 두 줄로 유령 비용 40% 날리기 (Action Item)

원인을 알았으니 탈출구도 명확합니다. HikariCP 커넥션 풀 설정과 JPA 프로퍼티를 조율하여 불필요한 토글 왕복을 쳐낼 수 있습니다.

spring:
  datasource:
    hikari:
      auto-commit: false                    # 풀이 이미 오토커밋 꺼진 커넥션을 준다
  jpa:
    properties:
      hibernate:
        connection:
          provider_disables_autocommit: true # 하이버네이트에게 그 사실을 알려준다


둘은 반드시 짝으로 씁니다. 앞줄만 켜면 하이버네이트는 여전히 자기가 껐다 켜야 한다고 믿고 SET autocommit을 던집니다. 뒷줄이 "풀이 이미 해뒀으니 너는 하지 마라"고 알려주는 역할입니다.

 

적용하고 같은 요청을 다시 잡아봤습니다.

 

set session transaction read only
select ci1_0.id, ci1_0.issued_at, ... from coupon_issue ci1_0 join coupon_policy p1_0 on ...
commit
set session transaction read write

 

SET autocommit 두 줄이 사라졌습니다. 추가 왕복이 5회에서 3회로 줄어듭니다. (읽기 전용 세션 모드 전환과 커밋 과정은 유지됩니다.)

 

실제로 이 튜닝을 적용하고 보조 측정 라운드를 돌려본 결과, JPA와 JDBC 간의 지연 증가 슬로프(Slope)가 약 41~43% 가량 극적으로 감소했습니다. 왕복 횟수 감소 비율(5회 ➔ 3회, 약 40% 감소)과 정확히 맞아떨어지는 수치였습니다. RTT 1.5ms 환경을 기준으로 요청당 약 5ms 이상의 지연을 코드 수정 없이 설정 두 줄만으로 걷어낸 셈입니다.

 

이 튜닝 결과도 사전 등록 밖입니다.

원인을 찾은 다음에 "그러면 왕복 횟수를 줄이면 실제로 그만큼 빨라지는가"를 확인하려고 나중에 추가로 잰 것입니다. 원인 규명이 목적이라면 왕복 횟수를 세는 것만으로도 충분하지만, 그 왕복이 진짜 비용인지는 왕복을 줄여봐야 알 수 있다고 판단했습니다.

다만 이 라운드 역시 반복 없는 단일 실행입니다. 방향과 크기는 믿을 만하지만 소수점까지 신뢰할 값은 아닙니다.


참고로 이 진단은 누구나 따라할 수 있습니다. 오래 걸리는 부하 테스트가 필요한 것도 아닙니다.

# 1. 문장 단위 로깅을 테이블로 켠다 (재시작 불필요)
SET GLOBAL log_output='TABLE'; SET GLOBAL general_log=ON;

# 2. 로그를 비우고, 요청을 딱 한 번 던지고, 순서대로 읽는다
TRUNCATE mysql.general_log;
-- 여기서 API를 한 번 호출한다
SELECT CONVERT(argument USING utf8) FROM mysql.general_log
 WHERE command_type='Query' ORDER BY event_time;

# 3. 반드시 되돌린다
SET GLOBAL general_log=OFF; SET GLOBAL log_output='FILE';


요청 하나가 데이터베이스와 몇 번 대화하는지, 그 목록을 눈으로 보는 것.
이게 이 절 전체의 출발점이었습니다.


빗나간 예상 둘: 제가 저를 이기라고 걸어둔 가설(H4)

사전 등록에는 제 주장에 유리한 가설만 적을 수 없습니다. 유리한 것만 적어두고 그것만 맞았다고 보고하면 사전 등록을 할 이유가 없기 때문입니다. 그래서 일부러 제 편이 지는 쪽에 거는 예측을 두 개 넣었습니다. 그중 하나가 H4입니다.

 

상황은 이렇습니다. "이 회원의 특정 상태 쿠폰을, 정책의 만료일 순으로 정렬해서 상위 몇 건만" 같은 조회가 있다고 해봅시다. 여기서 정렬 기준인 만료일은 쿠폰이 아니라 정책 테이블에 있습니다. JOIN을 쓰면 데이터베이스가 조건과 정렬을 한 번에 처리하고 필요한 만큼만 돌려줍니다. 반대로 애플리케이션에서 조합하려면 후보를 넉넉히 긁어와서 JVM에서 정렬한 다음 잘라내야 합니다. 교과서적으로는 JOIN의 완승입니다. 술어 푸시다운을 포기하는 대가가 크니까요.

 

그래서 저는 "앱에서 조합하는 방식은 JOIN보다 p50 기준 최소 5배는 나쁠 것"이라고 미리 적었습니다. ID 참조 진영에 서 있는 제게 불리한 예측입니다.

 

결과는 기각이었습니다. 다섯 개 RTT 조건 중 5배를 넘긴 곳이 하나도 없었습니다.

실측 왕복 JOIN 앱 조합 비율
0 5.9ms 4.8ms 0.8배
0.3ms 7.3ms 8.3ms 1.1배
1.5ms 16.6ms 20.5ms 1.2배
5ms 44.8ms 51.6ms 1.2배
10ms 77.2ms 93.2ms 1.2배

 

지연이 없는 환경에서는 오히려 앱 조합이 더 빨랐고, 지연이 커져도 20퍼센트 차이에서 멈췄습니다. 5배는커녕 1.2배입니다.

 

다만 조건이 하나 붙습니다. 위 표의 앱 조합은 후보를 회원 범위로 좁혀서 긁어온 버전입니다. 같은 앱 조합이라도 회원 범위 없이 상태 조건만으로 긁어오는 버전은 모든 RTT 조건에서 포화됐습니다. 측정값을 낼 수도 없었습니다.

 

둘의 차이는 코드 한 줄입니다.

 

// 포화한 쪽: 상태만으로 긁는다. 전체 테이블에서 후보를 퍼온다
List<CouponIssueRef> candidates = issueRefRepo.findByStatus(status, page);

// 살아남은 쪽: 회원으로 먼저 좁힌다. 인덱스를 탄다
List<CouponIssueRef> candidates = issueRefRepo.findByMemberIdAndStatus(memberId, status, page);

 

app/src/main/java/lab/service/ScenarioCService.java입니다. 뒤에 붙는 정책 배치 조회와 JVM 정렬은 두 버전이 완전히 같습니다. 한쪽은 아예 서지 못하고 한쪽은 JOIN과 대등합니다. 차이는 후보를 긁는 쿼리에 memberId가 들어가느냐뿐입니다.

 

그러니 이 결과는 "JOIN이 필요 없다"가 아닙니다. "JOIN이냐 앱 조합이냐"가 애초에 잘못된 질문이었다는 쪽에 가깝습니다. 승부는 후보 집합을 좁혔느냐에서 갈렸습니다. 좁히면 앱 조합도 JOIN과 대등하고, 안 좁히면 앱 조합은 아예 서지 못합니다. 3절에서 매핑 철학이 아니라 쿼리 패턴이 범인이었던 것과 정확히 같은 모양입니다.

 

제 예상이 두 번 빗나갔습니다. 한 번은 제게 불리한 쪽으로(H7), 한 번은 제게 유리한 쪽으로(H4). 후자가 더 위험합니다. 유리한 결과는 검증을 덜 하고 넘어가기 쉽기 때문입니다. 측정 전에 판정 공식을 동결해두지 않았다면 저는 아마 H4를 조용히 다시 썼을 겁니다.

6. Outro: 감에 의존하지 않는 엔지니어를 위한 2-Tier 의사결정 가이드

우리는 실무에서 아키텍처의 이상과 성능이라는 현실 사이에서 종종 타협을 강요받습니다. 하지만 이번 실험의 전체 데이터를 관통하는 가장 중요한 교훈은, '설계 계층(매핑 철학)'과 '쿼리 계층(접근 패턴)'을 철저히 분리해서 바라봐야 한다는 점입니다.

 

도메인의 결합도를 낮추고 Aggregate의 경계를 명확히 하기 위해 ID 참조 구조가 필요하다면, 막연한 N+1의 두려움 때문에 그 설계를 포기할 필요가 없습니다. 성능을 결정짓는 것은 설계 철학 자체가 아니라 쿼리를 어떻게 모아서 쏘느냐(명시적 IN-batch 등)에 달려 있기 때문입니다. 반대로, 연관 매핑을 유지하더라도 쿼리 계층에서의 적절한 제어(default_batch_fetch_size)가 없다면 시스템 붕괴의 위험은 동일하게 존재합니다.

 

표로 정리하면 이렇습니다. 세로가 설계 계층이고 가로가 쿼리 계층입니다.

  단건 반복 IN-batch JOIN
연관 매핑 LAZY 프록시 반복
왕복 1+N, 50 req/s에서 붕괴
default_batch_fetch_size
왕복 1+ceil(참조/배치)
JOIN FETCH
왕복 1, 100 req/s까지 평탄
ID 참조 findById 반복
왕복 1+N, 50 req/s에서 붕괴
명시적 IN-batch
왕복 2, JOIN 대비 2.5 RTT 이내
앱 조합(범위 좁힌 뒤)
JOIN 대비 1.2배 이내

 

같은 행끼리 비교하지 마시고 같은 열끼리 비교하시면 됩니다. 행이 다르면 성능은 거의 같고, 열이 다르면 성능이 갈립니다. 이 표가 이 글 전체의 요약입니다.

 

중요한 것은 막연한 감이나 "안티 패턴이니 피하라"는 격언에 기대는 것이 아니라, "내 시스템이 어디서 무너지는가"에 대한 정확한 좌표를 아는 것입니다.

  • 우리 인프라 환경의 평균적인 네트워크 지연(RTT)은 얼마인가?
  • 특정 쿼리 패턴이 유발하는 커넥션 점유 시간이 트래픽(req/s)과 만났을 때, 풀(Pool)이 포화되는 임계점은 어디인가?
  • 프레임워크가 뒤에서 조용히 만들어내는 유령 같은 네트워크 왕복 비용은 없는가?

이러한 지표들을 숫자로 인지하고 통제할 수 있다면, 우리는 그 안전망 안에서 훨씬 더 주도적이고 자유롭게 DDD 혹은 헥사고날 아키텍처 기반의 복잡한 구조를 그려나갈 수 있을 것입니다.

 

직접 돌려보실 수 있습니다

이 글의 숫자를 믿으실 필요는 없습니다. 세 줄이면 같은 실험이 돌아갑니다.

git clone https://github.com/renechoi/rdb-join-lab && cd rdb-join-lab

# 1. MySQL 8 + 앱 + netem 사이드카를 띄운다
docker compose up -d --build

# 2. 1,240만 건을 적재한다 (시간이 좀 걸립니다)
SCALE=full bash db/seed.sh

# 3. 셀 하나를 측정한다
#    문법: run-cell.sh 시나리오 스타일 RTT(us) 도착률 시간 [추가 쿼리스트링]
bash scripts/run-cell.sh b lazy    1500 40 2m "limit=100"   # 버티는 쪽
bash scripts/run-cell.sh b lazy    1500 50 2m "limit=100"   # 무너지는 쪽
bash scripts/run-cell.sh b inbatch 1500 50 2m "limit=100"   # 비교군

 

STYLE을 바꾸면 본문의 아홉 가지 스타일을 전부 재현할 수 있고, RTT를 바꾸면 로컬부터 대륙 간 지연까지 옮겨 다닐 수 있습니다. 결과는 results/ 아래에 JSON으로 떨어지고, analysis/judge.py가 사전 등록된 판정 공식을 그대로 적용해 가설 판정을 다시 계산합니다. 제가 손으로 고른 결론이 아니라 스크립트가 내놓은 판정입니다.

 

이번 실험에 사용된 모든 테스트 코드와 1,240만 건의 데이터셋 생성 스크립트, 그리고 원시 측정 데이터는 누구나 직접 돌려보고 검증할 수 있도록 GitHubZenodo에 퍼블릭으로 열어두었습니다. 심사가 끝나면 정식 논문 링크도 갱신하겠습니다. 그 전에 상세한 통계 검증 과정이 궁금하시다면 프리프린트 전문을 참고해 주시면 좋겠습니다. 이 글에서 다 담지 못한 것들이 거기 있습니다. 냉시작 버퍼 풀 조건, 두 번째 환경에서의 재현, IN 리스트 길이 경계, 그리고 N+1이 InnoDB 퍼지를 지연시켜 같은 데이터베이스를 쓰는 다른 워크로드까지 오염시킨다는 관측까지요.

 

이 글이 언젠가 회의실에서 명확한 데이터 없이 겉도는 논의로 답답함을 느꼈을 어느 백엔드 엔지니어 분께, 숫자에 기반한 작은 돌파구가 되기를 바랍니다.

 

 


 

참고 자료

이 글의 원자료

  • 프리프린트 전문, Zenodo DOI 10.5281/zenodo.21594741. 국문본과 영문본이 함께 올라가 있습니다. CC BY 4.0.
  • 측정 하네스, github.com/renechoi/rdb-join-lab. 도커 구성, 시드 스크립트, k6 시나리오, 원시 측정 JSON, 분석 스크립트가 전부 들어 있습니다.
  • 사전 등록 문서는 위 저장소의 PREREGISTRATION.md입니다. 가설 일곱 개와 판정 공식, 그리고 프로토콜을 벗어난 일들을 적어둔 개정 이력이 여기 있습니다. 본문에서 사전 등록 밖이라고 밝힌 보조 라운드 둘도 여기 개정 이력에 사유와 함께 남겼습니다.
  • 가설 판정은 analysis/judge.py가 계산합니다. 

측정 방법론

함께 읽으면 좋은 연구

이 글이 위 연구들과 다른 지점은 하나입니다. 저 연구들은 안티패턴을 찾아내거나 설정을 최적화합니다. 이 글은 그 비용이 배포 환경(네트워크 지연, 도착률)의 함수로 어떻게 자라는지, 그리고 어디서 꺾이는지를 잽니다.

설계 쪽 배경

  • Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, Addison-Wesley, 2003.
  • Vaughn Vernon, Implementing Domain-Driven Design, Addison-Wesley, 2013. 애그리게이트 설계 규칙 3번이 "다른 애그리게이트는 식별자로만 참조하라"입니다. 이 글의 출발점이 된 회의실 논쟁이 정확히 그 규칙을 두고 벌어진 것입니다.
  • Martin Fowler, DDD_Aggregate.

공식 문서

이 블로그의 다른 글

 

 

A Note on Graphics

이 글에 사용된 모든 다이어그램과 일러스트 이미지는 개념의 시각화를 위해 생성형 AI 도구를 활용해 제작되었습니다.

반응형