시리즈

- JMH 마이크로벤치마크

- TestContainers로 실제 인프라 테스트

- 03 대량 데이터 시딩과 벤치마크 설계

- 결과 분석과 병목 진단

100만 건으로 벤치마크를 돌리겠다고 결정한 순간, 데이터를 어떻게 넣을지가 첫 번째 관문이 된다. JPA saveAll()로 100만 건을 넣으면 수십 분이 걸린다. 데이터의 분포가 현실과 동떨어지면 벤치마크 결과도 의미가 없다. 이 편에서는 빠르게, 현실적인 분포로 대량 데이터를 시딩하는 전략과, 벤치마크 시나리오를 설계하는 방법을 다룬다.

JPA saveAll()이 느린 이유

100만 건을 JPA로 넣으면 왜 느린지 구조를 보면 명확하다.

sequenceDiagram autonumber participant App as Application participant PC as 영속성 컨텍스트 participant DB as Database loop 100만 번 반복 App->>PC: persist(entity) Note right of PC: 1차 캐시에 저장
더티 체킹 대상 등록 end App->>PC: flush() loop 100만 번 반복 PC->>DB: INSERT INTO ... VALUES (?) DB-->>PC: OK end Note over PC,DB: 네트워크 왕복 100만 번

문제는 두 가지다.

  • 영속성 컨텍스트가 100만 개의 엔티티를 메모리에 들고 있다. 더티 체킹 비용이 엔티티 수에 비례해서 늘어난다.
  • flush() 시점에 INSERT가 한 건씩 나간다. hibernate.jdbc.batch_size를 설정해도 Hibernate가 배치로 묶을 수 있는 조건(ID 생성 전략 등)이 맞아야 한다.

@GeneratedValue(strategy = GenerationType.IDENTITY)를 사용하면 Hibernate 배치 INSERT가 동작하지 않는다. IDENTITY 전략은 INSERT 후 DB가 생성한 ID를 바로 알아야 하므로, 각 INSERT를 개별 실행해야 하기 때문이다.

JDBC 배치 INSERT

JPA를 우회하고 JDBC로 직접 INSERT하면 이 제약에서 벗어난다.

기본 구조

@Component
@RequiredArgsConstructor
public class DataSeeder {

    private final JdbcTemplate jdbc;
    private static final int BATCH_SIZE = 5_000;

    public void seedFeeds(int totalCount, List<UUID> userIds) {
        String sql = "INSERT INTO feeds (id, author_id, content, " +
                     "weather_snapshot, created_at) " +
                     "VALUES (?, ?, ?, ?::jsonb, ?)";

        for (int offset = 0; offset < totalCount; offset += BATCH_SIZE) {
            int batchEnd = Math.min(offset + BATCH_SIZE, totalCount);
            int batchStart = offset;

            jdbc.batchUpdate(sql, new BatchPreparedStatementSetter() {
                @Override
                public void setValues(PreparedStatement ps, int i)
                        throws SQLException {
                    int index = batchStart + i;
                    ps.setObject(1, UUID.randomUUID());
                    ps.setObject(2, userIds.get(index % userIds.size()));
                    ps.setString(3, "피드 내용 " + index);
                    ps.setString(4, randomWeatherJson());
                    ps.setTimestamp(5, Timestamp.from(
                            Instant.now().minusSeconds(totalCount - index)));
                }

                @Override
                public int getBatchSize() {
                    return batchEnd - batchStart;
                }
            });
        }
    }
}

for 루프로 5,000건씩 끊어서 batchUpdate()를 호출한다. 한 번의 batchUpdate() 호출에서 JDBC 드라이버가 5,000건의 INSERT를 하나의 네트워크 패킷으로 묶어 보낸다. 100만 건이면 200번의 왕복으로 끝난다.

왜 한 번에 100만 건을 넣지 않는가

getBatchSize()가 100만을 반환하면 setValues()가 100만 번 호출되면서 PreparedStatement 파라미터가 모두 메모리에 쌓인다. 100만 × 5개 컬럼 × 평균 100바이트 = 약 500MB다. OOM이 발생하거나, GC 압박으로 시딩 자체가 극도로 느려진다.

5,000~10,000건 단위가 메모리와 네트워크 효율의 균형점이다.

데이터 분포 설계

