콘텐츠로 이동

HERA-V4 엑셀 업로드 가이드

일반 파일 업로드(TUS/filepond/Object Storage)는 HERA-V4 업로드 가이드를 우선 참조한다. 본 문서는 엑셀 데이터를 DB에 일괄 적재하는 메뉴를 작성하는 절차이다. 참조 구현은 sys0101 공통코드 관리(CommonCodeExcelAdapter)이다.

목차

  1. 개요
  2. 빠른 시작
  3. 단계별 적용 가이드
  4. 3-1. 시작 전 준비 — 변경 항목 표 + 필수/선택 요소 표
  5. 핵심 패턴 & 안티패턴
  6. 트러블슈팅
  7. 레퍼런스 링크

1. 개요

엑셀 업로드는 일반 파일 업로드와 달리 사용자가 입력한 엑셀 데이터를 검증·변환하여 DB에 일괄 적재하는 일련의 파이프라인이다. HERA-V4는 메뉴별로 반복되는 검증/변환 로직을 메타데이터로 추출하여 신규 메뉴 작성 비용을 약 80% 감소시켰다.

쉽게 말해, 엑셀 한 줄 = DB 한 행으로 만드는 작업이다. 사용자가 양식에 맞춰 엑셀을 채워서 올리면, 시스템이 자동으로 검사하고 정제해서 DB에 저장한다.

1.1 용어 빠른 참조

본 가이드에서 자주 등장하는 용어를 미리 정리한다.

용어 한 줄 설명
Adapter 메뉴별로 작성하는 클래스. "이 메뉴는 어떻게 저장하는지"를 정의
AbstractAdapter Adapter의 부모 클래스. 모든 메뉴가 공통으로 쓰는 로직을 미리 구현해둠. 신규 메뉴는 자기 도메인 부분만 채우면 된다
Coordinator 엑셀 파싱부터 검증/변환/저장까지의 흐름을 지휘하는 컴포넌트. 사용자가 직접 호출하지 않음
Context 처리 도중 컴포넌트끼리 주고받는 정보 보관소 (Map처럼 동작). uploadId, menuId 등이 들어있다
menuId 메뉴 식별자(예: sys0101). Coordinator가 이 ID로 어떤 Adapter를 쓸지 결정
검증룰 / 변환룰 sys0402 화면(레이아웃 상세)에서 운영자가 컬럼별로 등록하는 규칙. 코드 수정 없이 룰만 바꿔 동작 변경 가능
scope "어디 안에서 검증할 것인가"의 범위. 예: 공통코드는 categoryCode + groupCode 조합 안에서만 unique 체크
chunk 행을 쪼개서 처리하는 단위. 기본 500행씩 나눠 저장한다 (한 번에 다 보내면 메모리 부담)
postDbAction "기존 DB와 어떻게 합칠지" 정책. 0=추가만 / 1=수정만 / 2=둘 다 / 3=삭제 후 추가 / 4=완전 동기화 (5종 기획). 현재 구현은 0=추가만이며 1~4는 미구현 (운영자가 sys0402 UI에서 0 외 값 선택 시에도 실제 동작은 추가만 수행)
<V> (Generic) "여기에 도메인 VO 타입을 넣어주세요"라는 빈 자리. 예: AbstractAdapter<CommonCodeVO>
_row 가상 컬럼 chunk 저장 실패 후 행 단위 재시도에서 cell 위치를 식별할 수 없을 때 마킹되는 가상 키. 오류 검토 모달이 이 키를 행 단위 시스템 오류로 처리한다
saveBatch 엑셀 업로드 전용 batch INSERT 엔드포인트. 화면 단건 저장(save)의 부가 로직(UPDATE 등)을 우회하고 multi-row INSERT 만 수행해 대량 적재 성능을 확보

1.2 일반 업로드와의 차이

항목 일반 파일 업로드 (NORMAL) 엑셀 업로드 (EXCEL)
목적 파일 자체 저장 파일 내용을 DB로 적재
처리 파일 보관 (_temp/upload/) 파싱 → 검증 → 변환 → 저장
파일 형식 화이트리스트 컬럼별 검증/변환 룰
결과물 저장된 파일 경로 DB row 적재 + 오류 엑셀
운영자 작업 endpoint 등록 sys0401~0404 메타데이터 등록

1.3 핵심 컴포넌트

컴포넌트 역할
ExcelUploadCoordinator 멀티시트 라이프사이클 오케스트레이션
ValidationRuleEngine 행별 검증 + 시트 간 unique
ConversionRuleEngine 행별 변환 (저장 전 사전처리)
ExcelUploadMenuAdapter (interface) 메뉴별 Adapter 계약
AbstractExcelUploadMenuAdapter<V> 공통 패턴 7종 통합 base
ExcelDatWriter .dat 청크 직렬화 (Kryo)
ExcelErrorFileBuilder 오류 엑셀 빌드 (시트 이름 보존)
DbColumnGuard SQL Injection 3중 방어

1.4 지원 기능

  • 검증룰 9종: notNull / contains / equals / textSize / regex / startWith / endsWith / dateFormat / range / unique
  • 변환룰 7종: trim / replace / subString / padLeft / padRight / upperCase / lowerCase / dateFormat
  • 멀티시트 처리: 시트별 .dat 청크 분리 + 시트별 트랜잭션 + 시트 간 unique 검증
  • postDbAction: 추가만 구현됨 (0=추가). 1=수정만 / 2=추가+수정 / 3=삭제 후 추가 / 4=완전 동기화는 기획 단계, 미구현
  • 자동 DB 중복 체크: Layout 매핑 기반 dynamic allow-list (메뉴별 코드 수정 0)
  • Cell 단위 정확 오류 마킹: chunk 저장 실패 시 행 단위로 재시도하여 실제 원인 행/컬럼만 마킹 (chunk 전체 누명 방지)
  • 시스템 미응답 일관 처리: Connection refused / timeout 감지 시 남은 행 전체에 _row 가상 컬럼 + 시스템 오류 메시지 마킹 후 즉시 중단
  • 엑셀 전용 batch INSERT: 화면 단건 저장 경로가 도메인별 부가 로직(검증 / UPDATE / 이력 기록 등) 을 포함하는 경우, saveBatch 엔드포인트로 분리하여 batch INSERT 만 수행. 예: sys0101 은 saveCode 의 revision UPDATE 우회. JDBC executeBatch + PostgreSQL reWriteBatchedInserts 로 multi-row INSERT 변환
  • 오류 엑셀: 원본 시트 이름 보존 + 오류 셀 마킹

1.5 적용 가능한 시나리오

  • 운영자가 사용자 엑셀 양식을 받아 DB에 일괄 적재해야 하는 메뉴 (예: 공통코드, 사용자, 조직, 메시지)
  • 검증/변환 룰이 컬럼 단위로 표현 가능
  • 입력 규모: ~수만 행 동기 처리

1.6 적용 부적절한 시나리오

  • 행 간 복잡한 의존성(예: 트리 구조 검증)
  • 엑셀 외 형식(CSV, JSON) 우선 처리
  • 수십만 행 이상의 대용량 (현재는 동기 처리만 지원)

2. 빠른 시작

본 가이드의 참조 구현은 sys0101 공통코드 관리CommonCodeExcelAdapter.java이다.

AbstractExcelUploadMenuAdapter<V>(부모 클래스)를 상속하면 chunk 저장, 자동 DB 중복 체크, 에러 처리 같은 공통 로직이 이미 구현되어 있다. 신규 메뉴는 "우리 도메인은 어떻게 저장하나? 어떻게 조회하나?" 두 가지만 채우면 된다.

비유: 부모 클래스가 자판기의 본체이고, 자식 Adapter는 그 자판기에 어떤 음료(VO)를 넣을지 + 어디서 음료를 받아올지(Feign client) 알려주는 역할.

2.1 Adapter 골격 (단순 케이스)

package kr.co.dandisoft.hera.domain.sys.excel.sys0101;

import jakarta.annotation.Resource;
import kr.co.dandisoft.hera.domain.sys.CommonCodeClient;
import kr.co.dandisoft.hera.domain.sys.code.common.CommonCodeVO;
import kr.co.dandisoft.hera.domain.sys.excel.upload.FieldExistsRequest;
import kr.co.dandisoft.hera.excel.upload.AbstractExcelUploadMenuAdapter;
import kr.co.dandisoft.hera.excel.upload.ExcelUploadSaveResult;
import kr.co.dandisoft.minu.utils.map.MapContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;

@Slf4j
@Component   // Spring이 자동으로 인스턴스를 만들고 관리
public class CommonCodeExcelAdapter extends AbstractExcelUploadMenuAdapter<CommonCodeVO> {

    @Resource
    private CommonCodeClient commonCodeClient;   // bootstrap-system 모듈로 호출하는 Feign client

    // === 메뉴 식별 ===

    @Override
    public String getDomainCode() { return "SYS_0101"; }   // 임시 파일 경로 prefix

    @Override
    public String getMenuId() { return "sys0101"; }   // Coordinator가 이 ID로 Adapter 매핑

    // === 도메인 콜백 (부모 클래스가 호출) ===

    /** chunk 단위 저장 — 부모의 saveInChunks()가 자동 호출한다. */
    @Override
    protected ResponseEntity<?> doSaveChunk(List<CommonCodeVO> chunk) {
        return commonCodeClient.saveBatch(chunk);   // 엑셀 전용 batch INSERT (UPDATE 우회)
    }

