From 14652099416ae3d587aa93bcb7e46518d5d85dcb Mon Sep 17 00:00:00 2001 From: z3rotig4r Date: Tue, 9 Jun 2026 11:53:47 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20EgovReflectionSupport=20=EB=A9=80?= =?UTF-8?q?=ED=8B=B0=EC=8A=A4=EB=A0=88=EB=93=9C=20lazy-init=20=EA=B2=BD?= =?UTF-8?q?=ED=95=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EgovFieldExtractor·EgovJdbcBatchItemWriter 는 FieldExtractor/ItemWriter 빈으로 등록되어 멀티스레드 step 에서 공유되며, 그 안의 EgovReflectionSupport 인스턴스도 함께 공유된다. generateGetterMethodMap 은 methods 를 먼저 대입한 뒤 methodMap 을 채웠다. 이로 인해 한 스레드가 methods 만 비-null 로 만든 상태에서 다른 스레드가 `methods != null` 조건만 보고 아직 채워지지 않은(또는 null 인) methodMap 을 사용하여 NPE 가 발생할 수 있었다(cold-start 경합). double-checked locking 으로 초기화를 1회만 수행하고, methods·methodMap 을 지역변수로 완성한 뒤 methodMap 을 먼저 발행하고 methods 를 마지막에 대입하여 부분초기화 상태가 외부에 보이지 않게 한다. 두 필드를 volatile 로 선언해 가시성을 보장한다. 단일스레드 동작은 동일하다. EgovReflectionSupportConcurrencyTest 를 추가한다(32스레드 cold-start 동시호출 + 단일스레드 회귀). 수정 전에는 round 0 에서 NullPointerException 으로 실패하고 수정 후 통과한다. --- .../reflection/EgovReflectionSupport.java | 25 +++- .../EgovReflectionSupportConcurrencyTest.java | 112 ++++++++++++++++++ 2 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 Batch/org.egovframe.rte.bat.core/src/test/java/org/egovframe/rte/bat/core/reflection/EgovReflectionSupportConcurrencyTest.java diff --git a/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/reflection/EgovReflectionSupport.java b/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/reflection/EgovReflectionSupport.java index 46074e13..20e2e7cd 100755 --- a/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/reflection/EgovReflectionSupport.java +++ b/Batch/org.egovframe.rte.bat.core/src/main/java/org/egovframe/rte/bat/core/reflection/EgovReflectionSupport.java @@ -48,8 +48,8 @@ public class EgovReflectionSupport { private static final Logger LOGGER = LoggerFactory.getLogger(EgovReflectionSupport.class); private Object object = null; - private Method[] methods; - private HashMap methodMap; + private volatile Method[] methods; + private volatile HashMap methodMap; private Type[] fieldType; public EgovReflectionSupport() { @@ -151,16 +151,26 @@ public T generateObject(Class type, List tokens, String[] names) { * Bean 생성시 한 번만 실행 된다. */ public void generateGetterMethodMap(String[] names, T item) { - if (methods == null) { - methods = item.getClass().getMethods(); - methodMap = new HashMap(); + // 멀티스레드 step에서 FieldExtractor/ItemWriter 빈이 공유되면 이 인스턴스도 공유된다. + // 기존 코드는 methods를 먼저 대입한 뒤 methodMap을 채워, 다른 스레드가 methods != null 만 보고 + // 아직 채워지지 않은(또는 null인) methodMap을 사용하다 NPE/부분초기화에 노출되었다. + // double-checked locking + 지역변수로 완성한 뒤 발행하여 부분초기화 상태가 외부에 보이지 않게 한다. + if (methods != null) { + return; + } + synchronized (this) { + if (methods != null) { + return; + } + Method[] localMethods = item.getClass().getMethods(); + HashMap localMap = new HashMap(); try { if (ArrayUtils.isNotEmpty(names) && names.length > 0) { for (int i = 0; i < names.length; i++) { String strMethod; if (names[i].length() > 0) { strMethod = "get" + (names[i].substring(0, 1)).toUpperCase() + names[i].substring(1); - methodMap.put(names[i], retrieveMethod(methods, strMethod)); + localMap.put(names[i], retrieveMethod(localMethods, strMethod)); } } } @@ -168,6 +178,9 @@ public void generateGetterMethodMap(String[] names, T item) { } catch (StringIndexOutOfBoundsException | NullPointerException e) { ReflectionUtils.handleReflectionException(e); } + // 완성된 map을 먼저 발행한 뒤 methods를 마지막에 대입(초기화 완료 표식)한다. + methodMap = localMap; + methods = localMethods; } } diff --git a/Batch/org.egovframe.rte.bat.core/src/test/java/org/egovframe/rte/bat/core/reflection/EgovReflectionSupportConcurrencyTest.java b/Batch/org.egovframe.rte.bat.core/src/test/java/org/egovframe/rte/bat/core/reflection/EgovReflectionSupportConcurrencyTest.java new file mode 100644 index 00000000..10b64ca9 --- /dev/null +++ b/Batch/org.egovframe.rte.bat.core/src/test/java/org/egovframe/rte/bat/core/reflection/EgovReflectionSupportConcurrencyTest.java @@ -0,0 +1,112 @@ +package org.egovframe.rte.bat.core.reflection; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * EgovReflectionSupport 의 멀티스레드 안전성 테스트. + * + *

FieldExtractor/ItemWriter 빈이 멀티스레드 step 에서 공유되면 그 안의 + * EgovReflectionSupport 인스턴스도 공유된다. 과거 generateGetterMethodMap 은 + * methods 를 먼저 대입한 뒤 methodMap 을 채워, 다른 스레드가 부분초기화 상태를 + * 사용하다 NPE 에 노출되었다(cold-start 경합). 본 테스트로 회귀를 방지한다.

+ * + * @author 기여자 + * @version 1.0 + * @since 2026.06.09 + */ +public class EgovReflectionSupportConcurrencyTest { + + private static final String[] NAMES = {"name", "age"}; + + @Test + public void sharedInstanceColdStartIsThreadSafe() throws InterruptedException { + final int threads = 32; + final int rounds = 500; + ExecutorService pool = Executors.newFixedThreadPool(threads); + try { + for (int round = 0; round < rounds; round++) { + // 라운드마다 새 공유 인스턴스를 만들어 cold-start lazy-init 경합을 재현한다. + final EgovReflectionSupport shared = new EgovReflectionSupport(); + final SampleVo item = new SampleVo("egov", 42); + final CountDownLatch startGate = new CountDownLatch(1); + final CountDownLatch doneGate = new CountDownLatch(threads); + final List failures = new CopyOnWriteArrayList(); + final List names = new CopyOnWriteArrayList(); + final List ages = new CopyOnWriteArrayList(); + + for (int t = 0; t < threads; t++) { + pool.execute(() -> { + try { + startGate.await(); + shared.generateGetterMethodMap(NAMES, item); + names.add(shared.invokeGettterMethod(item, "name")); + ages.add(shared.invokeGettterMethod(item, "age")); + } catch (Throwable e) { + failures.add(e); + } finally { + doneGate.countDown(); + } + }); + } + + startGate.countDown(); + assertTrue(doneGate.await(10, TimeUnit.SECONDS), "round " + round + " timed out"); + assertTrue(failures.isEmpty(), + "round " + round + " concurrent failure: " + failures); + for (Object n : names) { + assertEquals("egov", n, "round " + round + " name mismatch"); + } + for (Object a : ages) { + assertEquals(42, a, "round " + round + " age mismatch"); + } + } + } finally { + pool.shutdownNow(); + } + } + + @Test + public void singleThreadGetterExtractsValues() { + EgovReflectionSupport reflection = new EgovReflectionSupport(); + SampleVo item = new SampleVo("egov", 42); + reflection.generateGetterMethodMap(NAMES, item); + List values = new ArrayList(); + for (String name : NAMES) { + values.add(reflection.invokeGettterMethod(item, name)); + } + assertEquals("egov", values.get(0)); + assertEquals(42, values.get(1)); + } + + /** + * 테스트용 VO. public getter 가 reflection 으로 조회된다. + */ + public static class SampleVo { + private final String name; + private final int age; + + public SampleVo(String name, int age) { + this.name = name; + this.age = age; + } + + public String getName() { + return name; + } + + public int getAge() { + return age; + } + } +}