Repository: tikkle-a501/tikkle
Pull request #22: feat: 계좌 스냅샷 생성 배치 처리 최적화
Author: login: glenn-syj
Created: 2025-03-05T07:09:04Z | Merged: 2025-03-10T04:59:36Z

## 계좌 스냅샷 생성 배치 처리 최적화

### 요약
- 계좌 스냅샷 생성 시 성능 개선
- JPA 배치 사이즈 기준 100개, 1000개 테스트
- 다양한 전략(JPA, JDBC, Stream, Paging)을 테스트하여 최적의 방식 도출

### 주요 변경 사항
1. **배치 처리 최적화**  
   - 배치 크기를 5000, 1000, 100으로 조정하여 시도하며 메모리 사용량 및 속도 측정
   - 5만 건 데이터에 대해 약 190_000ms, 130_000ms, 100_000ms로 배치 사이즈 100 기준 비교

2. **다양한 스냅샷 생성 전략 구현 및 테스트**  
   - 테스트 시 버퍼 풀 사이즈 256MB이므로, 운영 환경에서는 더욱 좋은 효율 예상 
   - JPA 기반 스냅샷 전략 최적화
     - saveAll() with batch size 100 
   - JDBC 템플릿 기반 직접 배치 처리 구현
     - 엔티티 가공 비즈니스 로직이 따로 필요 없으므로 INSERT INTO 문 이용
   - Stream API 및 Paging 기반 전략
   - 각 전략별 성능 비교 테스트 구현
   - 트랜잭션 관리 방식 개선으로 테스트 효율성 확보
     - 스냅샷 생성에 필요한 멤버 및 계좌 테스트 데이터 생성 이후 롤백되지 않아야 함 
     - `@Transactional(propagation = Propagation.NOT_SUPPORTED)` 어노테이션 추가 

3. **성능 개선 결과**
   - 50,000개 데이터 삽입 시간 약 1.1 ~ 1.3초로 최적화 (JDBC 전략 사용 시)
   - JDBC 전략은 따로 어플리케이션 내 메모리 이용하지 않음

### 기능 검증
- `SnapshotPerformanceTest`를 통한 각 전략별 성능 테스트 완료
- 테스트 환경에서 제한된 리소스(버퍼 풀 256MB)에서도 우수한 성능 확인
- 트랜잭션 관리 최적화로 테스트 간 데이터 일관성 유지

### 향후 개선점
- 대용량 데이터(100만 건 이상)에 대한 추가 성능 테스트
- 병렬 처리를 통한 추가 성능 개선 가능성 검토
- 프로덕션 환경에 맞는 버퍼 풀 크기 및 배치 크기 최적화
- 스냅샷에 기반하는 정산 로직도 JDBC 템플릿 이용해 개선

### 관련 이슈
- #20 

---



Diff:
diff --git a/back/tikkle/build.gradle b/back/tikkle/build.gradle
index 4c6285a..ac52c04 100644
--- a/back/tikkle/build.gradle
+++ b/back/tikkle/build.gradle
@@ -54,6 +54,9 @@ dependencies {
     testImplementation 'org.springframework.kafka:spring-kafka-test'
     testImplementation 'org.springframework.modulith:spring-modulith-starter-test'
     testImplementation 'org.springframework.security:spring-security-test'
+    testImplementation("org.testcontainers:testcontainers")
+    testImplementation("org.testcontainers:mariadb")
+    testImplementation("org.testcontainers:junit-jupiter")
     testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
 }
 
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/entity/Account.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/entity/Account.java
index c5c976e..e6deb8d 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/entity/Account.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/entity/Account.java
@@ -67,5 +67,7 @@ private void validTimeQntAndRankingPoint(Integer timeQnt, Integer rankingPoint)
 	public void setBalance(Integer timeQnt) {
 		this.timeQnt = timeQnt;
 	}