    /** 검증룰 unique:Y 필드 자동 DB 체크 시 호출된다. */
    @Override
    protected List<String> doFindExistingValues(
            String dbFieldName, List<String> values, Map<String, Object> scopeContext) {
        return commonCodeClient.findExistingValues(new FieldExistsRequest(dbFieldName, values, scopeContext));
    }

    // === 라이프사이클 ===

    /** Coordinator가 검증 통과 행을 넘겨주면 호출된다. */
    @Override
    public ExcelUploadSaveResult save(List<Map<String, Object>> validRows, MapContext context) {
        return saveInChunks(toVoList(validRows, context));   // Map → VO 변환 후 부모의 chunk 저장 호출
    }

    /** 엑셀 row(Map) → 도메인 VO 변환. 도메인마다 필드가 다르므로 메뉴별로 작성한다. */
    private List<CommonCodeVO> toVoList(List<Map<String, Object>> rows, MapContext context) {
        List<CommonCodeVO> list = new ArrayList<>(rows.size());
        for (Map<String, Object> row : rows) {
            CommonCodeVO vo = new CommonCodeVO();
            vo.setCategoryCode(str(row, "categoryCode"));   // str()는 부모가 제공하는 헬퍼 (null 안전 + trim)
            vo.setGroupCode(str(row, "groupCode"));
            vo.setCode(str(row, "code"));
            vo.setName(str(row, "name"));
            list.add(vo);
        }
        return list;
    }
}

실제 CommonCodeExcelAdapter.java는 위 골격에 buildScope() override(scope에 categoryCode/groupCode 추가) + 도메인 특수 에러 메시지 등이 추가되어 있다. 자동 DB unique 체크는 검증룰 unique:Y + buildScope 만으로 동작하며, adapterHandledFields() 는 default(Set.of())를 사용한다. chunk 저장 실패 시 cell 위치 식별은 부모 클래스가 자동 수행한다 (필요 시 extractFieldErrors() 만 override). 단순 메뉴는 위 골격만으로 충분하다.

2.2 결과 확인

  1. bootstrap-system 실행 (포트 18010)
  2. hera-webapp 실행 (포트 8000, HTTPS)
  3. 운영자가 메타데이터 등록 — sys0402(레이아웃: 신규/필드추출/DB매핑) → sys0401(템플릿) → sys0402(검증룰 + 변환룰 적용)
  4. sys0101 화면에서 엑셀 업로드 시:
  5. 행별 검증 (notNull, regex 등) 자동 적용
  6. 변환 룰 (trim, replace 등) 자동 적용
  7. 시트 간 unique 중복 검출
  8. chunk 단위 저장 (chunk size = 500)
  9. chunk 실패 시 행 단위 재시도로 cell 단위 정확 마킹
  10. 시스템 미응답 감지 시 _row 가상 컬럼에 즉시 마킹 + 중단

3. 단계별 적용 가이드

신규 메뉴 기준 단계별 절차이다. 본 가이드는 sys0101 공통코드의 실제 구현을 기준으로 설명한다.

3-1. 시작 전 준비 — 변경 항목 표 + 필수/선택 요소 표

본인 메뉴 적용 시 다음 두 표만 채우면 Step 1~9에서 무엇을 바꿔야 하는지 명확해진다. 각 Step의 코드 스니펫에는 ${...} 형식의 placeholder를 명시했으니 (A)표 값으로 일괄 치환하면 된다.

(A) 변경 항목 표 (Find & Replace)

# 항목 이 가이드(sys0101 공통코드) 본인 메뉴 (채워서 사용) 위치 모듈
1 메뉴 코드 (${MenuCode}) sys0101 xxx0000
2 도메인 약어 (${domain}) commonCode xxxItem
3 VO 클래스 (${VO}) CommonCodeVO XxxItemVO hera-domain-data
4 DataRow 클래스 (${DataRow}) CommonCodeDataRow XxxItemDataRow hera-webapp
5 Adapter 클래스 (${Adapter}) CommonCodeExcelAdapter XxxItemExcelAdapter hera-webapp
6 Handler 클래스 (${Handler}) CommonCodeExcelHandler XxxItemExcelHandler hera-webapp
7 Service 빈 (${Service}) CommonCodeServiceImpl XxxItemServiceImpl hera-system (또는 hrm/workflow)
8 Mapper (${Mapper}) CommonCodeMapper XxxItemMapper hera-system
9 Feign Client (${Client}) CommonCodeClient XxxItemClient hera-api-client
10 DB 테이블 (${table}) sys_common_code xxx_item DbColumnGuard 검증 대상
11 템플릿 xlsx (${template}) tmpl-sys0101.xlsx tmpl-xxx0000.xlsx hera-webapp/src/main/resources/excel/
12 API 라우트 base (${route}) /api/sys/sys0101/excel /api/.../xxx0000/excel application-routes.yml

사용법: 본인 메뉴 적용 전에 위 표 "본인 메뉴" 컬럼을 채운 뒤, Step별 코드의 ${VO}, ${MenuCode} 등을 일괄 치환한다.

(B) 필수/선택 요소 표 (구현 체크리스트)

# 요소 필수 위치 (Step) 시그니처/속성 설명
1 ${VO} 클래스 Step 1 extends BaseVO, @Column 매핑 도메인 모델 (DB ↔ 자바)
2 ${Mapper}.findExistingValuesByColumn Step 2 Set<String> findExistingValuesByColumn(@Param String column, @Param List<String> values) UNNEST 기반 bulk lookup
3 ${Mapper}.findTableColumns Step 2 Set<String> findTableColumns(@Param String table) DbColumnGuard용 컬럼 메타
4 ${Service}.findExistingValues Step 3 Set<String> findExistingValues(String dbField, List<String> values) DbColumnGuard 검증 → Mapper 호출
5 Controller endpoint Step 4 POST ${route}/find-existing Adapter ↔ Service 브리지
6 ${Client} Feign Step 5 @FeignClient + endpoint 매핑 webapp ↔ system 통신
7 ${Adapter}.getDomainCode() Step 6 String getDomainCode() (예: "SYS_0101") 도메인 식별자 (대문자_숫자 규약)
8 ${Adapter}.getMenuId() Step 6 String getMenuId() (예: "sys0101") 메뉴 ID (URL/매핑 키)
9 ${Adapter}.doSaveChunk Step 6 ResponseEntity<?> doSaveChunk(List<${VO}> chunk) 청크 단위 DB 저장 (Feign Client 호출 결과 그대로 반환)
10 ${Adapter}.doFindExistingValues Step 6 List<String> doFindExistingValues(String dbField, List<String> values, Map<String,Object> scope) DB 중복 검증 (Feign 위임)
11 ${DataRow} 클래스 Step 6 @ExcelProperty(...) 컬럼 매핑 엑셀 시트 행 모델
12 템플릿 xlsx Step 6 헤더 행 + 검증 룰 시트 resources/excel/ 배치
13 application-routes.yml 등록 Step 6 ${MenuCode}-excel 경로 업로드/다운로드 라우팅
14 모달 fragment 포함 Step 7 <div th:replace="~{fragments/modal/modal-excel-upload}"> 업로드 UI
15 sys0402 매핑 등록 Step 8 운영자 작업 (UI) 엑셀 컬럼 ↔ DB 컬럼 매핑
16 검증룰/변환룰 등록 Step 8 운영자 작업 (UI) NOT NULL / unique / 타입 변환
17 ${Adapter}.adapterHandledFields() Step 6 Set<String> 반환 hook (default: Set.of()) 자동 DB unique 체크에서 제외할 도메인 자체 검증 필드. 어댑터가 직접 validateBeforeSave()에서 별도 복합키 검증을 수행하는 경우 그 필드를 등록. 현재 CommonCodeExcelAdapter는 override 없이 default 사용
18 ${Adapter}.extractErrorMessage(e) Step 6 DB 예외 → 사용자 메시지 도메인별 SQLState 매핑 시만
19 ${Adapter}.systemUnavailableMessage() Step 6 String 반환 hook 메시지 커스터마이징 시만
20 ${Adapter}.buildScope(MapContext) Step 6 Map<String,Object> 반환 hook unique 검증 스코프 분기 시만 (예: categoryCode + groupCode 조합 안에서만 unique)
21 ${Adapter}.doSaveSingle(V) Step 6 ResponseEntity<?> 반환 hook (default: doSaveChunk(List.of(row)) 위임) chunk 실패 후 행 단위 재시도에서 호출. 도메인의 단건 저장 API 가 따로 있으면 override 하여 성능 최적화
22 ${Adapter}.extractFieldErrors(Exception, V) Step 6 Map<String,String> 반환 hook (default: {_row: 일반메시지}) chunk 실패 후 행 단위 재시도에서 어느 cell 이 원인인지 식별. 도메인이 정확한 컬럼 매칭을 원할 때만 override

표기: 필수=✅(빠지면 컴파일/런타임 에러), 선택=⬜(미구현 시 default 동작). 빠른 시작은 ✅ 항목만 구현해도 작동한다.

Step 1: 도메인 VO 정의

왜 필요한가: 엑셀 한 행을 받아 자바 객체(VO)로 만들고, 그 객체를 DB에 저장한다. VO는 "엑셀 컬럼 ↔ DB 컬럼"의 중간 다리 역할.

hera-domain-data/src/main/java/.../sys/code/common/CommonCodeVO.java:

@Data
@EqualsAndHashCode(callSuper = true)
public class CommonCodeVO extends AuditVO {
    private String categoryCode;
    private String categoryName;
    private String groupCode;
    private String groupName;
    private String code;
    private String name;
    private String useYn;
    private Integer orderNo;
    // ... attribute01~05, refId 등
}

