Spring Boot 애플리케이션에서 Antock Public Data Harvester 시스템의 회원 더미 데이터를 CommandLineRunner
를 통해 대규모로 생성하는 과정에서 N+1 문제가 발생했습니다. 로그 상에서 불필요한 SELECT 쿼리가 반복적으로 실행되는 것이 확인되었습니다.
현재 개발 중인 Antock Public Data Harvester는 공공데이터를 수집하고 관리하는 시스템으로, 다음과 같은 주요 엔티티들로 구성되어 있습니다:
개발/테스트 환경에서 실제적인 데이터로 테스트하기 위해 애플리케이션 시작 시점에 실행되는 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);
}
}
처음에는 데이터 생성 완료 후 통계를 출력하는 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 문제가 해결되지 않았습니다.
더 자세한 로그 분석 결과, 실제로는 Member 엔티티와 다른 엔티티들 간의 연관관계 매핑에서 문제가 발생하고 있음을 발견했습니다.
문제가 된 연관관계들:
@Entity
@Table(name = "files")
public class File extends BaseTimeEntity {
@ManyToOne(fetch = FetchType.LAZY) // ✅ LAZY 설정됨
@JoinColumn(name = "uploader_id")
private Member uploader;
// ... 기타 필드들
}
@Entity
@Table(name = "member_password_history")
public class MemberPasswordHistory {
@ManyToOne(fetch = FetchType.LAZY) // ✅ LAZY 설정됨
@JoinColumn(name = "member_id", nullable = false)
private Member member;
// ... 기타 필드들
}
@Entity
public class CorpMastHistory extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) // ✅ LAZY 설정됨
@JoinColumn(name = "corp_mast_id")
private CorpMast corpMast;
// ... 기타 필드들
}
현재 프로젝트의 엔티티들을 분석한 결과, 대부분의 연관관계가 이미 FetchType.LAZY
로 올바르게 설정되어 있었습니다. 그렇다면 N+1 문제의 원인은 다른 곳에 있었을 것입니다.
코드를 더 자세히 분석한 결과, 만약 시스템에 @OneToOne 관계가 있었다면 다음과 같은 문제가 발생할 수 있었습니다:
// 예상되는 문제 케이스 (실제 코드에는 없지만 가능한 시나리오)
@Entity
public class MemberProfile {
@OneToOne // ❌ 기본이 FetchType.EAGER
@JoinColumn(name = "member_id")
private Member member;
// ... 기타 필드들
}
문제점:
@OneToOne
의 기본 FetchType
은 EAGER
현재 Antock Public Data Harvester 프로젝트에서는 다음과 같은 예방적 조치들이 이미 적용되어 있습니다:
// 모든 @ManyToOne 관계에서 LAZY 로딩 명시
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "uploader_id")
private Member uploader;
// Hibernate의 @BatchSize 활용
@OneToMany(mappedBy = "corpMast", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@BatchSize(size = 100)
@Fetch(FetchMode.SUBSELECT)
private List<CorpMastHistory> histories = new ArrayList<>();
// 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);
만약 @OneToOne
연관관계로 인한 N+1 문제가 발생했다면 다음과 같이 해결했을 것입니다:
// ❌ 문제가 되는 코드
@OneToOne
@JoinColumn(name = "member_id")
private Member member;
// ✅ 해결된 코드
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
현재 시스템에서는 다음과 같은 방식으로 성능을 모니터링합니다:
// 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
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));
}
}
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());
}
현재는 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();
}
});
}
}
// 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; // 설정 가능한 배치 크기
}
JPA 연관 관계의 이해: @OneToOne
의 기본 FetchType.EAGER
가 의도치 않은 N+1 문제를 야기할 수 있다는 점을 명확히 인지하게 되었습니다.
예방적 설계의 중요성: 처음부터 모든 연관관계에 FetchType.LAZY
를 명시적으로 설정하는 것이 중요합니다.
성능 모니터링: SQL 로깅과 실행 시간 측정을 통해 성능 병목을 조기에 발견할 수 있습니다.
로그 분석의 중요성: SQL 로그를 통해 실제 실행되는 쿼리를 확인하는 것이 문제 파악의 핵심이었습니다.
단계별 접근: 가설을 세우고 단계별로 검증하는 과정을 통해 정확한 원인을 파악할 수 있었습니다.
코드 리뷰: 엔티티 설계부터 연관관계 매핑까지 전체적인 관점에서 검토하는 것이 중요합니다.
성능 테스트 자동화: 대용량 데이터 처리 시 자동으로 성능을 측정하고 임계값을 초과하면 알림을 받을 수 있도록 개선
프로파일링 도구 활용: JProfiler, VisualVM 등을 활용하여 더 정밀한 성능 분석
데이터베이스 튜닝: 인덱스 최적화, 쿼리 플랜 분석을 통한 데이터베이스 레벨 최적화
연관관계 매핑 가이드: 팀 내에서 JPA 연관관계 매핑 시 준수해야 할 가이드라인 수립
앞으로는 엔티티 연관관계 매핑 시 FetchType
을 명시적으로 지정하고, N+1 문제와 같은 성능 이슈를 사전에 검토하고 예방하는 데 더 큰 노력을 기울일 것입니다.