Skip to content

Commit 5b99108

Browse files
authored
Merge pull request #79 from TAVE-9RP/dev
ETL 추출 코드 추가, 입출고 이력 추가, 일부 에러 대응 (Dev -> Main)
2 parents 85bff70 + 2149280 commit 5b99108

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1491
-55
lines changed

build.gradle

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,61 @@
11
plugins {
2-
id 'java'
3-
id 'org.springframework.boot' version '3.2.5'
4-
id 'io.spring.dependency-management' version '1.1.7'
2+
id 'java'
3+
id 'org.springframework.boot' version '3.2.5'
4+
id 'io.spring.dependency-management' version '1.1.7'
55
}
66

77
group = 'com'
88
version = '0.0.1-SNAPSHOT'
99
description = 'nexerp project'
1010

1111
java {
12-
toolchain {
13-
languageVersion = JavaLanguageVersion.of(17)
14-
}
12+
toolchain {
13+
languageVersion = JavaLanguageVersion.of(17)
14+
}
1515
}
1616

1717
configurations {
18-
compileOnly {
19-
extendsFrom annotationProcessor
20-
}
18+
compileOnly {
19+
extendsFrom annotationProcessor
20+
}
2121
}
2222

2323
repositories {
24-
mavenCentral()
24+
mavenCentral()
2525
}
2626

2727
dependencies {
28-
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
29-
implementation 'org.springframework.boot:spring-boot-starter-security'
30-
implementation 'org.springframework.boot:spring-boot-starter-validation'
31-
implementation 'org.springframework.boot:spring-boot-starter-web'
28+
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
29+
implementation 'org.springframework.boot:spring-boot-starter-security'
30+
implementation 'org.springframework.boot:spring-boot-starter-validation'
31+
implementation 'org.springframework.boot:spring-boot-starter-web'
3232

33-
runtimeOnly 'com.mysql:mysql-connector-j'
33+
runtimeOnly 'com.mysql:mysql-connector-j'
3434

35-
compileOnly 'org.projectlombok:lombok'
36-
annotationProcessor 'org.projectlombok:lombok'
35+
compileOnly 'org.projectlombok:lombok'
36+
annotationProcessor 'org.projectlombok:lombok'
3737

38-
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
38+
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
3939

40-
testImplementation 'org.springframework.boot:spring-boot-starter-test'
41-
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
40+
testImplementation 'org.springframework.boot:spring-boot-starter-test'
41+
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
4242
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
4343
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
4444
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
4545

46+
implementation 'org.apache.commons:commons-csv:1.10.0'
47+
4648
}
4749

4850

4951
tasks.named('test') {
50-
useJUnitPlatform()
52+
useJUnitPlatform()
5153
}
5254

5355
bootJar {
54-
archiveFileName = 'app.jar'
56+
archiveFileName = 'app.jar'
57+
}
58+
59+
test {
60+
useJUnitPlatform()
5561
}

src/main/java/com/nexerp/NexerpApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
67