VO는 엑셀에서 추출할 컬럼 + DB 저장 컬럼 모두 포함한다.

Step 2: Mapper에 Bulk Lookup 메서드 추가

왜 필요한가: 엑셀에 1000행이 있을 때 "이 1000개 값이 DB에 이미 있나?"를 확인해야 한다. 한 줄씩 조회하면 1000번 호출(N+1 문제)이지만, 한 번에 모아 조회(Bulk Lookup)하면 1번이면 끝.

Bulk Lookup 비유: 우체국에서 우편물 1000건의 도착 여부를 확인할 때, 한 건씩 묻지 말고 운송장 번호 목록을 통째로 넘겨 한 번에 확인하는 것.

hera-system/.../sys/code/common/CommonCodeMapper.java:

@Mapper
public interface CommonCodeMapper extends BaseMapper<CommonCode> {

    List<String> findExistingValuesByField(
        @Param("columnName") String columnName,
        @Param("categoryCode") String categoryCode,
        @Param("groupCode") String groupCode,
        @Param("refId") String refId,
        @Param("refIdBlank") boolean refIdBlank,
        @Param("values") String[] values
    );

    Set<String> findTableColumns(@Param("tableName") String tableName);
}

hera-system/.../resources/mybatis/sys/code/common/common-code-mapper.xml:

<select id="findExistingValuesByField" resultType="string">
    SELECT cc.${columnName}
      FROM sys_common_code cc
     WHERE cc.category_code = #{categoryCode}
       AND cc.group_code = #{groupCode}
       AND (#{refIdBlank} = true OR cc.ref_id = #{refId})
       AND cc.${columnName} = ANY(
             #{values, jdbcType=ARRAY,
               typeHandler=kr.co.dandisoft.hera.mapper.handler.type.StringArrayTypeHandler}::varchar[]
           )
</select>

<select id="findTableColumns" resultType="string">
    SELECT column_name FROM information_schema.columns
     WHERE table_schema = current_schema()
       AND table_name = #{tableName}
</select>

${columnName}은 동적 컬럼이므로 반드시 Step 3의 DbColumnGuard.validate()로 검증 후 mapper에 전달한다.

Step 3: Service 구현 (DbColumnGuard 활용)

왜 필요한가: Step 2의 SQL은 컬럼명을 동적으로 받는다(${columnName}). 누군가 악의적인 값을 넣어 다른 테이블을 들여다보거나 SQL을 변조할 수 있으므로 검증이 필수.

DbColumnGuard의 3중 안전망 (해킹 방어): 1. 정규식 ^[a-z][a-z0-9_]*$ — snake_case 형식만 허용 (따옴표/세미콜론/공백 차단) 2. information_schema 캐시 — 실제 DB에 존재하는 컬럼만 허용 3. Layout 매핑 — 운영자가 sys0402에서 명시한 컬럼만 통과 (Coordinator가 사전 필터)

비유: 회사 출입 시 ① 사원증 형식 검사 → ② 실제 사원 명부 대조 → ③ 오늘 출근 권한 확인. 어느 한 단계라도 실패하면 입장 차단.

hera-system/.../sys/code/common/CommonCodeServiceImpl.java:

@Service
public class CommonCodeServiceImpl extends AuditServiceImpl<CommonCode, CommonCodeMapper>
        implements CommonCodeService {

    @Resource
    private CommonCodeMapper commonCodeMapper;

    @Resource
    private DbColumnGuard dbColumnGuard;

    @Override
    public List<String> findExistingValues(
            String dbFieldName, List<String> values, Map<String, Object> scopeContext) {
        // SQL Injection 3중 방어 (정규식 + information_schema 캐시)
        dbColumnGuard.validate(dbFieldName, "sys_common_code", commonCodeMapper::findTableColumns);

        if (values == null || values.isEmpty()) return List.of();

        Map<String, Object> scope = scopeContext != null ? scopeContext : Map.of();
        String categoryCode = (String) scope.get("categoryCode");
        String groupCode = (String) scope.get("groupCode");
        String refId = (String) scope.get("refId");
        boolean refIdBlank = (refId == null || refId.isEmpty());

        List<String> distinctValues = values.stream()
                .filter(v -> v != null && !v.isBlank())
                .distinct()
                .toList();
        if (distinctValues.isEmpty()) return List.of();

        return Lists.partition(distinctValues, BULK_UNIQUE_LOOKUP_CHUNK_SIZE).stream()
                .flatMap(chunk -> commonCodeMapper.findExistingValuesByField(
                        dbFieldName, categoryCode, groupCode, refId, refIdBlank, chunk.toArray(new String[0])
                ).stream())
                .distinct()
                .toList();
    }
}

dbColumnGuard.validate(...) 한 줄로 SQL Injection 방어 (정규식 + information_schema 캐시) 완료.

Step 4: Controller endpoint 추가

왜 필요한가: hera-webapp에서 호출할 수 있도록 HTTP endpoint를 만든다. 모듈 간 통신은 모두 Feign + Controller 조합이다.

hera-system/.../web/api/sys/CommonCodeApiController.java:

@PostMapping("/common-codes/exists-by-field")
public List<String> findExistingValues(@RequestBody FieldExistsRequest request) {
    return commonCodeService.findExistingValues(
        request.getDbFieldName(),
        request.getValues(),
        request.getScopeContext()
    );
}

/**
 * 엑셀 업로드 전용 batch INSERT.
 * 화면 단건 저장의 부가 로직(예: revision UPDATE) 을 우회하고 multi-row INSERT 만 수행한다.
 */
@PostMapping("/common-codes/batch")
public ResponseEntity<?> saveBatch(@RequestBody List<CommonCodeVO> commonCodeVOList) {
    commonCodeService.saveCodeListBatch(commonCodeVOList);
    return ok();
}

saveCodeListBatch 는 Service 측에서 super.saveVO(list, 1000) 한 줄로 구현하면 된다 (MyBatis Flex 의 batch INSERT 사용). 화면 단건 저장(save/saveCode) 경로는 그대로 보존하고, 엑셀 전용 경로만 분리한다.

Step 5: Feign Client 추가

왜 필요한가: hera-webapp에서 Step 4의 endpoint를 호출하기 위한 Java 인터페이스. 인터페이스만 선언하면 Spring이 자동으로 HTTP 호출 코드를 만들어준다 (구현체 작성 불필요).

hera-api-client/.../sys/CommonCodeClient.java:

@PostMapping("/common-codes/exists-by-field")
List<String> findExistingValues(@RequestBody FieldExistsRequest request);

/** 엑셀 업로드 전용 batch INSERT. 화면 단건 저장의 부가 로직 우회. */
@PostMapping("/common-codes/batch")
ResponseEntity<?> saveBatch(@RequestBody List<CommonCodeVO> codeVOList);

어댑터의 doSaveChunk() 는 화면 저장용 save() 가 아니라 이 saveBatch() 를 호출한다.

Step 6: ExcelAdapter 작성

왜 필요한가: 엑셀 업로드 처리의 도메인 진입점. Coordinator가 menuId로 해당 Adapter를 찾아 검증·저장 호출을 위임한다.

§2.1의 골격을 그대로 사용한다. 도메인 콜백 doSaveChunk, doFindExistingValues 두 개만 구현하면 chunk 저장, 자동 DB 중복 체크, 에러 처리가 자동 동작한다.

단순 메뉴는 §2.1 골격으로 충분하다. 다음 hook들은 도메인별 특수 요구가 있을 때만 override한다 — sys0101은 buildScope만 override하고 adapterHandledFields는 default(Set.of())를 사용한다.

// scope 분기가 필요한 경우 (예: categoryCode + groupCode 조합 안에서만 unique)
@Override
protected Map<String, Object> buildScope(MapContext context) {
    Map<String, Object> scope = super.buildScope(context);
    scope.putIfAbsent("categoryCode", context.get("categoryCode"));
    scope.putIfAbsent("groupCode", context.get("groupCode"));
    return scope;
}

// 어댑터에서 별도 복합키를 직접 검증하는 경우에만 (자동 DB unique 체크 제외)
// → §4.5 "자주 쓰는 적용 패턴" 참조. CommonCodeExcelAdapter는 override 안 함.
// @Override
// protected Set<String> adapterHandledFields() {
//     return Set.of("code");
// }

@Override
public ExcelUploadSaveResult validateBeforeSave(List<Map<String, Object>> validRows, MapContext context) {
    ExcelUploadSaveResult result = ExcelUploadSaveResult.success();
    // 검증룰 unique:Y 자동 DB 체크 (sys0402 매핑 + buildScope 기반)
    if (!Boolean.TRUE.equals(context.get("skipDbUniqueCheck"))) {
        applyAutoDbUniqueCheck(validRows, context, result);
    }
    return result;
}

어댑터 측 별도 복합키 검증이 필요한 경우(예: in-memory 중복 + 추가 비즈니스 룰)에만 위 1단계를 추가한다. sys0101은 검증룰 + buildScope 조합으로 충분해서 단순화했다.

자세한 구현은 CommonCodeExcelAdapter.java 참조.

Step 7: 프론트엔드 모달 추가

왜 필요한가: 사용자가 엑셀을 업로드할 화면 UI. 모달 자체는 이미 작성된 fragment를 재사용하므로 삽입 한 줄이면 된다.

hera-webapp/src/main/resources/templates/sys/sys0101.html:

<th:block layout:insert="~{fragments/modal/modal-excel-upload :: content(${@apiUrl}+'/sys0101/excel-upload', 'sys0101')}"/>

두 번째 인자('sys0101')는 menuId. Coordinator가 이 ID로 ExcelAdapter를 dispatch한다.

Step 8: DB 메타데이터 등록 (운영자 작업)

운영자가 화면에서 등록한다 (코드 작성 0줄). 사용자가 본 메뉴의 엑셀 업로드 모달을 열기 전에 반드시 선행되어야 하는 단계이다.

화면 등록 내용 주요 항목
sys0402 레이아웃 엑셀 헤더 ↔ DB 컬럼 매핑 (필드 추출 + DB 테이블 매핑 + 자동 매핑 버튼)
sys0401 템플릿 menuId, sheet 정보, error_threshold, firstExecRule 등
sys0402 검증룰 매핑된 컬럼별 9종 검증룰 (템플릿 등록 후 가능)
sys0402 변환룰 매핑된 컬럼별 7종 변환룰 (템플릿 등록 후 가능)

8-1. sys0402 엑셀 레이아웃 등록

레이아웃은 "엑셀 헤더 ↔ DB 컬럼" 매핑 정의이다. sys_excel_upload_layout 테이블에 저장된다.

레이아웃 등록은 다음 3단계로 진행한다.

(1) 레이아웃 신규 등록

기본 정보를 입력하여 레이아웃 레코드를 생성한다.

sys0402 레이아웃 신규 등록 화면

입력 항목:

  1. 레이아웃 ID 자동 생성id 컬럼은 INSERT 시 PostgreSQL uuidv7() 으로 자동 부여된다. 운영자가 직접 입력하지 않는다.
  2. 레이아웃 이름(name) 입력 — 식별용 표시 이름 (예: 공통코드 레이아웃). 사용자가 자유롭게 부여
  3. 소속 메뉴(menu_id) 지정 — 본 메뉴 ID 또는 부모 메뉴 ID
  4. use_yn = 'Y' 로 활성화
(2) 필드명 추출 — 엑셀 업로드로 헤더 자동 추출

사용자가 다운로드받을 양식 엑셀을 업로드하면 헤더 행을 읽어 layout_configfieldName 을 자동으로 채운다. 수동 입력보다 빠르고 오타가 없다.

sys0402 추출 업로드 모달

sys0402 헤더 추출 결과 — layout_config 자동 채움

layout_config 결과 예시 (자동 추출 후):

[
  {"fieldName":"카테고리코드"},
  {"fieldName":"그룹코드"},
  {"fieldName":"코드(필수)"},
  {"fieldName":"이름(필수)"}
]

양식 엑셀은 사용자가 다운로드받게 될 헤더 순서와 동일해야 한다. 이 순서가 그대로 업로드 모달의 "양식 다운로드" 결과가 된다.

(3) DB 테이블 매핑

추출된 fieldName 들을 DB 컬럼과 매핑한다. DB 테이블을 선택하면 해당 테이블의 컬럼 목록이 표시되며, 각 fieldName 에 알맞은 dbfieldName 을 선택한다. uniqueField 체크박스로 자동 DB 중복 체크 대상도 함께 지정한다.

sys0402 DB 테이블 매핑 화면

매핑 방법 2가지:

  • 드래그&드롭 — 왼쪽 DB 컬럼 목록에서 항목을 끌어 오른쪽 fieldName 행에 떨어뜨려 매핑한다. 한 컬럼씩 시각적으로 확인하며 매핑할 때 유용
  • 자동 매핑 버튼 — fieldName 과 DB 컬럼명의 유사도를 기반으로 일괄 매핑한다. 매칭 우선순위는 정확 일치 → snake_case 변환 일치 → 라벨/별칭 매칭 순. 매핑 후 운영자가 검토/수정 가능

두 방법은 혼용 가능하다. 자동 매핑으로 1차 적용한 뒤 미매핑/오매핑된 항목만 드래그&드롭으로 보정하는 흐름이 가장 빠르다.

binding_config 결과 예시 (매핑 후):

{
  "0": [
    {"fieldName":"카테고리코드","dataType":"STRING","dbfieldName":"category_code","uniqueField":false},
    {"fieldName":"그룹코드","dataType":"STRING","dbfieldName":"group_code","uniqueField":false},
    {"fieldName":"코드(필수)","dataType":"STRING","dbfieldName":"code","uniqueField":true},
    {"fieldName":"이름(필수)","dataType":"STRING","dbfieldName":"name","uniqueField":true}
  ]
}

  • Coordinator 는 이 정보로 row → VO 변환과 자동 DB unique 체크의 컬럼 식별을 수행한다.
  • uniqueField: true 로 지정된 컬럼은 검증룰의 unique:Y 와 연계되어 DB 중복 검증 대상이 된다.

멀티시트인 경우 binding_config 의 키를 "0", "1", ... 순서로 늘려 시트별로 분리한다.

검증룰/변환룰 적용은 템플릿 등록(§8-2) 후 진행한다 — templateId 가 키로 필요하기 때문. 자세한 절차는 §8-3 검증룰 등록, §8-4 변환룰 등록 참조.

8-2. sys0401 엑셀 업로드 관리 — 템플릿 등록

템플릿은 "메뉴별로 어떤 레이아웃을 어떻게 처리할지" 의 운영 설정이다. sys_excel_upload_config 테이블에 저장된다.

sys0401 엑셀 업로드 관리 메인 화면

등록 절차:

  1. 템플릿 ID 자동 생성id 컬럼은 INSERT 시 PostgreSQL uuidv7() 으로 자동 부여된다. 운영자가 직접 입력하지 않는다.
  2. 템플릿 이름(name) 입력 — 식별용 표시 이름 (예: 공통코드 템플릿)
  3. menu_id : 본 메뉴 ID (Coordinator 가 이 ID 로 어댑터를 dispatch)
  4. error_threshold : 한 시트 내 허용 오류 행 수 (기본 10). 초과 시 해당 시트는 저장 차단
  5. config_attrs : 시트별 처리 옵션
    {
      "0": {
        "layout": "0190f8a4-7c25-7a3b-8e1f-3b91d8c5a2d4",
        "layoutName": "공통코드 레이아웃",
        "sheetNo": 1,
        "startRowNo": 3,
        "workflowJob": false,
        "firstExecRule": "VALIDATION"
      }
    }
    
  6. layout : 8-1 에서 등록한 레이아웃의 UUID. 운영자가 직접 입력하지 않고 sys0401 화면의 레이아웃 셀렉트에서 선택하면 자동 입력됨
  7. layoutName : 선택 편의를 위한 표시 이름 (선택과 함께 자동 채워짐)
  8. sheetNo : 엑셀 시트 번호 (1-based)
  9. startRowNo : 데이터 시작 행 (헤더가 1~2행이면 3 부터)
  10. firstExecRule : VALIDATION 또는 CONVERSION (변환을 먼저 적용할지)
  11. workflowJob : 워크플로 적재 여부 (현재 false 권장)
  12. use_yn = 'Y' 로 활성화

sys0401 템플릿 상세 — config_attrs 시트별 옵션

8-3. sys0402 검증룰 등록

검증룰은 레이아웃(§8-1)에서 DB 테이블에 매핑된 컬럼에 한해 적용할 수 있다. 매핑되지 않은 fieldName 은 룰 선택 셀렉트에 노출되지 않는다.

검증룰 등록은 템플릿(§8-2)이 먼저 등록된 후 가능하다. templateId + config_key(시트 번호) 조합을 키로 sys_excel_upload_validation 에 저장된다.

sys0402 검증룰 적용 화면

rule_config 예시 — 컬럼 인덱스(키)별로 fieldName + validationRule 모음:

{
  "0": {
    "fieldName":"code",
    "validationRule":{"notNull":"Y","textSize":"50","unique":"Y"}
  },
  "1": {
    "fieldName":"name",
    "validationRule":{"notNull":"Y","textSize":"100"}
  }
}
  • 적용된 컬럼은 업로드 시 검증되고, 위반 시 오류 셀로 마킹된다.
  • unique:Y 로 표시한 컬럼은 어댑터의 applyAutoDbUniqueCheck() 가 자동으로 DB 사전 조회까지 수행한다 (Layout 의 dbfieldName 사용).
  • 룰 종류와 옵션의 상세 명세는 §4.1 검증룰 9종 참조.

8-4. sys0402 변환룰 등록

변환룰도 검증룰과 동일하게 매핑된 컬럼에 한해 적용 가능하며, 템플릿(§8-2) 등록 후에 진행한다. sys_excel_upload_conversion 에 저장된다.

sys0402 변환룰 적용 화면

rule_config 예시 — 구조는 검증룰과 동일:

{
  "0": {
    "fieldName":"code",
    "conversionRule":{"trim":"Y","upperCase":"Y"}
  }
}
  • 적용된 컬럼 값은 자동 변환된다.
  • 변환과 검증의 실행 순서는 sys0401 템플릿(§8-2)의 firstExecRule 로 제어한다 (VALIDATION 우선 / CONVERSION 우선).
  • 룰 종류와 옵션의 상세 명세는 §4.2 변환룰 7종 참조.

8-5. 메뉴 활성화 플래그

sys_menu.config_attrs"excelUpload": "Y" 를 등록해야 메뉴 화면에 엑셀 업로드 버튼이 노출된다.

{ "excelUpload": "Y" }

8-6. 사용자 업로드 모달 동작 흐름

