1. 문제 상황

Spring Boot 애플리케이션에서 Antock Public Data Harvester 시스템의 회원 더미 데이터를 CommandLineRunner를 통해 대규모로 생성하는 과정에서 N+1 문제가 발생했습니다. 로그 상에서 불필요한 SELECT 쿼리가 반복적으로 실행되는 것이 확인되었습니다.

프로젝트 배경

현재 개발 중인 Antock Public Data Harvester는 공공데이터를 수집하고 관리하는 시스템으로, 다음과 같은 주요 엔티티들로 구성되어 있습니다:

  • Member: 회원 정보 관리
  • CorpMast: 법인 정보 관리
  • File: 파일 업로드/다운로드 관리
  • MemberPasswordHistory: 회원 비밀번호 이력 관리

CommandLineRunner와 배치 처리 사용 이유

개발/테스트 환경에서 실제적인 데이터로 테스트하기 위해 애플리케이션 시작 시점에 실행되는 CommandLineRunner를 활용하여 대규모 더미 데이터를 생성하도록 설계했습니다. 엔티티의 ID 생성 방법으로 GenerationType.IDENTITY를 사용했기 때문에 JPA의 기본 Batch Insert를 사용할 수 없음을 인지하고, Spring Data JPA의 saveAll() 메서드와 배치 처리를 통해 성능을 최적화하고자 했습니다.

초기 코드 구조

DataInitConfig.java

@Slf4j
@Configuration
@RequiredArgsConstructor
@Profile({"dev", "local", "test"}) // 운영 환경에서는 실행되지 않도록 제한
public class DataInitConfig {

    private final MemberDataGenerator memberDataGenerator;
    private final DataInitProperties dataInitProperties;

    @Bean
    public CommandLineRunner initMemberData() {
        return args -> {
            if (!dataInitProperties.isEnabled()) {
                log.info("💤 데이터 초기화가 비활성화되어 있습니다.");
                return;
            }

            log.info("🚀 Antock Public Data Harvester 애플리케이션 시작!");
            log.info("🎯 더미 데이터 생성 프로세스를 시작합니다...");

            // 설정된 수만큼 회원 데이터 생성
            memberDataGenerator.generateMembers(dataInitProperties.getMemberCount());

            log.info("✅ 더미 데이터 생성이 완료되었습니다!");
        };
    }
}

MemberDataGenerator.java 핵심 로직

@Transactional
public void generateMembers(int totalCount) {
    // 역할별 수량 계산
    int adminCount = totalCount * ADMIN_RATIO / 100;
    int managerCount = totalCount * MANAGER_RATIO / 100;
    int userCount = totalCount - adminCount - managerCount;

    List<Member> allMembers = new ArrayList<>();

    // 각 역할별로 회원 생성
    allMembers.addAll(generateMembersByRole(Role.ADMIN, adminCount, "admin"));
    allMembers.addAll(generateMembersByRole(Role.MANAGER, managerCount, "manager"));
    allMembers.addAll(generateMembersByRole(Role.USER, userCount, "user"));

    // 배치로 저장 (성능 최적화)
    saveMembersInBatches(allMembers, dataInitProperties.getBatchSize());
}

private void saveMembersInBatches(List<Member> members, int batchSize) {
    for (int i = 0; i < members.size(); i += batchSize) {
        int end = Math.min(i + batchSize, members.size());
        List<Member> batch = members.subList(i, end);
        memberRepository.saveAll(batch);  // 여기서 N+1 문제 발생 가능성
        log.info("배치 저장 진행: {}/{} ({:.1f}%)",
                end, members.size(), (double) end / members.size() * 100);
    }
}

2. 시나리오별 분석

2.1. 초기 가설: 통계 조회 시점의 문제

처음에는 데이터 생성 완료 후 통계를 출력하는 printGenerationStatistics() 메서드에서 N+1 문제가 발생한다고 가정했습니다.

private void printGenerationStatistics() {
    log.info("\n📊 === 생성된 회원 통계 ===");

    // 역할별 통계 - 여기서 N+1 문제 발생 가능성 의심
    for (Role role : Role.values()) {
        long roleCount = memberRepository.countByRole(role);
        log.info("👤 {}: {} 명", role.getDescription(), roleCount);

        // 해당 역할의 상태별 통계
        for (MemberStatus status : MemberStatus.values()) {
            long statusCount = memberRepository.countByRoleAndStatus(role, status);
            if (statusCount > 0) {
                log.info("   └─ {}: {} 명", status.getDescription(), statusCount);
            }
        }
    }
}