78
@SpringBootApplication
89
@EnableJpaAuditing
10+
@EnableScheduling
911
public class NexerpApplication {
1012

1113
public static void main(String[] args) {
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package com.nexerp.domain.analytics.application;
2+
3+
import static java.util.concurrent.TimeUnit.NANOSECONDS;
4+
5+
import com.nexerp.domain.analytics.domain.ExportFileName;
6+
import com.nexerp.domain.analytics.domain.ExportTable;
7+
import com.nexerp.domain.analytics.port.CsvWriterPort;
8+
import com.nexerp.domain.analytics.port.ExtractorPort;
9+
import com.nexerp.domain.analytics.port.StoragePort;
10+
import java.io.OutputStream;
11+
import java.time.LocalDate;
12+
import java.time.YearMonth;
13+
import java.util.Collections;
14+
import java.util.EnumMap;
15+
import java.util.List;
16+
import java.util.Map;
17+
import java.util.concurrent.CompletableFuture;
18+
import java.util.concurrent.CompletionException;
19+
import java.util.concurrent.CopyOnWriteArrayList;
20+
import java.util.concurrent.Executor;
21+
import java.util.concurrent.atomic.AtomicBoolean;
22+
import java.util.concurrent.atomic.AtomicInteger;
23+
import lombok.RequiredArgsConstructor;
24+
import lombok.extern.slf4j.Slf4j;
25+
import org.springframework.stereotype.Service;
26+
27+
@Slf4j
28+
@Service
29+
@RequiredArgsConstructor
30+
public class AnalyticsExportOrchestrator {
31+
32+
private final List<ExtractorPort> extractors;
33+
private final StoragePort storage;
34+
private final CsvWriterPort writer;
35+
private final Executor analyticsExportExecutor;
36+
37+
/**
38+
* [Fail-Fast 병렬 내보내기] 1. 하나라도 실패하면 즉시 전체 작업을 중단합니다. 2. 실패 시 이미 성공하여 생성된 파일들도 모두 삭제(Cleanup)합니다.
39+
* 3. 원자적 파일 생성을 위해 임시 파일(tmp)에 먼저 쓰고 성공 시 최종 위치로 이동합니다.
40+
*/
41+
public Map<ExportTable, ExportResult> exportAllFailFastParallel(LocalDate date) {
42+
// 파일을 저장할 폴더가 있는지 확인하고 없으면 만들기
43+
storage.ensureBaseDir();
44+
45+
long allStart = System.nanoTime();
46+
log.info("[AnalyticsExport] Fail-Fast Parallel Export Start");
47+
48+
Map<ExportTable, ExportResult> results = Collections.synchronizedMap(
49+
new EnumMap<>(ExportTable.class));
50+
51+
// 작성된 파일 이름 리스트 (CopyOnWriteArrayList)
52+
List<String> createdFinalFiles = new CopyOnWriteArrayList<>();
53+
54+
CompletableFuture<Void> firstFailure = new CompletableFuture<>();
55+
// completeExceptionally(실패) 를 한번만 전파하기 위한 원자적 블리언
56+
AtomicBoolean failureSignaled = new AtomicBoolean(false);
57+
58+
List<CompletableFuture<ExportResult>> futures = extractors.stream()
59+
.map(extractor -> CompletableFuture.supplyAsync(() -> {
60+
long start = System.nanoTime();
61+
62+
// 수정된 원자적 파일 생성 로직 호출
63+
ExportResult result = exportByExtractorAtomic(extractor, date, createdFinalFiles);
64+
65+
long elapsedMs = NANOSECONDS.toMillis(System.nanoTime() - start);
66+
67+
// 비동기와 동기 비교를 위한 로그
68+
log.info("[AnalyticsExport] table={} rows={} elapsedMs={}",
69+
result.table(), result.rowCount(), elapsedMs);
70+
71+
return result;
72+
73+
}, analyticsExportExecutor).whenComplete((r, e) -> {
74+
if (e != null) {
75+
// 하나라도 예외 발생 시 firstFailure을 울려 race를 종료시킴
76+
if (failureSignaled.compareAndSet(false, true)) {
77+
firstFailure.completeExceptionally(e);
78+
}
79+
} else {
80+
results.put(r.table(), r);
81+
}
82+
}))
83+
.toList();
84+
85+
// 모든 스레드가 작업을 종료 했는지 검사
86+
CompletableFuture<Void> allDone = CompletableFuture.allOf(
87+
futures.toArray(new CompletableFuture[0]));
88+
89+
// race는 전체 성공 혹은 실패를 의미
90+
CompletableFuture<Object> race = CompletableFuture.anyOf(allDone, firstFailure);
91+
92+
try {
93+
// 전체 성공 시 통과, 하나라도 실패 시 예외가 여기서 터짐
94+
race.join();
95+
96+
long allElapsedMs = NANOSECONDS.toMillis(System.nanoTime() - allStart);
97+
log.info("[AnalyticsExport] 전체 테이블 수={} 총 소요 시간={}", results.size(),
98+
allElapsedMs);
99+
return results;
100+
101+
} catch (CompletionException e) {
102+
Throwable cause = (e.getCause() != null) ? e.getCause() : e;
103+
log.error("[AnalyticsExport] FAIL-FAST triggered. Export stopped.", cause);
104+
105+
//나머지 스레드 모두 중지
106+
futures.forEach(f -> f.cancel(true));
107+
//성공했던 파일들도 모두 제거
108+
cleanupFiles(createdFinalFiles);
109+
110+
throw new RuntimeException("분석 데이터 내보내기 중 오류가 발생하여 전체 작업을 중단합니다.", cause);
111+
}
112+
}
113+
114+
/**
115+
* 부분 파일 방지: - tmp 파일에 쓰고 - 성공하면 final 파일로 move - 실패하면 tmp 삭제
116+
*/
117+
private ExportResult exportByExtractorAtomic(
118+
ExtractorPort extractor,
119+
LocalDate date,
120+
List<String> createdFinalFiles
121+
) {
122+
//final 파일 경로
123+
String finalFileName = ExportFileName.of(extractor.table().filePrefix(), date).toFileName();
124+
String finalPath = storage.resolve(finalFileName); // 최종 결과 경로
125+
String tmpPath = storage.resolveTemp(finalPath); // 임시 파일 경로
126+
127+
try (OutputStream os = storage.openOutputStream(tmpPath)) {
128+
// 실제 쓰기는 임시에
129+
long rowCount = writer.write(os, extractor.header(), extractor.extractRows(date));
130+
131+
// 성공하면 최종 파일로 이동
132+
storage.moveAtomic(tmpPath, finalPath);
133+
134+
// 최종 파일 생성 기록 (실패 시 cleanup 용도)
135+
createdFinalFiles.add(finalPath);
136+
137+
return new ExportResult(extractor.table(), date, rowCount);
138+
139+
} catch (Exception e) {
140+
// 실패하면 tmp 파일 삭제
141+
try {
142+
storage.deleteIfExists(tmpPath);
143+
} catch (Exception ignored) {
144+
// tmp 삭제 실패는 로깅
145+
log.warn("[AnalyticsExport] tmp 삭제 실패 tmp={}", tmpPath);
146+
}
147+
throw new RuntimeException("테이블 추출 실패: table=" + extractor.table(), e);
148+
}
149+
}
150+
151+
/**
152+
* 실패 시 이미 만들어진 파일들을 정리
153+
*/
154+
private void cleanupFiles(List<String> createdFinalFiles) {
155+
for (String path : createdFinalFiles) {
156+
try {
157+
storage.deleteIfExists(path);
158+
log.info("[AnalyticsExport] cleanup deleted file={}", path);
159+
} catch (Exception e) {
160+
log.warn("[AnalyticsExport] cleanup failed file={}", path, e);
161+
}
162+
}
163+
}
164+
165+
public int deleteTwoMonthsAgo(LocalDate now) {
166+
YearMonth target = YearMonth.from(now.minusMonths(4)); // 1월이면 9월
167+
AtomicInteger deleted = new AtomicInteger();
168+
169+
storage.listBaseFiles()
170+
.forEach(fileName -> {
171+
ExportFileName parsed;
172+
try {
173+
parsed = ExportFileName.parse(fileName);
174+
} catch (IllegalArgumentException e) {
175+
return;
176+
}
177+
178+
if (YearMonth.from(parsed.date()).equals(target)) {
179+
String fullName = storage.resolve(fileName).toString();
180+
storage.deleteIfExists(fullName);
181+
deleted.incrementAndGet();
182+
}
183+
});
184+
185+
return deleted.get();
186+
}
187+
188+
//ExportResult 객체 하나는 데이터베이스의 특정 테이블 하나를 CSV 파일 하나로 추출한 결과
189+
public record ExportResult(ExportTable table, LocalDate date, long rowCount) {
190+
191+
}
192+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.nexerp.domain.analytics.config;
2+
3+
import java.util.concurrent.Executor;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
7+
8+
@Configuration
9+
public class AnalyticsExportAsyncConfig {
10+
11+
@Bean(name = "analyticsExportExecutor")
12+
public Executor analyticsExportExecutor() {
13+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
14+
// 기본 스레드 수
15+
executor.setCorePoolSize(6);
16+
// 최대 스레드 수
17+
executor.setMaxPoolSize(8);
18+
// 대기 작업 큐
19+
executor.setQueueCapacity(100);
20+
// 스레드의 이름 접두사
21+
executor.setThreadNamePrefix("analytics-export-");
22+
23+
executor.initialize();
24+
25+
return executor;
26+
}
27+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.nexerp.domain.analytics.config;
2+
3+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
4+
import org.springframework.context.annotation.Configuration;
5+
6+
@Configuration
7+
@EnableConfigurationProperties(AnalyticsExportProperties.class)
8+
public class AnalyticsExportConfig {
9+
10+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.nexerp.domain.analytics.config;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
5+
@ConfigurationProperties(prefix = "analytics.export")
6+
public record AnalyticsExportProperties(
7+
// 로컬 저장 위치 파일 명 제외
8+
String localPath,
9+
// 제거 주기
10+
int retentionMonths
11+
) {
12+
13+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.nexerp.domain.analytics.domain;
2+
3+
import java.time.LocalDate;
4+
5+
// CSV 파일 이름 규칙
6+
public record ExportFileName(String tableName, LocalDate date) {
7+
8+
public static ExportFileName of(String tableName, LocalDate date) {
9+
return new ExportFileName(tableName, date);
10+
}
11+
12+
public String toFileName() {
13+
return tableName + "--" + date + ".csv";
14+
}
15+
16+
// 삭제 스케줄러(매월 1일에 2개월 전 삭제) 만들 때 사용
17+
public static ExportFileName parse(String fileName) {
18+
19+
// csv 파일 아니면 발생
20+
if (!fileName.endsWith(".csv")) {
21+
throw new IllegalArgumentException("Not csv: " + fileName);
22+
}
23+
24+
// 이름의 .csv 제거
25+
String base = fileName.substring(0, fileName.length() - 4);
26+
27+
// 구분자 -- 찾기
28+
int idx = base.lastIndexOf("--");
29+
if (idx < 0) {
30+
throw new IllegalArgumentException("Invalid format: " + fileName);
31+
}
32+
33+
//테이블명/날짜 문자열 분리
34+
String table = base.substring(0, idx);
35+
String dateStr = base.substring(idx + 2);
36+
return new ExportFileName(table, LocalDate.parse(dateStr));
37+
}
38+
}

0 commit comments

Comments
 (0)