diff --git a/Foundation/org.egovframe.rte.fdl.property/src/main/java/org/egovframe/rte/fdl/property/impl/EgovPropertyServiceImpl.java b/Foundation/org.egovframe.rte.fdl.property/src/main/java/org/egovframe/rte/fdl/property/impl/EgovPropertyServiceImpl.java index 59e079e0..421b46c1 100755 --- a/Foundation/org.egovframe.rte.fdl.property/src/main/java/org/egovframe/rte/fdl/property/impl/EgovPropertyServiceImpl.java +++ b/Foundation/org.egovframe.rte.fdl.property/src/main/java/org/egovframe/rte/fdl/property/impl/EgovPropertyServiceImpl.java @@ -65,7 +65,7 @@ public class EgovPropertyServiceImpl implements EgovPropertyService, Application private static final Logger LOGGER = LoggerFactory.getLogger(EgovPropertyServiceImpl.class); - private PropertiesConfiguration egovProperties; + private volatile PropertiesConfiguration egovProperties; private ResourceLoader resourceLoader; private MessageSource messageSource; private Set extFileName; @@ -242,12 +242,19 @@ public void refreshPropertyFiles() throws IOException, FdlException { if (egovProperties == null) { return; } - egovProperties.clear(); - loadExternalPropertyFiles(); - applyProgrammaticProperties(); + PropertiesConfiguration refreshedProperties = createPropertiesConfiguration(); + loadExternalPropertyFiles(refreshedProperties); + applyProgrammaticProperties(refreshedProperties); + egovProperties = refreshedProperties; } - private void loadExternalPropertyFiles() throws IOException { + private PropertiesConfiguration createPropertiesConfiguration() { + PropertiesConfiguration propertiesConfiguration = new PropertiesConfiguration(); + propertiesConfiguration.setListDelimiterHandler(new DefaultListDelimiterHandler(',')); + return propertiesConfiguration; + } + + private void loadExternalPropertyFiles(PropertiesConfiguration propertiesConfiguration) throws IOException { if (extFileName == null) { return; } @@ -263,12 +270,12 @@ private void loadExternalPropertyFiles() throws IOException { } else { fileName = (String) element; } - loadPropertyResources(fileName, enc); + loadPropertyResources(propertiesConfiguration, fileName, enc); } } @SuppressWarnings("rawtypes") - private void applyProgrammaticProperties() throws FdlException { + private void applyProgrammaticProperties(PropertiesConfiguration propertiesConfiguration) throws FdlException { if (properties == null) { return; } @@ -280,7 +287,7 @@ private void applyProgrammaticProperties() throws FdlException { if (key == null || key.isEmpty()) { throw new FdlException(messageSource, "error.properties.check.essential", null); } - egovProperties.addProperty(key, value); + propertiesConfiguration.addProperty(key, value); } } @@ -288,8 +295,7 @@ private void applyProgrammaticProperties() throws FdlException { * Bean 초기화 함수로 최초 생성시 필요한 Property 세티처리 */ public void afterPropertiesSet() throws IOException, FdlException { - egovProperties = new PropertiesConfiguration(); - egovProperties.setListDelimiterHandler(new DefaultListDelimiterHandler(',')); + egovProperties = createPropertiesConfiguration(); refreshPropertyFiles(); } @@ -333,46 +339,49 @@ public void setApplicationContext(ApplicationContext applicationContext) throws /** * 파일위치정보를 가지고 resources 정보 추출 * + * @param propertiesConfiguration 프로퍼티를 적재할 대상 설정 * @param location 파일위치 * @param encoding Encoding 정보 */ - private void loadPropertyResources(String location, String encoding) throws IOException { + private void loadPropertyResources(PropertiesConfiguration propertiesConfiguration, String location, String encoding) throws IOException { if (resourceLoader instanceof ResourcePatternResolver) { Resource[] resources = ((ResourcePatternResolver) resourceLoader).getResources(location); - loadPropertyLoop(resources, encoding); + loadPropertyLoop(propertiesConfiguration, resources, encoding); } else { Resource resource = resourceLoader.getResource(location); - loadPropertyRes(resource, encoding); + loadPropertyRes(propertiesConfiguration, resource, encoding); } } /** * 멀티로 지정된 경우 처리를 위해 LOOP 처리 * + * @param propertiesConfiguration 프로퍼티를 적재할 대상 설정 * @param resources 리소스정보 * @param encoding 인코딩정보 */ - private void loadPropertyLoop(Resource[] resources, String encoding) { + private void loadPropertyLoop(PropertiesConfiguration propertiesConfiguration, Resource[] resources, String encoding) { Assert.notNull(resources, "Resource array must not be null"); for (int i = 0; i < resources.length; i++) { - loadPropertyRes(resources[i], encoding); + loadPropertyRes(propertiesConfiguration, resources[i], encoding); } } /** * 파일 정보를 읽어서 egovProperties에 저장 * + * @param propertiesConfiguration 프로퍼티를 적재할 대상 설정 * @param resource 리소스정보 * @param encoding 인코딩정보 */ - private void loadPropertyRes(Resource resource, String encoding) { + private void loadPropertyRes(PropertiesConfiguration propertiesConfiguration, Resource resource, String encoding) { InputStream inputStream = null; InputStreamReader inputStreamReader = null; // 2026.02.28 KISA 보안취약점 조치 try { inputStream = resource.getInputStream(); inputStreamReader = new InputStreamReader(inputStream, StringUtils.isEmpty(encoding) ? DEFAULT_ENCODING : encoding); - egovProperties.read(inputStreamReader); + propertiesConfiguration.read(inputStreamReader); } catch (ConfigurationException | IOException e) { throw new RuntimeException(e); } finally { diff --git a/Foundation/org.egovframe.rte.fdl.property/src/test/java/org/egovframe/rte/fdl/property/PropertyServiceAtomicReloadScenarioTest.java b/Foundation/org.egovframe.rte.fdl.property/src/test/java/org/egovframe/rte/fdl/property/PropertyServiceAtomicReloadScenarioTest.java new file mode 100644 index 00000000..f238acee --- /dev/null +++ b/Foundation/org.egovframe.rte.fdl.property/src/test/java/org/egovframe/rte/fdl/property/PropertyServiceAtomicReloadScenarioTest.java @@ -0,0 +1,193 @@ +package org.egovframe.rte.fdl.property; + +import org.egovframe.rte.fdl.property.impl.EgovPropertyServiceImpl; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * EgovPropertyServiceImpl이 atomic 하게 reload 되는지를 검증합니다. + */ +public class PropertyServiceAtomicReloadScenarioTest { + + private final Path testDir = Path.of("target", "property-service-atomic-reload-scenario", UUID.randomUUID().toString()); + + @Test + public void failedReloadDoesNotPublishPartiallyLoadedSnapshotAndCanRecover() throws Exception { + Path applicationFile = testDir.resolve("application.properties"); + Path databaseFile = testDir.resolve("database.properties"); + Path missingFile = testDir.resolve("missing.properties"); + + write(applicationFile, """ + scenario.version=v1 + application.mode=stable + feature.enabled=false + """); + write(databaseFile, """ + datasource.url=jdbc:old + shared.key=file-v1 + """); + + EgovPropertyServiceImpl service = createPropertyService( + orderedExtFiles(applicationFile, databaseFile), + Map.of("shared.key", "programmatic")); + + assertSnapshot(service, "v1", "stable", "false", "jdbc:old", "file-v1"); + + write(applicationFile, """ + scenario.version=v2 + application.mode=refreshed + feature.enabled=true + """); + write(databaseFile, """ + datasource.url=jdbc:new + shared.key=file-v2 + """); + + service.refreshPropertyFiles(); + + assertSnapshot(service, "v2", "refreshed", "true", "jdbc:new", "file-v2"); + + write(applicationFile, """ + scenario.version=v3 + application.mode=partial-candidate + feature.enabled=false + """); + write(databaseFile, """ + datasource.url=jdbc:partial-candidate + shared.key=file-v3 + """); + service.setExtFileName(orderedExtFiles(applicationFile, missingFile, databaseFile)); + + assertThrows(RuntimeException.class, service::refreshPropertyFiles); + + assertSnapshot(service, "v2", "refreshed", "true", "jdbc:new", "file-v2"); + + service.setExtFileName(orderedExtFiles(applicationFile, databaseFile)); + service.refreshPropertyFiles(); + + assertSnapshot(service, "v3", "partial-candidate", "false", "jdbc:partial-candidate", "file-v3"); + } + + @Test + public void concurrentReadersDoNotObserveClearedOrMissingPropertiesDuringRefresh() throws Exception { + Path applicationFile = testDir.resolve("concurrent-application.properties"); + Path databaseFile = testDir.resolve("concurrent-database.properties"); + writeSnapshot(applicationFile, databaseFile, "v1"); + EgovPropertyServiceImpl service = createPropertyService( + orderedExtFiles(applicationFile, databaseFile), + Map.of("shared.key", "programmatic")); + CountDownLatch start = new CountDownLatch(1); + AtomicBoolean reading = new AtomicBoolean(true); + ExecutorService executorService = Executors.newSingleThreadExecutor(); + Future reader = executorService.submit(() -> { + try { + start.await(5, TimeUnit.SECONDS); + while (reading.get()) { + assertPublishedPropertiesPresent(service); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + try { + start.countDown(); + for (int i = 0; i < 50; i++) { + String version = i % 2 == 0 ? "v2" : "v1"; + writeSnapshot(applicationFile, databaseFile, version); + service.refreshPropertyFiles(); + } + + reading.set(false); + reader.get(5, TimeUnit.SECONDS); + } finally { + reading.set(false); + executorService.shutdownNow(); + } + } + + private EgovPropertyServiceImpl createPropertyService(Set extFileName, Map properties) throws Exception { + EgovPropertyServiceImpl service = new EgovPropertyServiceImpl(); + service.setResourceLoader(new PathMatchingResourcePatternResolver()); + service.setExtFileName(extFileName); + service.setProperties(properties); + service.afterPropertiesSet(); + return service; + } + + private Set orderedExtFiles(Path... paths) { + Set extFileName = new LinkedHashSet<>(); + for (Path path : paths) { + extFileName.add(toFileLocation(path)); + } + return extFileName; + } + + private void assertSnapshot( + EgovPropertyService propertyService, + String version, + String mode, + String featureEnabled, + String datasourceUrl, + String sharedFileValue) { + assertEquals(version, propertyService.getString("scenario.version")); + assertEquals(mode, propertyService.getString("application.mode")); + assertEquals(featureEnabled, propertyService.getString("feature.enabled")); + assertEquals(datasourceUrl, propertyService.getString("datasource.url")); + assertArrayEquals(new String[]{sharedFileValue, "programmatic"}, propertyService.getStringArray("shared.key")); + } + + private void assertPublishedPropertiesPresent(EgovPropertyService propertyService) { + String version = propertyService.getString("scenario.version"); + String mode = propertyService.getString("application.mode"); + String datasourceUrl = propertyService.getString("datasource.url"); + String[] sharedValues = propertyService.getStringArray("shared.key"); + + assertTrue("v1".equals(version) || "v2".equals(version)); + assertTrue("v1-mode".equals(mode) || "v2-mode".equals(mode)); + assertTrue("jdbc:v1".equals(datasourceUrl) || "jdbc:v2".equals(datasourceUrl)); + assertEquals(2, sharedValues.length); + assertTrue("file-v1".equals(sharedValues[0]) || "file-v2".equals(sharedValues[0])); + assertEquals("programmatic", sharedValues[1]); + } + + private void writeSnapshot(Path applicationFile, Path databaseFile, String version) throws Exception { + write(applicationFile, """ + scenario.version=%s + application.mode=%s-mode + """.formatted(version, version)); + write(databaseFile, """ + datasource.url=jdbc:%s + shared.key=file-%s + """.formatted(version, version)); + } + + private void write(Path path, String content) throws Exception { + Files.createDirectories(testDir); + Files.writeString(path, content, StandardCharsets.UTF_8); + } + + private String toFileLocation(Path path) { + return path.toAbsolutePath().toUri().toString(); + } + +} diff --git a/Foundation/org.egovframe.rte.fdl.property/src/test/java/org/egovframe/rte/fdl/property/PropertyServiceRefreshTest.java b/Foundation/org.egovframe.rte.fdl.property/src/test/java/org/egovframe/rte/fdl/property/PropertyServiceRefreshTest.java index b04e7af7..23e14d87 100755 --- a/Foundation/org.egovframe.rte.fdl.property/src/test/java/org/egovframe/rte/fdl/property/PropertyServiceRefreshTest.java +++ b/Foundation/org.egovframe.rte.fdl.property/src/test/java/org/egovframe/rte/fdl/property/PropertyServiceRefreshTest.java @@ -4,14 +4,25 @@ import org.egovframe.rte.fdl.cmmn.exception.FdlException; import org.egovframe.rte.fdl.property.config.PropertyServiceExtendConfig; import org.egovframe.rte.fdl.property.config.PropertyTestConfig; +import org.egovframe.rte.fdl.property.impl.EgovPropertyServiceImpl; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * PropertyServiceRefreshTest @@ -33,6 +44,8 @@ public class PropertyServiceRefreshTest { @Resource(name = "propertyServiceExtend") private EgovPropertyService propertyService; + private final Path testDir = Path.of("target", "property-service-refresh-test", UUID.randomUUID().toString()); + @Test public void testRefreshPropertiesFiles() throws FdlException, IOException { for (String value : propertyService.getStringArray("tokens_on_multiple_lines")) { @@ -47,4 +60,68 @@ public void testRefreshPropertiesFiles() throws FdlException, IOException { assertEquals(Double.valueOf(1234), Double.valueOf(propertyService.getDouble("number.double"))); } + @Test + public void refreshPropertyFilesReloadsChangedFile() throws Exception { + Path propertyFile = writeProperties("reload.properties", "reload.key=old\n"); + EgovPropertyServiceImpl service = createPropertyService(Set.of(toFileLocation(propertyFile)), null); + + assertEquals("old", service.getString("reload.key")); + + Files.writeString(propertyFile, "reload.key=new\n", StandardCharsets.UTF_8); + service.refreshPropertyFiles(); + + assertEquals("new", service.getString("reload.key")); + } + + @Test + public void refreshPropertyFilesPreservesExistingPropertiesWhenReloadFails() throws Exception { + Path propertyFile = writeProperties("stable.properties", "stable.key=old\n"); + Path missingFile = testDir.resolve("missing.properties"); + Set extFileName = new LinkedHashSet<>(); + extFileName.add(toFileLocation(propertyFile)); + extFileName.add(toFileLocation(missingFile)); + EgovPropertyServiceImpl service = createPropertyService(Set.of(toFileLocation(propertyFile)), null); + + Files.writeString(propertyFile, "stable.key=new\n", StandardCharsets.UTF_8); + service.setExtFileName(extFileName); + + assertThrows(RuntimeException.class, service::refreshPropertyFiles); + assertEquals("old", service.getString("stable.key")); + } + + @Test + public void refreshPropertyFilesAppliesProgrammaticPropertiesAfterFileProperties() throws Exception { + Path propertyFile = writeProperties("override.properties", "override.key=file\n"); + EgovPropertyServiceImpl service = createPropertyService( + Set.of(toFileLocation(propertyFile)), + Map.of("override.key", "programmatic")); + + assertArrayEquals(new String[]{"file", "programmatic"}, service.getStringArray("override.key")); + + Files.writeString(propertyFile, "override.key=file-refresh\n", StandardCharsets.UTF_8); + service.refreshPropertyFiles(); + + assertArrayEquals(new String[]{"file-refresh", "programmatic"}, service.getStringArray("override.key")); + } + + private Path writeProperties(String fileName, String content) throws IOException { + Files.createDirectories(testDir); + Path propertyFile = testDir.resolve(fileName); + Files.writeString(propertyFile, content, StandardCharsets.UTF_8); + return propertyFile; + } + + private String toFileLocation(Path path) { + return path.toAbsolutePath().toUri().toString(); + } + + private EgovPropertyServiceImpl createPropertyService(Set extFileName, Map properties) throws Exception { + EgovPropertyServiceImpl service = new EgovPropertyServiceImpl(); + service.setResourceLoader(new PathMatchingResourcePatternResolver()); + service.setExtFileName(extFileName); + service.setProperties(properties); + service.afterPropertiesSet(); + return service; + } + }