+
+	public void setRankingPoint(Integer rankingPoint) { this.rankingPoint = rankingPoint; }
 }
 
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/repository/AccountRepository.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/repository/AccountRepository.java
index 01cd041..7ed71af 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/repository/AccountRepository.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/repository/AccountRepository.java
@@ -40,5 +40,6 @@ Optional<Integer> getTotalQuantityByExchangeTypeAndPeriod(@Param("startTime") Lo
 
 	Optional<Account> findByMemberIdAndDeletedAtIsNull(UUID id);
 
+	@Query("SELECT a FROM Account a")
 	Slice<Account> findAllAsSlice(Pageable pageable);
 }
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/repository/BalanceSnapshotCustomRepositoryImpl.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/repository/BalanceSnapshotCustomRepositoryImpl.java
index b3d7513..4898ebc 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/repository/BalanceSnapshotCustomRepositoryImpl.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/repository/BalanceSnapshotCustomRepositoryImpl.java
@@ -22,11 +22,12 @@ public int insertDirectlyFromAccounts() {
         // INSERT INTO SELECT 문으로 애플리케이션 메모리 사용 없이 DB 내부에서 직접 데이터 변환 및 삽입
         int rowsAffected = jdbcClient.sql(
                         "INSERT INTO balance_snapshot (id, account_id, time_qnt, ranking_point, created_at) " +
-                        "SELECT " +
-                        "  CONCAT(UNHEX(LPAD(HEX(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000)), 12, '0')), RANDOM_BYTES(10)), " +
-                        "  id, time_qnt, ranking_point, NOW() " +
-                        "FROM accounts")
-                    .update();
+                                "SELECT " +
+                                "  CONCAT(UNHEX(LPAD(HEX(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000)), 12, '0')), " +
+                                "  UNHEX(LEFT(MD5(CONCAT(UUID(), RAND())), 20))), " + // 랜덤 부분 (80비트 = 10바이트)
+                                "  id, time_qnt, ranking_point, NOW() " +
+                                "FROM accounts")
+                .update();
 
         logger.info("INSERT INTO SELECT completed, inserted {} records", rowsAffected);
 
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SettlementService.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SettlementService.java
index 4f760a9..c7ce60d 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SettlementService.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SettlementService.java
@@ -3,17 +3,17 @@
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.*;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 import com.taesan.tikkle.domain.account.dto.ExchangeType;
 import com.taesan.tikkle.domain.account.entity.BalanceSnapshot;
-import com.taesan.tikkle.domain.account.entity.DepositLog;
 import com.taesan.tikkle.domain.account.repository.BalanceSnapshotRepository;
 import com.taesan.tikkle.domain.account.repository.DepositLogRepository;
-import com.taesan.tikkle.domain.appointment.repository.AppointmentRepository;
+import com.taesan.tikkle.domain.account.service.strategy.SnapshotStrategy;
+import com.taesan.tikkle.domain.account.service.strategy.SnapshotStrategyType;
 import com.taesan.tikkle.domain.board.entity.Board;
 import com.taesan.tikkle.domain.board.repository.BoardRepository;