균등 분포 vs 편향 분포

실제 서비스에서 좋아요 수는 균등하지 않다. 소수의 인기 피드가 좋아요 대부분을 차지하고, 대다수 피드는 좋아요가 거의 없다. 이런 편향이 쿼리 성능에 영향을 미친다.

flowchart LR subgraph Uniform ["균등 분포"] direction TB U1["피드 1: 좋아요 5개"] U2["피드 2: 좋아요 5개"] U3["피드 N: 좋아요 5개"] end Uniform ~~~ Skewed subgraph Skewed ["편향 분포 (현실)"] direction TB S1["피드 1: 좋아요 500개"] S2["피드 2: 좋아요 50개"] S3["피드 N: 좋아요 0개"] end style U1 fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style U2 fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style U3 fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style S1 fill:#FFEBEE,stroke:#F44336,stroke-width:2px style S2 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style S3 fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px style Uniform fill:#f0f4ff,stroke:#2196F3 style Skewed fill:#fff8f0,stroke:#FF9800

벤치마크 목적에 따라 분포를 선택한다.

  • 균등 분포 — 쿼리 방식 간 순수한 구조적 차이를 비교할 때. 변수를 최소화한다.
  • 편향 분포 — 실제 운영 환경과 비슷한 조건에서의 성능을 예측할 때. 인덱스 선택도, 캐시 효율 등이 영향을 받는다.

처음에는 균등 분포로 시작해서 구조적 차이를 먼저 확인하고, 이후 편향 분포로 실제 환경에 가까운 테스트를 추가하는 것이 좋다.

랜덤 데이터 생성

테스트 데이터는 단순할수록 좋다. Faker나 Instancio 같은 라이브러리도 있지만, 벤치마크용 대량 시딩에서는 직접 만드는 편이 빠르고 제어가 쉽다.

private static final String[] SKY_STATUSES =
        {"CLEAR", "MOSTLY_CLOUDY", "CLOUDY", "OVERCAST"};

private static final String[] PRECIP_TYPES =
        {"NONE", "RAIN", "SNOW", "SLEET"};

private String randomWeatherJson() {
    ThreadLocalRandom r = ThreadLocalRandom.current();
    return String.format("""
        {"skyStatus":"%s","precipitationType":"%s",
         "temperature":{"current":%d,"min":%d,"max":%d}}""",
        SKY_STATUSES[r.nextInt(SKY_STATUSES.length)],
        PRECIP_TYPES[r.nextInt(PRECIP_TYPES.length)],
        r.nextInt(-10, 36),
        r.nextInt(-15, 20),
        r.nextInt(20, 40));
}

JSONB 컬럼에 들어갈 JSON 문자열을 직접 만든다. 실제 엔티티의 WeatherSnapshot 구조와 필드명을 맞추는 것이 중요하다. 필드명이 다르면 jsonb_extract_path_text 쿼리가 빈 결과를 반환해서 벤치마크가 의미 없어진다.

시딩 순서와 의존 관계

테이블 간 FK 관계 때문에 시딩 순서가 중요하다.

flowchart TD U["User 1,000건"] --> F["Feed 1,000,000건"] F --> FC["FeedClothes 3,000,000건"] F --> FL["FeedLike 5,000,000건"] F --> C["Comment 2,000,000건"] U --> FL U --> C style U fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style F fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px style FC fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style FL fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style C fill:#FFF3E0,stroke:#FF9800,stroke-width:2px

User → Feed → (FeedClothes, FeedLike, Comment) 순서로 시딩한다. FeedClothes, FeedLike, Comment는 서로 의존이 없으므로 병렬로 시딩할 수도 있다.

public void seed(int feedCount) {
    log.info("시딩 시작: feedCount={}", feedCount);
    long start = System.nanoTime();

    List<UUID> userIds = seedUsers(1_000);
    List<UUID> feedIds = seedFeeds(feedCount, userIds);
    seedFeedClothes(feedIds);
    seedFeedLikes(feedIds, userIds);
    seedComments(feedIds, userIds);

    long elapsed = TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - start);
    log.info("시딩 완료: {}초 소요", elapsed);
}
시딩 시간 측정