사용자가 본 메뉴 화면에서 엑셀 업로드 버튼을 누르면 modal-excel-upload.html 모달이 열린다.

엑셀 업로드 모달 — 템플릿 선택 화면

  1. 템플릿 선택 — 8-2 에서 등록된 템플릿 중 menu_id 가 본 메뉴인 항목들이 셀렉트로 나타남. 보통 1개
  2. 양식 보기 / 다운로드 — 선택된 템플릿의 layout_config 기반으로 빈 엑셀이 즉시 생성됨 (resources 의 tmpl-*.xlsx 가 없어도 동작)
  3. 파일 드래그&드롭 또는 브라우저 선택 — TUS 임시 업로드
  4. post-action 호출uploadId + fileName + menuId 를 백엔드에 전달
  5. Coordinator 자동 수행 — 파싱 → 변환(8-4 룰) → 검증(8-3 룰) → 어댑터 save() 호출 (chunk 단위, cell 마킹)
  6. 결과 표시
  7. 성공: 진행상태 100% + "엑셀 업무처리 결과를 준비했습니다" 안내
  8. 부분 오류: 오류 검토 모달(modal-excel-error-review.html) 자동 열림. 오류 셀에 빨간 마킹 + 셀 코멘트
  9. 시스템 미응답: _row 가상 컬럼에 시스템 오류 메시지 마킹 후 중단

엑셀 업로드 모달 — 업로드 진행 중

엑셀 업로드 — 오류 검토 모달

오류 기준 개수(error_threshold)에 따른 분기 - 기준 이하 — 오류 검토 모달의 그리드로 표시되어 사용자가 셀 단위로 직접 수정/재저장 가능 (Case A) - 기준 초과 — 그리드 표시를 생략하고 오류 셀이 마킹된 엑셀 다운로드 파일만 제공한다. 외부 편집기에서 수정 후 다시 업로드 (Case B)

기준값은 sys0401 템플릿 등록 시 error_threshold 로 지정한다 (기본 10).

  1. 오류 엑셀 다운로드 — 오류 알림 영역의 "오류 엑셀 다운로드" 버튼으로 원본 시트 이름이 보존된 결과 엑셀 받기

모달은 fragment 로 공용 제공되므로 메뉴별 추가 작업 없이 Step 7 의 <th:block layout:insert="..."> 한 줄로 사용 가능.

Step 9: 동작 검증

  1. 운영자 계정으로 sys0101 화면 진입
  2. 빈 양식 다운로드 → 데이터 입력 → 업로드
  3. 검증 오류 시 오류 검토 모달 + 오류 엑셀 다운로드 동작 확인
  4. 정상 입력 시 chunk 단위 저장 + savedCount 배너 표시 확인

4. 핵심 패턴 & 안티패턴

4.1 검증룰 9종

운영자가 sys0402 화면(레이아웃 상세)에서 매핑된 컬럼별로 등록한다. 등록 절차는 §8-3 참조.

의미 dataType rule_config 예시
notNull 빈값 금지 All "notNull": "Y"
contains / notContains 부분 문자열 포함/제외 STRING "contains": "abc"
equals / notEquals 동일/불일치 STRING "equals": "Y"
textSize 문자 길이 STRING "textSize": "10"
regexMatch / regexContains 정규식 일치/포함 STRING "regexMatch": "^[a-z]+$"
startWith / endsWith 시작/끝 문자열 STRING "startWith": "PRE_"
dateFormat 날짜 포맷 DATE/DATETIME "dateFormat": "yyyyMMdd"
greater / less 수치 비교 LONG/DOUBLE "greater": "0"
unique 행 간 + DB 기존값 중복 금지 All "unique": "Y"

4.2 변환룰 7종

운영자가 sys0402 화면(레이아웃 상세)에서 매핑된 컬럼별로 등록한다. 등록 절차는 §8-4 참조. 변환은 검증보다 먼저 적용된다 (사용자는 변환 후 결과를 검증한다는 직관) — sys0401 템플릿의 firstExecRule 로 순서 제어 가능.

의미 예시
trim 양 끝 공백 제거 " abc " → "abc"
replace 문자열 치환 {from:"-", to:""}
subString 부분 추출 {start:0, length:8}
padLeft / padRight 좌우 패딩 {ch:"0", length:5}
upperCase / lowerCase 대소문자 변환 "abc" → "ABC"
dateFormat 날짜 포맷 변환 {from:"yyyy-MM-dd", to:"yyyyMMdd"}

4.3 멀티시트 처리

엑셀에 여러 시트가 있어도 별도 코드 작성 불필요. sys0401에 시트별 attrs를 등록하면 Coordinator가 자동 처리한다.

시나리오 동작
시트 1 정상 + 시트 2 임계값 초과 시트 1만 저장, 시트 2는 차단 (시트별 트랜잭션)
시트 1, 3에 동일 unique 키 양쪽 모두 오류 (시트 간 통합 검증)
오류 엑셀 다운로드 원본 시트 이름 보존 ("오류데이터" 같은 하드코딩 없음)

4.4 postDbAction

운영자가 sys0402에서 모드를 선택한다. 현재 구현은 0=추가만이다. 1~4는 기획만 있고 미구현 상태이므로, 본인 메뉴 적용 시 운영자에게 "0만 사용 가능"을 안내해야 한다.

Mode 의미 동작 구현 상태
0 추가 INSERT만. 기존 데이터 무시 구현됨
1 수정 UPDATE만. 기존 데이터 없으면 오류 ⏳ 미구현
2 추가 + 수정 UPSERT (MERGE) ⏳ 미구현
3 삭제 후 추가 scope 기준 DELETE → INSERT ⏳ 미구현
4 추가 + 수정 + 삭제 scope 안에서 동기화 (없는 row는 삭제) ⏳ 미구현

주의: sys0402 UI에서 1~4 선택은 가능하나 Coordinator가 0과 동일하게 처리한다. 향후 mode별 SP/Mapper 분기가 추가될 예정.

4.5 자주 쓰는 적용 패턴

도메인 자체 검증과 자동 DB 중복 체크의 분리 (adapterHandledFields)

언제 override 하나: 어댑터의 validateBeforeSave() 안에서 별도 복합키 검증을 직접 수행하는 필드가 있을 때만 override 한다. 그 필드를 자동 DB unique 체크 대상에서 제외하기 위함이다.

default 동작: Set.of() 반환 — 즉, 검증룰에서 unique:Y로 표시한 모든 필드는 applyAutoDbUniqueCheck()가 자동으로 DB 중복 체크를 수행한다. 대부분의 메뉴는 default로 충분하다.

// 예: 도메인이 (categoryCode + groupCode + code) 3컬럼 복합키를 별도 검증하는 경우
@Override
protected Set<String> adapterHandledFields() {
    return Set.of("code");   // 'code'는 자동 체크에서 제외
}

현재 CommonCodeExcelAdapter는 override 없이 default(Set.of())를 사용한다. sys0101은 검증룰 unique:Y + buildScope()에서 categoryCode/groupCode를 scope에 넣어주는 것만으로 자동 체크가 정확히 동작하기 때문이다. 본인 메뉴도 검증룰만으로 처리 가능하면 override 하지 말 것.

Scope 키 보강 (buildScope)

Scope 란 무엇인가

Scope (스코프)unique 검증에서 "어느 범위 안에서 unique 인가" 를 결정하는 한정 조건이다. 검증룰에 unique:Y 만 지정하면 부족할 수 있는데, 실제 DB UNIQUE 제약은 보통 복합 컬럼 인 경우가 많기 때문이다.

예시: - 공통코드의 code 컬럼은 전체 DB 에서 unique 가 아니라 (category_code, group_code) 안에서만 unique 이다. - SYS_GENDER 카테고리의 M 코드 ≠ USER_TYPE 카테고리의 M 코드 (둘 다 공존 허용) - 따라서 unique 체크 SQL 의 WHERE 절은 WHERE code = ? AND category_code = ? AND group_code = ? 여야 한다.

이 한정 조건 category_code = ? + group_code = ? 가 바로 scope 다.

흐름 — scope 가 자동 DB unique 체크에 어떻게 쓰이는가
1. ValidationRuleEngine 이 검증룰에서 unique:Y 필드 발견
2. AbstractExcelUploadMenuAdapter.applyAutoDbUniqueCheck 진입
3. 어댑터의 buildScope(context) 호출 → Map<String, Object> scope 획득
4. doFindExistingValues(dbFieldName, values, scope) 호출
5. 도메인 Feign Client → 서버 API → Service → Mapper
6. Mapper 가 scope 의 키를 WHERE 절에 적용:
   SELECT code FROM sys_common_code
    WHERE code IN (?, ?, ...)
      AND category_code = #{scope.categoryCode}    ← scope 키 활용
      AND group_code    = #{scope.groupCode}        ← scope 키 활용

→ scope 가 비어있으면 WHERE 절에 한정 조건이 안 들어가서 전혀 다른 카테고리의 같은 코드 까지 중복으로 잡힐 수 있다.

기본 동작

AbstractExcelUploadMenuAdapter.buildScope() 의 기본 구현:

@SuppressWarnings("unchecked")
protected Map<String, Object> buildScope(MapContext context) {
    Map<String, Object> raw = (Map<String, Object>) context.get("scopeContext");
    return raw != null ? new LinkedHashMap<>(raw) : new LinkedHashMap<>();
}