-import com.taesan.tikkle.domain.config.security.CustomUserDetails;
 import jakarta.annotation.PostConstruct;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -50,12 +50,12 @@ public class SettlementService {
 
     @PostConstruct
     public void init() {
-        // 기본 전략으로 원래 findAll()만 이용하던 JPA 코드 이용
-        snapshotStrategyType = SnapshotStrategyType.JPA;
+        // 성능 테스트 이후 어플리케이션 메모리 사용 거의 없으며, 수행 시간 낮은 JDBC 전략 이용
+        snapshotStrategyType = SnapshotStrategyType.JDBC;
         snapshotStrategy = snapshotStrategyMap.get(snapshotStrategyType.getBeanName());
 
         if (snapshotStrategy == null) {
-            throw new IllegalStateException("Default strategy (JPA) not found!");
+            throw new IllegalStateException("Default strategy (JDBC) not found!");
         }
 
         logger.info("Initialized with default strategy: {} ({})",
@@ -154,11 +154,11 @@ public void createSnapshots() {
         logger.info("Creating snapshots with strategy: {} ({})",
                 snapshotStrategyType.name(), snapshotStrategyType.getDescription());
 
-        long startTime = System.currentTimeMillis();
+        long startTime = System.nanoTime();
         snapshotStrategy.createSnapShots();
-        long endTime = System.currentTimeMillis();
+        long endTime = System.nanoTime();
 
-        logger.info("Completed snapshot creation in {} ms", (endTime - startTime));
+        logger.info("Completed snapshot creation in {} ms", TimeUnit.NANOSECONDS.toMillis(endTime - startTime));
     }
 }
 
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/JdbcSnapshotStrategy.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/JdbcSnapshotStrategy.java
new file mode 100644
index 0000000..75e96f0
--- /dev/null
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/JdbcSnapshotStrategy.java
@@ -0,0 +1,39 @@
+package com.taesan.tikkle.domain.account.service.strategy;
+
+import com.taesan.tikkle.domain.account.entity.Account;
+import com.taesan.tikkle.domain.account.entity.BalanceSnapshot;
+import com.taesan.tikkle.domain.account.repository.BalanceSnapshotCustomRepository;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@Component("jdbcSnapshotStrategy")
+@RequiredArgsConstructor
+public class JdbcSnapshotStrategy implements SnapshotStrategy {
+
+    private final BalanceSnapshotCustomRepository balanceSnapshotCustomRepository;
+    private static final Logger logger = LoggerFactory.getLogger(JdbcSnapshotStrategy.class);
+
+    @Override
+    @Transactional
+    public void createSnapShots() {
+        logger.info("Creating snapshots using JDBC");
+        int insertedCount = balanceSnapshotCustomRepository.insertDirectlyFromAccounts();
+        logger.info("Created {} snapshots using JDBC directly SELECT INTO", insertedCount);
+    }
+
+    @Override
+    public String getStrategyName() {
+        return SnapshotStrategyType.JDBC.getDescription();
+    }
+
+    private BalanceSnapshot convertToSnapshot(Account account) {
+        return BalanceSnapshot.builder()
+                .accountId(account.getId())
+                .timeQnt(account.getTimeQnt())
+                .rankingPoint(account.getRankingPoint())
+                .build();
+    }
+}
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/JpaSnapshotStrategy.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/JpaSnapshotStrategy.java
similarity index 96%
rename from back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/JpaSnapshotStrategy.java
rename to back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/JpaSnapshotStrategy.java
index 4984239..9664dbc 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/JpaSnapshotStrategy.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/JpaSnapshotStrategy.java
@@ -1,4 +1,4 @@
-package com.taesan.tikkle.domain.account.service;
+package com.taesan.tikkle.domain.account.service.strategy;
 
 import com.taesan.tikkle.domain.account.entity.Account;
 import com.taesan.tikkle.domain.account.entity.BalanceSnapshot;
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/PagingSnapshotStrategy.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/PagingSnapshotStrategy.java
similarity index 92%
rename from back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/PagingSnapshotStrategy.java
rename to back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/PagingSnapshotStrategy.java
index 0788e6f..faefabf 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/PagingSnapshotStrategy.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/PagingSnapshotStrategy.java
@@ -1,4 +1,4 @@
-package com.taesan.tikkle.domain.account.service;
+package com.taesan.tikkle.domain.account.service.strategy;
 
 import com.taesan.tikkle.domain.account.entity.Account;
 import com.taesan.tikkle.domain.account.entity.BalanceSnapshot;
@@ -9,17 +9,13 @@
 import lombok.RequiredArgsConstructor;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.domain.Slice;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.time.LocalDateTime;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.UUID;
 import java.util.stream.Collectors;
 
 @Component("pagingSnapshotStrategy")
@@ -66,7 +62,7 @@ public void createSnapShots() {
 
             // 4. 다음 Slice 준비
             pageNumber++;
-            hasMoreData = !accountSlice.hasNext();
+            hasMoreData = accountSlice.hasNext();
         }
 
         logger.info("Created {} snapshots using Paging strategy", totalProcessed);
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SnapshotStrategy.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/SnapshotStrategy.java
similarity index 62%
rename from back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SnapshotStrategy.java
rename to back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/SnapshotStrategy.java
index 29f03ec..9cc4ac1 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SnapshotStrategy.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/SnapshotStrategy.java
@@ -1,4 +1,4 @@
-package com.taesan.tikkle.domain.account.service;
+package com.taesan.tikkle.domain.account.service.strategy;
 
 public interface SnapshotStrategy {
     void createSnapShots();
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SnapshotStrategyType.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/SnapshotStrategyType.java
similarity index 56%
rename from back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SnapshotStrategyType.java
rename to back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/SnapshotStrategyType.java
index 4faf4ea..7495931 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SnapshotStrategyType.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/SnapshotStrategyType.java
@@ -1,10 +1,10 @@
-package com.taesan.tikkle.domain.account.service;
+package com.taesan.tikkle.domain.account.service.strategy;
 
 public enum SnapshotStrategyType {
-    JPA("JPA findAll()", "jpaSnapshotStrategy"),
-    STREAM("STREAM API", "streamSnapshotStrategy"),
-    PAGING("PAGING", "pagingSnapshotStrategy"),
-    JDBC("JDBC BULK INSERT", "jdbcSnapshotStrategy");
+    JPA("JPA findAll() saveAll() with Batch", "jpaSnapshotStrategy"),
+    STREAM("STREAM API with Batch", "streamSnapshotStrategy"),
+    PAGING("PAGING with Batch", "pagingSnapshotStrategy"),
+    JDBC("JDBC SELECT INTO", "jdbcSnapshotStrategy");
 
     private final String description;
     private final String beanName;
diff --git a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/StreamSnapshotStrategy.java b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/StreamSnapshotStrategy.java
similarity index 92%
rename from back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/StreamSnapshotStrategy.java
rename to back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/StreamSnapshotStrategy.java
index ac884ca..f312e2f 100644
--- a/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/StreamSnapshotStrategy.java
+++ b/back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/strategy/StreamSnapshotStrategy.java
@@ -1,9 +1,8 @@
-package com.taesan.tikkle.domain.account.service;
+package com.taesan.tikkle.domain.account.service.strategy;
 
 import com.taesan.tikkle.domain.account.entity.Account;
 import com.taesan.tikkle.domain.account.entity.BalanceSnapshot;
 import com.taesan.tikkle.domain.account.repository.AccountCustomRepository;
-import com.taesan.tikkle.domain.account.repository.AccountRepository;
 import com.taesan.tikkle.domain.account.repository.BalanceSnapshotRepository;
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.PersistenceContext;
@@ -11,13 +10,10 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
 
-import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
-import java.util.UUID;
 import java.util.stream.Stream;
 
 @Component("streamSnapshotStrategy")
diff --git a/back/tikkle/src/test/java/com/taesan/tikkle/domain/account/SnapshotPerformanceTest.java b/back/tikkle/src/test/java/com/taesan/tikkle/domain/account/SnapshotPerformanceTest.java
new file mode 100644
index 0000000..85099ac
--- /dev/null
+++ b/back/tikkle/src/test/java/com/taesan/tikkle/domain/account/SnapshotPerformanceTest.java
@@ -0,0 +1,328 @@
+package com.taesan.tikkle.domain.account;
+
+import com.github.f4b6a3.ulid.UlidCreator;
+import com.taesan.tikkle.domain.account.repository.AccountRepository;
+import com.taesan.tikkle.domain.account.repository.BalanceSnapshotCustomRepository;
+import com.taesan.tikkle.domain.account.repository.BalanceSnapshotRepository;
+import com.taesan.tikkle.domain.account.service.*;
+import com.taesan.tikkle.domain.account.service.strategy.*;
+import com.taesan.tikkle.domain.member.repository.MemberRepository;
+import com.taesan.tikkle.domain.organization.entity.Organization;
+import com.taesan.tikkle.domain.organization.repository.OrganizationRepository;
+import org.junit.jupiter.api.*;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.BatchPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import org.testcontainers.containers.MariaDBContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.nio.ByteBuffer;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+@DataJpaTest
+@Testcontainers
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Import({
+        JpaSnapshotStrategy.class,
+        JdbcSnapshotStrategy.class,
+        PagingSnapshotStrategy.class,
+        StreamSnapshotStrategy.class,
+        SnapshotTestConfig.class,
+        SettlementService.class,
+})
+public class SnapshotPerformanceTest {
+
+    private static final Logger logger = LoggerFactory.getLogger(SnapshotPerformanceTest.class);
+
+    @Container
+    static MariaDBContainer<?> mariaDBContainer = new MariaDBContainer<>("mariadb:10.6")
+            .withDatabaseName("snapshot_test")
+            .withUsername("test")
+            .withPassword("test")
+            .withCommand("--character-set-server=utf8mb4",
+                    "--collation-server=utf8mb4_unicode_ci",
+                    "--innodb-buffer-pool-size=256M");
+
+    @DynamicPropertySource
+    static void registerMariaDBProperties(DynamicPropertyRegistry registry) {
+        registry.add("spring.datasource.url", () -> mariaDBContainer.getJdbcUrl() + "?rewriteBatchedStatements=true");
+
+        registry.add("spring.datasource.username", mariaDBContainer::getUsername);
+        registry.add("spring.datasource.password", mariaDBContainer::getPassword);
+        registry.add("spring.datasource.driver-class-name", mariaDBContainer::getDriverClassName);
+
+        // JPA 설정 추가
+        registry.add("spring.jpa.hibernate.ddl-auto", () -> "create");
+        registry.add("spring.jpa.show-sql", () -> "false");
+        registry.add("spring.jpa.properties.hibernate.format_sql", () -> "true");
+        registry.add("spring.jpa.properties.hibernate.generate_statistics", () -> "true");
+        registry.add("logging.level.org.hibernate.SQL", () -> "DEBUG");
+        registry.add("logging.level.org.hibernate.type.descriptor.sql.BasicBinder", () -> "TRACE");
+        registry.add("logging.level.org.hibernate.jdbc.batch", () -> "TRACE");
+
+
+        registry.add("spring.jpa.properties.hibernate.jdbc.batch_size", () -> "100");
+        registry.add("spring.jpa.properties.hibernate.order_inserts", () -> "true");
+        registry.add("spring.jpa.properties.hibernate.order_updates", () -> "true");
+        registry.add("spring.jpa.properties.hibernate.jdbc.batch_versioned_data", () -> "true");
+    }
+
+    @Autowired
+    private AccountRepository accountRepository;
+
+    @Autowired
+    private MemberRepository memberRepository;
+
+    @Autowired
+    private OrganizationRepository organizationRepository;
+
+    @Autowired
+    private BalanceSnapshotRepository balanceSnapshotRepository;
+
+    @Autowired
+    private BalanceSnapshotCustomRepository balanceSnapshotCustomRepository;
+
+    // Bean으로 올라간 snapshotStrategy에 대한 Map 주입
+    @Autowired
+    private Map<String, SnapshotStrategy> snapshotStrategyMap;
+
+    @Autowired
+    private SettlementService settlementService;
+
+    @Autowired
+    private JdbcTemplate jdbcTemplate;
+
+    private static final int TEST_DATA_SIZE = 50000; // 5만 개의 계정으로 테스트
+    private static boolean dataInitialized = false;
+
+    @BeforeAll
+    static void setUp() {
+        logger.info("Starting MariaDB container for performance tests");
+    }
+
+    @BeforeEach
+    void setUpEach() {
+        if (!dataInitialized) {
+            logger.info("Initializing test data...");
+
+            // 테스트 조직 생성
+            Organization testOrg = Organization.builder()
+                    .name("TEST_ORG")
+                    .domainAddr("test.tikkle.com")
+                    .provider(0)
+                    .paymentPolicy("test")
+                    .build();
+            organizationRepository.save(testOrg);
+
+            // 테스트 데이터 생성
+            generateTestData();
+
+            dataInitialized = true;
+            logger.info("Test data initialization completed. {} accounts created.", TEST_DATA_SIZE);
+        }
+
+        // 각 테스트 전에 스냅샷 테이블 비우기
+        balanceSnapshotRepository.deleteAllInBatch();
+        logger.info("Cleared balance snapshot table for next test");
+    }
+
+    @AfterAll
+    static void tearDown() {
+        logger.info("Performance tests completed. Shutting down MariaDB container");
+    }
+
+    // 테스트 데이터 생성 시에는 JDBC 배치 이용함
+    private void generateTestData() {
+        logger.info("Generating {} test accounts using JdbcClient...", TEST_DATA_SIZE);
+
+        // 테스트 조직 가져오기
+        Organization org = organizationRepository.findAll().get(0);
+        UUID orgId = org.getId();
+
+        // 배치 크기 설정
+        int batchSize = 5000;
+        int count = TEST_DATA_SIZE;
+
+        try {
+            for (int i = 0; i < count; i += batchSize) {
+                int currentBatchSize = Math.min(batchSize, count - i);
+                long startTime = System.nanoTime();
+
+                // 1. 회원 일괄 삽입 준비
+                final String memberSql = "INSERT INTO members (id, email, name, organization_id, role, created_at) VALUES (?, ?, ?, ?, ?, NOW())";
+                List<UUID> memberIds = new ArrayList<>(currentBatchSize);
+
+                int finalI1 = i;
+                int[] memberResults = jdbcTemplate.batchUpdate(memberSql, new BatchPreparedStatementSetter() {
+                    @Override
+                    public void setValues(PreparedStatement ps, int j) throws SQLException {
+                        int uniqueId = finalI1 + j;
+                        UUID memberId = UlidCreator.getMonotonicUlid().toUuid();
+                        memberIds.add(memberId);
+
+                        ps.setObject(1, uuidToBytes(memberId));
+                        ps.setString(2, "test" + uniqueId + "@example.com");
+                        ps.setString(3, "Test User " + uniqueId);
+                        ps.setObject(4, uuidToBytes(orgId));
+                        ps.setString(5, "ROLE_USER");
+                    }
+
+                    @Override
+                    public int getBatchSize() {
+                        return currentBatchSize;
+                    }
+                });
+
+                logger.debug("Inserted {} members", Arrays.stream(memberResults).sum());
+
+                // 2. 계정 일괄 삽입 준비
+                final String accountSql = "INSERT INTO accounts (id, member_id, time_qnt, ranking_point, created_at) VALUES (?, ?, ?, ?, NOW())";
+
+                int finalI = i;
+                int[] accountResults = jdbcTemplate.batchUpdate(accountSql, new BatchPreparedStatementSetter() {
+                    @Override
+                    public void setValues(PreparedStatement ps, int j) throws SQLException {
+                        UUID accountId = UlidCreator.getMonotonicUlid().toUuid();
+                        int timeQnt = (finalI + j) % 1000;
+                        int rankingPoint = (finalI + j) % 500;
+
+                        ps.setObject(1, uuidToBytes(accountId));
+                        ps.setObject(2, uuidToBytes(memberIds.get(j)));
+                        ps.setInt(3, timeQnt);
+                        ps.setInt(4, rankingPoint);
+                    }
+
+                    @Override
+                    public int getBatchSize() {
+                        return currentBatchSize;
+                    }
+                });
+
+                long endTime = System.nanoTime();
+                logger.info("Generated batch {} to {} in {} ms",
+                        i, i + currentBatchSize - 1, TimeUnit.NANOSECONDS.toMillis(endTime - startTime));
+            }
+
+            // 저장된 계정 수 확인
+            Long accountCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM accounts", Long.class);
+
+            logger.info("Account generation completed. Total accounts: {}", accountCount);
+
+            if (accountCount == 0) {
+                logger.error("No accounts were saved! Check database constraints and table names.");
+            }
+
+        } catch (Exception e) {
+            logger.error("Error generating test data: {}", e.getMessage(), e);
+            throw new RuntimeException("Failed to generate test data", e);
+        }
+    }
+
+    @Test
+    @Transactional(propagation = Propagation.NOT_SUPPORTED)
+    @Order(1)
+    public void testJpaStrategy() {
+        logger.info("Testing JPA strategy...");
+        SnapshotStrategy strategy = snapshotStrategyMap.get(SnapshotStrategyType.JPA.getBeanName());
+        testStrategy(strategy, SnapshotStrategyType.JPA);
+    }
+
+    @Test
+    @Transactional(propagation = Propagation.NOT_SUPPORTED)
+    @Order(2)
+    public void testStreamStrategy() {
+        logger.info("Testing Stream API strategy...");
+        SnapshotStrategy strategy = snapshotStrategyMap.get(SnapshotStrategyType.STREAM.getBeanName());
+        testStrategy(strategy, SnapshotStrategyType.STREAM);
+    }
+
+    @Test
+    @Transactional(propagation = Propagation.NOT_SUPPORTED)
+    @Order(3)
+    public void testPagingStrategy() {
+        logger.info("Testing Paging strategy...");
+        SnapshotStrategy strategy = snapshotStrategyMap.get(SnapshotStrategyType.PAGING.getBeanName());
+        testStrategy(strategy, SnapshotStrategyType.PAGING);
+    }
+
+    @Test
+    @Transactional(propagation = Propagation.NOT_SUPPORTED)
+    @Order(4)
+    public void testJdbcStrategy() {
+        logger.info("Testing JDBC strategy...");
+        SnapshotStrategy strategy = snapshotStrategyMap.get(SnapshotStrategyType.JDBC.getBeanName());
+        if (strategy == null) {
+            logger.warn("JDBC strategy not implemented yet. Skipping test.");
+            return;
+        }
+        testStrategy(strategy, SnapshotStrategyType.JDBC);
+    }
+
+    private void testStrategy(SnapshotStrategy strategy, SnapshotStrategyType strategyType) {
+        if (strategy == null) {
+            logger.warn("Strategy {} not implemented. Skipping test.", strategyType.getDescription());
+            return;
+        }
+
+        try {
+            Thread.sleep(1000);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+
+        // 메모리 및 시간 측정
+        long startMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
+        long startTime = System.nanoTime();
+
+        // 전략 실행
+        settlementService.setSnapshotStrategy(strategyType, strategy);
+        settlementService.createSnapshots();
+
+        long endTime = System.nanoTime();
+        long endMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
+
+        // 결과 계산
+        long duration = TimeUnit.NANOSECONDS.toMillis(endTime - startTime);
+        long memoryUsed = (endMemory - startMemory) / (1024 * 1024); // MB 단위
+        long snapshotCount = balanceSnapshotRepository.count();
+
+        logger.info("Strategy: {}, Execution time: {} ms, Memory used: {} MB, Snapshots created: {}",
+                strategyType.getDescription(), duration, memoryUsed, snapshotCount);
+
+        Assertions.assertEquals(TEST_DATA_SIZE, snapshotCount,
+                "Expected " + TEST_DATA_SIZE + " snapshots, but found " + snapshotCount);
+    }
+    /**
+     * UUID를 BINARY(16) 형식으로 변환
+     */
+    private byte[] uuidToBytes(UUID uuid) {
+        ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
+        bb.putLong(uuid.getMostSignificantBits());
+        bb.putLong(uuid.getLeastSignificantBits());
+        return bb.array();
+    }
+
+    /**
+     * BINARY(16)를 UUID로 변환
+     */
+    private UUID bytesToUuid(byte[] bytes) {
+        ByteBuffer bb = ByteBuffer.wrap(bytes);
+        long mostSigBits = bb.getLong();
+        long leastSigBits = bb.getLong();
+        return new UUID(mostSigBits, leastSigBits);
+    }
+}
\ No newline at end of file
diff --git a/back/tikkle/src/test/java/com/taesan/tikkle/domain/account/SnapshotTestConfig.java b/back/tikkle/src/test/java/com/taesan/tikkle/domain/account/SnapshotTestConfig.java
new file mode 100644
index 0000000..59840b4
--- /dev/null
+++ b/back/tikkle/src/test/java/com/taesan/tikkle/domain/account/SnapshotTestConfig.java
@@ -0,0 +1,24 @@
+package com.taesan.tikkle.domain.account;
+
+import com.taesan.tikkle.domain.account.repository.AccountCustomRepository;
+import com.taesan.tikkle.domain.account.repository.AccountCustomRepositoryImpl;
+import com.taesan.tikkle.domain.account.repository.BalanceSnapshotCustomRepository;
+import com.taesan.tikkle.domain.account.repository.BalanceSnapshotCustomRepositoryImpl;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.simple.JdbcClient;
+
+@TestConfiguration
+public class SnapshotTestConfig {
+
+    @Bean
+    public BalanceSnapshotCustomRepository balanceSnapshotCustomRepository(JdbcClient jdbcClient) {
+        return new BalanceSnapshotCustomRepositoryImpl(jdbcClient);
+    }
+
+    @Bean
+    public AccountCustomRepository accountCustomRepository(JdbcClient jdbcClient) {
+        return new AccountCustomRepositoryImpl();
+    }
+}


Reviews:
user: login: Lee9Bin
body: 고생하셨습니다!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-05T09:29:28Z
user: login: glenn-syj
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T16:39:49Z
user: login: glenn-syj
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T17:06:42Z
user: login: HurDong
body: 오늘 열강 잘 들었습니다. IT 서비스 기업 잘 가실 것 같습니다.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-09T11:18:41Z

Review comments:
diff_hunk: @@ -54,6 +54,9 @@ dependencies {
     testImplementation 'org.springframework.kafka:spring-kafka-test'
     testImplementation 'org.springframework.modulith:spring-modulith-starter-test'
     testImplementation 'org.springframework.security:spring-security-test'
+    testImplementation("org.testcontainers:testcontainers")
+    testImplementation("org.testcontainers:mariadb")
+    testImplementation("org.testcontainers:junit-jupiter")
path: back/tikkle/build.gradle
user: login: glenn-syj
body: ### Testcontainers

Testcontainers는 테스트 시 H2와 같은 인메모리 데이터베이스 대신, 테스트 환경에서도 운영 환경의 DB와 동일한 mariadb를 기준으로 테스트하기 위해 사용했습니다. 저희 프로젝트가 Java 17을 사용하는 만큼, testconatiners의 실행 요구 사항도 만족하구요. 

평소에 관심도 있던 오픈소스 라이브러리이기도 하고, docker를 사용하는 환경에서 따로 테스트 DB를 만들어 수동으로 올려주고 관리해야 하는 불편을 감소할 수 있어 채택했습니다.

### References

- [https://testcontainers.com/guides/testing-spring-boot-rest-api-using-testcontainers/](https://testcontainers.com/guides/testing-spring-boot-rest-api-using-testcontainers/)
- [https://docs.spring.io/spring-boot/reference/testing/testcontainers.html](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html)
- [https://www.docker.com/blog/testcontainers-cloud-vs-docker-in-docker-for-testing-scenarios/#:~:text=Testcontainers%20is%20a%20popular%20open,run%20in%20a%20Docker%20container.](https://www.docker.com/blog/testcontainers-cloud-vs-docker-in-docker-for-testing-scenarios/#:~:text=Testcontainers%20is%20a%20popular%20open,run%20in%20a%20Docker%20container.)

created_at: 2025-03-05T16:39:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -154,11 +154,11 @@ public void createSnapshots() {
         logger.info("Creating snapshots with strategy: {} ({})",
                 snapshotStrategyType.name(), snapshotStrategyType.getDescription());
 
-        long startTime = System.currentTimeMillis();
+        long startTime = System.nanoTime();
         snapshotStrategy.createSnapShots();
-        long endTime = System.currentTimeMillis();
+        long endTime = System.nanoTime();
path: back/tikkle/src/main/java/com/taesan/tikkle/domain/account/service/SettlementService.java
user: login: glenn-syj
body: ### 왜 벤치마크를 안 썼나?

사실 엄밀하게 말하면 JMH(Java Microbenchmark Harness)와 같은 벤치마킹 테스트 툴을 이용하는 게 더 좋다고 생각하는데요. Java의 경우 JVM에서 GC를 통제할 수 없기도 하고, `System.gc()` 자체도 비용도 클 뿐더러 JVM에서 최적화된 GC를 이용하는 장점이 없다는 생각도 들어서요. 반면, JMH는 JIT 컴파일러 최적화나 웜업을 통해서 JVM을 안정화시키기도 합니다.

완전한 벤치마크보다는, 투박하더라도 빠르게 기능을 작성하고 최적을 선택하기 위한 비교가 목표이니 이번에는 위와 같이 시간을 측정했습니다.

### System.currentTimeMillis() vs. System.nanoTime()

nanoTime()은 currentTimeMillis()보다 운영체제 환경 등에 따라 차이가 나긴하지만, 소요된 시간(elapsed time)에 대해서 더 알맞다고 판단해 수정했습니다.

### References
- [https://www.baeldung.com/java-microbenchmark-harness](https://www.baeldung.com/java-microbenchmark-harness)
- [https://stas-blogspot.blogspot.com/2012/02/what-is-behind-systemnanotime.html](https://stas-blogspot.blogspot.com/2012/02/what-is-behind-systemnanotime.html)
- [https://www.baeldung.com/java-system-currenttimemillis-vs-system-nanotime](https://www.baeldung.com/java-system-currenttimemillis-vs-system-nanotime)



created_at: 2025-03-05T17:06:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: glenn-syj
created_at: 2025-03-05T07:31:32Z
body: 제가 origin/feat/opt-conciliation 에서 작업하던 내용을 upstream/feat/opt-conciliation 으로 1번 Merge 한 이후 해당 origin 저장소 브랜치에서 계속 작업한 까닭에 중복 커밋이 발생한 거 같네요. Rebase가 분기의 시작점을 바꾸게 되는 거라, 이런 일이 발생하나 봅니다.

origin 내의 브랜치가 일단 병합되면, 삭제하고 새로운 브랜치를 파서 기존 upstream 브랜치에 PR 날리겠습니다.

```
git fetch upstream
git checkout -b feat/def upstream/feat/abc  # 새 이름의 로컬 브랜치 생성
# 작업 및 커밋
git push origin feat/def  # origin에 새 이름의 브랜치 생성
```

[https://git-scm.com/book/ko/v2/Git-%EB%B8%8C%EB%9E%9C%EC%B9%98-Rebase-%ED%95%98%EA%B8%B0#Rebase%EC%9D%98-%EC%9C%84%ED%97%98%EC%84%B1](https://git-scm.com/book/ko/v2/Git-%EB%B8%8C%EB%9E%9C%EC%B9%98-Rebase-%ED%95%98%EA%B8%B0#Rebase%EC%9D%98-%EC%9C%84%ED%97%98%EC%84%B1)
author_association: CONTRIBUTOR
user: login: glenn-syj
created_at: 2025-03-08T14:38:30Z
body: ![memory+allocation](https://github.com/user-attachments/assets/00152e35-45e6-4f3e-a4b6-ba4cdc12b4ba)
![cpu+time](https://github.com/user-attachments/assets/925a2784-b338-46e5-a8e4-9754375d136b)

- CPU 타임

JPA: 5795 ms
Paging: 3970 ms
Stream: 3883 ms
JDBC: 프로파일러에 나타나지 않음

- 메모리 할당량

JPA: 586.74 MB
Paging: 575.05 MB
Stream: 569.85 MB
JDBC: 프로파일러에 나타나지 않음

author_association: CONTRIBUTOR