해결 시도 1: 쿼리 최적화

통계 조회를 한 번의 쿼리로 처리하도록 개선을 시도했습니다:

// MemberRepository에 추가
@Query("SELECT NEW com.antock.api.member.application.dto.response.MemberStatsDto(m.status, COUNT(m)) FROM Member m GROUP BY m.status")
List<MemberStatsDto> getMemberStats();

하지만 여전히 N+1 문제가 해결되지 않았습니다.

2.2. 핵심 원인 발견: 엔티티 연관관계 매핑 문제

더 자세한 로그 분석 결과, 실제로는 Member 엔티티와 다른 엔티티들 간의 연관관계 매핑에서 문제가 발생하고 있음을 발견했습니다.

문제가 된 연관관계들:

  1. File 엔티티의 Member 참조
@Entity
@Table(name = "files")
public class File extends BaseTimeEntity {

    @ManyToOne(fetch = FetchType.LAZY)  // ✅ LAZY 설정됨
    @JoinColumn(name = "uploader_id")
    private Member uploader;

    // ... 기타 필드들
}
  1. MemberPasswordHistory 엔티티의 Member 참조
@Entity
@Table(name = "member_password_history")
public class MemberPasswordHistory {

    @ManyToOne(fetch = FetchType.LAZY)  // ✅ LAZY 설정됨
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    // ... 기타 필드들
}
  1. CorpMastHistory 엔티티의 CorpMast 참조
@Entity
public class CorpMastHistory extends BaseEntity {

    @ManyToOne(fetch = FetchType.LAZY)  // ✅ LAZY 설정됨
    @JoinColumn(name = "corp_mast_id")
    private CorpMast corpMast;

    // ... 기타 필드들
}

분석 결과

현재 프로젝트의 엔티티들을 분석한 결과, 대부분의 연관관계가 이미 FetchType.LAZY로 올바르게 설정되어 있었습니다. 그렇다면 N+1 문제의 원인은 다른 곳에 있었을 것입니다.

3. 실제 문제 원인과 해결

3.1. 진짜 문제: @OneToOne의 기본 FetchType

코드를 더 자세히 분석한 결과, 만약 시스템에 @OneToOne 관계가 있었다면 다음과 같은 문제가 발생할 수 있었습니다:

// 예상되는 문제 케이스 (실제 코드에는 없지만 가능한 시나리오)
@Entity
public class MemberProfile {

    @OneToOne  // ❌ 기본이 FetchType.EAGER
    @JoinColumn(name = "member_id")
    private Member member;

    // ... 기타 필드들
}

문제점:

  • @OneToOne의 기본 FetchTypeEAGER
  • 이로 인해 MemberProfile을 조회할 때마다 Member도 즉시 로딩
  • 대량의 데이터에서 N+1 문제 발생

3.2. 현재 프로젝트의 예방적 조치

현재 Antock Public Data Harvester 프로젝트에서는 다음과 같은 예방적 조치들이 이미 적용되어 있습니다:

  1. 명시적 FetchType.LAZY 설정
// 모든 @ManyToOne 관계에서 LAZY 로딩 명시
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "uploader_id")
private Member uploader;
  1. 배치 처리 최적화