contextscopeContext 키에서 scope 를 꺼내 그대로 반환한다. scopeContext 는 화면 측 (또는 Coordinator) 에서 업로드 시 함께 전달된다.

언제 override 해야 하나

다음 두 케이스 중 하나라면 override 한다.

케이스 이유
컨텍스트의 다른 키를 scope 에 합쳐야 할 때 context.scopeContext 에는 없지만 context.categoryCode 같은 별도 키가 있는 경우
scope 키 이름을 변환해야 할 때 예: 화면에서는 compCd 로 보내는데 Mapper 는 companyCode 키를 기대
고정 scope 키를 항상 추가해야 할 때 예: 모든 unique 체크에 useYn = 'Y' 조건을 추가

화면이 처음부터 scopeContext 에 모든 키를 정확히 넣어 보내면 override 불필요 (default 동작으로 충분).

sys0101 의 실제 override
@Override
protected Map<String, Object> buildScope(MapContext context) {
    Map<String, Object> scope = super.buildScope(context);              // 1) 기본 scope 가져오기
    scope.putIfAbsent("categoryCode", context.get("categoryCode"));     // 2) 카테고리 추가
    scope.putIfAbsent("groupCode", context.get("groupCode"));           // 3) 그룹 추가
    return scope;
}

라인별 동작:

  1. super.buildScope(context) — 부모의 기본 동작을 먼저 호출해서 context.scopeContext 가 있으면 그 값을 가져온다. 없으면 빈 Map.
  2. putIfAbsent("categoryCode", ...) — context 의 별도 키 categoryCode 를 scope 에 추가. putIfAbsent 이므로 이미 같은 키가 있으면 덮어쓰지 않는다 (안전).
  3. putIfAbsent("groupCode", ...) — 동일 방식으로 groupCode 추가.

putIfAbsent 인가: 화면 또는 Coordinator 가 scopeContext 에 이미 categoryCode / groupCode 를 명시적으로 넣어 보낼 가능성이 있는데, 그때 어댑터가 덮어쓰면 화면의 의도가 무시된다. putIfAbsent"화면이 안 보냈을 때만 보강" 한다는 의도를 표현한다.

scope 키의 데이터 흐름 — 끝까지 추적

scope 키가 실제 SQL 의 WHERE 절에 도달하기까지의 전체 경로:

1. 화면 JS (sys0101.js) → 업로드 모달에서 categoryCode/groupCode 선택
2. POST /api/excel-upload-runtime/{menuId}/excel/upload/post-action
   요청 본문에 { uploadId, categoryCode, groupCode, ... } 포함
3. ExcelUploadRuntimeApiController 가 받아 MapContext 에 모든 키 주입
4. ExcelUploadCoordinator.process 호출 → context 에 scopeContext 등 자동 채움
   (Coordinator 가 일부 도메인 키를 scopeContext 로 옮기기도 함)
5. ValidationRuleEngine 이 unique:Y 필드 발견
6. AbstractExcelUploadMenuAdapter.applyAutoDbUniqueCheck 진입
7. adapter.buildScope(context) 호출:
   - default: context.scopeContext 그대로
   - CommonCodeExcelAdapter (override): context.categoryCode + groupCode 추가
8. adapter.doFindExistingValues(dbField, values, scope) 호출
9. commonCodeClient.findExistingValues(new FieldExistsRequest(dbField, values, scope))
   → FieldExistsRequest 의 scopeContext 필드에 scope 가 그대로 들어감
10. CommonCodeApiController.findExistingValues 가 받아
    CommonCodeServiceImpl.findExistingValues(dbField, values, scope) 호출
11. Service 가 scope 에서 키 추출:
    String categoryCode = (String) scope.get("categoryCode");
    String groupCode    = (String) scope.get("groupCode");
    String refId        = (String) scope.get("refId");
12. Mapper 호출:
    mapper.findExistingValuesByField(dbField, categoryCode, groupCode, refId, refIdBlank, values)
