Compare commits
4 Commits
88b0549a6d
...
8a9285f13e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a9285f13e | |||
| 8606f0aaa3 | |||
| 067193efa9 | |||
| 8a10edd8e1 |
@@ -136,6 +136,15 @@ public class BatchManagementController {
|
||||
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getBatchSparkline(params)));
|
||||
}
|
||||
|
||||
/** GET /api/batch-management/sparkline — 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (24개 슬롯 고정) */
|
||||
@GetMapping("/sparkline")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getGlobalSparkline(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getGlobalSparkline(params)));
|
||||
}
|
||||
|
||||
/** GET /api/batch-management/batch-configs/:id/recent-logs — 최근 실행 로그 (최대 20건) */
|
||||
@GetMapping("/batch-configs/{id}/recent-logs")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
|
||||
|
||||
@@ -296,6 +296,11 @@ public class BatchManagementService extends BaseService {
|
||||
return sqlSession.selectList(NS + "getBatchManagementSparklineData", params);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getGlobalSparkline(Map<String, Object> params) {
|
||||
commonService.applyCompanyCodeFilter(params);
|
||||
return sqlSession.selectList(NS + "getBatchManagementGlobalSparklineData", params);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getBatchRecentLogs(Map<String, Object> params) {
|
||||
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,32 @@
|
||||
ORDER BY hour_slot
|
||||
</select>
|
||||
|
||||
<!-- 글로벌 스파크라인: 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (빈 슬롯 포함 24개 고정) -->
|
||||
<select id="getBatchManagementGlobalSparklineData" parameterType="map" resultType="map">
|
||||
WITH hours AS (
|
||||
SELECT generate_series(
|
||||
DATE_TRUNC('hour', NOW() - INTERVAL '23 hours'),
|
||||
DATE_TRUNC('hour', NOW()),
|
||||
INTERVAL '1 hour'
|
||||
) AS hour_slot
|
||||
),
|
||||
filtered_logs AS (
|
||||
SELECT DATE_TRUNC('hour', start_time) AS hour_slot,
|
||||
execution_status
|
||||
FROM batch_execution_logs
|
||||
WHERE start_time >= NOW() - INTERVAL '24 hours'
|
||||
<include refid="common.companyCodeFilter"/>
|
||||
)
|
||||
SELECT h.hour_slot,
|
||||
COUNT(l.execution_status) AS total_count,
|
||||
COALESCE(SUM(CASE WHEN l.execution_status = 'SUCCESS' THEN 1 ELSE 0 END), 0) AS success_count,
|
||||
COALESCE(SUM(CASE WHEN l.execution_status = 'FAILED' THEN 1 ELSE 0 END), 0) AS failed_count
|
||||
FROM hours h
|
||||
LEFT JOIN filtered_logs l ON l.hour_slot = h.hour_slot
|
||||
GROUP BY h.hour_slot
|
||||
ORDER BY h.hour_slot
|
||||
</select>
|
||||
|
||||
<!-- 최근 실행 로그 목록 (최대 20건) -->
|
||||
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
|
||||
SELECT id,
|
||||
|
||||
@@ -128,9 +128,11 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
return (
|
||||
<div className="flex h-8 items-end gap-[2px]">
|
||||
{data.map((slot, i) => {
|
||||
const hasFail = slot.failed > 0;
|
||||
const hasSuccess = slot.success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
|
||||
const failed = Number(slot.failed_count) || 0;
|
||||
const success = Number(slot.success_count) || 0;
|
||||
const hasFail = failed > 0;
|
||||
const hasSuccess = success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + success * 10))}%` : "8%";
|
||||
const colorClass = hasFail
|
||||
? "bg-destructive/70 hover:bg-destructive"
|
||||
: hasSuccess
|
||||
@@ -141,7 +143,7 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
key={i}
|
||||
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
|
||||
style={{ height }}
|
||||
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
|
||||
title={`${slot.hour_slot?.slice(11, 16) || i}시 | 성공: ${success} 실패: ${failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -278,8 +280,10 @@ function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
if (!stats) return null;
|
||||
function GlobalSparkline({ data }: { data: SparklineData[] }) {
|
||||
if (!data || data.length === 0) return null;
|
||||
// 24개 슬롯 중 가장 큰 success_count 를 100% 로 맞춰 비율 스케일링
|
||||
const maxSuccess = data.reduce((m, s) => Math.max(m, Number(s.success_count) || 0), 0);
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
@@ -294,22 +298,31 @@ function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-end gap-[3px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const hasExec = Math.random() > 0.3;
|
||||
const hasFail = hasExec && Math.random() < 0.08;
|
||||
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
|
||||
{data.map((slot, i) => {
|
||||
const success = Number(slot.success_count) || 0;
|
||||
const failed = Number(slot.failed_count) || 0;
|
||||
const hasFail = failed > 0;
|
||||
const hasExec = success > 0 || hasFail;
|
||||
// 실패가 하나라도 있으면 실패 색으로 강조, 아니면 success 비율
|
||||
const h = hasFail
|
||||
? Math.max(35, Math.min(95, 35 + (failed / Math.max(maxSuccess, 1)) * 60))
|
||||
: hasExec
|
||||
? Math.max(20, Math.min(95, (success / Math.max(maxSuccess, 1)) * 90))
|
||||
: 6;
|
||||
const hour = slot.hour_slot?.slice(11, 16) || "";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
|
||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/10"}`}
|
||||
style={{ height: `${h}%` }}
|
||||
title={`${hour} | 성공 ${success} 실패 ${failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>24시간 전</span>
|
||||
<span>12시간 전</span>
|
||||
<span>6시간 전</span>
|
||||
<span>지금</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +340,7 @@ export default function BatchManagementPage() {
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
|
||||
const [stats, setStats] = useState<BatchStats | null>(null);
|
||||
const [globalSparkline, setGlobalSparkline] = useState<SparklineData[]>([]);
|
||||
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
|
||||
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
@@ -339,10 +353,12 @@ export default function BatchManagementPage() {
|
||||
const loadBatchConfigs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [configsResponse, statsData] = await Promise.all([
|
||||
const [configsResponse, statsData, globalSpark] = await Promise.all([
|
||||
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||
BatchAPI.getBatchStats(),
|
||||
BatchAPI.getGlobalSparkline(),
|
||||
]);
|
||||
setGlobalSparkline(globalSpark);
|
||||
// cross-tenant 메타 (단일 모드면 undefined → null)
|
||||
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
|
||||
if (configsResponse.success && configsResponse.data) {
|
||||
@@ -461,8 +477,12 @@ export default function BatchManagementPage() {
|
||||
|
||||
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
||||
const inactiveBatches = batchConfigs.length - activeBatches;
|
||||
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
|
||||
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
|
||||
const todayExec = Number(stats?.today_count) || 0;
|
||||
const todayFail = Number(stats?.today_failed_count) || 0;
|
||||
const yestExec = Number(stats?.yesterday_count) || 0;
|
||||
const yestFail = Number(stats?.yesterday_failed_count) || 0;
|
||||
const execDiff = todayExec - yestExec;
|
||||
const failDiff = todayFail - yestFail;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
@@ -502,7 +522,7 @@ export default function BatchManagementPage() {
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">오늘 실행</span>
|
||||
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
|
||||
<span className="text-lg font-bold text-emerald-600">{todayExec}</span>
|
||||
{execDiff !== 0 && (
|
||||
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||||
어제보다 {execDiff > 0 ? "+" : ""}{execDiff}
|
||||
@@ -512,8 +532,8 @@ export default function BatchManagementPage() {
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">실패</span>
|
||||
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{stats.todayFailures}
|
||||
<span className={`text-lg font-bold ${todayFail > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{todayFail}
|
||||
</span>
|
||||
{failDiff !== 0 && (
|
||||
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
|
||||
@@ -525,7 +545,7 @@ export default function BatchManagementPage() {
|
||||
)}
|
||||
|
||||
{/* 24시간 차트 */}
|
||||
<GlobalSparkline stats={stats} />
|
||||
<GlobalSparkline data={globalSparkline} />
|
||||
|
||||
{/* 검색 + 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -331,14 +331,14 @@ export default function BatchManagementNewPage() {
|
||||
// 내부 데이터베이스 선택
|
||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||
} else {
|
||||
// 외부 데이터베이스 선택
|
||||
const connectionId = parseInt(connectionValue);
|
||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||||
// 외부 데이터베이스 선택 — id 가 number/string 어느 쪽이든 안전하게 비교
|
||||
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||
}
|
||||
|
||||
setToConnection(connection);
|
||||
setToTable("");
|
||||
setToColumns([]);
|
||||
setToTables([]);
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
@@ -383,12 +383,12 @@ export default function BatchManagementNewPage() {
|
||||
if (connectionValue === "internal") {
|
||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||
} else {
|
||||
const connectionId = parseInt(connectionValue);
|
||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||||
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||
}
|
||||
setFromConnection(connection);
|
||||
setFromTable("");
|
||||
setFromColumns([]);
|
||||
setFromTables([]);
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
|
||||
@@ -37,19 +37,22 @@ export interface NodeFlowInfo {
|
||||
node_count: number;
|
||||
}
|
||||
|
||||
// 백엔드 mapper (batchManagement.xml) 가 snake_case 로 응답하므로 그대로 사용한다.
|
||||
// 프로젝트 컨벤션: Map 키 = snake_case (CLAUDE.md 백엔드 규칙 참조)
|
||||
export interface BatchStats {
|
||||
totalBatches: number;
|
||||
activeBatches: number;
|
||||
todayExecutions: number;
|
||||
todayFailures: number;
|
||||
prevDayExecutions: number;
|
||||
prevDayFailures: number;
|
||||
total_count: number;
|
||||
active_count: number;
|
||||
today_count: number;
|
||||
today_failed_count: number;
|
||||
yesterday_count: number;
|
||||
yesterday_failed_count: number;
|
||||
}
|
||||
|
||||
export interface SparklineData {
|
||||
hour: string;
|
||||
success: number;
|
||||
failed: number;
|
||||
hour_slot: string;
|
||||
total_count: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
export interface RecentLog {
|
||||
@@ -621,6 +624,24 @@ export class BatchAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사 전체 배치의 최근 24시간 스파크라인 데이터 (24개 슬롯 고정)
|
||||
*/
|
||||
static async getGlobalSparkline(): Promise<SparklineData[]> {
|
||||
try {
|
||||
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
|
||||
`/batch-management/sparkline`
|
||||
);
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("글로벌 스파크라인 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 24시간 스파크라인 데이터
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# 배치 파이프라인 작업 핸드오프 (2026-05-13, 2026-05-14 업데이트)
|
||||
|
||||
작성자: hjjeong
|
||||
관련 시작 노트: `notes/hjjeong/2026-05-12-batch-pipeline-current-state.md`
|
||||
|
||||
다음 새 세션에서 이 노트만 읽으면 즉시 컨텍스트 잡고 이어갈 수 있게 정리.
|
||||
|
||||
## 한 줄 상태
|
||||
|
||||
vexplor_rps → INVYONE 배치 파이프라인 이식 **Phase 0~5 코드 완료 + 푸시 완료 + 정적 검증 완료 + PR #12 머지 완료 (2026-05-13)**. **Phase 3 conditional 룰 종단 런타임 검증은 첫 사용자 시점으로 deferred** (사용자 결정 2026-05-14). JUnit @SpringBootTest 종단 시도 (2026-05-14) → SELECT path OK, INSERT path 는 transaction context 차이로 검증 불가 → 옵션 B (보류) 결정.
|
||||
|
||||
## 2026-05-14 세션 업데이트 (정적 점검 + 운영 DB read-only 확인)
|
||||
|
||||
이번 세션에서 한 일:
|
||||
1. **코드 ↔ XML namespace.id 매칭 정합성 확인** — `batchExecutionLog.{insert,update}BatchExecutionLog` 둘 다 존재. 매핑 OK
|
||||
2. **`batch_execution_logs` 실 컬럼 타입 vs Service INSERT 값 정합성** — 운영 4개 DB (`invyone`, `siflex_invyone`, `test01_invyone`, `test02_invyone`) read-only 조회. `id`/`batch_config_id`/`duration_ms`/`*_records` 전부 VARCHAR. Service 가 `String.valueOf` 명시 송출 → 정합 OK
|
||||
3. **`useGeneratedKeys="true" keyProperty="id"` 동작 가능성** — `id` 가 VARCHAR + `nextval('batch_execution_logs_id_seq')` 디폴트. PG JDBC 가 String 으로 회수 → mapper UPDATE `WHERE id = #{id}` 그대로 사용 가능. OK
|
||||
4. **tenant routing 정합성 (정적)** — `executeBatchConfig` 가 `@Transactional` 아님 → 매 `sqlSession.getConnection()` 마다 `TenantRoutingDataSource.determineCurrentLookupKey()` 호출 → `TenantHolder` 통과. OK
|
||||
5. **과거 dev 실행 흔적 발견** — 메타 DB `invyone.batch_execution_logs` 에서:
|
||||
- `id=1027 (2026-02-08, total=1, success=1, server_name='unknown', process_id=54848)` — 신 코드 `safeHostName()` "unknown" fallback 지문 + 높은 PID = 로컬 dev JVM. **Phase 4 ETL 1행 처리 성공 흔적**
|
||||
- `id=14 (2026-04-03, total=0, server_name='unknown', process_id=54454)` — **Phase 5 INSERT/UPDATE 성공 흔적** (전체 코드 경로 통과 후 SUCCESS 마무리)
|
||||
6. **Phase 3 conditional 룰은 0건 사용 중** — 4개 DB 통틀어 `batch_mappings.mapping_config` 채워진 행 0건. UI 는 살아있지만 사용자가 입력한 적 없음 → 운영 첫 사용 시점에 종단 검증 필요
|
||||
|
||||
결론: **정적 점검 + dev 흔적 기준으로 Phase 4/5 골격은 동작 확인.** PR 올리고 종결, Phase 3 conditional 종단은 첫 운영 사용자 시점에 함께 검증.
|
||||
|
||||
## 2026-05-14 후반 세션 — JUnit @SpringBootTest 종단 시도 → 옵션 B 보류
|
||||
|
||||
PR #12 머지 후, 사용자가 종단 검증을 자동화로 시도하길 요청. `@SpringBootTest` 통합 테스트를 만들어 운영 invyone 메타 DB 에 임시 `__phase3_*` 테이블 + `BatchExecutor.execute` 직접 호출로 검증 시도.
|
||||
|
||||
### 시도한 것
|
||||
|
||||
- `backend-spring/src/test/java/com/erp/batch/BatchExecutorIntegrationTest.java` (이 파일은 검증 후 삭제 — 운영 DB 가리키는 위험한 테스트라 PR 에 남기지 않음)
|
||||
- 3개 테스트: `conditional_endToEnd`, `upsert_secondRunUpdates`, `rowFailureIsolation`
|
||||
- `@Transactional @Commit` 으로 transaction context 부여, `DataSource` 직접 주입으로 setup/teardown
|
||||
- Cleanup: `@BeforeAll` setupSchema (DROP/CREATE), `@AfterAll` teardown (DROP), `@BeforeEach` TRUNCATE
|
||||
|
||||
### 발견한 것
|
||||
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| Spring 컨텍스트 로딩 | ✅ OK (`JWT_SECRET` 더미 env 만 필요) |
|
||||
| 운영 invyone 메타 connection | ✅ OK (`TenantRoutingDataSource` 가 META fallback) |
|
||||
| 임시 테이블 setup/teardown | ✅ OK (잔여 0건) |
|
||||
| `BatchExecutor.readFromInternal` (SELECT) | ✅ `totalRecords=4` 정확히 통과 |
|
||||
| `BatchExecutor.writeToInternal` (INSERT) | ❌ "Connection is closed" — 전 row INSERT fail |
|
||||
|
||||
### 진단
|
||||
|
||||
운영 HTTP 요청 흐름 (Tomcat thread + Spring transaction-aware connection 관리) 에서는 `try (Connection c = sqlSession.getConnection()) { ... c.prepareStatement(...) }` 패턴이 잘 동작 (dev 흔적 `id=1027` 가 증거).
|
||||
|
||||
JUnit `@SpringBootTest` 는 `@Transactional` 붙여도 `SqlSessionTemplate` 의 transaction binding 이 완벽히 작동 안 함 → 외부 `try (Connection c = ...)` 의 c 가 body 진입 시점에 release 되어 다음 `c.prepareStatement(...)` 가 "Connection is closed" 던짐.
|
||||
|
||||
흥미로운 점: SELECT path 는 `try (Connection c = ...; PreparedStatement ps = c.prepareStatement(...); ResultSet rs = ...)` 처럼 한 줄에서 resource 전부 init → 시간차 없어 OK. INSERT path 는 외부 try body 에서 별도 prepareStatement 호출이라 fail.
|
||||
|
||||
### 결정 (사용자, 2026-05-14)
|
||||
|
||||
옵션 B (보류) 선택. 이유:
|
||||
|
||||
- BatchExecutor 의 핵심 변환 로직 (MappingTransformer) 은 이미 단위 테스트 18건으로 검증됨
|
||||
- INSERT 흐름의 connection 관리는 production HTTP 컨텍스트에서 dev 흔적 (`id=14, 1027`) 으로 검증됨
|
||||
- JUnit 으로 종단 검증하려면 ① backend 띄우고 HTTP curl (셋업 ~30분) 또는 ② BatchExecutor 코드 수정 (머지된 코드라 위험) 둘 다 비용 vs 가치 불균형
|
||||
- 원래 2026-05-14 초반 결정: 종단은 운영 첫 사용자 시점에 6개 검증 항목 (A~F) 으로 흡수
|
||||
|
||||
### 다음 세션 참고
|
||||
|
||||
만약 종단 검증을 꼭 자동화하려면 **backend 띄우고 HTTP curl** 이 정공법:
|
||||
|
||||
1. `docker compose -f docker/dev/docker-compose.backend.mac.yml up -d` 또는 `./gradlew bootRun` (운영 DB 가리킴)
|
||||
2. 인증 토큰 발급 (`POST /api/auth/login` 같은 endpoint — admin 계정 필요)
|
||||
3. 메타 DB 에 batch_config + batch_mappings INSERT (SQL)
|
||||
4. `POST /api/batch-management/batch-configs/{id}/execute` 호출
|
||||
5. `batch_execution_logs` 확인 + cleanup
|
||||
|
||||
BatchExecutor 코드 자체 수정은 PR #12 머지된 코드라 의도적 회피.
|
||||
|
||||
## Git 상태 (세션 끝 기준)
|
||||
|
||||
- 현재 브랜치: `hjjeong`
|
||||
- 푸시: `origin/hjjeong` 에 22 커밋 (`7315603f..f53307a7`)
|
||||
- `origin/main` 과 무충돌 머지 완료 (`f53307a7`)
|
||||
- PR 미생성 — 사용자가 직접 https://git.junggomoa.com/gbpark/invyone/pulls/new/hjjeong 에서 생성 예정
|
||||
|
||||
## 완료된 커밋 (12개, 가독성 순)
|
||||
|
||||
```
|
||||
54a8f97f fix(batch): 미리보기 → 매핑 카드 표시 흐름 정상화 + 매핑 카드 컴팩트화
|
||||
cbf94dc9 feat(batch): TO DB 자동 선택 (internal) + Select 컴포넌트 controlled 화
|
||||
b752de23 fix(batch): previewRestApiData convertCamelToSnake (실은 no-op, 54a8f97f 에서 정정)
|
||||
6fcb101f style(batch): API 파라미터 설정을 collapsible 로 변경 — 기본 접힘
|
||||
47eed680 fix(external-rest-api): WHERE ID = #{id} 에 ::varchar 캐스팅 추가
|
||||
d8f606ab style(batch): 기본 정보를 모드 토글과 한 행으로 통합
|
||||
e8f517ed fix(batch): batch-management-new 도 풀폭 적용
|
||||
d02bc38f style(batch): FROM 카드 행 그룹화 + 컴팩트
|
||||
0c9e22a6 feat(batch): 등록 REST API 연결 자동 호출 + 응답 필드 추출
|
||||
570b3267 feat(batch): batch-management-new 에 conditional 매핑 추가
|
||||
0bba1836 fix(batch): 빈 화면 원인 openTab 키명 정정 + 풀폭
|
||||
f70719ae fix(batch): batch_execution_logs VARCHAR 컬럼에 String.valueOf
|
||||
3ab7deb1 test(batch): Phase 3 — MappingTransformerTest (18 cases)
|
||||
d5925472 fix(batch): writeTo @Transactional 제거 + end_time Timestamp 객체
|
||||
6f8461a5 feat(batch): Phase 5 — executeBatchConfig + batch_execution_logs 기록
|
||||
17172cf9 feat(batch): Phase 4 — BatchExecutor ETL 본체
|
||||
f9a9c678 feat(batch): Phase 3 — MappingTransformer lookup 엔진
|
||||
f31a7f85 feat(batch): Phase 2 — 프런트 ConditionalEditor + 조건 변환 매핑 UI
|
||||
2675c829 feat(batch): Phase 1 — MAPPING_CONFIG JSONB 컬럼 + JSON 직렬화
|
||||
dce665ca feat(batch): Phase 0 — batch_mappings CRUD path
|
||||
f53307a7 Merge remote-tracking branch 'origin/main' into hjjeong
|
||||
```
|
||||
|
||||
## 검증 상태 (2026-05-14 갱신)
|
||||
|
||||
| Phase | 검증 방식 | 결과 |
|
||||
|---|---|---|
|
||||
| Phase 0 (CRUD path) | DB 스키마 + GET 응답 — 부분 검증 | ✅ 스키마 OK / 런타임 미검증 |
|
||||
| Phase 1 (MAPPING_CONFIG) | 운영 DB 컬럼 존재 확인 | ✅ 이미 컬럼 있음 (멱등 안전) |
|
||||
| Phase 2 (ConditionalEditor) | 사용자 브라우저 UI 검증 | ✅ 화면 표시/조작 확인 |
|
||||
| Phase 3 (MappingTransformer) | JUnit 18 tests | ✅ 전부 통과 |
|
||||
| Phase 4 (BatchExecutor) | 정적 점검 + dev 실행 흔적 | 🟡 골격 OK (`id=1027 (2026-02-08, total=1)` 1행 처리 성공 흔적). 실 운영 데이터로 종단 미검증 |
|
||||
| Phase 5 (executeBatchConfig + 로그) | 정적 점검 + dev 실행 흔적 | 🟡 INSERT/UPDATE 동작 확인 (`id=14 (2026-04-03, server_name='unknown')`). 다중 회사 동시 실행은 미검증 |
|
||||
|
||||
## 🚨 아직 테스트 안 한 것 / 운영 첫 사용 시 검증할 것 (★ 필독)
|
||||
|
||||
PR 머지 후 첫 운영 사용자 또는 QA 가 반드시 직접 확인할 항목.
|
||||
|
||||
### A. Phase 3 conditional 룰 종단 (가장 큰 갭)
|
||||
|
||||
- 현재 4개 DB 통틀어 `batch_mappings.mapping_config` 채워진 행 **0건**
|
||||
- 첫 사용자가 ConditionalEditor 로 룰 입력 → save → execute 까지의 전체 흐름이 종단으로 동작하는 적이 한 번도 없었음
|
||||
- **확인 절차**:
|
||||
1. `batchmngList/edit/[id]` 또는 `batch-management-new` 진입
|
||||
2. ConditionalEditor 로 conditional 룰 1건 추가 (예: `from_value='A' → to_value='가나'`)
|
||||
3. 저장 → DB 에서 `batch_mappings.mapping_config` 가 JSONB 로 잘 들어갔는지 확인 (`SELECT id, mapping_type, mapping_config FROM batch_mappings WHERE batch_config_id=...`)
|
||||
4. 수동 실행 버튼 → `batch_execution_logs` 새 행 추가 확인 + TO 테이블에 `to_value` 가 `'가나'` 로 변환되어 들어갔는지 확인
|
||||
- 잠재 이슈: `MappingTransformer.applyConditional` 의 default fallback / 매칭 우선순위 / NULL 처리 — 단위 테스트 통과지만 실 데이터 형태가 다를 수 있음
|
||||
|
||||
### B. Phase 4 외부 소스/대상 종단
|
||||
|
||||
- **FROM = `external_db`** (외부 DB SELECT) — `ExternalDbConnectionService.executeQuery` 경유. dev 흔적 없음
|
||||
- **FROM = `restapi`** (등록 REST API 호출) — `ExternalRestApiConnectionService.fetchData` + `dataArrayPath` 추출. dev 흔적 없음
|
||||
- **TO = `restapi`** (행 단위 POST/PUT/DELETE) — `ExternalRestApiConnectionService.testConnection` 경유. dev 흔적 없음
|
||||
- 확인: 각 타입별로 1건씩 실 호출 성공/실패 카운트가 `batch_execution_logs.success_records`/`failed_records` 에 정확히 반영되는지
|
||||
|
||||
### C. Phase 5 다중 회사 동시 실행 (tenant routing 충돌 가능성)
|
||||
|
||||
- 정적으로는 `executeBatchConfig` 가 `@Transactional` 아님 + 매번 `sqlSession.getConnection()` 새로 borrow → 안전하게 보임
|
||||
- 하지만 같은 시각에 회사 A 의 cron 과 회사 B 의 수동 실행이 겹칠 때 `TenantHolder` (ThreadLocal) 가 정확히 매번 set/clear 되는지 미검증
|
||||
- 확인: 회사 2개 (`siflex`, `test01`) 에서 동시에 같은 batch 실행 → 각자의 `batch_execution_logs` 에만 행 추가되고 cross-DB 오염 없는지
|
||||
|
||||
### D. UPSERT (`save_mode=UPSERT` + `conflict_key`) 종단
|
||||
|
||||
- `BatchExecutor.buildInsertSql` 의 `ON CONFLICT (...) DO UPDATE SET ... = EXCLUDED. ...` 분기 — dev 흔적 없음
|
||||
- 확인: UPSERT 모드 batch 1건 만들어 실행 → 같은 키로 두 번째 실행 시 INSERT 가 아니라 UPDATE 로 동작하는지
|
||||
|
||||
### E. 행 단위 실패 격리
|
||||
|
||||
- `writeToInternal` 가 try-catch 로 row-level 실패만 카운트 — 전체 트랜잭션 롤백 없음 (vexplor_rps 와 동일 패턴, 의도적)
|
||||
- 확인: 일부러 NOT NULL 위반 행 1건 + 정상 행 9건 섞어 실행 → `success=9, failed=1` 정확히 집계되는지
|
||||
|
||||
### F. 회사 코드 필터 (`companyCode`) 누수
|
||||
|
||||
- `BatchExecutor.execute` 가 `config.get("company_code")` 로만 회사 식별 → MappingTransformer 의 fixed/conditional 룰에 회사 정보 주입은 정확한지
|
||||
- 확인: 회사 A 의 batch 가 회사 B 의 데이터를 fetch/write 하지 않는지 (multi-tenant 핵심)
|
||||
|
||||
## 알려진 잠재 리스크 (정적 점검에서 OK 였지만 운영 시 마주칠 가능성)
|
||||
|
||||
1. ~~**`sqlSession.getConnection()` + tenant routing**~~ — ✅ 2026-05-14 정적 OK 확인 (`@Transactional` 미사용 → 매번 routing). 다중 회사 동시 실행 종단 검증은 위 "🚨 C" 항목
|
||||
2. ~~**MyBatis namespace.id 매칭**~~ — ✅ 2026-05-14 XML 대조로 `batchExecutionLog.{insert,update}BatchExecutionLog` 둘 다 존재 확인
|
||||
3. **`@Transactional writeTo` self-call** — `d5925472` 에서 제거함. 행 단위 독립 commit (vexplor_rps 와 동일 패턴)
|
||||
4. ~~**`duration_ms / *_records` VARCHAR**~~ — ✅ 2026-05-14 운영 DB read-only 로 컬럼 타입 일치 확인 + `String.valueOf` 송출 정합 OK
|
||||
5. **외부 호출 인증 안내** — Wehago HMAC 자동 안내 / `auth_tokens` 자동 조회는 의도적 미구현 (Amaranth 회사 전용이라 INVYONE 일반에는 부적합)
|
||||
|
||||
## Phase 4 의 의도적 단순화 (vexplor_rps 대비)
|
||||
|
||||
- `to_api_body` 템플릿 기반 일괄 전송 — 미지원 (행 단위 POST/PUT/DELETE 만)
|
||||
- `URL_PATH_PARAM` 컬럼 처리 — 미지원
|
||||
- inline-mode REST(`from_connection_id` 없이 직접 URL/Key) — 미지원
|
||||
- `auth_tokens` 자동 조회 — 미지원
|
||||
- `row_filter_config` — 미지원
|
||||
- `external_db` TO 쓰기 — 미지원 (INVYONE `ExternalDbConnectionService` 가 보안상 SELECT-only)
|
||||
|
||||
위 항목이 운영 배치에 필요해지면 Phase 4.x 로 incremental 추가.
|
||||
|
||||
## 다음 후보 작업 (우선순위 무관)
|
||||
|
||||
### A. 편집 화면 일관성
|
||||
`batchmngList/edit/[id]/page.tsx` 에 신규 생성 화면 (batch-management-new) 과 동일한 흐름 적용:
|
||||
- TO DB 자동 선택
|
||||
- Select controlled (value prop)
|
||||
- ConditionalEditor 동작 검증 (Phase 2 에서 이미 추가했지만 사용자 직접 확인 필요)
|
||||
|
||||
### B. 같은 패턴 일괄 점검
|
||||
- Select `value` prop 누락 다른 페이지에 있을 가능성
|
||||
- ::varchar 캐스팅 누락 매퍼들 (`externalDbConnection.xml`, `externalCallConfig.xml`, `booking.xml`, `delivery.xml`, `multiConnection.xml`, `taxInvoice.xml` 등)
|
||||
- camelCase / snake_case mismatch 다른 진입점에 있을 가능성
|
||||
|
||||
### ~~C. Phase 4+5 통합 검증 ★~~ — 2026-05-14 deferred
|
||||
|
||||
PR 머지 + 운영 첫 사용 시점에 위 "🚨 아직 테스트 안 한 것" 의 A~F 항목으로 흡수. 별도 사전 검증 라운드는 안 함 (사용자 결정).
|
||||
|
||||
### D. vexplor_rps 차이 보강
|
||||
- 응답 미리보기 Quick Test 버튼 (`runQuickResponseTest`) — 등록 연결 외 inline 모드에서도 즉시 응답 확인
|
||||
- 인증 토큰 자동 안내 UI
|
||||
|
||||
### E. 사이드 이슈
|
||||
- 사이드바 메뉴 로딩 timeout (사용자 사용 중 단발성 발생, `/admin/user-menus` 30s timeout) — 단발성이라 패스했지만 재현되면 별도 진단 필요
|
||||
- Frontend tsc 타입 에러 2871건 — pre-existing 누적, dev 동작은 OK 라 무시 가능하지만 정리 가치 있음
|
||||
|
||||
## 참고 메모리
|
||||
|
||||
- `feedback_no_db_no_settings.md` — 명시 요청 없으면 DB/env/build 변경 금지
|
||||
- `feedback_commit_after_solved.md` — 중간 단계마다 커밋 X, 해결 후 묶어서
|
||||
- `project_batch_varchar.md` — `batch_*` 의 `_id` 컬럼은 VARCHAR. ::varchar 캐스팅 필수
|
||||
|
||||
## 운영 DB 접속 (검증용 read-only 만)
|
||||
|
||||
```
|
||||
host: 183.99.177.40
|
||||
port: 5432
|
||||
user: postgres
|
||||
password: invyone0909!!
|
||||
db: invyone (메타) / 테넌트 별 invyone_* DB
|
||||
```
|
||||
|
||||
사용자 허락한 범위: **read-only SELECT 만**.
|
||||
@@ -0,0 +1,132 @@
|
||||
# 외부 DB 커넥션 멀티 DB 지원 + UI 정돈 핸드오프
|
||||
|
||||
**작업자**: hjjeong
|
||||
**날짜**: 2026-05-18
|
||||
**관련 PR**: #21 (머지 완료, main 반영)
|
||||
**관련 커밋**:
|
||||
- `d61777ab` fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈
|
||||
- `46707bd1` feat(admin): 외부 DB 커넥션 멀티 DB 테스트 + 프로비저닝 시퀀스 reset 보강
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경
|
||||
|
||||
`/admin/automaticMng/exconList` (외부커넥션관리) 페이지가 작동은 했지만 다음 문제가 누적:
|
||||
- 페이지 자체 스크롤이 안 생겨 잘림, 컬럼/탭 폰트가 다른 admin 페이지보다 큼
|
||||
- 연결 테스트 시 500 (mapper SQL 의 VARCHAR vs bigint 비교 오류)
|
||||
- 비-PostgreSQL DB 등록은 되는데 테스트 단계에서 "PostgreSQL 만 지원" 가드로 막힘
|
||||
- 모달이 길어지면 저장/취소 버튼이 화면 밖으로 밀려나감
|
||||
- 회사 프로비저닝 시 외부커넥션 INSERT 가 `duplicate key (id)=(5)` 로 실패
|
||||
|
||||
배치관리 (`/admin/automaticMng/batchmngList`) 도 같은 컨테이너 잘림 + 페이지네이션 부재.
|
||||
|
||||
---
|
||||
|
||||
## 2. 변경 사항
|
||||
|
||||
### 2.1 UI / 레이아웃
|
||||
|
||||
#### `frontend/app/(main)/admin/automaticMng/exconList/page.tsx`
|
||||
- 컨테이너: `flex h-full min-h-0 overflow-hidden` + Tabs/TabsContent 가 flex 컬럼 → 페이지는 viewport 에 고정, 테이블만 자체 스크롤
|
||||
- 폰트 컴팩트: `text-3xl → text-lg`, `text-sm → text-xs`, `h-10 → h-8`
|
||||
- ResponsiveDataView 에 `scrollContainer` + `compact` 활성화
|
||||
|
||||
#### `frontend/components/admin/RestApiConnectionList.tsx`
|
||||
- 동일 패턴: `flex flex-1 min-h-0 overflow-hidden` 컨테이너, `<Table divClassName="flex-1 overflow-auto">`, `<TableHeader className="sticky top-0 z-10 bg-muted">`
|
||||
- 테이블 헤더/행 컴팩트화
|
||||
|
||||
#### `frontend/components/common/ResponsiveDataView.tsx`
|
||||
- `compact` 모드일 때 폰트/셀패딩/카드 폰트도 함께 축소
|
||||
- `scrollContainer` 모드일 때 `@3xl:block` 이 `flex` 를 덮어쓰던 우선순위 충돌 수정 (`@3xl:flex` + `flex-col` 분기)
|
||||
- sticky header bg 알파 50% → 100% (bg-muted) — 본문이 헤더 뒤로 비치던 문제
|
||||
|
||||
#### `frontend/components/admin/ExternalDbConnectionModal.tsx`
|
||||
- DialogContent 를 flex 컬럼으로, 본문 div 자체 스크롤, DialogFooter `shrink-0` → 폼이 길어도 저장/취소 항상 보임
|
||||
|
||||
#### `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx`
|
||||
- 컨테이너 잘림 해결 (동일 패턴)
|
||||
- **페이지네이션 추가**: RPS `vexplor_rps` 배치관리 페이지 참고. `Pagination` 컴포넌트(`frontend/components/common/Pagination.tsx`) 활용. 페이지당 10/20/50/100 선택, 필터 변경 시 1페이지 리셋
|
||||
|
||||
### 2.2 mapper SQL — VARCHAR 캐스팅
|
||||
|
||||
V001 legacy 마이그레이션(`notes/gbpark/2026-05-03-legacy-sql-archive/V001__varchar_migration.sql`)이 `EXTERNAL_DB_CONNECTIONS` 의 다음 컬럼들을 VARCHAR 로 바꿔놨는데 mapper 가 안 따라가서 500 발생:
|
||||
|
||||
| 컬럼 | 원래 타입 | V001 후 | 영향 SQL |
|
||||
|---|---|---|---|
|
||||
| `ID` | bigint | varchar | WHERE ID = #{id} 비교 |
|
||||
| `port` | int | varchar | INSERT/UPDATE 바인딩 |
|
||||
| `connection_timeout` | int | varchar | 동일 |
|
||||
| `query_timeout` | int | varchar | 동일 |
|
||||
| `max_connections` | int | varchar | 동일 |
|
||||
|
||||
**`backend-spring/src/main/resources/mapper/externalDbConnection.xml`** 수정 — 모든 위치에 `::varchar` 캐스팅 추가:
|
||||
- 단건 조회 / 비밀번호 조회 / 이름 중복 체크(exclude) / UPDATE / DELETE 의 ID 비교 6곳
|
||||
- INSERT/UPDATE 의 숫자→VARCHAR 컬럼 4종
|
||||
|
||||
이미 `externalRestApiConnection.xml` 은 캐스팅 처리돼 있어서(REST API 테스트는 정상이었음) 정작 누락된 건 DB mapper 만이었음. 동일 패턴이 다른 mapper 에도 잠재. 의심되는 mapper 가 있으면 `WHERE *_id = #{*_id}` 패턴 검색해서 `::varchar` 캐스트 보강 필요.
|
||||
|
||||
### 2.3 백엔드 — 멀티 DB 테스트 지원
|
||||
|
||||
**`backend-spring/build.gradle`** — 드라이버 4종 `runtimeOnly` 추가:
|
||||
```
|
||||
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1'
|
||||
runtimeOnly 'com.mysql:mysql-connector-j:8.4.0'
|
||||
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11'
|
||||
runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.0'
|
||||
```
|
||||
|
||||
Oracle 은 라이선스로 미포함 (UI 에 옵션 있지만 백엔드 default 분기로 "지원하지 않는 DB 타입" 응답).
|
||||
|
||||
**`backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java`**
|
||||
- `executeConnectionTest` 의 PostgreSQL-only 가드 제거
|
||||
- `switch (type)` 으로 dbType 별 JDBC URL/props 분기:
|
||||
- **postgresql**: `jdbc:postgresql://h:p/d` + `connect_timeout` / `sslmode=require`
|
||||
- **mysql**: `jdbc:mysql://h:p/d` + `connectTimeout`(ms) / `useSSL` / `allowPublicKeyRetrieval`
|
||||
- **mariadb**: `jdbc:mariadb://h:p/d` + `connectTimeout`(ms) / `useSsl`
|
||||
- **mssql/sqlserver**: `jdbc:sqlserver://h:p;databaseName=d;loginTimeout=...;encrypt=...`
|
||||
- **sqlite**: `jdbc:sqlite:<database_name 을 파일경로로>` (host/port 무시)
|
||||
- `defaultPort(String dbType)` 헬퍼 추가 (mysql/mariadb=3306, mssql=1433 등)
|
||||
|
||||
### 2.4 프로비저닝 — VARCHAR PK 시퀀스 reset
|
||||
|
||||
**문제**: 회사 신규 생성 시 `DataCopier.resetSequences()` 가 integer 컬럼만 setval 하고 VARCHAR PK 는 건너뛰어, V001 으로 INT→VARCHAR 변환됐지만 `DEFAULT nextval(...)` 의존성이 남은 컬럼(external_db_connections.id 등) 이 매번 새 회사에서 충돌.
|
||||
|
||||
**원래 코드의 의도** (DataCopier.java 의 주석):
|
||||
> 레거시 DB 에선 SERIAL 이었다가 나중에 TEXT 로 타입 변경된 컬럼이 있을 수 있음. 이런 컬럼에 setval 을 호출하면 "COALESCE types text and integer cannot be matched" 예외 발생.
|
||||
> invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때 nextval 을 사용하지 않으므로 setval 은 no-op.
|
||||
|
||||
→ 두 번째 가정이 V001 영향 테이블에는 안 맞았음.
|
||||
|
||||
**`backend-spring/src/main/java/com/erp/provisioning/DataCopier.java`** `resetSequences()` 확장:
|
||||
- `isIntegerLike()` 외에 `isVarcharLike()` 분기 추가
|
||||
- VARCHAR 인 경우 `setval(seq, GREATEST(COALESCE((SELECT MAX(col::bigint) FROM tbl WHERE col ~ '^[0-9]+$'), 0), 1))` 사용
|
||||
- `::bigint` 명시 캐스트로 COALESCE 타입 충돌 회피
|
||||
- `^[0-9]+$` 정규식으로 UUID 같은 비숫자 PK 거름 → 0 으로 떨어져 무해
|
||||
|
||||
→ 새 회사 생성 시 자동 처리. 기존 회사 DB 들은 1회성 SQL 필요:
|
||||
```sql
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('external_db_connections', 'id'),
|
||||
COALESCE((SELECT MAX(id::bigint) FROM external_db_connections), 0)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 미해결 / 후속 작업
|
||||
|
||||
1. **암호화 키 mismatch 데이터** — `external_db_connections.password` 컬럼이 다른 인스턴스에서 가져온 경우(예: VEX or 다른 INVYONE) AES 키가 달라 `Given final block not properly padded` 로 복호화 실패. 코드 차원 해결 불가. 각 행 편집해서 비밀번호 재입력하거나, 이전 인스턴스의 `encryption.secret-key` 값을 알면 SQL 일괄 재암호화 가능.
|
||||
|
||||
2. **SQL 실행 / 테이블 메타 조회 가드** — `ExternalDbConnectionService.executeQuery` (L369), `getTables` (L406 부근), `getColumns` (L446 부근) 에 동일한 PostgreSQL-only 가드가 남아있음. 멀티 DB 테스트는 풀었지만 이 기능들은 아직 PG 전용. 사용자 요청 시 같은 패턴으로 풀 수 있음.
|
||||
|
||||
3. **Oracle 드라이버 미포함** — `com.oracle.database.jdbc:ojdbc11` 추가 + service 의 `switch` 에 `oracle` case 추가하면 됨. 라이선스 검토 필요.
|
||||
|
||||
4. **다른 mapper 의 VARCHAR 캐스팅 누락 잠재** — V001 로 INT→VARCHAR 변환된 컬럼이 다른 mapper 에도 있을 수 있음. 신규 500 보이면 mapper 가 `WHERE *_id = #{*_id}` 패턴인지 먼저 의심.
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증
|
||||
|
||||
- `./gradlew build` (backend) — BUILD SUCCESSFUL, unit test 통과
|
||||
- `npm run build` (frontend) — 전체 페이지 빌드 통과 (115+ 라우트)
|
||||
- main 머지 후 fast-forward 동기화 완료
|
||||
Reference in New Issue
Block a user