시딩에 소요되는 시간을 로그로 남겨두면, 배치 사이즈 조정이나 인덱스 생성 시점 결정에 참고할 수 있다. 100만 건 기준 JDBC 배치 INSERT로 2~5분 정도가 일반적이다.

인덱스 생성 시점

대량 INSERT 전에 인덱스가 있으면 각 INSERT마다 인덱스를 갱신해야 해서 시딩이 느려진다. 프로덕션에서는 이미 인덱스가 있는 상태에서 데이터가 점진적으로 쌓이지만, 벤치마크 시딩은 한꺼번에 대량을 넣으므로 전략이 다르다.

데이터 먼저, 인덱스 나중에

public void seed(int feedCount) {
    // 1. 인덱스 드랍 (있으면)
    jdbc.execute("DROP INDEX IF EXISTS idx_feeds_weather_gin");

    // 2. 대량 데이터 INSERT
    List<UUID> userIds = seedUsers(1_000);
    List<UUID> feedIds = seedFeeds(feedCount, userIds);
    // ...

    // 3. 인덱스 생성
    jdbc.execute("CREATE INDEX idx_feeds_weather_gin " +
                 "ON feeds USING GIN (weather_snapshot)");

    // 4. 통계 갱신
    jdbc.execute("ANALYZE feeds");
}

인덱스 없이 데이터를 넣고, 마지막에 인덱스를 생성하면 30~50% 빠르다. PostgreSQL이 정렬된 데이터를 한 번에 인덱스 구조로 변환하기 때문이다.

ANALYZE는 테이블 통계를 갱신한다. 쿼리 플래너가 정확한 실행 계획을 세우려면 최신 통계가 필요하다. 100만 건을 한 번에 넣으면 자동 ANALYZE가 아직 동작하지 않았을 수 있으므로 명시적으로 실행한다.

벤치마크 시나리오 설계

변수와 상수

벤치마크에서 측정하고 싶은 것(변수)과 고정할 것(상수)을 명확히 구분한다.

구분항목설정
변수조회 방식N+1, 배치, Elasticsearch
상수페이지 크기20건
상수총 데이터1,000,000건
상수정렬 기준createdAt DESC
상수필터없음 (전체 조회)
상수캐시비활성화

조회 방식만 바꾸고 나머지는 모두 동일하게 유지한다. 캐시를 비활성화하는 이유는, 캐시가 켜져 있으면 두 번째 반복부터 캐시 히트로 인해 쿼리 성능 차이가 가려지기 때문이다.

페이지 크기별 스케일링

추가 시나리오로 페이지 크기를 바꿔가며 N+1과 배치의 차이가 어떻게 변하는지 확인할 수 있다.

@Param({"10", "20", "50", "100"})
private int pageSize;

JMH의 @Param을 사용하면 여러 값을 자동으로 조합해서 벤치마크를 실행한다. 페이지 크기가 커질수록 N+1의 쿼리 수가 선형으로 증가하므로, 배치와의 차이가 더 극적으로 드러난다.

@Param 조합 폭발

@Param이 여러 개이면 모든 조합을 실행한다. 4개 페이지 크기 × 3개 조회 방식 × (워밍업 + 측정) × 2 Fork = 수 시간이 걸릴 수 있다. 처음에는 파라미터를 최소화하고, 필요한 것만 추가한다.

자주 하는 실수

JPA 엔티티로 대량 시딩

feedRepository.saveAll(millionFeeds)는 영속성 컨텍스트가 100만 개 엔티티를 관리하면서 OOM이 발생한다. entityManager.clear()로 주기적으로 비워도, 더티 체킹과 flush 오버헤드 때문에 JDBC 배치 대비 5~10배 느리다.

[!WARNING] FK 순서 무시

Feed를 넣기 전에 FeedLike를 넣으면 FK 제약 조건 위반으로 실패한다. DDL에 FK가 없더라도, 조회 쿼리에서 JOIN이 빈 결과를 반환해 벤치마크가 비현실적인 수치를 보여줄 수 있다.

[!WARNING] 시딩 데이터와 스키마 불일치

JSONB 컬럼에 넣는 JSON의 키 이름이 엔티티의 @JsonProperty와 다르면, jsonb_extract_path_text나 Hibernate 매핑에서 null이 반환된다. 프로덕션 코드의 직렬화 결과를 확인하고 동일한 형태로 시딩한다.