13. MyBatis XML 의 SQL 발행:
    SELECT cc.${columnName}
      FROM sys_common_code cc
     WHERE cc.category_code = #{categoryCode}    ← 9단계의 scope 값
       AND cc.group_code    = #{groupCode}       ← 9단계의 scope 값
       AND cc.${columnName} = ANY(#{values}::varchar[])
14. 결과 ← 해당 (category, group) 안에서 이미 존재하는 값 목록
15. AbstractExcelUploadMenuAdapter 가 결과를 result.addError(rowIdx, fieldName, msg) 로 마킹

scope 키 하나가 빠지면 11~13 단계에서 WHERE 절이 좁혀지지 않아 다른 카테고리의 같은 값까지 중복으로 오인 한다.

안티패턴

scope 를 어댑터 안에서 직접 SQL 에 쓰려 함

@Override
protected Map<String, Object> buildScope(MapContext context) {
    Map<String, Object> scope = super.buildScope(context);
    scope.put("whereClause", "category_code = 'SYS_USER'");   // ❌ SQL 조각 직접 주입
    return scope;
}

scope 는 키-값 쌍 이어야 한다. SQL 조각을 넣으면 SQL Injection 위험. Mapper 측에서 #{...} 바인딩으로 처리되어야 한다.

scope 키 이름을 매번 바꾸기

scope.put("CategoryCode", ...);   // ❌ 어댑터마다 다른 케이스 사용
scope.put("category_code", ...);  // ❌ Mapper 가 categoryCode 기대하는데 snake_case 로 넣음

scope 의 키 이름은 Mapper 의 @Param 또는 XML 의 #{} 이름과 정확히 일치 해야 한다. 도메인 컨벤션 (보통 camelCase) 을 따른다.

scope 값에 가공된 데이터 넣기

scope.put("categoryCode", context.get("categoryCode").toString().toUpperCase());   // ❌ 가공

가공은 ConversionRuleEngine 또는 Mapper 측에서 처리. scope 에는 화면이 보낸 원본 값을 그대로 전달.

Scope 사용 사례 비교
도메인 scope 키 의미
sys0101 공통코드 categoryCode, groupCode 카테고리/그룹 안에서만 unique
HRM0201 인사 (가상) companyCode 회사 안에서만 사번 unique
LDB0103 고객 (가상) cntcId (계약 ID) 계약 안에서만 고객번호 unique
테이블 전체 unique (없음) scope override 불필요. 검증룰 unique:Y 만으로 동작
정리
  • scope = unique 검증의 "어느 범위" 한정 조건
  • 화면이 scopeContext 로 직접 넣어 보내면 default 동작으로 충분
  • 컨텍스트의 다른 키를 합쳐야 하면 buildScope override
  • putIfAbsent 사용으로 화면 의도를 덮어쓰지 않음
  • scope 키 이름은 Mapper 의 파라미터 이름과 1:1 일치
  • SQL 조각이 아니라 키-값 데이터 만 전달
Override 표준 템플릿
@Override
protected Map<String, Object> buildScope(MapContext context) {
    Map<String, Object> scope = super.buildScope(context);
    scope.putIfAbsent("도메인_scope_키1", context.get("도메인_scope_키1"));
    scope.putIfAbsent("도메인_scope_키2", context.get("도메인_scope_키2"));
    // 필요한 만큼 추가
    return scope;
}

이중 저장 / UPDATE / upsert 가 필요한 경우 (save() override)

도메인에 따라 다음과 같은 케이스가 있을 수 있다.

  • 이중 저장: 한 행에 대해 2개 이상의 테이블에 저장이 필요 (예: LDB0103 의 ldb_cust + ldb_file_rslt)
  • UPDATE / upsert: 단순 INSERT 가 아니라 exists 확인 후 INSERT/UPDATE 분기 (예: HRM0201 의 사번 기준 upsert)
  • 저장 후 부가 작업: 캐시 갱신, 결과 캐시 보관, 이벤트 발행 등

이 경우 어댑터의 save() 메서드를 override 하여 도메인 흐름을 구성한다. 부모의 saveInChunks() / doSaveChunk() / doSaveSingle() 만으로는 부족하기 때문이다.

구현 원칙
원칙 설명
단순 batch INSERT 경로는 도메인 Service 에 분리해서 둔다 {Domain}Service.save{Entity}ListBatch 같이. 어댑터에서는 호출만
UPDATE / upsert / 이중 저장 경로도 도메인 Service 에 메서드로 분리 어댑터에 트랜잭션·비즈니스 로직을 직접 쓰지 않는다 (안티패턴)
어댑터의 save() 는 흐름 조립만 수행 변환 → 분리 → 호출 → 결과 집계
검증을 통과한 행만 저장 호출 validateBeforeSave 또는 도메인 검증으로 분리된 결과를 사용
부분 실패 처리 각 도메인 Service 호출의 응답을 확인하여 ExcelUploadSaveResultaddError(rowIdx, fieldName, msg) 로 마킹
패턴 1. 이중 저장 (LDB0103 스타일)
@Override
public ExcelUploadSaveResult save(List<Map<String, Object>> validRows, MapContext context) {
    // 1) 컨텍스트 가드 (필수 키 확인)
    String uploadId = (String) context.get("uploadId");
    if (!StringUtils.hasText(uploadId)) {
        return ExcelUploadSaveResult.ofError(0, primaryFieldName(), "업로드 처리 정보가 누락되었습니다.");
    }

    // 2) 도메인 Support 호출 → 저장 대상 분리
    //    (예: parseCustomerRows 가 saveList + resultList 를 분리)
    CustomerUploadResult parsed = ldb0103Support.parseCustomerRows(validRows, context);

    ExcelUploadSaveResult result = ExcelUploadSaveResult.success();

    // 3) 첫 번째 테이블 저장 (chunk 단위)
    if (!parsed.saveList().isEmpty()) {
        result = saveInChunks(parsed.saveList());   // ← cell 단위 마킹 자동
        if (result.hasErrors()) {
            return result;   // 1차 저장 실패 시 2차 저장 시도 안 함
        }
    }

    // 4) 두 번째 테이블 저장 (도메인 Service 의 별도 메서드)
    if (!parsed.resultList().isEmpty()) {
        try {
            ResponseEntity<?> res = ldbFileRsltClient.saveBatch(parsed.resultList());
            if (!res.getStatusCode().is2xxSuccessful()) {
                return ExcelUploadSaveResult.ofError(0, VIRTUAL_ROW_FIELD,
                        "업로드 결과 저장에 실패했습니다. (HTTP " + res.getStatusCode() + ")");
            }
        } catch (Exception e) {
            log.error("{}-save :: 결과 저장 실패", getClass().getSimpleName(), e);
            return ExcelUploadSaveResult.ofError(0, VIRTUAL_ROW_FIELD,
                    isSystemUnavailable(e) ? systemUnavailableMessage() : extractErrorMessage(e));
        }
    }

    // 5) 결과 캐시 보관 등 부가 작업
    resultByUploadId.put(uploadId, parsed);

    return result;
}

트랜잭션 주의: 2개 테이블 저장은 어댑터(webapp) 측에서 묶을 수 없다. 각 Feign 호출은 별도 HTTP 요청 = 별도 트랜잭션. 1차 성공 후 2차 실패 시 보상 정책(롤백 API 호출 또는 사용자에게 안내) 을 사전 결정해야 한다. 가장 단순한 보존 정책: 1차 성공 후 2차 실패는 사용자에게 명시적으로 알리고 운영자가 후속 처리.

패턴 2. UPDATE / upsert 가 필요한 경우 (HRM0201 스타일)
@Override
public ExcelUploadSaveResult save(List<Map<String, Object>> validRows, MapContext context) {
    List<HrmEmpeVO> voList = toVoList(validRows);
    return saveInChunks(voList);
}

@Override
protected ResponseEntity<?> doSaveChunk(List<HrmEmpeVO> chunk) {
    // 도메인 Service 의 upsertBatch 가 사번 기준 INSERT/UPDATE 분기 처리
    return hrmEmpeClient.upsertBatch(chunk);
}

도메인 Service (서버측) 구현:

@Transactional
public void upsertBatch(List<HrmEmpeVO> list) {
    // 1) 기존 사번 일괄 조회
    List<String> empNos = list.stream().map(HrmEmpeVO::getEmpNo).distinct().toList();
    Set<String> existing = new HashSet<>(mapper.findExistingEmpNos(empNos));

    // 2) 분리
    List<HrmEmpeVO> inserts = list.stream().filter(vo -> !existing.contains(vo.getEmpNo())).toList();
    List<HrmEmpeVO> updates = list.stream().filter(vo -> existing.contains(vo.getEmpNo())).toList();

    // 3) batch INSERT + batch UPDATE
    if (!inserts.isEmpty()) super.saveVO(inserts, 1000);
    if (!updates.isEmpty()) mapper.updateBatch(updates);
}

→ 어댑터는 doSaveChunk 한 줄로 위임만 하고, 분기 로직은 도메인 Service 가 책임진다.

패턴 3. INSERT 가 아니라 UPDATE only

특정 메뉴에서 "기존 행을 수정만 한다" 가 의도라면, doSaveChunk 가 호출하는 Feign 메서드를 update 계열로 두면 된다.

@Override
protected ResponseEntity<?> doSaveChunk(List<HrmEmpeVO> chunk) {
    return hrmEmpeClient.updateBatch(chunk);   // 도메인 Service 의 batch UPDATE
}

postDbAction 기획에는 1=수정만 / 2=추가+수정 / 3=삭제 후 추가 / 4=완전 동기화 가 있으나 현재 Coordinator 구현은 0=추가만 동작한다. 도메인이 자체 UPDATE 가 필요하면 위처럼 어댑터 측에서 명시적으로 처리한다.

어디에 어떤 책임이 가는가 — 정리
책임 위치
chunk 분할 / cell 마킹 / 행 단위 재시도 / 시스템 미응답 감지 AbstractExcelUploadMenuAdapter (자동)
검증 통과 행 → VO 변환 어댑터의 toVoList() (도메인 작성)
저장 대상 분리 (이중 저장 등) 어댑터의 save() override 또는 도메인 Support 클래스
실제 DB INSERT / UPDATE / upsert 분기 도메인 Service / Mapper (어댑터에 직접 쓰지 말 것)
트랜잭션 경계 도메인 Service 의 @Transactional
결과 캐시 / 이벤트 발행 등 부가 작업 어댑터의 save() override (도메인 흐름)

chunk 실패 후 cell 위치 식별 (extractFieldErrors)

chunk 저장이 실패하면 부모 클래스가 행 단위 재시도로 어느 행이 진짜 원인인지 자동 식별한다. 단, 행 단위 재시도에서 받은 예외가 어느 컬럼이 원인인지까지 알 수는 없으므로, 기본 동작은 _row 가상 컬럼에 일반 메시지를 마킹한다.

도메인이 JDBC/PostgreSQL 예외 메시지를 분석해서 정확한 컬럼을 알 수 있다면 extractFieldErrors() 를 override 한다.

@Override
protected Map<String, String> extractFieldErrors(Exception e, CommonCodeVO row) {
    if (e == null || e.getMessage() == null) {
        return super.extractFieldErrors(e, row);   // 기본: _row 가상 컬럼
    }
    String msg = e.getMessage();
    if (msg.contains("duplicate key") && msg.contains("code")) {
        return Map.of("code", "이미 존재하는 코드입니다.");
    }
    if (msg.contains("null value in column \"name\"")) {
        return Map.of("name", "이름은 필수입니다.");
    }
    return super.extractFieldErrors(e, row);   // 매칭 실패 시 default
}

override 하지 않으면 chunk 실패 행에 _row 가상 컬럼으로 시스템 오류 메시지가 마킹된다. 검증룰을 충실히 등록한 환경에서는 chunk 단계까지 도달하는 오류 자체가 드물어 override 가 필수는 아니다.

단건 저장 API 활용 (doSaveSingle)

도메인에 단건 저장용 별도 API 가 있다면 doSaveSingle() 을 override 하여 행 단위 재시도 비용을 줄일 수 있다. 기본은 doSaveChunk(List.of(row)) 위임이므로 별도 단건 API 가 없으면 override 불필요.

@Override
protected ResponseEntity<?> doSaveSingle(CommonCodeVO row) {
    return commonCodeClient.save(row);   // 단건 전용 endpoint
}

4.6 안티패턴 (해서는 안 되는 것)

❌ Adapter에서 직접 EasyExcel 호출

@Override
public ExcelUploadSaveResult save(List<Map<String, Object>> rows, MapContext context) {
    EasyExcel.read(filePath).sheet(0).doRead();   // ❌ Coordinator가 이미 파싱했다
}

Coordinator가 이미 시트별로 파싱한 row를 전달한다. Adapter는 row 리스트를 그대로 사용하면 된다.

❌ Adapter에 트랜잭션 어노테이션

@Transactional   // ❌
public ExcelUploadSaveResult save(...) { }

트랜잭션은 도메인 Service 레이어에서 관리한다. Adapter는 Feign client 호출만 한다.

❌ 한 행씩 INSERT

for (CommonCodeVO vo : list) {
    commonCodeClient.save(List.of(vo));   // ❌ N번 호출, 성능 저하
}

반드시 chunk 단위 batch save를 사용한다 — saveInChunks(list) 를 호출하면 자동으로 chunk 단위 처리되며, chunk 내부 저장은 도메인 Service 의 saveBatch (MyBatis Flex saveVO(list, 1000)) 를 통해 multi-row INSERT 로 묶인다.

❌ 화면 단건 저장 API 를 엑셀 업로드에 그대로 사용

@Override
protected ResponseEntity<?> doSaveChunk(List<CommonCodeVO> chunk) {
    return commonCodeClient.save(chunk);   // ❌ 화면 저장용 (UPDATE/revision 등 부가 로직 포함)
}

화면 단건 저장 경로(save/saveCode) 는 도메인별로 revision UPDATE, 트리거, 부가 검증 등이 들어있는 경우가 많다. 엑셀 5만건 일괄 적재 시 행마다 부가 로직이 반복되어 성능이 크게 떨어진다.

반드시 엑셀 전용 saveBatch 엔드포인트를 별도로 두고 거기에서는 multi-row INSERT 만 수행한다.

@Override
protected ResponseEntity<?> doSaveChunk(List<CommonCodeVO> chunk) {
    return commonCodeClient.saveBatch(chunk);   // ✅ 엑셀 전용 batch INSERT
}

❌ context 키를 임의 변경

context.put("uploadId", "수정된값");   // ❌

context는 Coordinator가 관리한다. Adapter는 read-only로 사용한다.

❌ DB 컬럼명을 매뉴얼 화이트리스트로 관리

private static final Set<String> ALLOWED_FIELDS = Set.of("code", "name", "use_yn");   // ❌

Layout 매핑(camelToDbMap)이 dynamic allow-list 역할을 하므로 화이트리스트 불필요. DbColumnGuard가 정규식 + information_schema로 안전성 보장.

❌ 라벨과 백엔드 키 의미 불일치

이전에 "중복허용" 라벨이 백엔드 unique:"Y" (중복금지)와 의미가 반대였던 사례가 있다. 체크 시 백엔드가 활성화되는 행위가 라벨에 명확히 드러나야 한다 (현재는 "중복체크"로 통일됨).


5. 트러블슈팅

5.1 자주 발생하는 오류

오류 메시지 원인 해결
허용되지 않은 dbFieldName 형식 동적 컬럼명이 ^[a-z][a-z0-9_]*$ 패턴 위반 sys0402에서 dbFieldName이 snake_case인지 확인
<table>에 존재하지 않는 컬럼 DbColumnGuard가 information_schema에서 컬럼 미발견 DB 스키마와 sys0402 매핑 일치 확인. 스키마 변경 후라면 dbColumnGuard.invalidate(tableName)
검증룰에 unique:Y 설정되었으나 레이아웃 매핑이 없어 DB 중복 체크를 건너뜁니다 검증룰에 unique 지정했지만 sys0402에 해당 컬럼 매핑 없음 sys0402에서 해당 컬럼 매핑 추가
시스템 API에 연결할 수 없습니다. bootstrap-system(18010) 실행 상태를 확인하세요 bootstrap-system 미가동 또는 Feign timeout bootstrap-system 프로세스 확인, 네트워크 점검
이미 존재하는 코드입니다 DB 또는 파일 내 중복값 입력 데이터 검토. unique 룰 의도 확인
오류 모달에서 _row 가상 컬럼으로 마킹된 행이 보임 chunk 실패 후 행 단위 재시도에서 cell 위치를 식별 못함 (예외 메시지가 컬럼 단서 없음) 정상 동작. 행 자체에 시스템 오류 발생을 의미. 더 정확한 컬럼 마킹이 필요하면 어댑터의 extractFieldErrors() override
5만건 엑셀 업로드가 수십 분 걸림 어댑터의 doSaveChunk 가 화면 단건 저장 API(save) 를 호출 — 행마다 부가 UPDATE 로직 반복 엑셀 전용 saveBatch 엔드포인트 신설 + 어댑터가 saveBatch 호출하도록 변경
시트 1, 3에 동일 키 입력 시 시트 3에서 미검출 extractValidRows 필터 버그 (해결됨) Coordinator 최신 버전 확인
자동 DB 중복 체크의 fieldName이 숫자(예: "5") validationConfig 구조 처리 버그 (해결됨) AbstractExcelUploadMenuAdapter 최신 버전 확인
chunk 실패 시 모든 행에 같은 컬럼 누명 마킹 구버전 chunkErrorFieldName 기반 로직 사용 중 AbstractExcelUploadMenuAdapter 최신 버전 확인. 어댑터에서 chunkErrorFieldName override 가 남아있으면 제거
sys0402 검증룰/변환룰 변경이 화면에 반영 안 됨 정적 리소스 캐시 미갱신 IntelliJ Run Config "Before launch"에 :hera-webapp:processResources 추가

5.2 디버깅 절차

  1. 로그 확인: _temp/logs/hera-webapp/_temp/logs/bootstrap-system/
  2. chunk 단위 격리: 실패한 chunk 범위(start~end)를 로그에서 확인
  3. 검증 결과 확인: ExcelUploadSaveResult.errorMap을 디버거로 확인
  4. context 키 확인: validationConfig, camelToDbMap이 비어있지 않은지 (Coordinator 주입 여부)
  5. 자동 DB 중복 체크 동작 여부: 로그에서 applyAutoDbUniqueCheck 검색

5.3 로그 위치

로그 경로
webapp 로그 _temp/logs/hera-webapp/hera-webapp-yyyy-MM-dd.log
시스템 API 로그 _temp/logs/bootstrap-system/bootstrap-system-yyyy-MM-dd.log
업로드 임시 파일 _temp/upload/{uuid}/
.dat 청크 _temp/excel/upload/{domainCode}/{uploadId}/SHEET-N/
오류 엑셀 출력 _temp/excel/error/오류_{uploadId}.xlsx

6. 레퍼런스 링크

6.1 프레임워크 핵심 코드 (hera-commons)

6.2 참조 구현 (hera-webapp, sys0101 공통코드)

6.3 도메인 DTO (hera-domain-data)

6.4 프론트엔드 Fragment

6.5 운영자 화면

화면 ID 용도
sys0402 컬럼 레이아웃 정의 (자동 매핑 지원) + 검증룰/변환룰 등록
sys0401 업로드 템플릿 등록/관리 (menuId, error_threshold, firstExecRule 등)

6.6 관련 매뉴얼

6.7 외부 라이브러리 의존성

라이브러리 버전 용도
EasyExcel 4.x 엑셀 파싱/생성 (스트리밍)
Kryo 5.x .dat 청크 직렬화
MyBatis-Flex 1.11.6 DB 매핑
Spring Cloud OpenFeign 4.x 모듈 간 통신
PostgreSQL 14+ UNNEST/array, information_schema
pq-grid 8.x 오류 검토 모달 그리드

6.8 변경 이력

날짜 버전 변경 요약 작성자
2026-04-29 1.0 초안 작성. AbstractExcelUploadMenuAdapter\<V> + DbColumnGuard 기반 신규 표준 반영. sys0101 공통코드 참조 구현 김영훈
2026-04-30 1.1 §3-1 시작 전 준비 추가: (A) 변경 항목 표(12개 placeholder) + (B) 필수/선택 요소 표(19개 항목, ✅/⬜ 표기). 본인 메뉴 적용 시 일괄 치환 가능하도록 Step 본문은 placeholder 표기 유지 김영훈
2026-04-30 1.2 최신 소스 반영: doSaveChunk 반환 타입 ResponseEntity<?> / doFindExistingValues 반환 List<String> 정정. getDomainCode/getMenuId 필수 항목 (B)표 추가 (총 21개로 확장). adapterHandledFields 가이드 재작성 — sys0101은 default 사용 명시. §4.4 postDbAction "0=추가만 구현됨, 1~4 미구현" 명시. §4.5 자주 쓰는 패턴 + Step 6 본문 최신 sys0101 코드 정합화 김영훈
2026-04-30 1.2.1 §6.6 관련 PDCA archive 섹션 제거 (해당 프로젝트에 PDCA archive 없음). Front matter related-pdca 키 제거. §6.7~6.9 → §6.6~6.8 재번호 김영훈
2026-05-13 1.3.0 Cell 단위 정확 오류 마킹 패턴 반영: chunkErrorFieldName/putChunkError 제거 → _row 가상 컬럼 + doSaveSingle/extractFieldErrors/retryChunkPerRow/markSystemUnavailable hook 도입. 엑셀 전용 saveBatch 엔드포인트 신설 (화면 단건 저장의 도메인 부가 로직 우회 — sys0101 의 revision UPDATE 가 사례). §1.1 용어표 갱신, §1.4 지원 기능 3종 추가, §3-1 (B) 표 #17~#22 hook 정리, §3 Step 4~5 saveBatch 명세 추가, §3 Step 8 운영자 작업 흐름을 sys0402 레이아웃 / sys0401 템플릿 / sys0403 검증 / sys0404 변환 / sys_menu 활성화 / 사용자 모달 흐름 6단계로 상세화, §4.5 패턴 보강 — "이중 저장 / UPDATE / upsert 가 필요한 경우 (save() override)" 신규 섹션 (LDB0103 이중 저장, HRM0201 upsert, UPDATE only 3가지 패턴 + 책임 분담표) + "Scope 키 보강 (buildScope)" 섹션 대폭 확장 (개념 정의 / 흐름도 / 기본 동작 / override 시점 / sys0101 실제 예시 / 15단계 데이터 흐름 추적 / 안티패턴 / 사례 비교 / 표준 템플릿) + extractFieldErrors·doSaveSingle 패턴 추가 (구 chunkErrorFieldName 섹션 삭제), §4.6 안티패턴 "화면 단건 저장 API 를 엑셀에 그대로 사용" 추가, §5.1 트러블슈팅 3건 추가 (_row 가상 컬럼 / 5만건 수십 분 문제 / chunk 누명 마킹) 김영훈
2026-05-14 1.4.0 운영자 화면 캡쳐 이미지 11장 첨부 (docs/manuals/images/excel-upload/). §8 운영자 흐름 재구성: 검증룰/변환룰을 sys0402 레이아웃 안에서 등록하는 흐름으로 통합 — §8-3, §8-4 제목 sys0403/sys0404sys0402. §8-1 sys0402 레이아웃 등록을 3단계 sub-section 으로 세분화 (① 신규 등록 ② 필드명 추출(엑셀 업로드) ③ DB 테이블 매핑(드래그&드롭/자동 매핑)). §8-1 끝에 "검증룰/변환룰 등록은 템플릿(§8-2) 후 가능" cross-link 보강. §8-2 sys0401 템플릿/§8-1 sys0402 레이아웃 ID uuidv7() 자동 생성 안내 정정 (기존: SYS0101_COMMON_CODE_LAYOUT 같은 사용자 부여 ID 표기 → UUID 예시로 변경). §8-6 오류 검토 모달에 error_threshold 기준 분기 설명 추가 (이하: 그리드 / 초과: 오류 엑셀 파일 다운로드). §1.1 용어표 / §2.2 운영자 작업 순서 / §8 도입표 / §4.1, §4.2 안내 / §5 트러블슈팅 / §6.5 운영자 화면표 모두 sys0402 단일 화면 기준으로 일관 정리 김영훈