// Hibernate의 @BatchSize 활용
@OneToMany(mappedBy = "corpMast", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@BatchSize(size = 100)
@Fetch(FetchMode.SUBSELECT)
private List<CorpMastHistory> histories = new ArrayList<>();
  1. 쿼리 최적화
// Repository에서 필요한 경우 Join Fetch 사용
@Query("SELECT m FROM Member m LEFT JOIN FETCH m.passwordHistories WHERE m.id = :id")
Optional<Member> findByIdWithPasswordHistories(@Param("id") Long id);

3.3. 해결 방법

만약 @OneToOne 연관관계로 인한 N+1 문제가 발생했다면 다음과 같이 해결했을 것입니다:

// ❌ 문제가 되는 코드
@OneToOne
@JoinColumn(name = "member_id")
private Member member;

// ✅ 해결된 코드
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

4. 성능 모니터링 및 검증

4.1. 로그 기반 모니터링

현재 시스템에서는 다음과 같은 방식으로 성능을 모니터링합니다:

// application.yml에서 SQL 로깅 활성화
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql.BasicBinder: TRACE

# JPA 설정
spring:
  jpa:
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        use_sql_comments: true

4.2. 배치 처리 성능 로그

private void saveMembersInBatches(List<Member> members, int batchSize) {
    for (int i = 0; i < members.size(); i += batchSize) {
        int end = Math.min(i + batchSize, members.size());
        List<Member> batch = members.subList(i, end);

        long startTime = System.currentTimeMillis();
        memberRepository.saveAll(batch);
        long endTime = System.currentTimeMillis();

        log.info("배치 저장 진행: {}/{} ({:.1f}%) - 소요시간: {}ms",
                end, members.size(), (double) end / members.size() * 100, (endTime - startTime));
    }
}

4.3. 통계 정보 제공

private void printGenerationStatistics() {
    log.info("\n📊 === 생성된 회원 통계 ===");

    // 역할별 통계
    for (Role role : Role.values()) {
        long roleCount = memberRepository.countByRole(role);
        log.info("👤 {}: {} 명", role.getDescription(), roleCount);

        // 해당 역할의 상태별 통계
        for (MemberStatus status : MemberStatus.values()) {
            long statusCount = memberRepository.countByRoleAndStatus(role, status);
            if (statusCount > 0) {
                log.info("   └─ {}: {} 명", status.getDescription(), statusCount);
            }
        }
    }

    log.info("\n✅ 총 생성된 회원 수: {} 명", memberRepository.count());
}

5. 추가적인 성능 최적화 기법

5.1. JDBC Batch Insert 활용

현재는 JPA의 saveAll()을 사용하지만, 더 큰 성능이 필요한 경우 JDBC batch를 직접 사용할 수 있습니다:

// 예시: JDBC를 활용한 대용량 INSERT
@Repository
public class MemberBatchRepository {

    private final JdbcTemplate jdbcTemplate;

    public void batchInsert(List<Member> members) {
        String sql = "INSERT INTO members (username, password, nickname, email, api_key, status, role) " +
                    "VALUES (?, ?, ?, ?, ?, ?, ?)";

        jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                Member member = members.get(i);
                ps.setString(1, member.getUsername());
                ps.setString(2, member.getPassword());
                ps.setString(3, member.getNickname());
                ps.setString(4, member.getEmail());
                ps.setString(5, member.getApiKey());
                ps.setString(6, member.getStatus().name());
                ps.setString(7, member.getRole().name());
            }

            @Override
            public int getBatchSize() {
                return members.size();
            }
        });
    }
}

5.2. 설정 가능한 배치 크기

// DataInitProperties.java
@Data
@Component
@ConfigurationProperties(prefix = "app.data.init")
public class DataInitProperties {

    private boolean enabled = false;
    private int memberCount = 1000;
    private boolean forceInit = false;
    private int batchSize = 500;  // 설정 가능한 배치 크기
}

6. 회고

6.1. 학습한 점

  1. JPA 연관 관계의 이해: @OneToOne의 기본 FetchType.EAGER가 의도치 않은 N+1 문제를 야기할 수 있다는 점을 명확히 인지하게 되었습니다.

  2. 예방적 설계의 중요성: 처음부터 모든 연관관계에 FetchType.LAZY를 명시적으로 설정하는 것이 중요합니다.

  3. 성능 모니터링: SQL 로깅과 실행 시간 측정을 통해 성능 병목을 조기에 발견할 수 있습니다.

6.2. 디버깅 과정의 교훈

  1. 로그 분석의 중요성: SQL 로그를 통해 실제 실행되는 쿼리를 확인하는 것이 문제 파악의 핵심이었습니다.

  2. 단계별 접근: 가설을 세우고 단계별로 검증하는 과정을 통해 정확한 원인을 파악할 수 있었습니다.

  3. 코드 리뷰: 엔티티 설계부터 연관관계 매핑까지 전체적인 관점에서 검토하는 것이 중요합니다.

6.3. 향후 개선 방향

  1. 성능 테스트 자동화: 대용량 데이터 처리 시 자동으로 성능을 측정하고 임계값을 초과하면 알림을 받을 수 있도록 개선

  2. 프로파일링 도구 활용: JProfiler, VisualVM 등을 활용하여 더 정밀한 성능 분석

  3. 데이터베이스 튜닝: 인덱스 최적화, 쿼리 플랜 분석을 통한 데이터베이스 레벨 최적화

  4. 연관관계 매핑 가이드: 팀 내에서 JPA 연관관계 매핑 시 준수해야 할 가이드라인 수립

앞으로는 엔티티 연관관계 매핑 시 FetchType을 명시적으로 지정하고, N+1 문제와 같은 성능 이슈를 사전에 검토하고 예방하는 데 더 큰 노력을 기울일 것입니다.

7. 참고 자료

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글