ITN Dev 07-22
fix: 시드 서비스 중복 카운트 및 트랜잭션 범위 버그 수정
같은 시트에 동일 기관이 두 번 들어오면 DB는 upsert로 한 행에 합치지만
집계는 둘 다 created로 세어 report가 실제 결과와 어긋났다. 처리 완료된
키를 그 자리에서 seen 집합에 반영해 두 번째 등장부터는 updated로 잡히게
했다.

또한 seed()가 엑셀 파싱까지 @Transactional 안에서 수행해 파싱 중에도
DB 커넥션을 점유하고 있었다. 파싱은 트랜잭션 밖으로 빼고, DB 조회+upsert
구간만 TransactionTemplate으로 감싸 커넥션 점유 구간을 최소화했다.
(같은 빈 self-invocation으로 @Transactional을 추가하면 프록시를 우회해
트랜잭션이 걸리지 않으므로 이 방식을 택함.)

중복 기관 업로드 시나리오에 대한 테스트를 추가했다.
@9e342fa7f6447d5ac764d0eec499e61cb87ad0a4
src/main/java/kr/itn/itnhub/seed/SeedService.java
--- src/main/java/kr/itn/itnhub/seed/SeedService.java
+++ src/main/java/kr/itn/itnhub/seed/SeedService.java
@@ -3,7 +3,8 @@
 import kr.itn.itnhub.org.Organization;
 import kr.itn.itnhub.org.OrganizationMapper;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
 
 import java.io.InputStream;
 import java.util.HashSet;
@@ -15,29 +16,42 @@
 
     private final SeedParser parser;
     private final OrganizationMapper mapper;
+    private final TransactionTemplate transactionTemplate;
 
-    public SeedService(SeedParser parser, OrganizationMapper mapper) {
+    public SeedService(SeedParser parser, OrganizationMapper mapper,
+                        PlatformTransactionManager transactionManager) {
         this.parser = parser;
         this.mapper = mapper;
+        this.transactionTemplate = new TransactionTemplate(transactionManager);
     }
 
-    @Transactional
+    /**
+     * 엑셀 파싱은 DB와 무관하므로 트랜잭션 밖에서 수행한다. 커넥션을 잡아두는 구간은
+     * 기존 행 조회 + upsert 루프뿐이며, {@link TransactionTemplate}으로 그 구간만 감싼다.
+     * (같은 빈의 @Transactional 메서드를 self-invocation으로 호출하면 프록시를 우회해
+     * 트랜잭션이 아예 적용되지 않으므로 이 방식을 쓴다.)
+     */
     public SeedReport seed(InputStream xlsx) {
         List<SeedRow> rows = parser.parse(xlsx);
+        return transactionTemplate.execute(status -> seedRows(rows));
+    }
 
-        Set<String> before = new HashSet<>();
+    private SeedReport seedRows(List<SeedRow> rows) {
+        // DB에 이미 있는 키와, 이번 업로드에서 먼저 처리된 키를 모두 "이미 존재함"으로 취급한다.
+        // 그래야 한 시트에 같은 기관이 두 번 들어와도 두 번째는 created가 아니라 updated로 집계된다.
+        Set<String> seen = new HashSet<>();
         for (Organization existing : mapper.findAll()) {
-            before.add(key(existing.getOrgNo(), existing.getOrgName()));
+            seen.add(key(existing.getOrgNo(), existing.getOrgName()));
         }
 
         int created = 0;
         int updated = 0;
 
         for (SeedRow row : rows) {
-            if (before.contains(key(row.orgNo(), row.orgName()))) {
-                updated++;
-            } else {
+            if (seen.add(key(row.orgNo(), row.orgName()))) {
                 created++;
+            } else {
+                updated++;
             }
             mapper.upsertBySeed(toOrganization(row));
         }
src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
--- src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
+++ src/test/java/kr/itn/itnhub/seed/SeedServiceTest.java
@@ -102,6 +102,20 @@
     }
 
     @Test
+    void 같은_기관이_한_시트에_두번_들어있으면_한_행만_남고_두번째는_갱신으로_집계된다() throws Exception {
+        byte[] xlsx = workbook(
+                new String[]{"001", "00", "국제방송교류재단", "팀", "송민지", "과장", "02-1", "a@b.kr"},
+                new String[]{"001", "00", "국제방송교류재단", "팀", "송민지", "과장", "02-1", "a@b.kr"});
+
+        SeedReport report = seedService.seed(new ByteArrayInputStream(xlsx));
+
+        assertThat(mapper.findAll()).hasSize(1);
+        assertThat(report.total()).isEqualTo(2);
+        assertThat(report.created()).isEqualTo(1);
+        assertThat(report.updated()).isEqualTo(1);
+    }
+
+    @Test
     void 담당자정보가_없는_기관은_정보대기로_들어온다() throws Exception {
         byte[] xlsx = workbook(
                 new String[]{"008", "00", "경찰청", "데이터정책계", "박진우", "경위", "02-3", "e@f.kr"},
Add a comment
List