Ongo Framework삼카 확장개발툴 홈코어 개발가이드개발툴 사용가이드배치실행

Ongo Framework 코어 개발가이드 · 삼카 확장

백엔드 개발자를 위한 프레임워크 레퍼런스예요. 각 컴포넌트를 ① 기본 동작 → ② yml 설정 → ③ SPI 커스텀 구현(케이스별) 순서로 설명해요. 개발툴 화면 사용법은 개발툴 사용가이드를 보세요.

개요·모듈 구성

모듈역할
ongo-framework-commonSPI 인터페이스, 공통 타입(BusinessException, MessageCoded, AuthPrincipal 등). 업무 코드는 여기 타입에만 의존하면 돼요.
ongo-framework-coreSPI 기본 구현(인메모리·yml 기반). 개발·테스트에 바로 쓸 수 있는 수준이에요.
ongo-framework-starterSpring Boot AutoConfiguration. 모든 기본 빈이 @ConditionalOnMissingBean이라 같은 타입 빈을 등록하면 자동으로 교체돼요(137개 지점).
ongo-framework-devtoolslocal/dev 전용 코드생성·전문 테스트 화면(/framework-devtools).
ongo-framework-batchSpring Batch 공통 정책·실행 화면.
ongo-framework-samsung-card표준전문(HEADER/MESSAGE) 코덱·레이아웃 규칙(삼카 고객사 확장).
ongo-framework-sc-demo삼카 쇼케이스·레퍼런스 앱. 설정 예시는 이 앱의 application.yml 기준이에요.

확장 원칙(SPI·yml)

  1. yml로 먼저 해결 — 대부분의 동작(경로, 타임아웃, 상태코드, 테이블·컬럼명)은 ongo.framework.* 속성으로 바꿀 수 있어요.
  2. 그래도 안 되면 SPI 빈 등록 — 인터페이스를 구현해 @Component/@Bean으로 올리면 기본 구현이 물러나요.
  3. 기본 구현을 감싸기(데코레이터) — 기본 동작을 유지하면서 일부만 바꾸려면 기본 클래스를 new로 만들어 위임하세요.
@Configuration
public class MyFrameworkConfig {
    // 기본 MaskingService 대신 사내 규칙 사용 — @ConditionalOnMissingBean 이라 이 빈이 우선해요.
    @Bean
    MaskingService maskingService() { return new CompanyMaskingService(); }
}
같은 타입 빈을 두 개 등록하면 주입 모호성 오류가 나요. 교체용은 하나만, 다중 등록형(InboundHandlerService, PhysicalTransportService, OngoHandlerInterceptor)만 여러 개 허용돼요.

빠른 시작

./gradlew :ongo-framework-sc-demo:bootRun --args='--spring.profiles.active=local'
# 쇼케이스  http://localhost:8080/
# 개발툴    http://localhost:8080/framework-devtools
# 이 가이드 http://localhost:8080/framework-devtools/guide

웹·응답 Envelope

① 기본 동작

컨트롤러 반환값을 {"code":"OK","message":..,"data":..,"traceId":..} 형태로 감싸요. HTML·파일 응답처럼 감싸면 안 되는 곳은 @NoEnvelope를 붙이세요. 요청마다 X-Correlation-Id를 받거나 생성해 MDC traceId에 넣어요.

② yml

ongo.framework.web: { envelope-enabled: true, correlation-header: X-Correlation-Id }
키기본값설명·언제 쓰나
envelope-enabledtruefalse면 ApiResponseBodyAdvice 빈 자체가 안 올라가 컨트롤러 반환값이 그대로 나가요(레거시 전체 화면용).
correlation-headerX-Correlation-Id상관ID 헤더명. 수신 시 읽고 없으면 생성, MDC traceId에 넣어요. 아웃바운드 전파(outbound.propagate-correlation-id)도 이 이름을 써요.
공통코드·메시지와의 차이 — 공통코드·메시지는 yml(컬럼·from 조인·query·mode)만으로 현장 스키마에 맞출 수 있지만, envelope(ApiResponse) 필드명·레이아웃(code/message/data/traceId)은 현재 코드 고정이라 yml로 바꿀 수 없어요(기본 레이아웃은 확정 대기 중인 열린 결정 — docs/TODO-LAYOUT-CUSTOM.md). 지금 가능한 방법은 ① envelope-enabled: false로 전체 끄기 ② 메서드·클래스에 @NoEnvelope ③ ApiResponseBodyAdvice와 같은 타입 빈(ongoApiResponseBodyAdvice, @ConditionalOnMissingBean)을 등록해 사이트 규격으로 교체, 뿐이에요. 상관ID 헤더명은 이와 별개로 correlation-header로 바꿀 수 있어요. 사이트별 필드명 매핑 확장 설계는 docs/ENVELOPE-CUSTOM-DESIGN.md.

③ SPI

케이스SPI
사내 거래추적번호 체계CorrelationIdGenerator.generate()

예외·에러코드

① 기본 동작

BusinessException(code, args...) → 422, Bean Validation → 400, SystemException/미처리 → 500. 코드는 메시지 카탈로그로 문구가 채워져요. 사용자 정의 enum은 MessageCoded를 구현하면 돼요.

enum OrderError implements MessageCoded { OUT_OF_STOCK("order.out-of-stock"); ... }
throw new BusinessException(OrderError.OUT_OF_STOCK, itemCode);

② yml

ongo.framework.exception:
  business-status: 422
  validation-status: 400
  system-status: 500
  validation-code: VALIDATION_FAILED
  unhandled-code: UNHANDLED
  not-found-code: NOT_FOUND
키기본값설명·언제 쓰나
enabledtruefalse면 기본 예외 핸들러만 빠져요(메시지 서비스는 유지). 사내 핸들러로 완전 교체할 때.
business-status422BusinessException HTTP 상태. 레거시 화면이 200+code를 원하면 200. 예외에 HTTP 힌트(withStatus)가 있으면 그게 우선.
validation-status400Bean Validation 실패 상태.
system-status500SystemException 상태.
unhandled-status500그 외 미처리 예외 상태.
validation-codeVALIDATION_FAILED검증 실패 envelope code.
unhandled-codeUNHANDLED미처리 예외 code.
malformed-codeMALFORMED_REQUEST본문 파싱 불가(JSON 오류) code.
not-found-codeNOT_FOUND404 code.
method-not-allowed-codeMETHOD_NOT_ALLOWED405 code.
unsupported-media-codeUNSUPPORTED_MEDIA_TYPE415 code.

③ SPI

케이스방법
업무 예외를 200+code로 내려야 하는 레거시 화면business-status: 200
특정 코드에서 롤백트랜잭션의 rollback-on-codes

메시지 카탈로그

키 규칙: {도메인}.{상황}(예: auth.token-expired). properties와 DB가 둘 다 있으면 DB가 우선이에요.

다중 인자 메시지

① 기본 동작

메시지 문구의 {0}, {1}…자리표시자를 인자 배열로 채워요. 해석 순서는 JDBC 메시지 테이블 → messages*.properties → 기본 문구예요. 모든 경로가 MessageSourceService.resolve(code, Object[] args, Locale, defaultMessage) 하나로 모여요.

// data.sql
INSERT INTO ongo_common_message(msg_code, locale, msg_text)
VALUES ('demo.session.limit', 'ko', '동시 세션 한도 초과: {0} ({1})');

// 업무 코드 — 예외에 인자 전달
throw new BusinessException("demo.session.limit", userId, maxPerUser);

// 직접 해석
String text = messages.resolve("demo.session.limit",
        new Object[]{"sample-user", 2}, Locale.KOREAN, null);
// → "동시 세션 한도 초과: sample-user (2)"

데모 API: GET /api/demo/messages/demo.session.limit?args=sample-user&args=2. 예전 ?name= 파라미터는 {0}으로 계속 동작해요(하위호환).

② yml (키별)

ongo.framework.message:
  basename: classpath:ongo/framework/messages
  default-locale: ko
  encoding: UTF-8
  jdbc: { enabled: true, table: ongo_common_message, code-column: msg_code, locale-column: locale, text-column: msg_text }
키기본값설명·언제 쓰나
basenameclasspath:ongo/framework/messages쉼표 구분 번들 목록. 앞 basename이 같은 코드에서 이겨요. 기본 번들은 항상 마지막 폴백.
default-localeko요청 로케일이 없을 때. HTTP 기본 로케일은 spring.web.locale.
encodingUTF-8번들 파일 인코딩.
messages{}코드 → MessageFormat 템플릿. 번들보다 우선. 점이 있는 코드는 "[demo.order.rejected]" 대괄호 키.
jdbc.enabledfalse켜면 테이블 → (yml 맵 → 번들) 순으로 찾아요.
jdbc.tableongo_common_message메시지 테이블명(from이 있으면 무시).
jdbc.code-column / locale-column / text-columnmsg_code / locale / msg_text현장 컬럼명에 맞춰 바꿔요. locale 값 예: ko, en, ko-KR(비면 모든 로케일 공통).
jdbc.use-column / use-value(없음) / Y사용여부 필터. 비우면 필터 없음(기존 테이블 호환).
jdbc.from(없음)FROM 절 직접 지정 — 마스터·로케일 상세 2테이블 JOIN. 컬럼 설정엔 별칭(d.msg_txt)을 써요.
jdbc.where(없음)추가 WHERE 조건.
jdbc.query(없음)전체 SQL. 결과 별칭 m_code·m_text(필수)·m_locale(선택), 바인드 없음.
cache.enabled / cache.ttltrue / 10m메시지 테이블 캐시. ttl 0이면 만료 없음(refresh() 때만 다시 읽음).

스키마가 다를 때 — 케이스별 yml

# (A) 단일 테이블, 컬럼명만 다름
ongo.framework.message.jdbc:
  enabled: true
  table: tb_msg
  code-column: msg_id
  locale-column: lang_cd
  text-column: msg_cn
  use-column: use_yn

# (B) 2개 테이블 JOIN — 메시지 마스터 + 로케일별 문구 상세
ongo.framework.message.jdbc:
  enabled: true
  from: cm_msg m join cm_msg_lang d on d.msg_id = m.msg_id
  code-column: m.msg_id
  locale-column: d.lang_cd
  text-column: d.msg_cn
  where: "m.del_yn = 'N'"

# (C) 그래도 안 맞으면 SQL 통째로(별칭 m_code/m_text/m_locale)
ongo.framework.message.jdbc:
  enabled: true
  query: select m.msg_id m_code, d.msg_cn m_text, d.lang_cd m_locale from cm_msg m join cm_msg_lang d on d.msg_id = m.msg_id
권장 순서: 컬럼 매핑 → from/where → query → 그래도 안 되면 SPI(MessageSourceService) 교체. 내장 JDBC + 캐시로 대부분 해결돼요.

③ SPI 커스텀

케이스방법
사내 메시지 서버(REST) 사용MessageSourceService 구현 후 빈 등록. 캐시는 CacheService.getOrLoad로.
인자 포맷(금액 콤마 등) 통일기본 구현을 감싸 args를 전처리한 뒤 위임.
@Component
class AmountFormattingMessageSource implements MessageSourceService {
    private final MessageSourceService delegate; // 기본 구현을 생성해 주입
    public String resolve(String code, Object[] args, Locale l, String def) {
        Object[] fmt = args == null ? null : Arrays.stream(args)
            .map(a -> a instanceof BigDecimal ? String.format("%,.0f", a) : a).toArray();
        return delegate.resolve(code, fmt, l, def);
    }
}

인증·인가

① 기본 동작

yml의 users로 인메모리 인증, role-permissions로 권한 판정("*"=전체). 컨트롤러에서는 @RequirePermission("sample.report.read")처럼 선언해요.

② yml

ongo.framework.auth:
  role-permissions: { SAMPLE_USER: [sample.report.read], SAMPLE_ADMIN: ["*"] }
  users:
    - { id: sample-user, name: 샘플사용자, password: ${ONGO_DEMO_USER_PW:Sample#User1}, roles: [SAMPLE_USER] }

③ SPI

케이스SPI메서드
사내 DB/LDAP 로그인AuthServiceOptional<AuthPrincipal> authenticate(loginId, credential)
메뉴·버튼 권한 DB 관리AuthorizationServiceboolean hasPermission(principal, permission)
토큰→사용자 재조회(SSO)IdentityServiceOptional<AuthPrincipal> findByUserId(userId)
@Service
class DbAuthService implements AuthService {
    public Optional<AuthPrincipal> authenticate(String id, String pw) {
        return userMapper.find(id).filter(u -> hash.matches(pw, u.pwHash()))
            .map(u -> new AuthPrincipal(u.id(), u.name(), u.roles()));
    }
}

로그인·세션·토큰 갱신(refresh)

① 기본 동작

API설명실패
POST /api/auth/login {"id","password"}세션 생성 + Bearer 토큰 발급401 auth.unauthenticated
POST /api/auth/refresh (Bearer)유휴 시간 연장, 새 만료 시각 반환401 auth.token-expired / auth.unauthenticated
POST /api/auth/logout세션 무효화—
GET /api/me현재 사용자401
curl -s -XPOST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
  -d '{"id":"sample-user","password":"Sample#User1"}'
# → 응답 data 에 토큰·만료 시각이 담겨요(형식은 docs/AUTH-TOKEN.md)
curl -s -XPOST localhost:8080/api/auth/refresh -H "Authorization: Bearer $TOKEN"

요청이 들어올 때마다 SessionStoreService.touch로 유휴 시간을 갱신해요. 프론트는 만료 직전 refresh를 호출하고, 401 auth.token-expired면 로그인 화면으로 보내면 돼요.

② yml

ongo.framework.session:
  timeout: 30m            # 유휴 만료
  max-per-user: 2         # 사용자당 동시 세션
  overflow: EXPIRE_OLDEST # 초과 시: EXPIRE_OLDEST(가장 오래된 세션 축출) | REJECT(신규 로그인 거부)
ongo.framework.auth:
  header: Authorization
  scheme: Bearer
  public-paths: ["/api/auth/login", "/api/auth/refresh"]
  protected-paths: ["/api/me", "/api/auth/logout", "/api/demo/auth/**"]
키기본값설명·언제 쓰나
session.timeout30m유휴 만료(마지막 접근 기준). 요청마다 touch로 연장.
session.max-per-user0(무제한)사용자당 동시 세션 수. 1이면 단일 기기.
session.overflowEXPIRE_OLDEST초과 시 정책: EXPIRE_OLDEST(오래된 세션 축출 → auth.duplicate-login) | REJECT(신규 로그인 거부).
session.enabledtrue세션 정책 빈 on/off. 저장소 자체는 SessionStoreService 빈 교체.

세션 저장소

SessionStoreService: create / touch / find / updateAttributes / invalidate / invalidateUser / sessionsOf / wasDisplaced. 기본은 인메모리. 다중 서버면 Redis 구현을 등록하세요(중복 로그인 참고). 관리자 강제 로그아웃은 invalidateUser(userId).

중복 로그인 차단

① 기본 동작

  1. 같은 사용자가 max-per-user를 넘겨 로그인하면 overflow=EXPIRE_OLDEST 규칙대로 가장 오래된 세션이 밀려나요.
  2. 밀려난 세션 ID는 SessionStoreService.wasDisplaced(id)가 true를 돌려줘요.
  3. AuthenticationFilter → AuthorizationInterceptor가 이를 감지해 401 auth.duplicate-login로 응답해요. 일반 만료(auth.token-expired)와 구분되니 프론트에서 "다른 기기에서 로그인되었습니다" 안내를 띄우세요.
// 프론트 예시
if (res.status === 401 && body.code === 'auth.duplicate-login') {
  alert('다른 곳에서 로그인되어 현재 세션이 종료되었어요.'); router.push('/login');
}

③ SPI 커스텀

케이스방법
단일 기기만 허용max-per-user: 1
새 로그인을 거부overflow: REJECT
다중 인스턴스(서버 여러 대)SessionStoreService를 Redis로 구현. wasDisplaced는 축출 ID를 TTL 키(displaced:{id})로 남겨 구현.

암호화·해시

① 기본

FieldCryptoService: 컬럼·필드 단위 암복호화(encrypt/decrypt). HashService: 비밀번호 해시(hash/matches), 다이제스트, HMAC(hmac/verifyHmac). ChannelCryptoService: 전문 바이트 암복호화.

② yml

ongo.framework.hash: { algorithm: SHA-512, hmac-algorithm: HmacSHA512, hmac-key: ${ONGO_HASH_HMAC_KEY}, salt-bytes: 16, iterations: 1, encoding: HEX }
ongo.framework.crypto.field.key: ${ONGO_FIELD_CRYPTO_KEY}
키기본값설명·언제 쓰나
hash.enabledtrue기본 HashService(JDK 다이제스트) 등록.
hash.algorithmSHA-512다이제스트 알고리즘(JDK MessageDigest 이름).
hash.hmac-algorithmHmacSHA512HMAC 알고리즘(JDK Mac 이름).
hash.hmac-key(없음)HMAC 키. 비우면 hmac() 호출 시 예외. 저장소에 실제 키 커밋 금지.
hash.salt-bytes16비밀번호 해시 솔트 길이(byte).
hash.iterations1해시 반복 횟수. 비밀번호면 올리거나 bcrypt/argon2로 SPI 교체.
hash.encodingHEX출력 인코딩(HEX | BASE64).
키는 반드시 환경변수/Vault로 주입하세요. yml의 placeholder 기본값은 데모 전용이에요.

③ SPI

케이스SPI
사내 HSM·KMS(ARIA/SEED)FieldCryptoService
bcrypt/argon2HashService(hash/matches만 바꾸고 나머지는 위임)
대외기관 전문 암호화ChannelCryptoService(채널별 name())

마스킹·XSS·비밀번호 규칙

ongo.framework.masking.rules: [ { fields: [password, secret, token], strategy: FULL } ]
ongo.framework.xss.whitelist-paths: ["/api/demo/secure/raw/**"]
ongo.framework.validation.password: { min-length: 8, max-length: 64, require-letter: true, require-digit: true, require-special: true, max-repeat: 3 }
SPI메서드커스텀 케이스
MaskingServiceshouldMask / mask / maskText주민·카드번호 사내 규칙
XssFilterServiceclean(input)에디터 HTML 허용 태그(OWASP Sanitizer)
PasswordRuleServiceList<String> violations(raw)직전 N개 재사용 금지, 아이디 포함 금지
ColumnAttributeService컬럼 메타(암호화·마스킹 여부)DB 메타 테이블 기반 자동 마스킹

Idempotency-Key(중복 요청 방지)

① 기본 동작

IdempotencyKeyFilter가 Idempotency-Key 헤더가 있는 요청을 사용자|METHOD 경로|키 단위로 기록해요.

상황응답
첫 요청정상 처리 후 응답 저장
같은 키·같은 본문 재요청(완료 후)저장된 응답 재생 + 헤더 Idempotent-Replayed: true
같은 키 재요청(처리 중)409 idempotency.in-progress
같은 키·다른 본문422 idempotency.payload-mismatch
처리 중 예외기록 해제(release) — 같은 키로 재시도 가능
KEY=$(uuidgen)
curl -XPOST localhost:8080/api/orders -H "Authorization: Bearer $T" \
  -H "Idempotency-Key: $KEY" -H 'Content-Type: application/json' -d '{"item":"A","qty":1}'
# 같은 명령 재실행 → 같은 응답 + Idempotent-Replayed: true
프론트는 "결제·이체·주문" 버튼 클릭 시 키를 한 번 만들고, 네트워크 재시도에는 같은 키를 재사용하세요. 화면 재진입 시에는 새 키를 만드세요.

② yml

ongo.framework.idempotency:
  enabled: true
  header: Idempotency-Key
  ttl: 24h
  paths: ["/api/orders/**", "/api/transfer/**"]   # Ant 패턴. 비우면 헤더가 있는 모든 요청
  filter-order: -2147483600
키기본값설명
enabledtrue헤더가 있는 변경 요청에만 동작.
headerIdempotency-Key요청 헤더명.
ttl24h기록 유지 시간 — 이 시간 안의 같은 키는 재생/거부.
paths[] (전체)대상 Ant 경로. 결제·주문 등만 좁힐 때.
filter-order-2147483600인증 필터(-2147483618) 뒤에 두어 사용자 단위 키가 되게 해요.

③ SPI 커스텀 — IdempotencyStore

기본은 InMemoryIdempotencyStore(단일 인스턴스용)예요. 운영(다중 서버)에서는 반드시 공유 저장소로 바꾸세요.

@Component
class RedisIdempotencyStore implements IdempotencyStore {
    private final StringRedisTemplate redis;
    // begin: SET key IN_PROGRESS NX EX ttl → 이미 있으면 기존 Record 반환
    // complete: 상태·본문 해시·응답(status, headers, body)을 저장
    // release: DEL key (처리 실패 시)
}
케이스방법
RedisSET NX + TTL, 응답은 JSON 직렬화
DB(key PK, status, payload_hash, response, expires_at) 테이블 + 유니크 위반으로 선점

로깅·인터셉터

ongo.framework.logging.logger-prefix: ongo.log    # 슬롯 로거 ongo.log.<slot>
ongo.framework.interceptor:
  access-log: { enabled: true, exclude-patterns: ["/actuator/**"] }
  handler-mdc: { enabled: true }
SPI케이스
AccessLogWriter.write(entry)접근로그를 DB/Kafka로 적재
OngoHandlerInterceptor업무 공통 인터셉터 — order(), includePatterns(), excludePatterns()로 범위 지정, 여러 개 등록 가능
LogLevelService, StructuredLogService, LoggingBridgeService런타임 로그레벨 변경, JSON 로그, 외부 로깅 연동

트랜잭션

ongo.framework.transaction: { propagation: REQUIRED, isolation: DEFAULT, rollback-for: [], no-rollback-for: [], rollback-on-codes: ["order.out-of-stock"] }

RollbackRuleService.rollbackOn(byTypeRules, ex)로 롤백 판정을 바꿀 수 있어요(예: 특정 BusinessException 코드만 커밋). 멀티 DataSource·XA는 DataSourceService, TransactionManagerService, DistributedTransactionManagerService.

MyBatis

ongo.framework.mybatis: { map-underscore-to-camel-case: true, plugins: { slow-query-threshold: 500ms }, paging: { default-size: 20, max-size: 100 } }
mybatis.mapper-locations: classpath*:mapper/**/*.xml
키기본값설명·언제 쓰나
enabledtrue프레임 MyBatis 기본값·페이징 헬퍼 등록.
map-underscore-to-camel-casetruesnake_case 컬럼 → camelCase. null이면 mybatis.configuration 값 유지.
default-statement-timeout(없음)문장 timeout(초 단위 반올림). 예: 30s.
default-fetch-size(없음)JDBC fetch size. 대용량 조회·배치에서 지정.
call-setters-on-nulls(없음)null 컬럼도 setter 호출(Map 결과에서 키 유지).
paging.default-size / max-size20 / 100페이지 크기 기본값·상한(요청이 더 커도 max-size로 잘려요).
plugins.slow-query-threshold(없음)지정 시 이 시간 이상 걸린 매퍼 호출을 WARN 로그.

매퍼 위치·타입 별칭 등은 Boot MyBatis 표준 키(mybatis.*)를 그대로 쓰고, 위 값은 그 위에 덧씌워져요(비우면 손대지 않음).

느린 쿼리는 경고 로그로 남고, 페이징 파라미터는 max-size로 상한이 걸려요.

캐시

ongo.framework.cache: { local: { ttl: 10m, max-size: 10000 }, distributed: { ttl: 30m, key-prefix: "demo:" } }
키기본값설명·언제 쓰나
enabledtrue프레임 캐시 빈(프로세스 내 맵) 등록.
local.ttl10m로컬 캐시 기본 TTL. 0s = 만료 없음.
local.max-size10000최대 항목 수. 0 = 무제한.
local.clear-on-meta-loadtrue메타 적재(Refreshable) 끝에 로컬 캐시 비우기.
distributed.ttl30m분산 캐시 TTL.
distributed.max-size10000분산(기본 인메모리 대체) 최대 항목.
distributed.key-prefixongo:키 접두어(앱·환경 구분). Redis 구현도 이 값을 읽기를 권장.

CacheService(get/put/evict/clear/getOrLoad). 로컬=LocalCacheService, 분산=DistributedCacheService(Redis 구현 등록).

Product p = cache.getOrLoad("product:" + id, Product.class, () -> mapper.find(id));

공통코드·메타로드

① 기본 동작

CodeLookupService: codes(group), find, children, name(group, code, fallback). 기본 저장소는 yml(groups), jdbc.enabled: true면 내장 JDBC로 코드 테이블을 읽고 캐시해요. 테이블 구조가 달라도 yml(컬럼 매핑·mode·from 조인·query)로 대부분 맞출 수 있고, 아예 다른 소스(REST·MDM)일 때만 SPI로 교체하세요. 캐시 갱신은 Refreshable/메타로드.

② yml (기본 예시)

ongo.framework.code:
  jdbc: { enabled: true, table: ongo_common_code, group-column: code_group, code-column: code, name-column: code_name,
          sort-column: sort_order, parent-column: parent_code, use-column: use_yn, use-value: "Y", attribute-columns: { extra: extra_1 } }
  cache: { enabled: true, ttl: 10m }
ongo.framework.meta-load.on-startup: false
키기본값설명·언제 쓰나
code.enabledtrue공통코드 기본 구현 on/off.
code.groups{}yml 코드: 그룹 → [{code, name, sort-order, parent-code, attributes}]. jdbc를 켜면 무시. DB 없는 개발·테스트용.
code.jdbc.enabledfalse켜면 SQL로 코드 테이블 조회.
code.jdbc.modeSINGLESINGLE: 한 테이블에 그룹 컬럼 · SELF_REFERENCE: 그룹행·코드행이 한 테이블(group-column이 상위 행 code-column 참조) · QUERY: query 그대로.
code.jdbc.tableongo_common_code코드 테이블(from이 있으면 무시). 기본값은 placeholder라 현장 스키마로 바꾸세요.
code.jdbc.group-column / code-column / name-column / sort-columncode_group / code / code_name / sort_order현장 컬럼명 매핑.
code.jdbc.parent-columnparent_code계층(children)용. 비우면 계층 없음.
code.jdbc.use-column / use-valueuse_yn / Y사용여부 필터. use-column 비우면 필터 없음.
code.jdbc.attribute-columns{}확장 컬럼: 결과 attributes 키 → 컬럼명(예 { color: attr_1 }).
code.jdbc.from(없음)FROM 절 직접 지정(다중 조인). 컬럼 설정엔 별칭(c.cd). 운영자 관리 값이라 ;·주석만 막아요.
code.jdbc.where(없음)추가 WHERE(from과 같은 규칙).
code.jdbc.query(없음)mode=QUERY 전체 SQL. 그룹 바인드 ? 1개, 별칭 c_code·c_name·c_sort(필수)·c_parent(선택) + attribute-columns 컬럼명.
code.cache.enabled / ttltrue / 10m코드 캐시. ttl 0이면 만료 없음(메타 적재·refresh 때만 비움).
meta-load.enabledtrueMetaLoadService 빈이 있을 때만 의미.
meta-load.on-startupfalse기동 완료(ApplicationReady) 시 한 번 적재 — 캐시 워밍업.
meta-load.cron(없음)Spring cron(예 0 0 4 * * *). 비우면 주기 실행 없음.
meta-load.zone(JVM 기본)cron 시간대(예 Asia/Seoul).

스키마가 다를 때 — 케이스별 yml

# (A) 단일 테이블, 컬럼명만 다름 (mode: SINGLE)
ongo.framework.code.jdbc:
  enabled: true
  table: tb_cmm_cd
  group-column: grp_cd
  code-column: cd
  name-column: cd_nm
  sort-column: sort_seq
  parent-column: ""        # 계층 없음
  use-column: use_yn

# (B) 단일 테이블 셀프참조 (mode: SELF_REFERENCE) — 그룹행과 코드행이 한 테이블
#     cd=CARD_TP(그룹행, up_cd=null) / cd=01, up_cd=CARD_TP (코드행)
ongo.framework.code.jdbc:
  enabled: true
  mode: SELF_REFERENCE
  table: tb_cmm_cd
  group-column: up_cd      # 상위(그룹) 행의 code-column 을 가리킴
  code-column: cd
  name-column: cd_nm
  sort-column: sort_seq

# (C) 2개 테이블 JOIN — 그룹 마스터 + 코드 상세
ongo.framework.code.jdbc:
  enabled: true
  from: cm_grp g join cm_cd c on c.grp_id = g.grp_id
  group-column: g.grp_cd
  code-column: c.cd
  name-column: c.cd_nm
  sort-column: c.sort_seq
  parent-column: ""
  use-column: c.use_yn
  where: "g.use_yn = 'Y'"
  attribute-columns: { grpName: g.grp_nm }

# (D) 그래도 안 맞으면 SQL 통째로 (mode: QUERY)
ongo.framework.code.jdbc:
  enabled: true
  mode: QUERY
  query: select c.cd c_code, c.cd_nm c_name, c.sort_seq c_sort from cm_grp g join cm_cd c on c.grp_id = g.grp_id where g.grp_cd = ?
권장 순서: 컬럼 매핑 → mode → from/where → query → 마지막에 SPI(CodeLookupService). 메시지 테이블도 같은 규칙이에요(메시지 카탈로그).

③ SPI

케이스SPI
코드가 DB가 아닌 REST/MDMCodeLookupService 구현 + Refreshable
여러 메타(코드·메시지·권한) 일괄 적재MetaLoadService + meta-load.cron

아웃바운드 HTTP

ongo.framework.outbound:
  connect-timeout: 2s
  read-timeout: 5s
  propagate-correlation-id: true
  unwrap-envelope: true
  retry.max-attempts: 1         # I/O 실패만 재시도
  client-error-code: OUTBOUND_CLIENT_ERROR

OpenFeign 공통 기본값이에요. 클라이언트별 값은 spring.cloud.openfeign.client.config.<name>.*가 우선해요.

키기본값설명·언제 쓰나
enabledtrue프레임 공통 Feign 빈(타임아웃·상관ID·envelope 해제·오류 변환) 등록.
connect-timeout2sTCP 연결 타임아웃.
read-timeout5s응답 대기 타임아웃. 느린 대외 API는 클라이언트별로 올리세요.
follow-redirectstrue3xx 따라가기.
propagate-correlation-idtrue상관ID 헤더 전파(헤더명은 web.correlation-header). 시스템 간 추적.
unwrap-envelopetrue상대가 공통 envelope으로 답하면 data만 꺼내 반환 타입으로. 반환 타입이 ApiResponse면 그대로.
retry.max-attempts1(재시도 없음)총 시도 횟수. I/O 실패만 재시도, HTTP 오류 응답은 재시도 안 함(비멱등 보호).
retry.period / max-period100ms / 1s재시도 간격·최대 간격(백오프).
client-error-codeOUTBOUND_CLIENT_ERRORenvelope이 아닌 4xx 응답의 메시지 코드.
server-error-codeOUTBOUND_SERVER_ERRORenvelope이 아닌 5xx 응답의 메시지 코드.
unavailable-codeOUTBOUND_UNAVAILABLE연결 실패·타임아웃(I/O) 메시지 코드.

채널·전문(MCI/EAI/FEP)

① 기본

채널별 전송(transport)과 레이아웃으로 고정길이 전문을 인코딩→송수신→디코딩해요. 기본 transport는 loopback(자기 응답)이라 대외 연결 없이 개발할 수 있어요.

② yml

ongo.framework.channel:
  charset: UTF-8
  channels:
    mci: { type: MCI, transport: loopback }
    fep: { type: FEP, transport: loopback, timeout: 5s }
  layouts:
    SAMPLE0001:
      request:  [ { name: acctNo, length: 12 }, { name: amount, length: 13, type: NUM } ]
      response: [ { name: balance, length: 13, type: NUM } ]
키기본값설명·언제 쓰나
enabledtrue채널 기능 on/off.
charsetUTF-8고정길이 코덱 기본 문자셋(기간계가 EUC-KR/MS949면 변경).
charset-defaults{ fep: EUC-KR }채널 type(또는 이름) → 기본 문자셋. 우선순위: channels.<이름>.charset > charset-defaults > charset.
channels.<이름>.typeMCI분류 MCI/EAI/FEP 등(라우팅·로그용).
channels.<이름>.transportloopbackPhysicalTransportService#name() — 기본 http / loopback, 또는 앱 빈 이름(tcp 등).
channels.<이름>.url(없음)대상 주소(http transport 등).
channels.<이름>.timeout10s송수신 타임아웃.
channels.<이름>.charset(상속)채널별 문자셋.
layouts.<ID>.request / response[]필드 [{name, length, type(CHAR 기본|NUM…)}]. response 비우면 request와 같음.
async.pool-size4비동기 송수신 전용 스레드 수.
inbound.enabled / pathtrue / /framework/channel/inbound수신 endpoint: POST {path}/{channel}/{interfaceId}, GET {path}/receipts/{id}.
inbound.max-receipts1000비동기 접수 결과 보관 개수.
inbound-message.*INB_OK / INB_ERR …수신 응답 MESSAGE 코드·문구·결과코드(result-field 기본 header.procsRsDvC, 0/1/8/9). 업무 시스템 체계로 전부 교체.
response-outcome.fields / success[procsRsDvC, header.procsRsDvC] / 0응답 결과코드 필드 후보(첫 존재 키)와 성공값 — 실패면 업무오류로 변환.

삼카 확장(ongo-framework-samsung-card) 설정

표준전문(HEADER/MESSAGE) 코덱·@ScTelegram 어노테이션 레이아웃을 붙여요. 이 절은 samsung-card 모듈이 classpath에 있을 때만 보여요.

ongo.framework.samsung-card:
  standard-telegram.enabled: true
  layout: { base-packages: [..], naming-pattern: "{package}.{prefix}{interfaceIdCamel}{suffix}", in-suffix: In, out-suffix: Out }
키기본값설명·언제 쓰나
enabledtrue삼카 확장 전체 on/off.
standard-telegram.enabledfalse표준전문 코덱을 채널 Primary로 등록.
standard-telegram.header / message{}송신 HEADER/MESSAGE 기본값(키 = std-header 필드명). 전문 values의 header.x/message.x가 우선.
standard-telegram.response-header / response-message{}수신에 대한 응답 HEADER/MESSAGE 기본값.
standard-telegram.message-code-length / message-text-length10 / 100반복 MESSAGE 1건의 코드·내용 길이.
layout.enabledtrue어노테이션 레이아웃 사용(끄면 yml 레이아웃만).
layout.base-packages[]@ScTelegram VO 스캔 패키지. 비면 어노테이션 레이아웃 없음.
layout.fallback-to-defaulttrue어노테이션에 없는 ID를 yml 레이아웃으로 넘김.
layout.naming-packages / naming-pattern / prefix[] / {package}.{prefix}{interfaceIdCamel}{suffix} / ""인터페이스 ID → VO 클래스명 규칙(예 ApcMIss00027InVo).
layout.kind-suffixes[Vo, Dto]코드생성 kind 접미사 후보.
layout.in-suffix / out-suffixIn / Out요청/응답 VO 접미사.
devtools.enabledtrue개발툴에 삼카 파서(sc-excel 등)·데코레이터 등록.

③ SPI

케이스SPI메서드
실제 TCP/MQ 연결PhysicalTransportServicename(), byte[] exchange(endpoint, payload)
사내 전문 포맷(EBCDIC 등)TelegramCodecServiceencode/decode, charset(), bodyOffset
레이아웃을 DB/메타에서TelegramLayoutServicefind(interfaceId)
타시스템 수신 처리InboundHandlerServiceinterfaceId(), supports(channel), handle(telegram)
응답코드 → 업무오류ChannelResponseOutcomeServiceverify(channel, ifId, values)
대외 응답코드 → 사내 메시지코드ChannelMessageCodeMappingServicemap(channel, code)
@Component
class TcpTransport implements PhysicalTransportService {
    public String name() { return "tcp"; }       // yml transport: tcp
    public byte[] exchange(ChannelEndpoint ep, byte[] payload) throws Exception {
        try (Socket s = new Socket(ep.host(), ep.port())) { /* 길이헤더 + payload 송신, 응답 수신 */ }
    }
}

데모 확장·커스텀 카탈로그(custom 프로필)

프레임 기본 동작을 빈 교체(SPI)와 yml 로 바꾸는 예시를 한곳에 모았어요. 가이드(이 문서)는 코어에, 예시 코드는 데모에 있어요. 모든 항목은 일반 코어 SPI 만 사용하며 고객사 확장 모듈에 의존하지 않아요.

코드: 이 레퍼런스 앱 ongo-framework-sc-demo 의 demo/custom/ — DemoCustomizations·DevtoolsDemoCustomizations·DemoExtensionMocks, 설정 application-custom.yml. 실행: ./gradlew :ongo-framework-sc-demo:bootRun --args='--spring.profiles.active=custom'

공통 규칙: ① 같은 SPI 타입 빈을 등록하면 프레임 기본 빈은 @ConditionalOnMissingBean 으로 물러나요(인가처럼 기본이 항상 등록되는 경우만 @Primary). ② 이름으로 고르는 SPI(PhysicalTransportService·LoggingBridgeService·SpecParseService·CodeGenDecorator)는 빈 등록 + yml 에 name() 값을 적어야 켜져요. ③ 데모 빈은 @Profile("custom") 로만 올라가요. ④ 확인은 아래 API(쇼케이스 카드)와 DemoCustomizationTest·ChannelCustomizationTest·LoggingCustomizationTest·ExtensionMockTest.

기능SPI데모 빈yml(ongo.framework.*)호출 경로데모 확인운영 교체
상관ID 생성기CorrelationIdGeneratordemoCorrelationIdGeneratorweb.correlation-header: X-Sample-Trace요청 필터 → MDC·응답 헤더GET /api/demo/chain/echo → 헤더 X-Sample-Trace: demo-000001UUID/채번 규칙 빈으로 교체
메시지 원천MessageSourceServicedemoMessageSourceServiceexception.business-status: 409, not-found-code예외 핸들러 → 메시지 해석GET /api/demo/errors/business → 문구 [sample-msg] …파일 기본 / message.jdbc(DB) / 운영 메시지 API
롤백 규칙RollbackRuleServicedemoRollbackRuletransaction.timeout: 15s@Transactional 예외 판정POST /api/demo/tx/entries (DemoCheckedException 도 롤백)업무 예외 계층에 맞게 판정
Feign 인터셉터feign.RequestInterceptordemoClientTagInterceptoroutbound.read-timeout, client-error-code아웃바운드 HTTP 요청 헤더GET /api/demo/outbound/greet → X-Demo-Client: sample-app인증 토큰·채널 헤더 주입
MyBatis 설정·플러그인ConfigurationCustomizer · InterceptordemoMyBatisCustomizer, demoQueryCountermybatis.paging(5/10), plugins.slow-query-thresholdSqlSessionFactory 생성 시GET /api/demo/mybatis/items?size=999 → 10건 상한fetchSize·감사 플러그인
접근 로그 출력처AccessLogWriterdemoAccessLogWriter(DemoAccessLogBuffer)interceptor.access-log.exclude-patterns인터셉터 완료 시 writeGET /api/demo/chain/echo 후 버퍼 확인(테스트)파일·ELK·DB 적재
XSS 정화기XssFilterServicedemoTagStrippingXssFilterxss.excluded-fields: [body]요청 파라미터·JSON 필드GET /api/demo/secure/search?q=<b>hi</b> → hiLucy/AntiSamy 등
비밀번호 규칙PasswordRuleServicedemoPasswordRulevalidation.password.min-length 12, max-repeat 2@ValidPassword 검증POST /api/demo/validation/members (sample 포함 → demo.password.forbidden-word)사내 정책·금지어 DB
해시HashServicedemoHashService(SHA-256·1000회·BASE64)—저장 해시·HMACPOST /api/demo/hash {"text":…}KMS 키·bcrypt/PBKDF2
OpenAPIGlobalOpenApiCustomizerdemoOpenApiCustomizeropenapi.profiles/title/version/v3/api-docs 생성GET /v3/api-docs/default → x-sample-owner보안 스킴·서버 목록
메타 적재MetaLoadServicedemoMetaLoadmeta-load.on-startup: true기동 시 1회 + 수동 reloadGET /api/demo/codes/SAMPLE_META, POST /api/demo/meta/reload운영 메타 원천(DB·API)
분산 캐시DistributedCacheServicedemoRemoteCache(DemoRemoteCache, TTL)cache.distributed.*CacheService.getOrLoad 등PUT/GET /api/demo/cache/distributed/{key} → impl DemoRemoteCacheRedis(아래 목업 참고)
인가AuthorizationService (@Primary)demoAuthorizationauth.role-permissions (sample.report.*)@RequiresPermission 판정GET /api/demo/auth/reports권한 DB·SSO 클레임
필드 암호FieldCryptoServicedemoFieldCrypto(SAMPLE-ENC: Base64 흉내)crypto.*@Encrypted VO 저장·조회POST /api/demo/crypto/fieldKMS/HSM 엔진
물리 전송PhysicalTransportServicedemoHostTransport(sample-host)channel.channels.fep.transport: sample-host, charset MS949ChannelService.send → transport.exchangePOST /api/demo/channel/fep/SAMPLE0001AnyLink·WebT·TCP 어댑터
로그 브리지LoggingBridgeServicedemoLogCollector(sample-collector)logging.bridgesStructuredLogService.log → 각 브리지 emitPOST /api/demo/logging/slots/sample-tradeELK·Kafka 수집기
세션 저장소SessionStoreServicedemoRedisLikeSessionStoresession.timeout 15m, max-per-user 1, overflow REJECT로그인·인증 필터POST /api/demo/sessions, GET /api/demo/ext/session/keysRedis
MQ(데모 전용)— (SPI 없음)demoInMemoryBroker—데모 API 직접POST/GET /api/demo/ext/mq/{topic}Rabbit/Kafka 선택 어댑터
TCP 수신(EAI tcp.port)InboundTelegramProcessor + InboundHandlerServiceDemoEaiTcpInboundListenerdemo.eai-tcp.enabled/port/bind/channel(앱 yml)ServerSocket accept → processor.process(eai, id, bytes) → 응답 프레임POST /api/demo/ext/eai-tcp/SAMPLE0001솔루션 규격 TCP 리스너(선택 어댑터)
로그 색인LoggingBridgeServicedemoElasticsearchBridge(sample-elasticsearch)logging.bridges 에 추가emit → 링버퍼GET /api/demo/ext/logsES Bulk/Logstash
정의서 파서(개발툴)SpecParseServicedemoCsvSpecParser(sample-csv)devtools.profiles 에 custom개발툴 코드생성·정의서 업로드개발툴 홈 → 정의서 유형 sample-csv엑셀·사내 정의서 포맷
코드생성 데코레이터(개발툴)CodeGenDecoratordemoSampleLayoutDecorator(sample-layout)devtools.codegen.decorators: [validation, sample-layout]생성 VO 어노테이션개발툴 코드생성 미리보기 → @SampleLayout사내 어노테이션·주석 규칙
// 빈 교체 최소 형태 (예: 상관ID)
@Configuration @Profile("custom")
class MyCustomizations {
    @Bean CorrelationIdGenerator myCorrelationId() { return () -> "APP-" + UUID.randomUUID(); }
}
흔한 실수: 이름 기반 SPI 를 빈만 등록하고 yml 에 이름을 안 적음 · 같은 타입 빈 2개 등록(모호성 오류, @Primary 필요) · 데모 키/평문 값을 운영 프로필에 복사.

외부 연계 확장 예시 — Redis 세션·캐시 · MQ · ES 로깅

원칙: 코어 JAR 에는 SPI 계약만 있고 Redis·RabbitMQ·Kafka·Elasticsearch 클라이언트 의존성은 없어요. 실제 연계는 같은 SPI 타입 빈을 선택 어댑터 모듈 또는 고객사 팩에 두고 등록해 교체해요. 데모는 custom 프로필에서 프로세스 내 목업으로 교체 지점만 보여 줘요.

목업 위치: 레퍼런스 앱 demo/custom/DemoExtensionMocks.java(+ 분산 캐시 DemoCustomizations.DemoRemoteCache), custom 프로필에서만 활성. 모두 일반 코어 SPI 만 사용하며 고객사 확장(표준전문 등)에 의존하지 않아요 — 어느 앱이든 같은 SPI 빈을 등록하면 동일하게 교체돼요.

케이스SPI(코어)데모 목업활성 방법시연 API운영 교체
세션 저장소SessionStoreServiceDemoRedisLikeSessionStore — 키 sample:session:{id}·sample:user:{uid}, 키별 TTL, max-per-user/overflow 준수빈 등록만(기본 InMemorySessionStore 는 @ConditionalOnMissingBean 로 양보)GET /api/demo/ext/session/keysRedis HASH/JSON + EXPIRE, 사용자 SET 인덱스, 동시 세션 판정은 Lua/트랜잭션
공용 캐시DistributedCacheServiceDemoRemoteCache — sample: 접두어, put(k,v,ttl) TTL빈 등록만PUT/GET /api/demo/cache/distributed/{key}SET key value PX ttl, 접두어는 distributed.key-prefix
MQ(Rabbit/Kafka)범용 Messaging SPI 없음(후속 설계)DemoInMemoryBroker — 토픽 pub/sub + 큐 poll(소비=ack)데모 전용 빈POST/GET /api/demo/ext/mq/{topic}, GET /api/demo/ext/mqspring-rabbit/spring-kafka 선택 어댑터. 기간계 전문은 PhysicalTransportService로 붙이기도 가능
로그 색인(ES/ELK)LoggingBridgeServiceDemoElasticsearchBridge(sample-elasticsearch, 최근 200건 링버퍼)yml ongo.framework.logging.bridges 에 이름 추가GET /api/demo/ext/logs?slot=&traceId=&size=ES Bulk / Logstash / Kafka 수집기로 비동기 전송(큐잉·드롭 정책 필수)
# application-custom.yml
ongo.framework.logging.bridges: [slf4j, sample-collector, sample-elasticsearch]

// 운영 교체 예 (어댑터 모듈) — 같은 SPI 타입 빈만 등록
@Bean SessionStoreService redisSessionStore(SessionPolicy p, RedisTemplate<String, Object> redis) { ... }
@Bean LoggingBridgeService esBridge(ElasticsearchAsyncClient es) { ... }   // name() = yml bridges 값
주의: 업무 코드는 DemoInMemoryBroker 를 직접 주입하지 마세요(코어 SPI 아님). 세션 목업은 단일 JVM 전용이라 다중 인스턴스 검증엔 실제 Redis 가 필요해요. 상세: docs/CUSTOMIZATION-EXAMPLES.md §M, 테스트 ExtensionMockTest.

EAI TCP 수신 목업 — tcp.port 로 밀어 넣는 솔루션 가정

원칙: 코어·스타터에는 TCP 서버가 없어요. 내장 수신은 HTTP 공통 endpoint POST /framework/channel/inbound/{channel}/{interfaceId} 하나이고, 실제 처리는 코어 InboundTelegramProcessor(구간 복호 → 요청 레이아웃 decode → InboundHandlerService → 응답 encode → 암호)가 맡아요. EAI 솔루션이 tcp.port 로 리슨 대상에 전문을 보내는 구성은 앱(또는 선택 어댑터 모듈)이 소켓을 열고 받은 바이트를 같은 처리기에 넘기는 방식으로 붙여요. 그래서 HTTP·TCP 수신이 레이아웃·코덱·문자셋·암호·업무 처리기를 그대로 공유해요. 일반 코어 SPI 확장 패턴이며 고객사 확장에 의존하지 않아요.

항목내용
위치레퍼런스 앱 demo/custom/DemoEaiTcpInboundListener.java — @Profile("custom") + @ConditionalOnProperty(demo.eai-tcp.enabled=true), SmartLifecycle 로 기동/종료 시 소켓 open/close
프레임(데모 임의 규격)요청 [길이 4B big-endian][인터페이스 ID 10B ASCII 공백패딩][전문 본문] → 응답 [길이 4B][결과코드 2B "00"정상/"99"오류][응답 전문 또는 오류 문구]. 연결당 여러 프레임 순차 처리(keep-alive), 최대 1MB
처리 경로accept → 프레임 분해 → processor.process("eai", interfaceId, body) → ChannelInboundDemoHandler(잔액=금액×2) → 응답 프레임. 예외는 99 + 문구로 회신(연결 유지)
ymldemo.eai-tcp.enabled(true) · port(19090, 0=OS 임의) · bind(127.0.0.1) · channel(eai — ongo.framework.channel.channels 의 문자셋·암호 설정을 따름)
데모 확인POST /api/demo/ext/eai-tcp/SAMPLE0001 {"acctNo":"110000000001","holder":"sample","amount":21} — 컨트롤러가 EAI 흉내 클라이언트로 실제 소켓 송신 → code=00, 응답 끝자리 …0000000000042. 직접: nc/소켓으로 같은 프레임 송신
테스트EaiTcpInboundDemoTest — port=0 임의 빈 포트, 왕복·keep-alive·미등록 ID(99)·확인 API. 데모 build.gradle 은 테스트 JVM 에 demo.eai-tcp.port=0 을 넣어 컨텍스트 간 포트 충돌을 막아요
운영 교체 포인트① readFrame/writeFrame 을 솔루션 규격(ASCII 길이 필드, 전문 ID 위치, ACK/NAK, 하트비트)으로 ② 스레드 풀 상한·소켓 타임아웃·백프레셔 ③ 비동기 처리가 필요하면 processor.accept(접수번호) ④ 접속 허용 IP·TLS ⑤ 다중 포트/채널이면 리스너를 채널별 빈으로. 솔루션 라이브러리(WebT·AnyLink 등) 수신 API가 있으면 그 콜백에서 같은 processor.process 만 호출하면 돼요
# application-custom.yml
demo:
  eai-tcp: { enabled: true, port: 19090, bind: 127.0.0.1, channel: eai }

// 핵심 (DemoEaiTcpInboundListener.serve)
byte[] res = processor.process(channel, interfaceId, body);   // HTTP 수신과 같은 코어 파이프라인
writeFrame(out, "00", res);
삼카 프로필: 채널 코덱이 표준전문(HEADER+MESSAGE+BODY+@@)이면 TCP 본문도 표준전문 그대로 넣으면 되고, 응답 HEADER 에코·@direction 규칙은 HTTP 수신과 동일하게 적용돼요.
주의: 송신(아웃바운드) TCP 는 이 리스너가 아니라 PhysicalTransportService 빈으로 붙여요. 데모 리스너는 로컬 바인딩·인증 없음이라 운영 복붙 금지. 상세: docs/CUSTOMIZATION-EXAMPLES.md §M5.

배치

ongo.framework.batch:
  launch: { allowed-jobs: [], recent-limit: 20 }
  failure: { skip-limit: 10, skippable: [java.lang.IllegalArgumentException], retry-limit: 0 }
  monitor.logging: true
키기본값설명·언제 쓰나
enabledtruefalse면 프레임 배치 빈(실행 서비스·실패 정책·모니터) 전체 OFF.
launch.allowed-jobs[] (전부)수동 실행 허용 잡 이름. 운영에선 명시 권장.
launch.recent-limit20최근 실행 조회 기본 개수.
failure.skip-limit0건너뛸 최대 건수(0 = 안 건너뜀).
failure.skippable[]건너뛸 예외 FQCN(하위 포함).
failure.retry-limit0재시도 횟수.
failure.retryable[]재시도할 예외 FQCN.
monitor.loggingtrue잡 시작/종료·건수·소요 로그.

실행 화면: /framework-devtools/batch. 잡 스켈레톤은 개발툴 「배치 잡 스켈레톤」 탭에서 생성해요.

개발툴 설정

ongo.framework.devtools:
  profiles: [local, dev]            # 화면 노출 프로파일
  codegen: { profiles: [local], output-dir: src/main/java, default-package: ..., style: RECORD }
  dynamic:                          # 전문 테스트 동적 VO
    enabled: true
    prefer-existing-vo: true
    vo-naming: { package-name: ..., pattern: "{package}.{prefix}{interfaceIdCamel}{suffix}", in-suffix: In, out-suffix: Out }

코드생성: 중첩 타입 생성 vs 기존 타입 참조

정의서생성 결과
{"name":"items","type":"ARRAY","fields":[..]}List<{Main}Sub01Vo> + 같은 패키지 별도 파일(separateNested=false면 내부 static 타입)
{"name":"items","type":"ARRAY","ref":"com.x.ItemVo"}List<ItemVo> + import, 새 파일 없음
{"name":"addr","type":"OBJECT","ref":"AddrVo"}AddrVo addr(같은 패키지 단순명)

ref(또는 attributes.ref)가 하위 fields보다 우선해요. 값은 단순명 또는 FQCN만(제네릭 불가). 전문용 VO 데코레이터는 참조 필드도 GRID/OBJECT 레이아웃으로 순번을 매겨요. 구현: JavaSourceGenerator.fieldType, TelegramVoCodeGenDecorator.ref.

운영 프로파일에는 개발툴이 올라가지 않아요. 코드생성(소스에 쓰기)은 local만, dev 서버는 전문 테스트만 허용하는 것이 기본이에요.

배포 가이드(사이트·인프라 케이스별)

정본: docs/DEPLOYMENT-GUIDE.md(케이스별 운영 플레이북 — 디렉터리·유닛·스크립트·YAML·체크리스트·롤백). 여기서는 케이스 선택과 각 케이스의 핵심 절차·설정을 정리해요. 레퍼런스 자산은 저장소 docker/(app·app-boot2·web·demo-web)와 ongo-framework-boot2/예요.

질문판단
컨테이너 플랫폼(K8s 등)?예 → Docker·CI/CD(D), Tekton·Jira·ArgoCD 조합이면 E
WAS 표준이 JEUS(+WebToB)?예 → WAR(B), 아니오 → 실행형 jar(A)
중앙 시스템이 원격 쉘로 빌드·배포?예 → C(A/B 와 조합)
운영 JDK / 서블릿 패키지17+ & jakarta(Servlet 6.0+) → Boot3 3.5.x · 8/11 또는 javax 전용 → Boot2 2.7.18 라인

공통 — 모든 케이스

항목기준
아티팩트Boot3 :app:bootJar -PongoJavaRelease=17|21(-plain 제외), Boot2 installDist/WAR. 불변 보관 + sha256 + BUILD-INFO(git sha·빌드 시각·JDK)
설정·비밀값jar 내부 yml 은 기본값만, 환경값은 외부 yml(spring.config.additional-location)·환경변수·configtree. 데모 사용자(auth.users) 운영 제거
헬스management.endpoint.health.probes.enabled=true → /actuator/health/readiness·liveness, /actuator 외부 차단(관리 포트 분리 권장)
종료server.shutdown=graceful + spring.lifecycle.timeout-per-shutdown-phase=30s < 프로세스 stop 타임아웃
타임아웃 체인LB/웹서버 > WAS/앱 요청 > 앱 아웃바운드(채널·DB) — 바깥이 항상 더 길게
멀티 인스턴스SessionStoreService·IdempotencyStore·DistributedCacheService 공유 구현(Redis 등) 필수(세션 저장소·Redis 목업)

상세 플레이북(docs/DEPLOYMENT-GUIDE.md 전문 — 펼쳐 보기)

대상: Ongo Framework 기반 업무 앱(Boot3 라인: JDK 17/21 · Spring Boot 3.5.x / Boot2 라인: JDK 8/11 · Spring Boot 2.7.18)을 개발·스테이징·운영에 배포하는 개발자와 운영 담당자. 처음 합류한 엔지니어가 추측 없이 순서대로 따라 할 수 있는 플레이북을 목표로 한다. 정본: 이 문서. 코어 개발가이드(/framework-devtools/guide#deploy)는 요약·바로가기이며, docs/CUSTOMIZATION.md §17.3 은 이 문서를 가리킨다. 표기 규칙: 일반 본문은 모든 사이트 공통. 특정 고객사(삼카) 실제 환경 차이는 <!--sc--> 구간(「삼카 부록」)에만 둔다. 벤더 제품(JEUS·WebToB 등)의 정확한 버전별 지원 사양은 「확인 필요」로 표기하며, 반드시 벤더 공식 문서·기술지원 회신으로 증빙한다. 레퍼런스 자산: 저장소 docker/(app·app-boot2·web·demo-web·각 데모 compose), ongo-framework-boot2/(Boot2 라인), docs/MULTI-JDK-PORTING.md, docs/CUSTOMIZATION.md §17, docs/CUSTOMIZATION-EXAMPLES.md.

공통 — 목차
공통 — 0. 케이스 선택 의사결정 트리
flowchart TD
  S([시작]) --> Q1{컨테이너 플랫폼<br/>K8s/OpenShift?}
  Q1 -- 예 --> Q2{빌드=Tekton<br/>승인=Jira<br/>배포=ArgoCD?}
  Q2 -- 예 --> E[E. Tekton+Jira+ArgoCD]
  Q2 -- 아니오 --> D[D. Dockerfile + CI/CD<br/>Jenkins/GHA/GitLab]
  Q1 -- 아니오 --> Q3{WAS 표준이<br/>JEUS 강제?}
  Q3 -- 예 --> B[B. JEUS + WebToB WAR]
  Q3 -- 아니오 --> A[A. 실행형 jar + systemd]
  A --> Q4{빌드/배포를 중앙 시스템이<br/>서버 에이전트 쉘로 수행?}
  B --> Q4
  Q4 -- 예 --> C[C. 중앙 관리 + 에이전트 쉘<br/>A/B 와 조합]
  Q4 -- 아니오 --> M[수동/사이트 표준 절차<br/>C 의 스크립트 재사용 권장]
판단 축 확인 질문 확인 방법 영향
JDK 운영 서버 JDK 버전? (8/11/17/21) java -version, WAS 기동 스크립트의 JAVA_HOME 8·11 → Boot2 라인(2.7.18), 17+ → Boot3 라인
서블릿 API WAS 가 Jakarta EE 9+(jakarta.*) 지원? Servlet 6.0 이상? 벤더 릴리스 노트·지원 매트릭스(확인 필요) javax.* 전용 → Boot2 라인만 가능
패키징 외부 WAS 필수인가? (보안·표준 정책) 사이트 아키텍처 표준서 필수 → WAR, 아니면 실행형 jar
세션 L4/웹서버 sticky 가능? Redis/DB 공유 저장소? 인프라팀 SessionStoreService 교체 여부
무중단 롤링/블루그린 요구? 허용 다운타임? 서비스 SLA 인스턴스 ≥2, readiness·graceful 필수
승인 반영 결재 시스템 연계? 변경관리 정책 [E] Jira 게이트, [C] 중앙 시스템 결재
네트워크 인터넷 차단(폐쇄망)? 사내 레지스트리/Nexus? 인프라팀 BASE_REGISTRY, Gradle 미러, 오프라인 빌드
채널 EAI/MCI/FEP TCP 수신 포트가 필요한가? 대외계 담당 방화벽·LB 에 TCP 포트 추가, 인스턴스별 리스너 정책

공통 — 1. 모든 케이스 공통 전제

1.1 산출물(아티팩트) 규칙

라인 명령 산출물 금지
Boot3 jar ./gradlew :<app>:clean :<app>:bootJar -x test -PongoJavaRelease=17 (또는 21) <app>/build/libs/<app>-<ver>.jar -plain.jar 배포 금지
Boot3 war ./gradlew :<app>:bootWar -PongoJavaRelease=17 (war 플러그인 적용 시) <app>/build/libs/<app>-<ver>.war 서블릿 API 동봉 금지
Boot2 ./gradlew -p ongo-framework-boot2 :<app>:installDist build/install/<app>/{bin,lib} 루트 Boot3 플러그인으로 Boot2 빌드 금지
이미지 docker build -f docker/app/Dockerfile ... registry/<svc>:<git-sha> latest 태그로 운영 배포 금지
  • 모든 산출물은 불변(immutable): 같은 버전 이름으로 다시 빌드하지 않는다. 버전 = <semver>+<git-sha12> 또는 <yyyyMMddHHmm>-<sha12>.
  • 산출물과 함께 BUILD-INFO(git sha, 브랜치, 빌드 시각 KST, JDK, Gradle, 빌더)와 sha256 을 남긴다. springBoot { buildInfo() } 를 켜면 /actuator/info 에 노출된다.
  • 운영 아티팩트에는 ongo-framework-devtools·springdoc(openapi) 를 포함하지 않는다. 프로파일로 끄는 것보다 의존성 자체를 빼는 것이 안전하다(예: if (!project.hasProperty('prod')) { developmentOnly project(':ongo-framework-devtools') }).

1.2 설정·비밀값

  • 프로파일: local(개발 PC) / dev / stg / prod. SPRING_PROFILES_ACTIVE 로 지정.
  • 설정 우선순위(높음→낮음): 명령행 인자 > 시스템 프로퍼티(-D) > OS 환경변수 > 외부 application-<env>.yml > jar 내부 yml.
  • 비밀값(DB 비밀번호, 암호화·HMAC 키, 외부 API 토큰)은 yml·Git 금지. 주입 경로: OS 환경변수 파일(권한 600) / Vault·KMS / K8s Secret(External Secrets·Sealed Secrets) → 프레임 CryptoKeyProvider 등 SPI(가이드 #crypto).
  • 데모용 ongo.framework.auth.users(평문 사용자) 는 운영 설정에서 반드시 제거하고 IdentityService 실구현으로 교체.

1.3 헬스·종료(모든 케이스 동일)

# application-prod.yml (공통 권장)
server:
  shutdown: graceful
  forward-headers-strategy: framework     # 프록시(WebToB/nginx/L4/Ingress) 뒤에서 X-Forwarded-* 신뢰
  tomcat:
    max-threads: 200                       # Boot3: server.tomcat.threads.max
    accept-count: 100
    connection-timeout: 20s
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
management:
  server:
    port: 18080                            # 관리 포트 분리(외부 LB 미노출)
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
  endpoint:
    health:
      probes:
        enabled: true                      # /actuator/health/liveness, /readiness
      show-details: never
  health:
    db:
      enabled: true
  • spring-boot-starter-actuator 의존성 추가 필요(데모 인터셉터 제외 패턴에 /actuator/** 가 이미 있다).
  • readiness = 트래픽 받을 준비(DB·필수 캐시 로드 완료). liveness = 프로세스 정상. LB/프로브는 readiness 로 판단한다.
  • Boot2(2.7)도 동일 키 사용 가능(server.tomcat.threads.max 가 2.3+ 공식 키).

1.4 타임아웃 체인(바깥이 항상 더 길게)

클라이언트(브라우저/채널) 60s > L4 idle 55s > 웹서버(nginx/WebToB) 50s > WAS 요청 처리 45s > 앱 아웃바운드(HTTP/EAI) 30s > DB 쿼리 timeout 20s

안쪽이 더 길면 바깥이 먼저 끊어 「응답은 실패, 처리는 성공」(이중 처리) 사고가 난다. 중복 방지는 Idempotency-Key(가이드 #idem)로 보완.

1.5 멀티 인스턴스 필수 교체 SPI

SPI 기본 구현 멀티 인스턴스에서 문제 교체
SessionStoreService 인메모리 인스턴스마다 로그인 상태 다름, 강제 로그아웃·중복로그인 차단 불가 Redis/DB 구현(가이드 #sessionstore)
IdempotencyStore 인메모리 같은 키 재요청이 다른 인스턴스로 가면 중복 처리 Redis SET NX PX / DB 유니크
DistributedCacheService 로컬 공통코드·메타 갱신이 한 인스턴스에만 반영 Redis + Refreshable 브로드캐스트
배치 JobRepository 임베디드 중복 실행 공유 DB 스키마

인메모리 기본 구현은 단일 인스턴스 전용이다. 인스턴스가 2개 이상이면 반드시 교체한다(예제: docs/CUSTOMIZATION-EXAMPLES.md, 가이드 #extmock).


A. IaaS — 실행형 jar 멀티 인스턴스

/app/my-biz-app/
 ├─ releases/20261030-1500-ab12cd3/app.jar  (+ app.jar.sha256, BUILD-INFO)
 ├─ current -> releases/20261030-1500-ab12cd3
 ├─ config/application-prod.yml        (외부 설정, 640 권한)
 ├─ env/inst1.env  env/inst2.env       (PORT, JAVA_OPTS)
 ├─ logs/inst1/ logs/inst2/   dumps/
# /etc/systemd/system/[email protected]
[Service]
User=ongo
EnvironmentFile=/app/my-biz-app/env/%i.env
WorkingDirectory=/app/my-biz-app
ExecStart=/usr/bin/java $JAVA_OPTS -jar /app/my-biz-app/current/app.jar \
  --server.port=${PORT} --spring.profiles.active=prod \
  --spring.config.additional-location=file:/app/my-biz-app/config/ \
  --logging.file.path=/app/my-biz-app/logs/%i
SuccessExitStatus=143
TimeoutStopSec=45
Restart=on-failure
LimitNOFILE=65535
# env/inst1.env
PORT=8081
JAVA_OPTS=-Xms2g -Xmx2g -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/my-biz-app/dumps -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul
# nginx upstream
upstream my_biz_app { server 10.0.1.11:8081 max_fails=3 fail_timeout=10s; server 10.0.1.11:8082 ...; keepalive 32; }
location /api/ { proxy_pass http://my_biz_app; proxy_set_header X-Correlation-Id $request_id; proxy_read_timeout 65s; }

롤링 절차(인스턴스별): ① 새 릴리스 업로드·sha256 검증 ② LB 에서 inst1 제외(drain 대기) ③ ln -sfn releases/<new> current ④ systemctl restart ongo-my-biz-app@inst1 ⑤ readiness 200 확인(최대 N초) ⑥ LB 재투입 → inst2 반복. 실패 시 링크를 직전 릴리스로 되돌리고 재기동.

상세 — A. IaaS(On-premise) — 실행형 jar 멀티 인스턴스

적용: VM/물리 서버에 JDK 만 설치하고 Spring Boot 실행형 jar 를 OS 서비스(systemd)로 N개 띄운 뒤 nginx 또는 L4 로 분산하는 방식. 외부 WAS 가 필요 없는 사이트의 기본 선택지.

A.1 표준 토폴로지

flowchart LR
  U[사용자/채널] --> L4[L4 / VIP :443]
  L4 --> W1[nginx web01 :80/443]
  L4 --> W2[nginx web02 :80/443]
  W1 --> A11[was01 inst1 :8081]
  W1 --> A12[was01 inst2 :8082]
  W1 --> A21[was02 inst1 :8081]
  W1 --> A22[was02 inst2 :8082]
  W2 --> A11 & A12 & A21 & A22
  A11 & A12 & A21 & A22 --> R[(Redis<br/>세션·멱등·캐시)]
  A11 & A12 & A21 & A22 --> DB[(DB)]
  • 최소 권장: WAS 서버 2대 × 인스턴스 2개(서버 장애와 인스턴스 재기동을 각각 흡수). 인스턴스 수는 CPU 코어·힙 기준으로 산정(서버당 코어/2 이하, 힙 합계 ≤ 물리 메모리의 60%).
  • 웹서버를 생략하고 L4 → WAS 직접도 가능하나, 정적 리소스·TLS 종료·요청 크기 제한·접근 로그를 위해 nginx 를 두는 것을 권장.

A.2 사전 준비(서버별 1회)

# 1) 실행 계정(로그인 불가, 홈=/app)
sudo groupadd -r ongo && sudo useradd -r -g ongo -d /app -s /sbin/nologin ongo
# 2) JDK (Boot3: 17/21, Boot2: 8/11) — 사내 저장소 rpm/tar 사용
sudo mkdir -p /opt/java && sudo tar -xzf OpenJDK21U-jdk_x64_linux.tar.gz -C /opt/java
sudo alternatives --install /usr/bin/java java /opt/java/jdk-21*/bin/java 1   # 또는 유닛에서 절대경로 사용
# 3) 디렉터리
SVC=ongo-app
sudo mkdir -p /app/$SVC/{releases,config,env,logs,tmp,dumps,bin}
sudo chown -R ongo:ongo /app/$SVC && sudo chmod 750 /app/$SVC/env
# 4) OS 한도·타임존
echo 'ongo soft nofile 65536
ongo hard nofile 65536' | sudo tee /etc/security/limits.d/ongo.conf
sudo timedatectl set-timezone Asia/Seoul

A.3 서버 디렉터리 표준

/app/<svc>/
├─ releases/
│   ├─ 202610301930-9018c6e1a2b3/
│   │   ├─ app.jar                 # 불변 릴리스 (Boot2: bin/ lib/)
│   │   ├─ BUILD-INFO
│   │   └─ app.jar.sha256
│   └─ 202610291010-f705a70c4d5e/
├─ current -> releases/202610301930-9018c6e1a2b3   # 배포 = 링크 전환
├─ previous -> releases/202610291010-f705a70c4d5e  # 롤백 대상
├─ config/
│   ├─ application-prod.yml        # 서버 공통 외부 설정(비밀값 없음)
│   └─ logback-spring.xml          # (선택) 외부 로그 설정
├─ env/
│   ├─ common.env                  # 공통 환경변수(비밀값, 600 권한)
│   ├─ inst1.env                   # 인스턴스별 PORT·MGMT_PORT·JAVA_OPTS
│   └─ inst2.env
├─ logs/inst1/ logs/inst2/         # 인스턴스별 로그
├─ dumps/                          # 힙덤프·GC 로그
├─ tmp/                            # java.io.tmpdir (멀티파트 임시파일)
└─ bin/                            # deploy.sh, health.sh, rollback.sh (§C 와 공유)
  • releases/ 는 최근 5개 보관(§A.12 정리 스크립트). 디스크 = jar 크기 × 5 + 로그 보관량.
  • tmp/ 를 별도로 두는 이유: /tmp 가 tmpfs/정리 cron 대상이면 업로드 중 파일이 삭제된다(java.io.tmpdir, server.tomcat.basedir).

A.4 외부 설정 예 (config/application-prod.yml)

spring:
  datasource:
    url: jdbc:oracle:thin:@//db-vip:1521/ONGO
    username: ongo_app
    password: ${DB_PASSWORD}             # env/common.env 에서 주입
    hikari:
      maximum-pool-size: 20              # 인스턴스 수 × 20 ≤ DB 허용 세션
      minimum-idle: 5
      connection-timeout: 3000
      max-lifetime: 1700000              # DB/방화벽 idle timeout 보다 짧게
  data:
    redis:
      host: redis-vip
      port: 6379
      password: ${REDIS_PASSWORD}
ongo:
  framework:
    crypto:
      key-ref: env:ONGO_CRYPTO_KEY       # CryptoKeyProvider 가 해석(사이트 구현)
logging:
  file:
    path: ${LOG_PATH}

env/common.env (권한 600, 소유 ongo)

SPRING_PROFILES_ACTIVE=prod
DB_PASSWORD=****
REDIS_PASSWORD=****
ONGO_CRYPTO_KEY=****
TZ=Asia/Seoul

env/inst1.env

INSTANCE=inst1
PORT=8081
MGMT_PORT=18081
JAVA_OPTS=-Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/ongo-app/dumps/inst1 -Xlog:gc*:file=/app/ongo-app/logs/inst1/gc.log:time,uptime:filecount=10,filesize=20m -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul -Djava.io.tmpdir=/app/ongo-app/tmp -Djava.security.egd=file:/dev/./urandom

env/inst2.env 는 INSTANCE=inst2, PORT=8082, MGMT_PORT=18082, 덤프·로그 경로만 다르게.

A.5 JVM 옵션 가이드

옵션 권장값 이유
-Xms=-Xmx 동일값(1~4g) 힙 리사이즈로 인한 지연 제거
GC JDK 11+: -XX:+UseG1GC, JDK 21 대용량: ZGC(-XX:+UseZGC -XX:+ZGenerational) 검토 기본 G1 이 무난
GC 로그 JDK 9+: -Xlog:gc*:file=..., JDK 8: -Xloggc:... -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation 장애 분석 필수
힙덤프 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath= 덤프 경로 디스크 여유 ≥ 힙 크기
-XX:+ExitOnOutOfMemoryError 선택 OOM 후 좀비 대신 systemd 재기동
인코딩/TZ -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul 로그·전문 시간 일관성 (EUC-KR 전문은 채널 charset 설정으로)
메타스페이스 -XX:MaxMetaspaceSize=512m 동적 클래스 누수 시 무한 증가 방지
JDK 8 전용 -XX:+UseStringDeduplication(G1) 선택 메모리 절감

A.6 systemd 유닛 — 방법 1: 템플릿 유닛(권장)

/etc/systemd/system/[email protected]

[Unit]
Description=Ongo App instance %i
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=ongo
Group=ongo
EnvironmentFile=/app/ongo-app/env/common.env
EnvironmentFile=/app/ongo-app/env/%i.env
Environment=LOG_PATH=/app/ongo-app/logs/%i
WorkingDirectory=/app/ongo-app/current
ExecStart=/opt/java/jdk-21/bin/java $JAVA_OPTS \
  -Dserver.port=${PORT} -Dmanagement.server.port=${MGMT_PORT} \
  -jar /app/ongo-app/current/app.jar \
  --spring.config.additional-location=file:/app/ongo-app/config/
# SIGTERM(143) 은 정상 종료로 간주
SuccessExitStatus=143
KillSignal=SIGTERM
# graceful(30s) + 여유 → 40s 후 SIGKILL
TimeoutStopSec=45
TimeoutStartSec=180
Restart=on-failure
RestartSec=10
StartLimitIntervalSec=300
StartLimitBurst=3
LimitNOFILE=65536
# 보안 하드닝(사이트 정책에 맞게)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/app/ongo-app

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now ongo-app@inst1 ongo-app@inst2
systemctl status 'ongo-app@*'
journalctl -u ongo-app@inst1 -f
  • WorkingDirectory 가 심볼릭 링크라도 systemd 는 기동 시점에 해석한다 → 링크 전환 후 반드시 restart.
  • Boot2 installDist: ExecStart=/app/ongo-app/current/bin/<app> 로 바꾸고 JAVA_OPTS 는 env 로 그대로 전달(스크립트가 $JAVA_OPTS 를 읽음). 인자 --server.port=${PORT} 추가.

A.6b systemd 유닛 — 방법 2: 인스턴스별 개별 서비스 파일

템플릿을 쓰지 못하는 사이트(감사 정책상 유닛별 승인 등)는 파일을 나눈다. /etc/systemd/system/ongo-app-inst1.service

[Unit]
Description=Ongo App inst1
After=network-online.target
[Service]
User=ongo
EnvironmentFile=/app/ongo-app/env/common.env
EnvironmentFile=/app/ongo-app/env/inst1.env
Environment=LOG_PATH=/app/ongo-app/logs/inst1
WorkingDirectory=/app/ongo-app/current
ExecStart=/opt/java/jdk-21/bin/java $JAVA_OPTS -Dserver.port=8081 -Dmanagement.server.port=18081 -jar app.jar --spring.config.additional-location=file:/app/ongo-app/config/
SuccessExitStatus=143
TimeoutStopSec=45
Restart=on-failure
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target

ongo-app-inst2.service 는 이름·env·포트만 바꾼다. 묶음 제어용 타깃(선택):

# /etc/systemd/system/ongo-app.target
[Unit]
Description=Ongo App all instances
Wants=ongo-app-inst1.service ongo-app-inst2.service

systemd 가 없는 구형 OS(RHEL6 등)는 bin/start.sh(nohup java ... & echo $! > inst1.pid) / stop.sh(kill -TERM $(cat pid) 후 45s 대기, 남으면 kill -9) 쌍으로 대체한다. PID 파일 경로를 인스턴스별로 분리할 것.

A.7 앞단 — nginx

/etc/nginx/conf.d/ongo.conf

upstream ongo_was {
    least_conn;
    server 10.0.0.11:8081 max_fails=2 fail_timeout=10s;
    server 10.0.0.11:8082 max_fails=2 fail_timeout=10s;
    server 10.0.0.12:8081 max_fails=2 fail_timeout=10s;
    server 10.0.0.12:8082 max_fails=2 fail_timeout=10s;
    keepalive 64;
}
server {
    listen 443 ssl http2;
    server_name app.example.com;
    ssl_certificate     /etc/nginx/tls/app.crt;
    ssl_certificate_key /etc/nginx/tls/app.key;
    ssl_protocols TLSv1.2 TLSv1.3;

    client_max_body_size 50m;            # = spring.servlet.multipart.max-request-size
    proxy_connect_timeout 3s;
    proxy_read_timeout 50s;              # §1.4 타임아웃 체인
    proxy_send_timeout 50s;

    location = /healthz { access_log off; return 200 'ok'; }   # L4 → nginx 헬스
    location /static/ { root /app/web; expires 7d; }           # 정적 리소스 직접 서빙
    location /actuator/ { deny all; }                           # 관리 엔드포인트 외부 차단
    location / {
        proxy_pass http://ongo_was;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Correlation-Id $request_id;        # 웹·WAS 로그 연결
        proxy_next_upstream error timeout http_502 http_503;  # 멱등 요청만 재시도 원하면 non_idempotent 미지정 유지
    }
}
  • 레퍼런스: docker/demo-web/default.conf.template(정적 + /api 프록시). 데모 백엔드는 2026-10-30 부터 컨테이너 앞단 nginx 없이 NPM(리버스 프록시) → WAS 직결이다(아래 Docker 절).
  • 오픈소스 nginx 는 능동 헬스체크가 없다(수동 max_fails 만). 롤링 중 무오류가 필요하면 배포 스크립트가 upstream 에서 down 처리 후 nginx -s reload 하거나, L4 의 능동 헬스체크(관리 포트 readiness)를 사용.
  • 앱 쪽 server.forward-headers-strategy=framework + 신뢰 프록시 대역 제한(내부망만 접근 가능하도록 방화벽).

A.8 세션 — sticky vs SessionStore SPI

방식 설정 장점 단점 권장도
nginx ip_hash upstream 에 ip_hash; 설정 간단 NAT 뒤 사용자 쏠림, 인스턴스 재기동 시 해당 사용자 재로그인 임시
nginx 쿠키 sticky sticky cookie(nginx Plus) / hash $cookie_ONGOSID consistent; 분산 균등 재기동 시 세션 유실은 동일 임시
L4 sticky(소스IP/쿠키 insert) L4 장비 설정 웹서버 무관 장비 의존, 동일 단점 임시
SessionStoreService 공유 구현 Redis/DB 빈 등록 롤링 재기동에도 로그인 유지, 강제 로그아웃·중복로그인 차단 동작 Redis HA 필요 표준

결론: sticky 는 공유 저장소 도입 전 임시로만 사용. 프레임 인증은 SessionStoreService 를 거치므로 sticky 만으로는 「다른 인스턴스에서 강제 로그아웃」·「중복 로그인 차단」이 동작하지 않는다.

A.9 롤링 재기동 순서(무중단)

sequenceDiagram
  participant D as deploy.sh
  participant LB as nginx/L4
  participant I as instN
  loop 서버 순서: was01 inst1 → was01 inst2 → was02 inst1 → was02 inst2
    D->>LB: instN 제외(down + reload / L4 disable)
    D->>D: drain 대기(15~30s, 진행 요청 소진)
    D->>I: systemctl stop (graceful 30s)
    D->>D: ln -sfn releases/new current
    D->>I: systemctl start
    loop 최대 120s
      D->>I: GET :MGMT/actuator/health/readiness
    end
    alt UP
      D->>LB: instN 재투입 → 스모크 1건
    else 실패
      D->>D: 즉시 롤백(previous 링크) 후 중단·알림
    end
  end

규칙 1. 한 번에 1개 인스턴스(전체의 25% 이하)만 내린다. 서버 2대면 서버를 번갈아가며가 아니라 「한 서버 내 인스턴스를 순서대로 → 다음 서버」로 해도 되지만, 첫 인스턴스는 카나리로 보고 5분 관찰 후 나머지 진행 권장. 2. 첫 인스턴스 실패 시 전체 중단(나머지는 구버전 그대로이므로 서비스 영향 없음). 3. DB 스키마 변경이 있으면 하위호환(확장 → 배포 → 축소): 신구 버전이 동시에 떠 있는 구간이 반드시 존재한다. 4. 배치 앱(ongo-framework-batch*)은 롤링 대상이 아니다. 배치 스케줄러(사이트 스케줄러/cron)가 jar 를 실행·종료하며, 실행 중 Job 이 없는 시간대에 jar 교체. 중복 실행 방지는 공유 JobRepository.

A.10 헬스·readiness 확인 스크립트 (bin/health.sh)

#!/usr/bin/env bash
set -euo pipefail
INST=${1:?instance}; source /app/ongo-app/env/$INST.env
URL="http://127.0.0.1:${MGMT_PORT}/actuator/health/readiness"
for i in $(seq 1 60); do
  if curl -fsS --max-time 2 "$URL" | grep -q '"status":"UP"'; then
    echo "[health] $INST UP (${i}x2s)"; exit 0
  fi
  sleep 2
done
echo "[health] $INST NOT READY after 120s" >&2
journalctl -u "ongo-app@$INST" -n 80 --no-pager >&2 || true
exit 1

A.11 로그·logrotate

  • 앱 로그: logback 롤링(Spring Boot 기본 logging.logback.rollingpolicy.max-file-size=100MB, max-history=30, total-size-cap=10GB). 앱이 스스로 롤링하므로 logrotate 대상에서 제외(이중 롤링 시 파일 핸들 유실).
  • logrotate 대상: GC 로그(JDK 8 처럼 자체 회전 미사용 시), nohup.out, nginx 로그, 기동 스크립트 출력. /etc/logrotate.d/ongo-app
/app/ongo-app/logs/*/console.out /app/ongo-app/logs/*/gc-jdk8.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    copytruncate        # 자바가 파일을 계속 열고 있으므로
    su ongo ongo
}
  • 힙덤프(dumps/)는 수 GB 이므로 cron 으로 7일 경과분 삭제: find /app/ongo-app/dumps -type f -mtime +7 -delete.
  • 구조화 로그는 StructuredLogService → LoggingBridgeService 로 ELK/수집기 병렬 전송(가이드 #logging). 수집 에이전트(Filebeat 등)는 logs/*/*.log 를 읽도록.

A.12 방화벽·포트 표

출발 도착 포트 용도 비고
인터넷/사내 L4 VIP 443 서비스
L4 web01/02 80/443 프록시 /healthz 헬스
web01/02 was01/02 8081-8084 서비스 트래픽 인스턴스 수만큼
L4/모니터링 was01/02 18081-18084 actuator(관리) 웹서버·외부에서 차단
was DB 1521/5432/3306 JDBC
was Redis 6379(/26379 sentinel) 세션·멱등·캐시
대외계(EAI/FEP) was 또는 전용 수신 서버 사이트 지정(예 9001) TCP 전문 수신 인스턴스별 리스너 정책 확인(가이드 #eaitcp)
배포 서버/에이전트 was 22 또는 에이전트 포트 배포 §C
# firewalld 예 (was 서버)
sudo firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=10.0.1.0/24 port port=8081-8084 protocol=tcp accept'
sudo firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=10.0.9.10/32 port port=18081-18084 protocol=tcp accept'
sudo firewall-cmd --reload

A.13 배포 스크립트 (bin/deploy.sh, 요약)

#!/usr/bin/env bash
set -euo pipefail
SVC=ongo-app; BASE=/app/$SVC; ART=${1:?artifact.jar}; VER=${2:?version}
INSTANCES=(inst1 inst2)
REL=$BASE/releases/$VER
[[ -d $REL ]] && { echo "이미 존재하는 버전 $VER — 재배포는 링크 전환만"; } || {
  mkdir -p "$REL" && cp "$ART" "$REL/app.jar" && sha256sum "$REL/app.jar" > "$REL/app.jar.sha256"; }
PREV=$(readlink -f $BASE/current || true)
for i in "${INSTANCES[@]}"; do
  ./lb.sh out "$i"; sleep 20
  sudo systemctl stop "$SVC@$i"
  ln -sfn "$REL" $BASE/current
  sudo systemctl start "$SVC@$i"
  if ! ./health.sh "$i"; then
     echo "롤백: $PREV"; ln -sfn "$PREV" $BASE/current; sudo systemctl restart "$SVC@$i"; ./health.sh "$i"; ./lb.sh in "$i"; exit 1
  fi
  ./lb.sh in "$i"
done
[[ -n $PREV && $PREV != $REL ]] && ln -sfn "$PREV" $BASE/previous
ls -1dt $BASE/releases/* | tail -n +6 | xargs -r rm -rf     # 최근 5개 보관

주의: 모든 인스턴스가 같은 current 를 보므로, 롤링 도중 이미 전환된 링크로 다른 인스턴스가 재기동(장애 재시작)되면 새 버전으로 뜬다. 인스턴스별 독립 버전이 필요하면 current-inst1, current-inst2 로 링크를 분리하고 유닛의 경로를 current-%i 로 둔다(권장).

A.14 롤백

상황 절차 소요
배포 중 첫 인스턴스 실패 스크립트 자동 롤백(링크 원복 + 재기동), 나머지 미진행 1~2분
배포 완료 후 장애 ln -sfn $(readlink previous) current → 인스턴스 롤링 재기동(§A.9 동일 순서) 인스턴스 × 1~2분
링크 미사용 사이트 app.jar → app.jar.<ver> 백업 보관, cp app.jar.<prev> app.jar 후 롤링 재기동 동일
DB 마이그레이션 포함 앱만 롤백 가능하도록 하위호환 마이그레이션 유지. 역마이그레이션 스크립트는 별도 결재 —
- 롤백도 「배포」다: 변경 이력(결재 번호·버전·시각 KST)을 남긴다.

A.15 체크리스트(A)

  • □ JDK 버전·경로 확정, java -version 출력 증빙
  • □ 실행 계정 ongo, 디렉터리 권한(env 600), ulimit 65536
  • □ application-prod.yml 외부화, 비밀값은 env/Vault, 데모 사용자 제거
  • □ 인스턴스별 PORT/MGMT_PORT/로그/덤프 경로 분리
  • □ actuator readiness/liveness, 관리 포트 외부 차단
  • □ server.shutdown=graceful 30s < TimeoutStopSec 45s
  • □ nginx upstream·타임아웃 체인·client_max_body_size = multipart 한도
  • □ 멀티 인스턴스 SPI(세션·멱등·캐시) 공유 구현
  • □ logback 롤링 + logrotate(비 logback 파일만) + 덤프 정리 cron
  • □ 방화벽 표 기준 개방 확인(nc -zv)
  • □ 롤링 리허설 1회(스테이징), 롤백 리허설 1회
  • □ 배치 jar 교체 시간대·스케줄러 등록 확인

B. IaaS — JEUS + WebToB(WAR)

JEUS 지원 스펙선택
Jakarta EE 10(Servlet 6.0, jakarta.*) + JDK 17+Boot3 3.5.x WAR
Jakarta EE 9.x(Servlet 5.0)Boot3 불가(6.0 필요) → Boot2 불가(javax) → WebToB 뒤 실행형 jar 하이브리드 또는 JEUS 업그레이드 협의
Java EE 8(Servlet 4.0, javax.*) / Servlet 3.1Boot2 2.7.18 라인 WAR(ongo-framework-boot2)
그 이하업그레이드 또는 하이브리드

정확한 JEUS/WebToB 버전별 지원 스펙·설정 문법은 벤더 문서/기술지원으로 확인 필요(정본 §B 확인 매트릭스 12항목).

// build.gradle (WAR)
plugins { id 'war' }
dependencies { providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat' }
// Application extends SpringBootServletInitializer → configure(builder) 에서 sources 지정
<!-- WEB-INF/jeus-web-dd.xml (개념 예, 요소명 확인 필요) -->
<jeus-web-dd>
  <context-root>/my-biz-app</context-root>
  <enable-jsp>false</enable-jsp>
  <webinf-first>true</webinf-first>   <!-- 웹앱 클래스 우선(로깅·jackson 충돌 방지) -->
</jeus-web-dd>
상세 — B. IaaS(On-premise) — JEUS + WebToB 멀티 인스턴스

적용: 사이트 WAS 표준이 TmaxSoft JEUS 이고 웹서버가 WebToB 인 경우. 앱은 WAR 로 JEUS 도메인의 클러스터(관리 서버 N개)에 배포하고, WebToB 가 정적 리소스 서빙·JEUS 라우팅을 맡는다. ⚠️ JEUS·WebToB 의 버전별 서블릿/JDK 인증 조합, 설정 파일 엘리먼트명, CLI 문법은 버전마다 다르다 — 모두 「확인 필요」. 이 절은 판단 규칙과 「무엇을 벤더 매트릭스에서 확인해야 하는가」를 구체적으로 제시한다.

B.1 표준 토폴로지

flowchart LR
  U[사용자] --> L4[L4 VIP]
  L4 --> WB1[WebToB web01<br/>정적·라우팅]
  L4 --> WB2[WebToB web02]
  WB1 -- WJP/JSV 커넥터 --> MS1[JEUS MS01<br/>was01]
  WB1 --> MS2[JEUS MS02<br/>was01]
  WB1 --> MS3[JEUS MS03<br/>was02]
  WB1 --> MS4[JEUS MS04<br/>was02]
  WB2 --> MS1 & MS2 & MS3 & MS4
  DAS[JEUS DAS<br/>도메인 관리 서버] -. 배포·설정 .-> MS1 & MS2 & MS3 & MS4
  MS1 & MS2 & MS3 & MS4 --> DS[(JNDI DataSource → DB)]
  MS1 & MS2 & MS3 & MS4 --> R[(Redis/DB SessionStore)]
  • JEUS 7 이후 계열은 도메인(DAS, Domain Administration Server) + 관리 서버(MS) + 클러스터 구조. JEUS 6 이하(컨테이너/엔진 구조)는 설정 모델이 달라 이 절의 용어와 1:1 대응되지 않는다(확인 필요).
  • WebToB ↔ JEUS 연결은 WebToB 가 리스닝하고 JEUS 웹 엔진이 역으로 접속(registration) 하는 구조가 일반적이다 → 방화벽은 JEUS → WebToB JSV 포트 방향으로 연다(일반 리버스 프록시와 반대 방향, 확인 필요).

B.2 Boot 버전 결정 트리(핵심)

Spring Boot 버전은 WAS 의 서블릿 API 패키지(javax vs jakarta)·서블릿 스펙 버전·인증 JDK 세 가지로 결정된다. 사양 기준 사실: - Spring Boot 3.x / Spring Framework 6.x → Jakarta EE 10 기준, Servlet 6.0 필요, JDK 17+. jakarta.servlet.* 패키지. - Spring Boot 2.7.x / Spring 5.3.x → Java EE 8(javax.servlet.*), Servlet 3.1+ (4.0 권장), JDK 8 이상(상한은 2.7 릴리스 노트로 확인, 이 저장소 Boot2 라인은 8/11 기준). - Jakarta EE 9/9.1(Servlet 5.0, jakarta.*)만 지원하는 WAS 는 Boot3 도 Boot2 도 아님에 주의 — Boot3 는 Servlet 6.0 을 요구하고, Boot2 는 javax 를 쓴다. 이 경우 WAS 업그레이드 또는 [A]/하이브리드.

flowchart TD
  S([JEUS 버전 확인<br/>jeusadmin version / 릴리스 노트]) --> J{서블릿 패키지?}
  J -- jakarta.* --> V6{Servlet 6.0 이상<br/>Jakarta EE 10?}
  V6 -- 예 --> K17{운영 JDK 17+<br/>해당 JEUS 인증 JDK?}
  K17 -- 예 --> B3[Boot3 라인 3.5.x WAR<br/>-PongoJavaRelease=17/21]
  K17 -- 아니오 --> UPJ[JDK 업그레이드 협의<br/>불가 시 하이브리드]
  V6 -- 아니오(EE9/Servlet5) --> NO1[Boot3 불가·Boot2 불가<br/>→ JEUS 패치/업그레이드 또는 하이브리드]
  J -- javax.* --> V4{Servlet 4.0 / 3.1?}
  V4 -- 4.0 EE8 --> K8{JDK 8 또는 11?}
  K8 -- 예 --> B2[Boot2 라인 2.7.18 WAR<br/>ongo-framework-boot2]
  V4 -- 3.1 EE7 --> B2L[Boot2 라인 가능<br/>기능 제약 점검 B.2.2]
  V4 -- 3.0 이하 --> NO2[미지원<br/>WAS 업그레이드 또는 A/하이브리드]
B.2.1 벤더 매트릭스에서 확인할 항목(확인 필요 — 반드시 문서/회신으로 증빙)
# 확인 항목 왜 필요한가 증빙
1 JEUS 정확한 버전·Fix 레벨 (jeusadmin -version 또는 관리 콘솔) 같은 메이저라도 Fix 에 따라 지원 JDK 가 다름 화면 캡처
2 지원 Java EE / Jakarta EE 버전, Servlet 스펙 버전 Boot 라인 결정 벤더 스펙 문서
3 인증 JDK 목록(벤더 JDK / OpenJDK 배포판 허용 여부) Boot3 는 17+ 필수 지원 매트릭스
4 WebSocket/HTTP2/비동기 서블릿 지원 프레임 SSE·비동기 사용 여부 문서
5 웹앱 클래스 우선 로딩 옵션 이름·위치(jeus-web-dd.xml) 라이브러리 충돌 회피 문서
6 공용 lib 에 포함된 jackson/xml/logging/validation 버전 충돌 예측 lib/ 목록
7 JNDI 이름 규칙, DataSource 풀 설정 위치 spring.datasource.jndi-name 도메인 설정
8 WebToB 버전, JEUS 연동(WJP/JSV) 호환 버전 조합 연동 장애 예방 매트릭스
9 WebToB TLS 지원 버전(1.2/1.3)·암호군, HTTP 헤더/요청 크기 한도 보안 심사·대용량 헤더(JWT) 문서
10 세션 클러스터링 방식(사용 안 할 경우 비활성 방법) 프레임은 SessionStore 사용 문서
11 롤링(클러스터 순차) 재배포·graceful suspend 지원 명령 무중단 CLI 매뉴얼
12 라이선스 범위(코어 수·MS 수) 인스턴스 증설 가능 여부 계약
B.2.2 Boot2 라인 + Servlet 3.1(EE7) 에서 점검할 제약
  • Servlet 4.0 전용 API(PushBuilder, HttpServletMapping) 미사용 확인. 프레임 코어는 의존하지 않음.
  • 멀티파트 처리: StandardServletMultipartResolver 동작 확인, 한도는 WebToB/JEUS/Spring 3곳 일치.
  • JDK 8 에서 TLS1.2 기본 활성 여부(구형 8u 초기 빌드 확인 필요), 외부 HTTPS 아웃바운드 영향.
B.2.3 하이브리드 대안

「JEUS 필수」가 아닌 「웹서버 WebToB 필수」인 사이트라면: WebToB → (리버스 프록시 또는 WebToB 의 HTTP 프록시 기능, 확인 필요) → 실행형 jar([A]) 인스턴스. 사전 아키텍처 심의 필수.

B.3 WAR 패키징

build.gradle(업무 앱 모듈, Boot3 예)

plugins {
    id 'org.springframework.boot'
    id 'io.spring.dependency-management'
    id 'war'
}
dependencies {
    implementation project(':ongo-framework-core-web')      // 사이트가 사용하는 프레임 모듈
    providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'  // 내장 톰캣은 WAR 에서 제외
    compileOnly 'jakarta.servlet:jakarta.servlet-api'       // Boot2: javax.servlet:javax.servlet-api:4.0.1
}
bootWar {
    archiveFileName = 'ongo-app.war'
}
war { enabled = false }   // plain war 생성 방지(선택)

초기화 클래스

public class ServletInitializer extends SpringBootServletInitializer {
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(OngoApplication.class);
    }
}
  • Embedded vs WAR: 같은 소스로 둘 다 만들 수 있다. bootWar 결과물은 java -jar 로도 실행 가능(내장 톰캣이 WEB-INF/lib-provided 에 들어감) → 개발·스테이징은 jar 처럼, 운영은 JEUS 에 배포. 단 JEUS 에서 lib-provided 는 로딩되지 않으므로 무해.
  • Boot2 라인: 저장소 ongo-framework-boot2 는 installDist 패키징이므로, 사이트 앱 저장소에서 위 war 구성을 별도로 한다(BOM 은 ongo-framework-boot2/core-web/build.gradle 의 Boot 2.7.18 오버라이드를 그대로 참고). 루트 Boot3 플러그인과 섞지 말 것.
  • WAR 에 넣으면 안 되는 것: 서블릿/JSP/EL API, JTA API, JEUS 가 제공하는 JDBC 드라이버(드라이버를 WAS 공용 lib 에 둘지 WAR 에 둘지 사이트 표준 확인 — JNDI 사용 시 보통 WAS 공용).

B.4 context path 와 배포 서술자

  • context-root 결정 우선순위(일반): WAS 배포 설정(관리 콘솔/명령 옵션) > jeus-web-dd.xml 의 context-root > WAR 파일명. Spring 의 server.servlet.context-path 는 외부 WAS 배포 시 무시된다.
  • src/main/webapp/WEB-INF/jeus-web-dd.xml (개념 예 — 엘리먼트명은 JEUS 버전별 확인 필요)
<jeus-web-dd xmlns="http://www.tmaxsoft.com/xml/ns/jeus">
    <context-path>/</context-path>
    <!-- 웹앱 클래스 우선 로딩: 이름·위치는 버전별 확인 필요 -->
    <webinf-first>true</webinf-first>
    <session-config>
        <timeout>30</timeout>          <!-- 프레임 세션은 SessionStore TTL 이 기준, 여기선 보조 -->
    </session-config>
</jeus-web-dd>
  • context-root 를 / 가 아닌 /ongo 로 두면: WebToB URI 매핑, 프론트 API base URL, 쿠키 Path, 정적 리소스 경로, 프레임 인터셉터 제외 패턴(/actuator/** → /ongo/actuator/** 아님, 서블릿 경로 기준) 을 모두 점검.

B.5 클래스로더·공유 라이브러리 주의

증상 원인 조치
NoSuchMethodError: com.fasterxml.jackson... JEUS 공용 lib 의 구버전 jackson 이 먼저 로딩 웹앱 클래스 우선 로딩 활성, 공용 lib 정리(벤더 확인)
ClassCastException: X cannot be cast to X 같은 클래스가 두 로더(공용·WAR)에서 로딩 한쪽만 두기(보통 WAR), JDBC 드라이버는 공용만
LinkageError ... loader constraint violation javax.xml/jakarta.xml.bind 중복 WAR 에서 API jar 제외
SLF4J multiple bindings / 로그 미출력 JEUS 로깅 브리지 + logback 중복 logback-classic 하나만, JUL 브리지 확인
jakarta.servlet.* ClassNotFound Boot3 WAR 를 javax WAS 에 배포 B.2 재판단(Boot2 라인)
재배포 후 메타스페이스 증가 → OOM 스레드·드라이버·ThreadLocal 누수로 이전 클래스로더 미회수 핫 리디플로이 금지, MS 재기동 배포
- 진단: MS JVM 옵션에 일시적으로 -verbose:class(JDK 9+: -Xlog:class+load=info)를 넣어 충돌 클래스의 출처 jar 확인 후 제거.
- 같은 MS 에 여러 WAR 를 함께 올리지 않는다(로더 누수·설정 간섭). 앱당 클러스터 분리 권장.

B.6 DataSource — JNDI vs Boot 자동구성

항목 JEUS JNDI DataSource Boot 자동구성(Hikari 내장)
설정 spring.datasource.jndi-name=jdbc/ongoDS spring.datasource.url/username/password
비밀번호 WAS 가 관리(암호화 저장) 앱 env/Vault
풀 모니터링 JEUS 관리 콘솔 actuator/hikari 메트릭
XA/JTA JEUS TM 사용 가능 별도 구성 필요
운영 권장 권장(사이트 표준이 대부분) WAS 독립성 필요 시
# application-prod.yml (JNDI)
spring:
  datasource:
    jndi-name: jdbc/ongoDS         # JEUS 에 등록한 export-name (java:comp/env 접두 여부 확인 필요)
  jta:
    enabled: false                 # XA 불필요 시
  • JNDI 사용 시 DataSourceAutoConfiguration 의 Hikari 는 생성되지 않는다. MyBatis(가이드 #mybatis)는 이 DataSource 를 그대로 사용.
  • 풀 크기 산정: MS 수 × 풀 max ≤ DB 최대 세션 × 0.8.
  • 트랜잭션: 단일 DB 는 DataSourceTransactionManager(가이드 #tx), 2PC 필요 시에만 JEUS TM(JTA).

B.7 WebToB → JEUS 연계 패턴

WebToB http.m(개념 예 — 섹션·키 문법은 버전별 확인 필요). 수정 후 wscfl -i http.m 로 컴파일(wsconfig), wsboot/wsdown 으로 기동/정지.

*DOMAIN
ongo_web

*NODE
web01   WEBTOBDIR="/app/webtob", SHMKEY=54000,
        DOCROOT="/app/web/static",
        PORT="80", HTH=2,
        JSVPORT=9900,                 # JEUS 웹엔진이 접속해 오는 포트
        LOGGING="log1", ERRORLOG="log2"

*SVRGROUP
htmlg   SVRTYPE=HTML
jsvg    SVRTYPE=JSV

*SERVER
MS01    SVGNAME=jsvg, MinProc=10, MaxProc=20
MS02    SVGNAME=jsvg, MinProc=10, MaxProc=20
MS03    SVGNAME=jsvg, MinProc=10, MaxProc=20
MS04    SVGNAME=jsvg, MinProc=10, MaxProc=20

*URI
api     Uri="/api/",      SvrType=JSV          # /api/** → JEUS (서버 그룹 내 분산)
act     Uri="/actuator/", SvrType=HTML, Deny   # 관리 엔드포인트 외부 차단(차단 문법 확인 필요)

*EXT
html    MimeType="text/html", SvrType=HTML
js      MimeType="application/javascript", SvrType=HTML
css     MimeType="text/css", SvrType=HTML

JEUS 측(각 MS 웹 엔진 Web Connection, 개념):

웹 엔진 > Web Connections > WebToB Connector 추가
  name               : webtob1
  server-address     : 10.0.0.21         # WebToB 서버
  port               : 9900              # = JSVPORT
  registration-id    : MS01              # = WebToB *SERVER 이름 (MS 별로 다르게)
  thread-pool min/max: 10 / 20           # = MinProc/MaxProc 와 맞춤
  (WebToB 2대면 커넥터 2개: webtob1, webtob2)

연계 패턴 비교 | 패턴 | 구성 | 사용 시점 | |---|---|---| | ① 전량 JEUS | Uri="/" → JSV | 정적 리소스가 WAR 안에 있을 때(비권장, WAS 부하) | | ② 정적 WebToB + API JEUS | *EXT 정적 + Uri="/api/" JSV | 표준. Nuxt 분리 FE 빌드물(docker/demo-web 참고) 은 DOCROOT 에 배포 | | ③ 앱별 서버 그룹 | jsvg_a, jsvg_b 로 URI 분기 | 한 WebToB 로 여러 업무 앱 서비스 | | ④ WebToB → 실행형 jar | HTTP 프록시(확인 필요) | 하이브리드(B.2.3) | - 커넥터 수·MaxProc 는 JEUS 요청 스레드 수와 맞춘다. WebToB MaxProc 합 > JEUS 스레드면 대기열이 WebToB 에 쌓여 타임아웃. - 클라이언트 IP: WebToB 가 X-Forwarded-For 를 넘기는지(또는 JEUS 커넥터가 원 IP 를 보존하는지) 확인 필요. 프레임 감사 로그·IP 제한에 영향.

B.8 세션·sticky

  • WebToB/JEUS 는 세션 라우팅(세션 ID 에 서버 식별자 부여) 기반 sticky 와 JEUS 세션 클러스터링을 지원한다(방식·설정은 버전별 확인 필요).
  • 프레임 인증 세션은 HttpSession 이 아니라 SessionStoreService 를 사용하므로 JEUS 세션 클러스터링만으로는 프레임 로그인이 공유되지 않는다 → Redis/DB SessionStoreService 교체(§1.5). 교체 후에는 sticky·세션 클러스터링을 꺼도 된다(불필요한 복제 부하 제거).
  • 쿠키: context-root 와 쿠키 Path, Secure/SameSite 속성은 WebToB TLS 종료 여부에 맞춰 설정.

B.9 MS JVM 옵션(JEUS 서버 설정의 JVM 옵션 항목)

-Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxMetaspaceSize=512m
-Dspring.profiles.active=prod
-Dspring.config.additional-location=file:/app/ongo/config/
-Dlogging.file.path=/app/jeus/domains/<domain>/servers/MS01/logs/app
-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/jeus/dumps/MS01
-Xlog:gc*:file=/app/jeus/domains/<domain>/servers/MS01/logs/gc.log:time:filecount=10,filesize=20m   (JDK 8 은 -Xloggc)
  • 비밀값은 JVM 옵션에 평문으로 넣지 않는다(관리 콘솔·ps 에 노출). JNDI·JEUS 암호화 프로퍼티·OS 환경변수 사용.

B.10 로깅 — 도메인 로그와 앱 로그 분리

로그 위치(일반) 내용
JEUS 서버 로그 $JEUS_HOME/domains/<domain>/servers/<MS>/logs/JeusServer.log (확인 필요) 기동·배포·스레드
JEUS 접근 로그 웹 엔진 access log 요청
앱 로그 -Dlogging.file.path=.../<MS>/logs/app 프레임·업무 로그
WebToB 로그 $WEBTOBDIR/log/ 접근·에러
- 앱 로그를 JEUS 로그 디렉터리 하위에 두면 운영팀의 기존 수집·보관 정책을 그대로 탄다. 단 파일은 분리(서로 다른 롤링 정책).
- 상관 ID: WebToB 에서 X-Correlation-Id 를 생성·전달할 수 없으면 프레임 CorrelationIdGenerator 가 신규 발급한다. 웹 로그와 WAS 로그 연결은 시각·URI·클라이언트 IP 로 대조.

B.11 배포·graceful undeploy 절차(클러스터 롤링)

  1. 아티팩트 반입: ongo-app-<ver>.war + sha256 검증 → DAS 배포 저장소에 업로드.
  2. MS01 을 WebToB 분산에서 제외(WebToB 관리 명령으로 해당 서버 down/suspend, 또는 JEUS 서버 suspend — 신규 요청 차단·진행 요청 완료, 명령 문법 확인 필요).
  3. 진행 요청 소진 대기(20~30s, JEUS 스레드 모니터에서 active 0 확인).
  4. MS01 정지 → 애플리케이션 교체(undeploy → deploy, 또는 redeploy 대상 MS 지정) → MS01 기동. 핫 리디플로이(서버 무정지 교체)는 운영 지양 — 클래스로더 누수.
  5. 헬스: curl -fs http://was01:<MS01 http 리스너>/actuator/health/readiness (관리 포트 분리 시 해당 포트). WebToB 경유 스모크 curl -fs https://app/api/....
  6. WebToB 재투입 → 5분 관찰 → MS02… 반복.
  7. 실패 시: 해당 MS 에 이전 WAR 재배포(DAS 에 직전 버전 보관 필수) → 기동 → 재투입, 배포 중단.

jeusadmin 사용 예(개념 — 명령·옵션명은 버전별 확인 필요)

jeusadmin -host das01:9736 -u administrator -f /app/secure/jeus.pw <<'CMD'
suspend-server MS01
stop-server MS01
undeploy ongo-app -servers MS01
install-application /deploy/ongo-app-1.4.0.war -id ongo-app
deploy ongo-app -servers MS01
start-server MS01
resume-server MS01
CMD
  • 비밀번호는 -p 인자로 넣지 말고 권한 600 파일/벤더 암호화 방식 사용.

B.12 자주 나는 장애(Failure modes)

증상 가능 원인 확인 조치
기동 시 ClassNotFoundException: jakarta.servlet.Filter Boot3 WAR 를 javax WAS 에 배포 JEUS 서블릿 스펙 Boot2 라인으로 재빌드
기동 시 SpringBootServletInitializer 미호출, 404 초기화 클래스 누락/war 플러그인 미적용 WAR 내 클래스 B.3
WebToB 503 / "no available server" JEUS 커넥터 미접속(registration-id 불일치, 방화벽 JEUS→WebToB) WebToB 접속 서버 목록, JEUS 로그 이름·포트·방화벽
응답 지연 후 WebToB 타임아웃 MaxProc > JEUS 스레드, 또는 DB 풀 고갈 스레드 덤프, 풀 모니터 수치 정렬, §1.4
한글 깨짐 WebToB/JEUS/앱 문자셋 불일치, EUC-KR 전문 요청 인코딩 필터 UTF-8 통일, 전문은 채널 charset
업로드 413/실패 3곳(WebToB·JEUS·Spring multipart) 한도 불일치 각 설정 일치
로그인 풀림(인스턴스 이동 시) 인메모리 SessionStore 구현 빈 Redis/DB 교체
재배포 반복 후 OOM Metaspace 핫 리디플로이 누수 힙덤프 classloader MS 재기동 배포
/actuator 외부 노출 URI 차단 누락 외부 curl WebToB URI 차단 + 관리 포트 분리
클라이언트 IP 가 WebToB IP XFF 미전달/미신뢰 앱 로그 XFF 전달 + forward-headers-strategy

B.13 체크리스트(B)

  • □ B.2.1 매트릭스 12개 항목 증빙 완료 → 라인(Boot2/Boot3)·JDK 확정
  • □ WAR: 서블릿/EL/JTA API 미포함, 내장 톰캣 providedRuntime, ServletInitializer
  • □ jeus-web-dd.xml context-root·클래스 우선 로딩
  • □ 공용 lib 충돌 기동 검증(-Xlog:class+load 1회)
  • □ JNDI 이름·풀 크기(MS 수 × max ≤ DB 세션 80%)
  • □ WebToB http.m URI/EXT, JSVPORT, MinProc/MaxProc = JEUS 스레드
  • □ JEUS 커넥터 registration-id = WebToB SERVER 이름, 방화벽 JEUS→WebToB
  • □ 타임아웃 WebToB > JEUS > 앱 아웃바운드 > DB
  • □ multipart 한도 3곳 일치, 문자셋 UTF-8
  • □ SessionStore 공유 구현, JEUS 세션 클러스터링 필요성 재검토
  • □ 앱 로그 경로 MS 별 분리, 수집 대상 등록
  • □ 롤링 절차(suspend→stop→deploy→start→health→resume) 리허설, 이전 WAR 보관
  • □ devtools/openapi 운영 제외, /actuator 외부 차단

C. 중앙 관리 시스템 + 에이전트 원격 쉘

중앙 시스템이 서버 상주 에이전트로 쉘을 내리면 서버가 단계 스크립트를 수행하고 종료코드·로그를 회신해요.

중앙 시스템 ──(작업: svc, version, sha256)──▶ 에이전트(서버)
  fetch.sh → verify(sha256) → stop.sh → switch(current 링크) → start.sh → health.sh
                                                         └─ 실패 시 rollback.sh
  ◀── 종료코드 + 로그 tail ──
종료코드의미중앙 시스템 처리
0성공다음 서버 진행
10아티팩트 취득/검증 실패중단(서비스 영향 없음)
20기동 실패rollback 자동 수행 후 중단
30헬스 실패rollback 자동 수행 후 중단
40롤백 실패즉시 수동 개입 알림
#!/usr/bin/env bash
# health.sh — readiness 대기
set -euo pipefail
PORT=${1:?port}; TRIES=${2:-60}
for i in $(seq 1 "$TRIES"); do
  curl -fsS "http://127.0.0.1:${PORT}/actuator/health/readiness" | grep -q '"UP"' && exit 0
  sleep 2
done
exit 30

삼카: 중앙 시스템 = ARM(자원관리·빌드/배포 관리), 에이전트 = 데몬형 oden. ARM 이 oden 으로 쉘을 내리면 각 서버가 위 단계 스크립트를 수행하고 종료코드를 ARM 에 회신해요. 실행 계정·sudo 범위·환경변수 전달·작업 타임아웃·회신 로그 크기·아티팩트 저장소 위치는 ARM/oden 운영팀 확인 필요. 표준전문 채널 yml 은 서버별 외부 설정으로 두고, 보관·롤백·비밀값 정책은 정본 §C 삼카 부록을 따르세요.

상세 — C. On-premise — 중앙 관리 시스템 + 에이전트(원격 쉘) 빌드/배포

적용: 서버마다 데몬형 에이전트가 상주하고, 중앙의 자원관리·빌드/배포 관리 시스템이 에이전트에게 쉘 명령을 내리면 각 서버가 그 쉘을 실행해 빌드·배포를 수행하는 구조. A 또는 B 와 조합된다. 중앙 시스템이 결재·이력·스케줄을, 서버 쉘이 실제 작업을 담당한다.

C.1 흐름

sequenceDiagram
  autonumber
  participant Dev as 개발자
  participant CM as 중앙 관리 시스템
  participant BA as 빌드 서버 에이전트
  participant REPO as 아티팩트 저장소(Nexus/NAS)
  participant WA as WAS 서버 에이전트(N대)
  Dev->>CM: 배포 요청(브랜치/태그, 대상 환경)
  CM->>CM: 결재/승인
  CM->>BA: build.sh <tag> 실행 명령
  BA->>BA: fetch → test → package → sha256 → BUILD-INFO
  BA->>REPO: 아티팩트 업로드(불변 버전)
  BA-->>CM: exit 0 + 로그 + 버전
  loop 대상 서버·인스턴스 순차
    CM->>WA: deploy.sh <svc> <ver> <inst>
    WA->>REPO: 다운로드 + sha256 검증
    WA->>WA: lb_out → stop → switch → start → health
    alt health 실패
      WA->>WA: rollback.sh
      WA-->>CM: exit 20 (중단)
    else 성공
      WA-->>CM: exit 0
    end
  end
  CM-->>Dev: 결과 통지·이력 저장

C.2 스크립트 계약(중앙 시스템과의 인터페이스)

항목 규칙
셸 #!/usr/bin/env bash + set -Eeuo pipefail + trap 로 실패 위치 출력
인자 위치 인자 고정(<svc> <version> [instance]), 누락 시 exit 2
종료코드 0 성공 / 2 인자 오류 / 10 빌드 실패 / 11 테스트 실패 / 12 아티팩트 검증 실패 / 20 헬스 실패(롤백 완료) / 21 롤백 실패(긴급) / 30 락 획득 실패
출력 한 줄 1이벤트 [ts KST][stage][level] msg. 마지막 줄에 RESULT=<OK|FAIL> VERSION=<v> (중앙 시스템 파싱용)
멱등 같은 인자로 재실행해도 결과 동일(이미 배포된 버전이면 헬스만 확인 후 0)
동시성 flock /app/<svc>/.deploy.lock 으로 서버 단위 직렬화
타임아웃 각 단계 자체 타임아웃(timeout 300 ...), 중앙 시스템 타임아웃보다 짧게
로그 크기 중앙 회신 로그 한도 고려, 상세는 서버 logs/deploy/<ts>.log 에 남기고 tail 200줄만 표준출력
실행 계정 배포 전용 계정(예 ongodeploy), sudo 는 systemctl start|stop|restart ongo-app@* 만 허용

/etc/sudoers.d/ongodeploy

ongodeploy ALL=(root) NOPASSWD: /usr/bin/systemctl start ongo-app@*, /usr/bin/systemctl stop ongo-app@*, /usr/bin/systemctl restart ongo-app@*, /usr/bin/systemctl status ongo-app@*

C.3 공통 라이브러리 (bin/lib.sh)

#!/usr/bin/env bash
set -Eeuo pipefail
export TZ=Asia/Seoul
LOG_DIR=${LOG_DIR:-/app/${SVC:-ongo-app}/logs/deploy}; mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/$(date +%Y%m%d-%H%M%S)-${STAGE:-job}.log"
log(){ printf '[%s][%s][%s] %s\n' "$(date '+%F %T KST')" "${STAGE:-?}" "$1" "$2" | tee -a "$LOG_FILE"; }
die(){ log ERROR "$2"; echo "RESULT=FAIL CODE=$1 VERSION=${VER:-?}"; exit "$1"; }
trap 'log ERROR "line $LINENO: $BASH_COMMAND"' ERR
with_lock(){ exec 9>"/app/$SVC/.deploy.lock"; flock -w 60 9 || die 30 "다른 배포 진행 중"; }

C.4 단계별 샘플 쉘

① fetch + build (build.sh, 빌드 서버)

#!/usr/bin/env bash
STAGE=build; source "$(dirname "$0")/lib.sh"
SVC=${1:?}; REF=${2:?git tag/sha}; MODULE=${3:?gradle module}; JDK=${4:-17}
WS=/build/ws/$SVC; mkdir -p "$WS"
# fetch — 태그/sha 고정 빌드(브랜치 HEAD 빌드 금지)
if [[ ! -d $WS/.git ]]; then git clone --no-tags "$GIT_URL" "$WS"; fi
cd "$WS" && git fetch --tags --force origin && git checkout --force "$REF" && git clean -fdx
SHA=$(git rev-parse --short=12 HEAD); VER="$(date +%Y%m%d%H%M)-$SHA"
# build — 사내 Gradle 미러/오프라인 캐시 사용(폐쇄망)
export GRADLE_USER_HOME=/build/gradle-home
timeout 1200 ./gradlew --no-daemon -PongoJavaRelease="$JDK" ":$MODULE:test" || die 11 "테스트 실패"
timeout 900  ./gradlew --no-daemon -PongoJavaRelease="$JDK" ":$MODULE:bootJar" -x test || die 10 "빌드 실패"
JAR=$(ls "$MODULE"/build/libs/*.jar | grep -v -- '-plain.jar' | head -1)
OUT=/build/out/$SVC/$VER; mkdir -p "$OUT"; cp "$JAR" "$OUT/app.jar"
( cd "$OUT" && sha256sum app.jar > app.jar.sha256 )
cat > "$OUT/BUILD-INFO" <<INFO
svc=$SVC
version=$VER
git.ref=$REF
git.sha=$(git rev-parse HEAD)
built.at=$(date '+%F %T KST')
jdk=$(java -version 2>&1 | head -1)
INFO
# 업로드 (Nexus raw 예) — 자격증명은 에이전트 계정의 ~/.netrc(600) 또는 중앙 시스템이 주입한 env
curl -fsS --netrc -T "$OUT/app.jar"        "$NEXUS/repository/ongo-raw/$SVC/$VER/app.jar"
curl -fsS --netrc -T "$OUT/app.jar.sha256" "$NEXUS/repository/ongo-raw/$SVC/$VER/app.jar.sha256"
curl -fsS --netrc -T "$OUT/BUILD-INFO"     "$NEXUS/repository/ongo-raw/$SVC/$VER/BUILD-INFO"
log INFO "built $VER"; echo "RESULT=OK VERSION=$VER"

Boot2 라인은 :$MODULE:installDist 후 build/install/<app> 을 tar.gz 로 묶어 업로드. JEUS([B])는 bootWar → app.war.

② stop / copy / start / health (deploy.sh, WAS 서버)

#!/usr/bin/env bash
STAGE=deploy; source "$(dirname "$0")/lib.sh"
SVC=${1:?}; VER=${2:?}; INST=${3:?}; BASE=/app/$SVC; REL=$BASE/releases/$VER
with_lock
# copy(다운로드 + 검증)
if [[ ! -f $REL/app.jar ]]; then
  mkdir -p "$REL.tmp"
  for f in app.jar app.jar.sha256 BUILD-INFO; do
    curl -fsS --netrc -o "$REL.tmp/$f" "$NEXUS/repository/ongo-raw/$SVC/$VER/$f" || die 12 "다운로드 실패 $f"
  done
  ( cd "$REL.tmp" && sha256sum -c app.jar.sha256 ) || die 12 "sha256 불일치"
  mv "$REL.tmp" "$REL"
fi
CUR=$(readlink -f "$BASE/current-$INST" || true)
[[ $CUR == "$REL" ]] && { "$(dirname "$0")/health.sh" "$INST" && { echo "RESULT=OK VERSION=$VER (already)"; exit 0; }; }
# stop
"$(dirname "$0")/lb.sh" out "$INST"; sleep "${DRAIN_SEC:-20}"
sudo systemctl stop "$SVC@$INST"
# switch
[[ -n $CUR ]] && ln -sfn "$CUR" "$BASE/previous-$INST"
ln -sfn "$REL" "$BASE/current-$INST"
# start + health
sudo systemctl start "$SVC@$INST"
if ! "$(dirname "$0")/health.sh" "$INST"; then
  log ERROR "health 실패 → 롤백"
  "$(dirname "$0")/rollback.sh" "$SVC" "$INST" || die 21 "롤백 실패(긴급)"
  die 20 "헬스 실패, 롤백 완료"
fi
"$(dirname "$0")/lb.sh" in "$INST"
log INFO "deployed $VER to $INST"; echo "RESULT=OK VERSION=$VER"

③ rollback (rollback.sh)

#!/usr/bin/env bash
STAGE=rollback; source "$(dirname "$0")/lib.sh"
SVC=${1:?}; INST=${2:?}; BASE=/app/$SVC
PREV=$(readlink -f "$BASE/previous-$INST") || die 21 "previous 없음"
sudo systemctl stop "$SVC@$INST" || true
ln -sfn "$PREV" "$BASE/current-$INST"
sudo systemctl start "$SVC@$INST"
"$(dirname "$0")/health.sh" "$INST" || die 21 "롤백 후에도 비정상"
"$(dirname "$0")/lb.sh" in "$INST"
echo "RESULT=OK VERSION=$(basename "$PREV") (rolled back)"

④ lb.sh — nginx upstream 토글 예(L4 는 장비 API/운영 요청으로 대체)

#!/usr/bin/env bash
# lb.sh out|in inst1  — web 서버에 ssh 하거나 중앙 시스템이 web 에이전트로 별도 명령
ACTION=$1; INST=$2; HOSTPORT="$(hostname -i):$(grep ^PORT= /app/ongo-app/env/$INST.env | cut -d= -f2)"
ssh web01 "sudo /app/nginx/bin/upstream-toggle.sh $ACTION $HOSTPORT && sudo nginx -s reload"
  • systemd 유닛은 §A.6 템플릿에서 WorkingDirectory/-jar 경로를 current-%i 로 바꿔 인스턴스별 독립 링크를 쓴다.
  • [B] JEUS 대상이면 deploy.sh 의 stop/switch/start 를 §B.11 jeusadmin 시퀀스로 교체하고, 아티팩트를 DAS 서버 에이전트에서만 다룬다.

C.5 아티팩트 보관(retention)

위치 보관 이유
아티팩트 저장소(Nexus/NAS) 운영 반영 버전 영구(또는 1년), 미반영 빌드 30일 감사·재현
WAS releases/ 최근 5개 빠른 롤백
빌드 서버 out/ 7일 디스크
배포 로그 logs/deploy/ 90일 이력
# cleanup.sh — 현재/이전 링크가 가리키는 릴리스는 절대 삭제하지 않는다
KEEP=5; BASE=/app/$1
PROTECT=$(for l in "$BASE"/current-* "$BASE"/previous-*; do readlink -f "$l"; done | sort -u)
ls -1dt "$BASE"/releases/* | tail -n +$((KEEP+1)) | grep -vxF "$PROTECT" | xargs -r rm -rf
find "$BASE/logs/deploy" -type f -mtime +90 -delete

C.6 롤백 정책

  • 단일 인스턴스 실패: deploy.sh 가 자동 롤백(exit 20) → 중앙 시스템은 후속 인스턴스 중단.
  • 전체 반영 후 장애: 중앙 시스템에서 「직전 버전으로 배포」 작업을 새로 실행(= 이전 VER 로 deploy.sh 롤링). 롤백도 결재/이력 대상.
  • exit 21(롤백 실패)은 자동화 중단 + 담당자 호출(서버 접속해 수동 복구: ls releases, 정상 버전으로 링크 고정, 기동 로그 확인).

C.7 비밀값 처리

  • 스크립트·중앙 시스템 작업 정의에 비밀번호 평문 금지. 중앙 시스템이 비밀 변수 기능을 제공하면 마스킹된 env 로 주입, 아니면 서버의 env/common.env(600) 를 서버 담당자가 직접 관리.
  • 저장소 접근은 읽기 전용 배포 토큰(~/.netrc 600, 에이전트 계정 소유). Git 은 deploy key(읽기 전용).
  • set -x 금지(비밀값이 로그로 회신됨). 디버그가 필요하면 log 함수로 키 이름만 출력.
  • 중앙 회신 로그·서버 로그에 토큰/카드번호가 찍히지 않는지 마스킹(프레임 MaskingService, 가이드 #security) 점검.

C.8 체크리스트(C)

  • □ 에이전트 실행 계정·sudo 허용 명령 최소화
  • □ 종료코드 계약을 중앙 시스템 단계 판정 규칙에 등록
  • □ 태그/sha 고정 빌드, 아티팩트 sha256 검증
  • □ 인스턴스별 current-*/previous-* 링크, cleanup 보호
  • □ flock 직렬화, 단계별 timeout < 중앙 타임아웃
  • □ 비밀값 주입 경로 확정, set -x 미사용
  • □ 롤백 실패(21) 시 호출 체계

C.9 삼카 부록 — ARM + oden

  • 삼카 On-premise 빌드/배포는 자원관리·빌드/배포 관리 시스템 ARM 에서 진행한다. 각 서버에는 데몬형 솔루션 oden 이 상주하고, ARM 이 oden 으로 쉘 명령을 내리면 해당 서버가 쉘을 수행하며 빌드/배포를 처리한다.
  • 매핑: 중앙 관리 시스템 = ARM, 서버 에이전트 = oden. §C.4 의 build.sh(빌드 서버 oden) / deploy.sh·rollback.sh(WAS 서버 oden) 를 ARM 작업 단계로 등록하고, §C.2 종료코드로 단계 성공/실패를 판정한다. 마지막 줄 RESULT=... 를 ARM 결과 메시지로 사용.
  • 권장 ARM 작업 구성: ①빌드(빌드 서버) → ②결재 → ③was01 inst1 배포(카나리) → ④관찰/확인 → ⑤나머지 인스턴스 순차 → ⑥결과 통지.
  • 확인 필요(ARM/oden 운영팀): oden 실행 계정·sudo 범위, 기본 작업 디렉터리, 환경변수/비밀 변수 전달 방식, 단계 타임아웃, 표준출력 회신 크기 한도, 아티팩트 저장 위치(ARM 자체 저장소 여부), 결재 단계 위치, 동시 실행 제어 기능 유무.
  • 삼카 표준전문 채널 설정(ongo.framework.samsung-card.*)·EUC-KR charset 은 서버별 외부 yml(config/application-prod.yml)로 관리(가이드 #samsungcard).

D. PaaS — Dockerfile + CI/CD

docker buildx build -f docker/app/Dockerfile \
  --build-context src=. --build-arg MODULE=app --build-arg RUNTIME_JDK=21 \
  -t registry.example.com/ongo/my-biz-app:$(git rev-parse --short=12 HEAD) --push .
CI핵심
JenkinsDeclarative + K8s agent, stage('test')→('image')→('scan')→('push')→('deploy-dev'), prod 는 input 승인 단계
GitHub Actionssetup-java+gradle 캐시, docker/build-push-action, OIDC 로 레지스트리 로그인, environment: prod 보호 규칙으로 승인
GitLab CIKaniko 이미지 빌드(/kaniko/executor), rules 로 브랜치 분기, when: manual+protected environment 로 prod 승인
# K8s Deployment 핵심
strategy: { type: RollingUpdate, rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } }
readinessProbe: { httpGet: { path: /actuator/health/readiness, port: 8080 }, periodSeconds: 5 }
livenessProbe:  { httpGet: { path: /actuator/health/liveness,  port: 8080 }, initialDelaySeconds: 60 }
lifecycle: { preStop: { exec: { command: ["sh","-c","sleep 10"] } } }
terminationGracePeriodSeconds: 45
envFrom: [ { configMapRef: { name: my-biz-app-config } } ]
volumeMounts: [ { name: secrets, mountPath: /run/secrets/app, readOnly: true } ]   # configtree

공통 단계: test → 스캔 → 이미지(sha 태그) → push → 매니페스트 갱신 → 스모크 → 승인 승격(같은 이미지). 전체 Jenkinsfile·workflow·.gitlab-ci.yml 은 정본 §D.

상세 — D. PaaS(클라우드) — Dockerfile + CI/CD 파이프라인

적용: 컨테이너 플랫폼(Kubernetes/OpenShift/관리형 K8s 또는 Docker 호스트)에 이미지로 배포. CI 도구(Jenkins·GitHub Actions·GitLab CI 등)는 무엇이든 단계는 같다.

D.1 레퍼런스 Dockerfile 이해 (docker/app/Dockerfile)

  • 빌드 컨텍스트 = docker/app, 소스 = 추가 컨텍스트 src(--build-context src=<repo> / compose additional_contexts). ARG MODULE(예 ongo-framework-generic-demo, ongo-framework-sc-demo, 배치 데모), ARG RUNTIME_JDK(17/21), ARG BASE_REGISTRY(사내 미러).
  • 런타임이 JDK 인 이유: 데모의 devtools 동적 VO 컴파일(ToolProvider.getSystemJavaCompiler()). 운영 앱은 devtools 미포함이므로 JRE 로 바꾼다.
  • Boot2: docker/app-boot2/Dockerfile(installDist, JDK 8/11). 백엔드 데모는 WAS 전용(앞단 도커 nginx docker/web 2026-10-30 폐지 — 이미지 빌드 1개 감소, TLS·Host 는 Cloudflare+NPM 이 담당, NPM → WAS AA01N 직결, 스모크 GET /api/runtime). 분리 FE: docker/demo-web(Nuxt, devfw-web :12003 → /api 12012) · docker/demo-web-custom(같은 빌드, devfw-web-custom :12004 → /api 12014). 데모별 compose: docker/{generic-demo,sc-demo,sc-custom-demo,boot2-*,boot3-j17-*}/compose.yaml. sc-custom-demo(APP ongo-sc-custom-demo, AA12 N4 WAS 12014, devfw21-sc-custom.ongosoft.com) = sc-demo 를 custom 프로필로 띄운 별도 참조 케이스(기본 ongo-sc-demo 그대로). FE 도 사이트로 분리 — devfw-web 은 sc, devfw-web-custom 은 sc-custom 으로 /api/** 전체를 보낸다.

D.2 운영용 멀티스테이지 Dockerfile 패턴

패턴 1 — 소스부터 빌드(자체 완결, 레퍼런스와 동일 구조 + 운영 하드닝)

# syntax=docker/dockerfile:1.7
ARG BASE_REGISTRY=docker.io
ARG RUNTIME_JDK=21
FROM ${BASE_REGISTRY}/library/eclipse-temurin:21-jdk AS build
ARG MODULE
ARG RUNTIME_JDK
WORKDIR /src
# 의존성 캐시 레이어: 빌드 스크립트만 먼저 복사
COPY --from=src gradlew settings.gradle build.gradle gradle.properties ./
COPY --from=src gradle ./gradle
RUN --mount=type=cache,target=/root/.gradle ./gradlew --no-daemon dependencies > /dev/null || true
COPY --from=src . .
RUN --mount=type=cache,target=/root/.gradle \
    ./gradlew --no-daemon --console=plain ":${MODULE}:bootJar" -x test -PongoJavaRelease=${RUNTIME_JDK} \
 && cp "$(ls ${MODULE}/build/libs/*.jar | grep -v -- '-plain' | head -1)" /app.jar \
 && mkdir /layers && cd /layers && java -Djarmode=tools -jar /app.jar extract --layers --launcher --destination .
#   Boot 3.3 미만: java -Djarmode=layertools -jar /app.jar extract

FROM ${BASE_REGISTRY}/library/eclipse-temurin:${RUNTIME_JDK}-jre
RUN groupadd -r -g 10001 ongo && useradd -r -u 10001 -g ongo ongo
WORKDIR /app
COPY --from=build /layers/dependencies/ ./
COPY --from=build /layers/spring-boot-loader/ ./
COPY --from=build /layers/snapshot-dependencies/ ./
COPY --from=build /layers/application/ ./
USER 10001
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Seoul" \
    SPRING_PROFILES_ACTIVE=prod
EXPOSE 8080 18080
# Docker 단독 실행용(K8s 는 probe 사용, HEALTHCHECK 무시됨)
HEALTHCHECK --interval=15s --timeout=3s --start-period=60s --retries=4 \
  CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/18080 && printf "GET /actuator/health/readiness HTTP/1.0\r\n\r\n" >&3 && grep -q UP <&3'
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher \"$@\"","--"]
#   Boot 3.2 미만/Boot2: org.springframework.boot.loader.JarLauncher

패턴 2 — CI 가 jar 를 만들고 Dockerfile 은 런타임만(Kaniko/Buildah·캐시 단순)

ARG BASE_REGISTRY=docker.io
ARG RUNTIME_JDK=21
FROM ${BASE_REGISTRY}/library/eclipse-temurin:${RUNTIME_JDK}-jre
RUN groupadd -r -g 10001 ongo && useradd -r -u 10001 -g ongo ongo
COPY build/app.jar /app/app.jar
USER 10001
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /app/app.jar \"$@\"","--"]
선택 패턴 1 패턴 2
재현성 Dockerfile 만으로 재현 CI 단계 의존
추가 컨텍스트 BuildKit 필요(--build-context) 불필요(Kaniko 호환)
테스트 별도 CI 단계 jar 빌드 단계에서 함께
권장 Docker/BuildKit CI Tekton+Kaniko, 폐쇄망

Boot2 이미지: docker/app-boot2/Dockerfile 처럼 installDist 결과(bin/, lib/)를 복사하고 ENTRYPOINT ["/app/bin/<app>"], 런타임 eclipse-temurin:8-jre 또는 11-jre. JDK 8 은 컨테이너 메모리 인식이 8u191+ 에서 기본 활성 — 그 이전이면 -XX:+UseContainerSupport 확인 필요.

D.3 이미지 규칙

  • 태그: 불변 <git-sha12>(+ 선택적으로 <semver>). 운영 매니페스트는 태그 대신 digest(@sha256:...) 고정 권장. latest 금지.
  • 비루트 UID(10001), 읽기 전용 루트 FS 가능하도록 쓰기 경로는 /tmp 볼륨(emptyDir)만.
  • 베이스 이미지는 사내 미러(BASE_REGISTRY)에서만, 정기 재빌드(OS CVE 패치)와 스캔(Trivy/Grype) 게이트(Critical 0).
  • .dockerignore: **/build, .git, **/node_modules, *.env.

D.4 환경변수·시크릿

종류 Docker/compose Kubernetes
일반 설정 environment: / env_file: ConfigMap → envFrom 또는 /config 마운트 + SPRING_CONFIG_ADDITIONAL_LOCATION
비밀값 Docker secrets(/run/secrets/*) + spring.config.import=configtree:/run/secrets/ Secret(External Secrets/Sealed Secrets 로 생성) → env 또는 configtree: 마운트
프로파일 SPRING_PROFILES_ACTIVE 동일
- 키 매핑: spring.datasource.password ↔ SPRING_DATASOURCE_PASSWORD(relaxed binding). 프레임 키도 동일(ONGO_FRAMEWORK_...).
- 비밀값을 ARG/ENV 로 이미지에 굽지 않는다(이미지 히스토리에 남음).

D.5 compose vs Kubernetes

항목 docker compose (레퍼런스 docker/*/compose.yaml) Kubernetes
용도 단일 호스트 dev/데모, 소규모 스테이징·운영
다중화 deploy.replicas(swarm) 또는 서비스 복제 + nginx Deployment replicas + Service
헬스 healthcheck + depends_on: service_healthy readiness/liveness/startup probe
무중단 수동(재생성 시 순단) RollingUpdate
시크릿 env_file/secrets Secret/ESO
배치 --profile batch run --rm Job/CronJob
레퍼런스 compose 의 WAS healthcheck 는 TCP 포트 확인(/dev/tcp)이다. 운영은 actuator readiness 로 강화.

D.6 CI 공통 단계

flowchart LR
  A[checkout] --> B[test<br/>gradlew test]
  B --> C[SAST/SCA<br/>의존성 취약점]
  C --> D[build image<br/>tag=sha]
  D --> E[image scan<br/>Critical 0]
  E --> F[push registry]
  F --> G[deploy dev<br/>매니페스트 갱신]
  G --> H[smoke/health]
  H --> I{승인}
  I --> J[promote stg → prod<br/>같은 digest]

원칙: 한 번 빌드한 이미지(같은 digest)를 환경만 바꿔 승격한다. 환경별 재빌드 금지.

D.7 Jenkins (Declarative) 스케치

pipeline {
  agent { kubernetes { yamlFile 'ci/jenkins-pod.yaml' } }   // jdk + buildkit/kaniko 컨테이너
  options { timestamps(); disableConcurrentBuilds(); timeout(time: 40, unit: 'MINUTES') }
  parameters { choice(name: 'MODULE', choices: ['ongo-framework-generic-demo','my-biz-app'], description: '빌드 모듈') }
  environment {
    REG = 'registry.example.com/ongo'
    SVC = 'my-biz-app'
    TAG = "${env.GIT_COMMIT.take(12)}"
  }
  stages {
    stage('Test') { steps { container('jdk') { sh './gradlew --no-daemon test' } }
      post { always { junit '**/build/test-results/test/*.xml' } } }
    stage('Scan deps') { steps { container('jdk') { sh './gradlew --no-daemon dependencyCheckAnalyze || true' } } }
    stage('Image') { steps { container('buildkit') {
      withCredentials([usernamePassword(credentialsId: 'registry', usernameVariable: 'U', passwordVariable: 'P')]) {
        sh '''
          buildctl build --frontend dockerfile.v0 --local context=docker/app --local dockerfile=docker/app \
            --local src=. --opt context:src=local:src --opt build-arg:MODULE=$MODULE \
            --output type=image,name=$REG/$SVC:$TAG,push=true
        '''
      } } } }
    stage('Image scan') { steps { sh 'trivy image --exit-code 1 --severity CRITICAL $REG/$SVC:$TAG' } }
    stage('Deploy dev') { steps { sh './ci/bump-image.sh dev $REG/$SVC:$TAG' } }   // GitOps 커밋 또는 kubectl set image
    stage('Smoke dev')  { steps { sh './ci/smoke.sh https://dev.example.com' } }
    stage('Approve prod') { when { branch 'main' } steps { timeout(time: 1, unit: 'DAYS') { input message: 'prod 반영 승인?', submitter: 'release-approvers' } } }
    stage('Deploy prod') { when { branch 'main' } steps { sh './ci/bump-image.sh prod $REG/$SVC:$TAG' } }
  }
  post { failure { echo '실패 알림(메신저/메일 연동)' } }
}

D.8 GitHub Actions 스케치

name: ci
on:
  push: { branches: [develop, main] }
  pull_request:
permissions: { contents: read, packages: write, id-token: write }
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }
env: { IMAGE: ghcr.io/${{ github.repository_owner }}/my-biz-app, MODULE: ongo-framework-generic-demo }
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: gradle }
      - run: ./gradlew --no-daemon test
  image:
    needs: test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    outputs: { digest: ${{ steps.push.outputs.digest }} }
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
      - id: push
        uses: docker/build-push-action@v6
        with:
          context: docker/app
          file: docker/app/Dockerfile
          build-contexts: src=.
          build-args: MODULE=${{ env.MODULE }}
          tags: ${{ env.IMAGE }}:${{ github.sha }}
          push: true
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - uses: aquasecurity/[email protected]
        with: { image-ref: '${{ env.IMAGE }}:${{ github.sha }}', severity: CRITICAL, exit-code: '1' }
  deploy-dev:
    needs: image
    runs-on: ubuntu-latest
    environment: dev
    steps: [ { uses: actions/checkout@v4 }, { run: './ci/bump-image.sh dev ${{ env.IMAGE }}@${{ needs.image.outputs.digest }}' } ]
  deploy-prod:
    needs: [image, deploy-dev]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: prod          # Environment protection rule = 승인자 지정
    steps: [ { uses: actions/checkout@v4 }, { run: './ci/bump-image.sh prod ${{ env.IMAGE }}@${{ needs.image.outputs.digest }}' } ]

D.9 GitLab CI 스케치

stages: [test, image, scan, deploy-dev, deploy-prod]
variables: { MODULE: ongo-framework-generic-demo, IMAGE: $CI_REGISTRY_IMAGE }
default: { interruptible: true }
test:
  stage: test
  image: eclipse-temurin:21-jdk
  cache: { key: gradle, paths: [.gradle/caches] }
  variables: { GRADLE_USER_HOME: $CI_PROJECT_DIR/.gradle }
  script: [ "./gradlew --no-daemon test" ]
  artifacts: { reports: { junit: "**/build/test-results/test/*.xml" } }
image:
  stage: image
  image: docker:27
  services: [docker:27-dind]
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
    - docker buildx create --use
    - docker buildx build -f docker/app/Dockerfile --build-context src=. --build-arg MODULE=$MODULE -t $IMAGE:$CI_COMMIT_SHORT_SHA --push docker/app
  rules: [ { if: '$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"' } ]
scan:
  stage: scan
  image: { name: aquasec/trivy:latest, entrypoint: [""] }
  script: [ "trivy image --exit-code 1 --severity CRITICAL $IMAGE:$CI_COMMIT_SHORT_SHA" ]
deploy_dev:
  stage: deploy-dev
  environment: { name: dev }
  script: [ "./ci/bump-image.sh dev $IMAGE:$CI_COMMIT_SHORT_SHA" ]
deploy_prod:
  stage: deploy-prod
  environment: { name: prod }
  when: manual                      # Protected environment 로 승인자 제한
  rules: [ { if: '$CI_COMMIT_BRANCH == "main"' } ]
  script: [ "./ci/bump-image.sh prod $IMAGE:$CI_COMMIT_SHORT_SHA" ]

ci/bump-image.sh (GitOps 저장소 태그 갱신 예)

#!/usr/bin/env bash
set -euo pipefail
ENV=$1; IMG=$2
git clone --depth 1 "https://oauth2:${GITOPS_TOKEN}@git.example.com/infra/gitops.git" /tmp/gitops
cd /tmp/gitops/apps/my-biz-app/overlays/$ENV
kustomize edit set image app=$IMG
git -c user.name=ci -c [email protected] commit -am "deploy(my-biz-app/$ENV): $IMG" && git push

D.10 Kubernetes 매니페스트 기준

apiVersion: apps/v1
kind: Deployment
metadata: { name: my-biz-app, labels: { app: my-biz-app } }
spec:
  replicas: 2
  revisionHistoryLimit: 5
  strategy: { type: RollingUpdate, rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } }
  selector: { matchLabels: { app: my-biz-app } }
  template:
    metadata: { labels: { app: my-biz-app } }
    spec:
      terminationGracePeriodSeconds: 45          # preStop 10s + graceful 30s + 여유
      securityContext: { runAsNonRoot: true, runAsUser: 10001, fsGroup: 10001 }
      topologySpreadConstraints:
        - { maxSkew: 1, topologyKey: kubernetes.io/hostname, whenUnsatisfiable: ScheduleAnyway, labelSelector: { matchLabels: { app: my-biz-app } } }
      containers:
        - name: app
          image: registry.example.com/ongo/my-biz-app@sha256:<digest>
          ports: [ { name: http, containerPort: 8080 }, { name: mgmt, containerPort: 18080 } ]
          env:
            - { name: SPRING_PROFILES_ACTIVE, value: prod }
            - { name: SERVER_PORT, value: "8080" }
            - { name: MANAGEMENT_SERVER_PORT, value: "18080" }
            - { name: SPRING_CONFIG_IMPORT, value: "optional:configtree:/run/secrets/app/" }
          envFrom: [ { configMapRef: { name: my-biz-app-config } } ]
          volumeMounts:
            - { name: secrets, mountPath: /run/secrets/app, readOnly: true }
            - { name: tmp, mountPath: /tmp }
          startupProbe:   { httpGet: { path: /actuator/health/liveness,  port: mgmt }, periodSeconds: 5, failureThreshold: 36 }  # 최대 180s 기동
          readinessProbe: { httpGet: { path: /actuator/health/readiness, port: mgmt }, periodSeconds: 5, failureThreshold: 3 }
          livenessProbe:  { httpGet: { path: /actuator/health/liveness,  port: mgmt }, periodSeconds: 10, failureThreshold: 3 }
          lifecycle: { preStop: { exec: { command: ["sh","-c","sleep 10"] } } }   # 엔드포인트 제거 전파 대기
          resources: { requests: { cpu: 500m, memory: 1Gi }, limits: { memory: 1Gi } }
          securityContext: { readOnlyRootFilesystem: true, allowPrivilegeEscalation: false }
      volumes:
        - { name: secrets, secret: { secretName: my-biz-app-secrets } }
        - { name: tmp, emptyDir: {} }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: my-biz-app }
spec: { minAvailable: 1, selector: { matchLabels: { app: my-biz-app } } }
  • Ingress: 요청 크기(nginx.ingress.kubernetes.io/proxy-body-size)·타임아웃을 §1.4 에 맞춘다. /actuator 는 Ingress 에 노출하지 않는다(관리 포트는 Service 에 미포함 또는 내부용 Service 분리).
  • HPA: CPU 70% 기준 min 2. 세션·멱등·캐시 공유 구현 필수(§1.5).
  • 배치: ongo-framework-batch* 이미지를 Job/CronJob(concurrencyPolicy: Forbid)으로.
  • EAI TCP 수신이 필요한 앱: Service type: LoadBalancer/NodePort TCP 포트 별도, 파드 다중화 시 수신 분산·세션 정책 확인(가이드 #eaitcp).

D.11 체크리스트(D)

  • □ 운영 이미지 JRE·비루트·devtools 제외, 스캔 Critical 0
  • □ 태그=sha, 운영 digest 고정, 동일 digest 승격
  • □ 시크릿은 Secret/ESO, 이미지·Git 평문 금지
  • □ startup/readiness/liveness 관리 포트, preStop + graceful, PDB
  • □ 리소스 requests/limits, MaxRAMPercentage
  • □ Ingress 크기·타임아웃·/actuator 차단
  • □ 롤백: 이전 digest 로 매니페스트 revert(또는 kubectl rollout undo 긴급)

E. 엔터프라이즈 PaaS — Tekton·Jira·ArgoCD

원칙: ① 클러스터 상태의 진실은 GitOps 저장소 ② CI 는 클러스터에 직접 apply 하지 않음 ③ Jira 승인 이슈와 배포 커밋이 1:1 추적.

머지 → Tekton EventListener → ongo-ci(clone·test·bootJar·Kaniko/Buildah·scan·push :sha12)
     → gitops overlays/dev digest 커밋 → ArgoCD auto-sync(dev) → argocd-wait → Jira 「반영요청」 생성
Jira 「STG 승인」/「PROD 승인」 전이 → Automation Webhook → Tekton ongo-promote
     → Jira REST 재검증(상태·승인자·digest) → 「반영중」 → overlays/stg|prod 갱신 → ArgoCD sync(prod 수동·sync window)
     → 결과 코멘트 + 「반영완료」/「실패」
영역기준
태그빌드 :<git-sha12>(불변), 매니페스트는 digest 고정, 승격은 같은 digest 복사(재빌드 금지), 선택적으로 cosign 서명 검증
TektonTask: gradle-build·kaniko·trivy-scan·gitops-bump(kustomize edit set image app=…@digest)·jira(create/comment/transition/verify)·argocd-wait. Task 버전 고정·사내 미러 이미지·gradle 캐시 PVC·PipelineRun pruner
TriggerEventListener 는 내부망 + 토큰/서명 검증, CEL 로 대상 전이만 통과, promote 는 멱등(같은 digest 면 no-op)
Jira이슈 유형 「반영요청」 필드: app·image·digest·대상 env·변경요약·롤백 계획. 전이 조건: STG=팀장, PROD=CAB, 요청자≠승인자. Cloud /rest/api/3, DC /rest/api/2, 커스텀 필드 ID 는 사이트 확인
ArgoCDAppProject(sourceRepos·destinations 제한, 클러스터 리소스 금지, prod sync window KST) + ApplicationSet(dev/stg/prod). dev auto+selfHeal, stg auto, prod 수동 sync. retry, PreSync DB 마이그레이션(하위호환), 선택적 Argo Rollouts canary
시크릿External Secrets Operator(Vault/Secret Manager 참조만 Git) 또는 Sealed Secrets. /run/secrets/app 마운트 + spring.config.import=configtree:/run/secrets/app/
환경 차이오버레이에만: replicas(1/2/≥3+HPA)·리소스·프로파일·ConfigMap·ExternalSecret 경로. devtools/openapi 는 운영 제외
# overlays/prod/kustomization.yaml (발췌)
images:
  - name: app
    newName: registry.example.com/ongo/my-biz-app
    digest: sha256:3f1c...e9          # ongo-promote 가 갱신
commonAnnotations: { ongo.io/jira-issue: REL-1234 }

롤백: 표준 = GitOps 배포 커밋 git revert → argocd app sync my-biz-app-prod → 헬스 확인 → Jira 「롤백완료」. 긴급 = argocd app rollback my-biz-app-prod <id>(auto-sync 앱은 먼저 비활성) 후 반드시 Git 을 같은 상태로 revert. Rollouts canary 중이면 kubectl argo rollouts abort.

Pipeline/Task/Trigger/AppProject/ApplicationSet/ExternalSecret 전체 YAML·Jira 상태도·체크리스트는 정본 §E, 공통 전환 체크리스트·장애 런북은 정본 §F·§G.

상세 — E. 엔터프라이즈 PaaS 패턴 — Tekton(빌드) + Jira(승인) + ArgoCD(배포)

적용: 빌드는 클러스터 내 Tekton, 반영 단계 결재/승인은 Jira 워크플로, 배포는 ArgoCD(GitOps) 로 분리된 엔터프라이즈 환경. 원칙 3가지: ① 클러스터 상태의 진실은 GitOps 저장소 ② CI 는 클러스터에 직접 apply 하지 않는다 ③ 승인 근거(Jira 이슈)와 배포 커밋이 1:1 로 추적된다.

E.1 전체 흐름

sequenceDiagram
  autonumber
  participant Dev as 개발자
  participant Git as 앱 저장소
  participant TK as Tekton (EventListener/Pipeline)
  participant REG as 이미지 레지스트리
  participant GO as GitOps 저장소
  participant AR as ArgoCD
  participant J as Jira
  Dev->>Git: PR 머지(develop/main)
  Git->>TK: Webhook (서명)
  TK->>TK: ongo-ci: clone → test → bootJar → image → scan
  TK->>REG: push :<sha12> (digest 획득)
  TK->>GO: overlays/dev 이미지 digest 커밋
  GO-->>AR: 변경 감지 → dev auto-sync
  AR-->>TK: (선택) 동기화/헬스 확인 태스크가 폴링
  TK->>J: 「반영요청」 이슈 생성(서비스·digest·변경요약·롤백계획)
  Note over J: 검토 → 결재자 「STG 승인」 전이
  J->>TK: Automation Webhook (issueKey, 대상 env)
  TK->>J: 이슈 상태·승인자·digest 재검증(REST)
  TK->>GO: overlays/stg 갱신 커밋(또는 PR)
  GO-->>AR: stg auto-sync
  TK->>J: 결과 코멘트 + 「STG 반영완료」
  Note over J: 운영 결재 「PROD 승인」
  J->>TK: Webhook (prod)
  TK->>GO: overlays/prod 갱신 PR → 자동/보호 머지
  AR->>AR: prod sync(수동 또는 sync window 내 자동)
  TK->>J: 헬스 결과 코멘트 + 「반영완료」/「실패」

E.2 저장소 구성

app-repo/                         gitops-repo/
├─ src/ ...                       ├─ apps/my-biz-app/
├─ docker/app/Dockerfile          │   ├─ base/ (deployment.yaml, service.yaml, configmap.yaml, pdb.yaml, kustomization.yaml)
├─ .tekton/                       │   └─ overlays/
│   ├─ pipeline-ci.yaml           │       ├─ dev/  (kustomization.yaml: images, replicas=1, profile=dev)
│   ├─ pipeline-promote.yaml      │       ├─ stg/  (replicas=2, profile=stg)
│   ├─ tasks/*.yaml               │       └─ prod/ (replicas=3, HPA, profile=prod)
│   └─ triggers/*.yaml            ├─ argocd/ (appproject.yaml, applicationset.yaml)
└─ ci/smoke.sh                    └─ secrets/ (ExternalSecret/SealedSecret 정의만, 평문 없음)

overlays/prod/kustomization.yaml

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ongo-prod
resources: [ ../../base, hpa.yaml, externalsecret.yaml ]
images:
  - name: app
    newName: registry.example.com/ongo/my-biz-app
    digest: sha256:3f1c...e9            # Tekton promote 가 갱신
replicas: [ { name: my-biz-app, count: 3 } ]
configMapGenerator:
  - name: my-biz-app-config
    behavior: merge
    literals: [ SPRING_PROFILES_ACTIVE=prod ]
commonAnnotations:
  ongo.io/jira-issue: REL-1234          # 승인 근거 추적

E.3 이미지 태그 전략

항목 규칙
빌드 태그 <git-sha12> (불변). 릴리스 브랜치는 추가로 <semver>
매니페스트 digest 고정(images[].digest) — 태그 재푸시 공격·실수 방지
승격 dev 에서 검증된 같은 digest 를 stg/prod 로 복사. 재빌드 금지
보존 레지스트리 정리 정책: prod 에 배포된 digest 는 1년, 나머지 30일
서명(선택) cosign 서명 + 클러스터 정책(Kyverno/Gatekeeper)으로 서명 이미지만 허용

E.4 Tekton — Task 예

gradle-build Task

apiVersion: tekton.dev/v1
kind: Task
metadata: { name: gradle-build }
spec:
  params:
    - { name: module, type: string }
    - { name: jdk, type: string, default: "21" }
  workspaces: [ { name: source }, { name: gradle-cache } ]
  results: [ { name: jar-path } ]
  steps:
    - name: test-and-jar
      image: registry.example.com/mirror/eclipse-temurin:21-jdk
      workingDir: $(workspaces.source.path)
      env:
        - { name: GRADLE_USER_HOME, value: $(workspaces.gradle-cache.path) }
      computeResources: { requests: { cpu: "1", memory: 2Gi }, limits: { memory: 3Gi } }
      script: |
        #!/usr/bin/env bash
        set -euo pipefail
        ./gradlew --no-daemon test ":$(params.module):bootJar" -PongoJavaRelease=$(params.jdk)
        JAR=$(ls $(params.module)/build/libs/*.jar | grep -v -- -plain | head -1)
        mkdir -p build && cp "$JAR" build/app.jar
        printf build/app.jar | tee $(results.jar-path.path)

gitops-bump Task

apiVersion: tekton.dev/v1
kind: Task
metadata: { name: gitops-bump }
spec:
  params: [ {name: app}, {name: env}, {name: image}, {name: digest}, {name: jira-issue, default: ""} ]
  workspaces: [ { name: gitops-cred } ]     # basic-auth 또는 ssh Secret
  steps:
    - name: bump
      image: registry.example.com/mirror/kustomize-git:5
      script: |
        set -euo pipefail
        git config --global credential.helper "store --file=$(workspaces.gitops-cred.path)/.git-credentials"
        git clone --depth 1 https://git.example.com/infra/gitops.git /w && cd /w/apps/$(params.app)/overlays/$(params.env)
        kustomize edit set image app=$(params.image)@$(params.digest)
        [ -n "$(params.jira-issue)" ] && kustomize edit add annotation --force ongo.io/jira-issue:$(params.jira-issue)
        git -c user.name=tekton -c [email protected] commit -am "deploy($(params.app)/$(params.env)): $(params.digest) [$(params.jira-issue)]"
        git push origin HEAD:main

jira Task(REST 호출 공통)

apiVersion: tekton.dev/v1
kind: Task
metadata: { name: jira }
spec:
  params: [ {name: action}, {name: issue, default: ""}, {name: payload, default: "{}"} ]
  results: [ { name: issue-key } ]
  steps:
    - name: call
      image: registry.example.com/mirror/curl-jq:latest
      env:
        - { name: JIRA_URL,   valueFrom: { secretKeyRef: { name: jira-cred, key: url } } }
        - { name: JIRA_USER,  valueFrom: { secretKeyRef: { name: jira-cred, key: user } } }
        - { name: JIRA_TOKEN, valueFrom: { secretKeyRef: { name: jira-cred, key: token } } }
      script: |
        set -euo pipefail
        AUTH="-u $JIRA_USER:$JIRA_TOKEN -H Content-Type:application/json"
        case "$(params.action)" in
          create)   curl -fsS $AUTH -X POST "$JIRA_URL/rest/api/2/issue" -d '$(params.payload)' | jq -r .key | tee $(results.issue-key.path) ;;
          comment)  curl -fsS $AUTH -X POST "$JIRA_URL/rest/api/2/issue/$(params.issue)/comment" -d '$(params.payload)' ;;
          transition) curl -fsS $AUTH -X POST "$JIRA_URL/rest/api/2/issue/$(params.issue)/transitions" -d '$(params.payload)' ;;
          verify)   # 승인 상태·승인자 그룹·digest 일치 확인
            J=$(curl -fsS $AUTH "$JIRA_URL/rest/api/2/issue/$(params.issue)?fields=status,customfield_digest,customfield_approver")
            echo "$J" | jq -e '.fields.status.name == "승인"' >/dev/null || { echo "미승인 이슈"; exit 1; } ;;
        esac

Jira Cloud 는 /rest/api/3(본문 ADF 형식), Data Center 는 /rest/api/2. 커스텀 필드 ID(customfield_xxxxx)는 사이트 Jira 에서 확인.

E.5 Tekton — Pipeline 예

ongo-ci

apiVersion: tekton.dev/v1
kind: Pipeline
metadata: { name: ongo-ci }
spec:
  params:
    - { name: git-url } 
    - { name: git-rev }
    - { name: app }
    - { name: module }
    - { name: image }                 # registry.example.com/ongo/my-biz-app
    - { name: jdk, default: "21" }
  workspaces: [ {name: src}, {name: gradle-cache}, {name: docker-cred}, {name: gitops-cred} ]
  tasks:
    - name: clone
      taskRef: { resolver: hub, params: [ {name: name, value: git-clone}, {name: version, value: "0.9"} ] }
      params: [ {name: url, value: $(params.git-url)}, {name: revision, value: $(params.git-rev)} ]
      workspaces: [ {name: output, workspace: src} ]
    - name: build
      runAfter: [clone]
      taskRef: { name: gradle-build }
      params: [ {name: module, value: $(params.module)}, {name: jdk, value: $(params.jdk)} ]
      workspaces: [ {name: source, workspace: src}, {name: gradle-cache, workspace: gradle-cache} ]
    - name: image
      runAfter: [build]
      taskRef: { resolver: hub, params: [ {name: name, value: kaniko}, {name: version, value: "0.6"} ] }   # 또는 buildah
      params:
        - { name: IMAGE, value: "$(params.image):$(tasks.clone.results.commit)" }
        - { name: DOCKERFILE, value: ./ci/Dockerfile.runtime }      # §D.2 패턴 2 (build/app.jar COPY)
        - { name: CONTEXT, value: . }
      workspaces: [ {name: source, workspace: src}, {name: dockerconfig, workspace: docker-cred} ]
    - name: scan
      runAfter: [image]
      taskRef: { name: trivy-scan }
      params: [ { name: image, value: "$(params.image)@$(tasks.image.results.IMAGE_DIGEST)" } ]
    - name: bump-dev
      runAfter: [scan]
      taskRef: { name: gitops-bump }
      params:
        - { name: app, value: $(params.app) }
        - { name: env, value: dev }
        - { name: image, value: $(params.image) }
        - { name: digest, value: $(tasks.image.results.IMAGE_DIGEST) }
      workspaces: [ {name: gitops-cred, workspace: gitops-cred} ]
    - name: wait-dev
      runAfter: [bump-dev]
      taskRef: { name: argocd-wait }       # argocd app wait <app>-dev --health --timeout 300
      params: [ {name: application, value: "$(params.app)-dev"} ]
    - name: jira-request
      runAfter: [wait-dev]
      taskRef: { name: jira }
      params:
        - { name: action, value: create }
        - name: payload
          value: |
            {"fields":{"project":{"key":"REL"},"issuetype":{"name":"반영요청"},
             "summary":"[$(params.app)] $(tasks.clone.results.commit) 반영요청",
             "description":"digest=$(tasks.image.results.IMAGE_DIGEST)\nPipelineRun=$(context.pipelineRun.name)"}}
  finally:
    - name: notify
      taskRef: { name: notify-result }
      params: [ { name: status, value: "$(tasks.status)" } ]

ongo-promote (Jira 승인 Webhook 전용)

apiVersion: tekton.dev/v1
kind: Pipeline
metadata: { name: ongo-promote }
spec:
  params: [ {name: issue}, {name: app}, {name: env}, {name: image}, {name: digest} ]
  workspaces: [ {name: gitops-cred} ]
  tasks:
    - name: verify
      taskRef: { name: jira }
      params: [ {name: action, value: verify}, {name: issue, value: $(params.issue)} ]
    - name: mark-in-progress
      runAfter: [verify]
      taskRef: { name: jira }
      params: [ {name: action, value: transition}, {name: issue, value: $(params.issue)}, {name: payload, value: '{"transition":{"id":"31"}}'} ]   # 「반영중」
    - name: bump
      runAfter: [mark-in-progress]
      taskRef: { name: gitops-bump }
      params: [ {name: app, value: $(params.app)}, {name: env, value: $(params.env)}, {name: image, value: $(params.image)}, {name: digest, value: $(params.digest)}, {name: jira-issue, value: $(params.issue)} ]
      workspaces: [ {name: gitops-cred, workspace: gitops-cred} ]
    - name: wait
      runAfter: [bump]
      taskRef: { name: argocd-wait }
      params: [ {name: application, value: "$(params.app)-$(params.env)"}, {name: sync, value: "true"} ]   # prod 수동 sync 정책이면 여기서 sync 실행
      timeout: 20m
  finally:
    - name: report
      taskRef: { name: jira }
      params:
        - { name: action, value: comment }
        - { name: issue, value: $(params.issue) }
        - { name: payload, value: '{"body":"반영 결과: $(tasks.status) / env=$(params.env) / digest=$(params.digest)"}' }

Trigger (Jira Webhook)

apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata: { name: jira-approval }
spec:
  serviceAccountName: tekton-triggers
  triggers:
    - name: promote
      interceptors:
        - ref: { name: cel }
          params:
            - name: filter
              value: >-
                header.match('X-Webhook-Token', '$(secret)') == false ? false :
                body.transition.to_status in ['STG 승인','PROD 승인']
        # 토큰 검증은 사이트 표준: 공유 토큰 헤더(Secret 비교), mTLS, 또는 사내 게이트웨이 서명 검증
      bindings:
        - { name: issue,  value: $(body.issue.key) }
        - { name: app,    value: $(body.issue.fields.customfield_app) }
        - { name: env,    value: "$(body.transition.to_status == 'PROD 승인' ? 'prod' : 'stg')" }
        - { name: image,  value: $(body.issue.fields.customfield_image) }
        - { name: digest, value: $(body.issue.fields.customfield_digest) }
      template: { ref: ongo-promote-template }

위 CEL 은 개념 예. Jira Automation 의 「Send web request」 본문을 사이트가 직접 정의(JSON 키)하는 편이 바인딩이 단순하다. EventListener 는 내부망 Ingress + 토큰/서명 검증 필수, Webhook 재전송에 대비해 promote 는 멱등(이미 같은 digest 면 no-op). - 공용 Tekton 운영 팁: PipelineRun TTL/pruner 로 이력 정리, gradle 캐시는 PVC 워크스페이스, 사내 미러 이미지만 사용(registry.example.com/mirror/...), Task 는 Tekton Bundles/Hub resolver 로 버전 고정.

E.6 Jira 승인 게이트 설계

stateDiagram-v2
  [*] --> 요청: Tekton 자동 생성
  요청 --> 검토: 담당 확인
  검토 --> STG승인: 결재(팀장)
  STG승인 --> STG반영중: Webhook→promote
  STG반영중 --> STG반영완료: Argo Healthy
  STG반영중 --> 실패: 헬스 실패
  STG반영완료 --> PROD승인: 결재(변경관리위원/CAB)
  PROD승인 --> PROD반영중: Webhook→promote(sync window 내)
  PROD반영중 --> 반영완료
  PROD반영중 --> 실패
  실패 --> 롤백완료: revert 커밋
  반영완료 --> [*]
설정 내용
이슈 유형/필드 「반영요청」: 서비스(app), 이미지(image), digest, 대상 환경, 변경요약, 영향도, 롤백 계획, 반영 희망 일시
전이 조건(Condition) 「STG 승인」은 팀장 그룹, 「PROD 승인」은 CAB 그룹만. 요청자 ≠ 승인자(자기 승인 금지)
검증(Validator) digest·롤백계획 필수 입력, 반영 희망 일시가 sync window 내
사후 기능(Post function) 승인자·시각 기록
Automation 「STG 승인」/「PROD 승인」 전이 시 → Send web request(EventListener URL, 토큰 헤더, 이슈 키·필드 JSON)
회신 promote 가 결과 코멘트 + 「반영완료」/「실패」 전이, ArgoCD 앱 링크·GitOps 커밋 링크 첨부
- 재검증이 핵심: Webhook 만 믿지 말고 promote 첫 단계에서 Jira REST 로 상태·승인자·digest 를 다시 읽는다(위변조·재전송·중간 수정 방지).
- 긴급 반영(Hotfix): 별도 이슈 유형 「긴급반영」 + 사후 승인 정책을 사이트 변경관리 규정으로 정의.

E.7 ArgoCD — AppProject / Application / ApplicationSet

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata: { name: ongo, namespace: argocd }
spec:
  sourceRepos: [ https://git.example.com/infra/gitops.git ]
  destinations:
    - { server: https://kubernetes.default.svc, namespace: ongo-dev }
    - { server: https://kubernetes.default.svc, namespace: ongo-stg }
    - { server: https://prod-cluster.example.com, namespace: ongo-prod }
  clusterResourceWhitelist: []            # 클러스터 범위 리소스 생성 금지
  syncWindows:
    - { kind: allow, schedule: "0 22 * * 1-4", duration: 3h, applications: ["*-prod"], timeZone: Asia/Seoul }   # 월~목 22~01시 KST
    - { kind: deny,  schedule: "0 0 * * 5-0",  duration: 72h, applications: ["*-prod"], timeZone: Asia/Seoul, manualSync: false }
  roles:
    - name: deployer
      policies: [ "p, proj:ongo:deployer, applications, sync, ongo/*-stg, allow", "p, proj:ongo:deployer, applications, sync, ongo/*-prod, allow" ]
---
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata: { name: my-biz-app, namespace: argocd }
spec:
  goTemplate: true
  generators:
    - list:
        elements:
          - { env: dev,  cluster: https://kubernetes.default.svc, auto: true }
          - { env: stg,  cluster: https://kubernetes.default.svc, auto: true }
          - { env: prod, cluster: https://prod-cluster.example.com, auto: false }
  template:
    metadata: { name: 'my-biz-app-{{.env}}' }
    spec:
      project: ongo
      source: { repoURL: https://git.example.com/infra/gitops.git, targetRevision: main, path: 'apps/my-biz-app/overlays/{{.env}}' }
      destination: { server: '{{.cluster}}', namespace: 'ongo-{{.env}}' }
      syncPolicy:
        syncOptions: [ CreateNamespace=false, PruneLast=true, ApplyOutOfSyncOnly=true ]
        retry: { limit: 3, backoff: { duration: 10s, factor: 2, maxDuration: 2m } }
  templatePatch: |
    {{- if .auto }}
    spec:
      syncPolicy:
        automated: { prune: true, selfHeal: true }
    {{- end }}
환경 sync 정책 이유
dev automated + prune + selfHeal 빠른 피드백, 수동 변경 자동 복구
stg automated(prune) Jira 승인 후 GitOps 커밋이 곧 반영
prod 수동 sync(promote 의 argocd-wait 가 sync 실행) + sync window 반영 시점 통제·감사
- 알림: argocd-notifications 로 sync 성공/실패/Degraded 를 메신저·Jira 로 전송(선택).
- DB 마이그레이션: PreSync hook Job(Flyway/Liquibase), hook-delete-policy: BeforeHookCreation. 하위호환(확장→전환→축소) 필수 — 롤백 시 앱만 되돌릴 수 있어야 한다.
- 점진 배포: Argo Rollouts canary(10%→50%→100%, AnalysisTemplate 로 5xx 비율·p95 지연 판정) — 실패 시 자동 abort.

E.8 다환경 승격(promotion) 규칙

  1. dev: 머지마다 자동. 2. stg: Jira 「STG 승인」 → 같은 digest. 3. prod: stg 에서 최소 관찰 시간(예: 1영업일) 경과 + 「PROD 승인」 → 같은 digest. - 환경별 차이는 오버레이(kustomize)/values(Helm) 에만 둔다: replicas, 리소스, 프로파일, ConfigMap, ExternalSecret 경로. 이미지는 동일. - 설정 변경(ConfigMap)도 GitOps 커밋 + 같은 승인 흐름(이슈 유형 「설정변경」)을 탄다. ConfigMap 변경 시 롤링 재기동을 위해 configMapGenerator(이름 해시) 사용. | 항목 | dev | stg | prod | |---|---|---|---| | sync | auto+selfHeal | auto | 수동 + sync window | | replicas | 1 | 2 | ≥3(HPA) | | devtools/openapi | on(데모 한정) | off | 아티팩트 제외 | | 세션 저장소 | 인메모리 허용(단일) | Redis | Redis(HA) | | 승인 | 없음 | 팀장 | CAB |

E.9 시크릿 관리

방식 구조 장점 주의
External Secrets Operator GitOps 에 ExternalSecret(참조만) → Vault/클라우드 Secret Manager 에서 동기화 비밀값이 Git 에 전혀 없음, 회전 용이 외부 저장소 가용성
Sealed Secrets 클러스터 공개키로 암호화한 SealedSecret 을 Git 에 커밋 외부 저장소 불필요 키 백업·클러스터별 재봉인
Vault Agent Injector/CSI 파드에 파일 주입 동적 시크릿 사이드카 운영
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: my-biz-app-secrets }
spec:
  refreshInterval: 1h
  secretStoreRef: { name: vault-ongo, kind: ClusterSecretStore }
  target: { name: my-biz-app-secrets }
  data:
    - { secretKey: spring.datasource.password, remoteRef: { key: ongo/prod/my-biz-app, property: db_password } }
    - { secretKey: ongo.framework.crypto.key,  remoteRef: { key: ongo/prod/my-biz-app, property: crypto_key } }
  • 위 Secret 을 /run/secrets/app 에 마운트하면 spring.config.import=configtree:/run/secrets/app/ 가 파일명=프로퍼티 키로 읽는다(§D.10). 프레임 CryptoKeyProvider 구현이 이 값을 사용.
  • Tekton 자격증명(레지스트리·GitOps·Jira)도 같은 방식으로 생성하고, 파이프라인 ServiceAccount 에만 바인딩.

E.10 롤백(ArgoCD)

상황 표준 절차 비고
prod 반영 후 장애(일반) GitOps 저장소에서 해당 배포 커밋 git revert → (prod 수동 sync 정책) argocd app sync my-biz-app-prod → 헬스 확인 → Jira 「롤백완료」 감사 이력 유지, 표준
긴급(분 단위) argocd app rollback my-biz-app-prod <history-id> auto-sync 앱은 먼저 auto-sync 비활성 필요. 이후 반드시 Git 을 같은 상태로 revert 해 drift 제거
Rollouts canary 중 kubectl argo rollouts abort my-biz-app (자동 분석 실패 시 자동 abort) stable 로 즉시 복귀
DB 포함 앱 롤백만 수행(하위호환 마이그레이션 전제), 역마이그레이션은 별도 승인
argocd app history my-biz-app-prod                 # 배포 이력(리비전·시각)
argocd app rollback my-biz-app-prod 42 --prune     # 긴급
argocd app get my-biz-app-prod --refresh           # 상태 확인

E.11 체크리스트(E)

  • □ 앱/GitOps 저장소 분리, CI 의 클러스터 직접 apply 권한 없음
  • □ 태그=sha, 매니페스트 digest, 같은 digest 승격
  • □ Tekton Task 버전 고정·사내 미러 이미지·캐시 PVC·PipelineRun pruner
  • □ EventListener 내부망 + 토큰/서명 검증, promote 멱등
  • □ Jira 이슈 유형·필드·전이 조건(자기 승인 금지)·Automation Webhook
  • □ promote 첫 단계 Jira 재검증
  • □ AppProject 제한·sync window(KST)·prod 수동 sync
  • □ ESO/Sealed Secrets, Git 평문 0
  • □ PreSync 마이그레이션 하위호환, 롤백 리허설(revert → sync)
  • □ argocd-notifications / Jira 결과 회신
  • □ 소스 웹훅(Bitbucket 등) → Tekton 트리거 구성(E.12)

E.12 Bitbucket 저장소 웹훅 → Tekton 빌드 트리거(PaaS 표준 연계)

적용: 소스 저장소가 Bitbucket(Data Center/Server 또는 Cloud) 이고 빌드를 클러스터 내 Tekton 이 수행하는 PaaS. E.1 의 「Webhook(서명)」 단계를 Bitbucket 기준으로 구체화한 것이며, 이후 단계(이미지 push → GitOps dev 커밋 → Jira 승인 → ArgoCD 승격)는 E.5~E.10 과 동일하다. 고객사에 관계없이 쓰는 일반 패턴이다.

E.12.1 구성 요소와 흐름
flowchart LR
  BB[Bitbucket 저장소<br/>Repository Webhook] -- HTTPS POST<br/>X-Hub-Signature --> ING[Ingress/Route<br/>내부망·IP 허용]
  ING --> EL[EventListener<br/>ongo-bitbucket-listener]
  EL --> IC{Interceptors<br/>bitbucket: 서명·이벤트<br/>cel: 브랜치·경로 필터}
  IC -- 통과 --> TB[TriggerBinding<br/>payload → params]
  TB --> TT[TriggerTemplate]
  TT --> PR[PipelineRun ongo-ci-*]
  PR --> REG[(레지스트리)] --> GO[(GitOps dev overlay)]
  PR -. 빌드 상태 .-> BB
리소스 역할 비고
Bitbucket Webhook 저장소 이벤트를 EventListener URL 로 POST 이벤트: repo:refs_changed(push), pr:merged(선택)
Secret bitbucket-webhook-secret 웹훅 서명 검증용 공유 비밀 Bitbucket 웹훅 「Secret」 값과 동일, ESO 로 주입
EventListener HTTP 수신, Interceptor 체인 실행 ServiceAccount 는 PipelineRun 생성 권한만
Interceptor bitbucket X-Hub-Signature(HMAC-SHA256) 검증 + X-Event-Key 필터 Tekton Triggers 내장(ClusterInterceptor)
Interceptor cel 브랜치·변경 경로 필터, 파생값(sha12·모듈) 계산 불필요 빌드 차단
TriggerBinding payload 필드 → git-url/git-rev/app 등 Server/DC 와 Cloud 필드 경로가 다름(아래)
TriggerTemplate ongo-ci PipelineRun 생성 E.5 Pipeline 재사용
E.12.2 Bitbucket 쪽 설정(저장소 관리자)
  1. 저장소 → Repository settings → Webhooks → Create webhook
  2. URL: https://tekton-hooks.<내부도메인>/bitbucket/<app> (Ingress 경로별 EventListener 또는 단일 리스너 + CEL 라우팅)
  3. Secret: 32바이트 이상 랜덤값(openssl rand -hex 32) → 같은 값을 클러스터 Secret 에 등록
  4. 이벤트: Repository → Push(repo:refs_changed). PR 빌드(검증용)가 필요하면 Pull request → Opened/Source branch updated(pr:opened, pr:from_ref_updated) 별도 트리거로 분리
  5. 「Test connection」으로 200/202 확인, Bitbucket 웹훅 「View details」에서 요청/응답 이력 확인
  6. 서비스 계정 토큰(읽기 전용, HTTP access token) → Tekton git-clone 용 basic-auth Secret. 빌드 상태 회신을 쓰면 Repository write 범위 토큰 별도

폐쇄망: Bitbucket 서버 → 클러스터 Ingress 방향 방화벽(443) 개통이 필요하다. 반대 방향(클러스터 → Bitbucket 7990/443/7999)은 clone·상태 회신용.

E.12.3 웹훅 payload(Bitbucket Data Center/Server, repo:refs_changed)
{
  "eventKey": "repo:refs_changed",
  "date": "2026-10-30T12:00:00+0900",
  "actor": { "name": "dev1", "displayName": "개발자1" },
  "repository": {
    "slug": "my-biz-app",
    "project": { "key": "ONGO" },
    "links": { "clone": [ { "href": "https://bitbucket.example.com/scm/ongo/my-biz-app.git", "name": "http" },
                          { "href": "ssh://[email protected]:7999/ongo/my-biz-app.git", "name": "ssh" } ] }
  },
  "changes": [ {
    "ref": { "id": "refs/heads/develop", "displayId": "develop", "type": "BRANCH" },
    "fromHash": "1111111111111111111111111111111111111111",
    "toHash":   "9ed13f8a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
    "type": "UPDATE"
  } ]
}

헤더: X-Event-Key: repo:refs_changed, X-Hub-Signature: sha256=<hmac>, X-Request-Id.

값 Data Center/Server 경로 Bitbucket Cloud(repo:push) 경로
브랜치 body.changes[0].ref.displayId body.push.changes[0].new.name
커밋 body.changes[0].toHash body.push.changes[0].new.target.hash
clone URL body.repository.links.clone[?name=http].href https://bitbucket.org/ + body.repository.full_name
삭제 여부 changes[0].type == "DELETE" push.changes[0].new == null

Cloud 는 웹훅 서명이 X-Hub-Signature(Secret 설정 시) 로 동일하나 이벤트 키가 repo:push 다. 아래 예는 Data Center 기준.

E.12.4 Tekton Triggers 매니페스트
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata: { name: ongo-bitbucket-listener, namespace: ongo-ci }
spec:
  serviceAccountName: tekton-triggers-sa
  resources:
    kubernetesResource: { replicas: 2 }
  triggers:
    - name: push-build
      interceptors:
        - ref: { name: bitbucket, kind: ClusterInterceptor }
          params:
            - name: secretRef
              value: { secretName: bitbucket-webhook-secret, secretKey: secret }
            - name: eventTypes
              value: [ "repo:refs_changed" ]
        - ref: { name: cel, kind: ClusterInterceptor }
          params:
            - name: filter
              value: >-
                body.changes[0].type != 'DELETE' &&
                body.changes[0].ref.displayId.matches('^(develop|main|release/.+)$')
            - name: overlays
              value:
                - { key: sha12,  expression: "body.changes[0].toHash.truncate(12)" }
                - { key: branch, expression: "body.changes[0].ref.displayId" }
                - { key: cloneUrl, expression: "body.repository.links.clone.filter(c, c.name == 'http')[0].href" }
      bindings: [ { ref: ongo-bitbucket-push } ]
      template: { ref: ongo-ci-template }
---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata: { name: ongo-bitbucket-push, namespace: ongo-ci }
spec:
  params:
    - { name: git-url,  value: $(extensions.cloneUrl) }
    - { name: git-rev,  value: $(body.changes[0].toHash) }
    - { name: sha12,    value: $(extensions.sha12) }
    - { name: branch,   value: $(extensions.branch) }
    - { name: app,      value: $(body.repository.slug) }
    - { name: actor,    value: $(body.actor.name) }
---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata: { name: ongo-ci-template, namespace: ongo-ci }
spec:
  params: [ {name: git-url}, {name: git-rev}, {name: sha12}, {name: branch}, {name: app}, {name: actor} ]
  resourcetemplates:
    - apiVersion: tekton.dev/v1
      kind: PipelineRun
      metadata:
        generateName: ongo-ci-$(tt.params.app)-
        labels:
          ongo.io/app: $(tt.params.app)
          ongo.io/git-sha: $(tt.params.sha12)
          ongo.io/branch: $(tt.params.branch)
        annotations:
          ongo.io/triggered-by: $(tt.params.actor)
      spec:
        pipelineRef: { name: ongo-ci }
        params:
          - { name: git-url, value: $(tt.params.git-url) }
          - { name: git-rev, value: $(tt.params.git-rev) }
          - { name: app,     value: $(tt.params.app) }
          - { name: module,  value: "app" }
          - { name: image,   value: "registry.example.com/ongo/$(tt.params.app)" }
        taskRunTemplate: { serviceAccountName: tekton-build-sa }
        timeouts: { pipeline: 45m }
        workspaces:
          - name: src
            volumeClaimTemplate:
              spec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 2Gi } } }
          - { name: gradle-cache, persistentVolumeClaim: { claimName: gradle-cache } }
          - { name: docker-cred,  secret: { secretName: registry-push } }
          - { name: gitops-cred,  secret: { secretName: gitops-write } }

Ingress(내부망 한정):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: tekton-hooks
  namespace: ongo-ci
  annotations:
    nginx.ingress.kubernetes.io/whitelist-source-range: "10.10.20.0/24"   # Bitbucket 서버 대역
spec:
  rules:
    - host: tekton-hooks.example.internal
      http:
        paths:
          - path: /bitbucket
            pathType: Prefix
            backend: { service: { name: el-ongo-bitbucket-listener, port: { number: 8080 } } }

RBAC 최소권한: tekton-triggers-sa 는 triggers.tekton.dev 리소스 get/list/watch + pipelineruns create 만. 빌드용 tekton-build-sa 와 분리한다(클러스터 apply 권한 없음 — E 원칙 ②).

E.12.5 빌드 상태 회신(선택, 권장)

ongo-ci 의 finally 에 상태 회신 Task 를 둬 Bitbucket 커밋/PR 화면에 빌드 결과를 표시한다.

finally:
  - name: bitbucket-status
    params:
      - { name: state, value: "$(tasks.status)" }      # Succeeded/Failed/Completed
    taskSpec:
      params: [ {name: state} ]
      steps:
        - name: notify
          image: registry.example.com/mirror/curl:8
          env: [ { name: TOKEN, valueFrom: { secretKeyRef: { name: bitbucket-status-token, key: token } } } ]
          script: |
            S=SUCCESSFUL; [ "$(params.state)" = "Failed" ] && S=FAILED
            curl -fsS -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
              "https://bitbucket.example.com/rest/build-status/1.0/commits/$(params.git-rev)" \
              -d "{\"state\":\"$S\",\"key\":\"ongo-ci\",\"name\":\"Tekton ongo-ci\",\"url\":\"https://tekton.example.internal/#/namespaces/ongo-ci/pipelineruns/$(context.pipelineRun.name)\"}"

(Cloud 는 POST /2.0/repositories/{ws}/{repo}/commit/{hash}/statuses/build.) Bitbucket 브랜치 권한의 「Merge checks → Minimum successful builds」 와 연결하면 PR 빌드 실패 시 머지가 막힌다.

E.12.6 기존 흐름과의 연결
단계 트리거 결과
PR 검증(선택) pr:opened/pr:from_ref_updated → ongo-pr-check(test·정적분석만, 이미지 push 없음) Bitbucket 빌드 상태 → 머지 체크
CI 빌드 develop/main push(repo:refs_changed) → ongo-ci :<sha12> push, GitOps overlays/dev digest 커밋 → ArgoCD dev auto-sync
승인 ongo-ci 의 Jira 이슈 생성(E.6) 「반영요청」에 Bitbucket 커밋 링크·digest 포함
승격 Jira Automation Webhook → 별도 EventListener → ongo-promote 같은 digest 로 stg/prod(E.8) — Bitbucket 웹훅은 승격을 트리거하지 않는다
E.12.7 운영 수칙·장애 대응
  • 중복 실행: Bitbucket 은 재시도/수동 재전송을 한다. ongo-ci 는 sha 단위 멱등(이미 존재하는 태그면 push 스킵, GitOps 커밋은 digest 동일 시 no-op). 필요하면 CEL 에서 X-Request-Id 로그를 남겨 추적
  • 동시 push: 같은 브랜치 연속 push 시 이전 PipelineRun 취소 정책(Tekton 자체 기능 없음 → 시작 Task 에서 같은 ongo.io/branch 라벨의 실행 중 Run 을 Cancelled 처리하거나 GitOps 커밋 단계에서 최신 sha 만 반영)
  • 필터: 문서·설정만 바뀐 커밋은 CEL 에서 제외(DC 는 payload 에 파일 목록이 없으므로 git diff --name-only fromHash toHash 를 첫 Task 에서 검사 후 스킵)
  • 태그 빌드: 릴리스 태그(refs/tags/v*, ref.type == 'TAG')는 별도 트리거로 semver 태그 추가(E.3)
  • 보안: 서명 검증 없는 EventListener 금지, 외부 공개 금지, Secret 은 90일 주기 교체(Bitbucket·클러스터 동시 갱신)
증상 확인 조치
Bitbucket 웹훅 이력에 timeout/연결 실패 방화벽·Ingress·DNS 443 개통, Ingress 허용 대역
응답 202 인데 PipelineRun 없음 kubectl logs -l eventlistener=ongo-bitbucket-listener CEL filter 불일치(브랜치명), 이벤트키 오타
403/signature mismatch Secret 값 공백·개행 printf %s 로 재생성, 양쪽 동일값
clone 실패(401) basic-auth Secret·토큰 만료 읽기 토큰 재발급, tekton.dev/git-0 어노테이션
상태 회신 안 됨 토큰 범위 Repository write 권한 토큰
E.12.8 체크리스트(Bitbucket → Tekton)
  • □ 웹훅 Secret 설정 + bitbucket Interceptor 서명 검증
  • □ 이벤트 repo:refs_changed(+ PR 이벤트 분리), CEL 브랜치 필터
  • □ EventListener 내부망 Ingress·허용 대역, 트리거 SA/빌드 SA 분리
  • □ TriggerTemplate → 기존 ongo-ci 재사용, 라벨(app·sha·branch)
  • □ 빌드 상태 회신 + Merge check 연동
  • □ 멱등·중복 웹훅·동시 push 정책
  • □ 승격은 Jira 경로만(Bitbucket 웹훅으로 stg/prod 반영 금지)

삼카 부록: 삼카 내부 PaaS 는 위 패턴 그대로 Bitbucket 저장소 웹훅 → Tekton EventListener → ongo-ci 로 빌드하고, 이후 Jira 승인 → ArgoCD 반영으로 이어진다. 웹훅 URL·허용 대역·Bitbucket 프로젝트 키는 삼카 인프라 담당 확인 필요.


상세 — F. 공통 운영 전환 체크리스트
  • □ 라인 확정(JDK/Boot) — §0, §B.2
  • □ 아티팩트 불변·sha256·BUILD-INFO — §1.1
  • □ 비밀값 외부 주입, yml 데모 사용자(auth.users) 제거 — §1.2
  • □ 멀티 인스턴스 → SessionStoreService·IdempotencyStore·DistributedCacheService 공유 구현 — §1.5
  • □ 채널 transport: loopback → 실제 transport, TCP 수신(EAI)은 확장 리스너 위치·포트 방화벽 확인(가이드 #extmock·#eaitcp)
  • □ health/readiness·graceful shutdown·타임아웃 체인 — §1.3, §1.4
  • □ 로그 경로·보관·수집(ELK 브리지)·마스킹
  • □ 롤백 절차 리허설(직전 릴리스/이미지 digest)
  • □ devtools·openapi 운영 제외, /actuator 외부 차단
  • □ 배치 실행 방식(스케줄러/CronJob)·중복 실행 방지
상세 — G. 장애 대응 런북(공통)
증상 1차 확인 조치
배포 직후 5xx 급증 신규 인스턴스/파드 로그, readiness 상태 즉시 롤백(케이스별 §A.14/§B.11/§C.6/§E.10) 후 원인 분석
기동 실패(포트 바인딩) ss -lntp | grep <port> 이전 프로세스 잔존 → stop 타임아웃·PID 확인
기동 느림 → 헬스 타임아웃 기동 로그 시간, DB 연결 startup 타임아웃 상향, 메타/코드 로드 지연 점검
로그인 풀림 세션 저장소 구현 공유 구현 교체
OOM 힙덤프(dumps/), GC 로그 힙 조정·누수 분석, ExitOnOutOfMemoryError
응답 지연 스레드 덤프 jcmd <pid> Thread.print, 풀 사용률 타임아웃 체인·풀 크기
디스크 풀 du -sh /app/*/logs /app/*/dumps /app/*/releases 보관 정책·cleanup
상세 — 관련 문서

docs/CUSTOMIZATION.md §17(배포), docs/MULTI-JDK-PORTING.md, docker/README.md, docs/CUSTOMIZATION-EXAMPLES.md, 코어 개발가이드 #deploy(요약·케이스별 핵심 절차).

SPI 전체 목록

영역인터페이스
인증AuthService, AuthorizationService, IdentityService, SessionStoreService
코드·메타CodeLookupService, MetaLoadService, Refreshable
암호화ChannelCryptoService, FieldCryptoService, HashService
예외·데이터MessageCoded, MessageSourceService, IdempotencyStore
보안ColumnAttributeService, MaskingService, XssFilterService, PasswordRuleService
캐시CacheService, LocalCacheService, DistributedCacheService
채널ChannelService, ChannelMessageCodeMappingService, ChannelResponseOutcomeService, InboundHandlerService, PhysicalTransportService, TelegramCodecService, TelegramLayoutService
로깅LogLevelService, LoggingBridgeService, StructuredLogService
트랜잭션DataSourceService, TransactionManagerService, DistributedTransactionManagerService, RollbackRuleService
웹AccessLogWriter, CorrelationIdGenerator, OngoHandlerInterceptor

운영 전환 체크리스트

상세 문서 전문(md 정본 웹 수록)

저장소 소스 없이도 읽을 수 있도록 핵심 md 정본 본문을 그대로 수록했어요(scripts/sync-docs.py 로 동기화). 문서를 눌러 펼치세요.

FE-08 토큰 갱신·중복 로그인·강제 로그아웃 설계 docs/AUTH-TOKEN.md

기준일 2026-10-30. 구현: apps/demo-web/app/utils/http.ts(갱신·재시도 파이프라인) · utils/auth.ts(오류 코드 정책) · utils/auth-mock.ts(목업 인증 서버) · plugins/http.ts(결합) · 데모 /token.

1. 백엔드 확인 결과

  • 인증 오류 코드: auth.unauthenticated(401) · auth.forbidden(403) · auth.bad-credentials(401) · auth.session-limit(409, 동시 세션 초과를 REJECT 로 둘 때).
  • 토큰 갱신 API·강제 로그아웃 코드는 없다. 중복 로그인은 반영됨, e9e1745): 동시 세션 초과(max-per-user: 2, EXPIRE_OLDEST)로 밀려난 세션은 다음 요청에서 401 auth.duplicate-login 으로 온다(SessionStoreService.wasDisplaced 기록 → AuthenticationFilter → AuthorizationInterceptor). 로그아웃·모르는 토큰은 계속 auth.unauthenticated.
  • 그래서 이 항목은 워크리스트대로 목업으로 완성하고, 실제 BE 는 설정으로 연결한다(신규 API 는 만들지 않음).

2. 오류 코드 정책 (runtimeConfig.public.auth)

분류 기본 코드 처리
refresh auth.token-expired 토큰 갱신 → 원래 요청 1회 재시도
forced auth.duplicate-login, auth.forced-logout, auth.session-revoked 안내 다이얼로그(메시지 카탈로그 FE-03) → terminateSession('forced')
그 밖의 401 (예: auth.unauthenticated) 기존 FE-06 세션 만료 처리
  • 코드 목록은 env(NUXT_PUBLIC_AUTH_REFRESH_CODES, NUXT_PUBLIC_AUTH_FORCED_LOGOUT_CODES, 쉼표)로 바꾼다. 백엔드가 401 전부를 갱신 대상으로 하려면 auth.unauthenticated 를 refresh 목록에 넣는다.
  • forced 는 상태 코드와 관계없이(401·403·409) 코드로 판정한다. 일반 오류 팝업·세션 만료 처리 대신 이 안내만 한 번 띄운다.

3. 갱신·재시도 (FE-02 파이프라인)

  1. 401(AUTH_ERROR)이고 코드가 refresh 목록이면 refreshAuth() 를 부른다. 로그인처럼 authEvent: false 이거나 refresh: false 인 요청은 갱신하지 않는다.
  2. 동시 요청 단일 갱신: 갱신 중이면 같은 Promise 를 기다린다. 요청을 보낸 뒤 이미 다른 요청이 갱신을 끝냈다면(갱신 순번 비교) 다시 갱신하지 않고 바로 재시도한다.
  3. 성공하면 request 훅을 다시 돌려(새 Authorization) 한 번만 재시도한다. 재시도도 401 이거나 갱신이 실패하면 일반 401 처리(세션 만료).
  4. 실제 BE 갱신 호출 위치: plugins/http.ts 의 refreshAuth(설정 refreshPath, 기본 비움 = 실제 토큰은 갱신하지 않음). BE 에 갱신 API 가 생기면 경로만 넣는다.

4. 목업 인증 서버 (/mock-api/**, 데모 전용)

브라우저 안에서 응답한다(백엔드·네트워크 없음). 목업 로그인 토큰(mock-token-*)만 받는다.

경로 동작
GET /mock-api/secure/data 토큰 상태가 정상이면 200, 만료면 401 auth.token-expired, 중복 로그인이면 401 auth.duplicate-login, 강제 종료면 409 auth.forced-logout
갱신 만료 토큰 → 새 토큰 발급(refreshFails 면 실패)

데모 화면이 토큰 상태를 바꾸고(만료·중복·강제·갱신 실패) 동시 요청을 보내 결과를 보여 준다.

웹접근성 체크리스트 (FE-23/24) docs/ACCESSIBILITY-CHECKLIST.md

화면 PR 전 자가 점검용. 자동 정적 게이트는 apps/demo-web/tests/a11y-static.test.ts, 데모는 /a11y.

# 항목 확인 방법
1 이미지 alt (장식이면 alt="") 정적 테스트
2 target="_blank" 는 rel="noopener noreferrer" + 새 창 안내 정적 테스트
3 텍스트 없는 버튼(✕·☰·아이콘)은 aria-label 정적 테스트
4 폼 컨트롤은 라벨 연결(UiFormField) 및 오류 aria-describedby 수동
5 키보드만으로 모든 기능 사용, 포커스 표시 유지 수동(Tab)
6 모달·드로어 포커스 트랩·Esc 닫기·닫힌 뒤 원래 위치로 포커스 복귀 (utils/focus-trap.ts) 수동
7 명도 대비 4.5:1 이상, 고대비 설정(html.hc) 확인 수동·axe
8 움직임 줄이기(prefers-reduced-motion·html.reduce-motion) 시 자동재생·애니메이션 정지 수동
9 페이지 <html lang>·제목·본문 바로가기(skip link) 수동
10 상태 변화(토스트·오류)는 aria-live 로 알림 수동

보류: axe-core 브라우저 실행(CI) — 별도 작업.

FE-07 세션·유휴 타임아웃 설계 docs/SESSION-IDLE.md

기준일 2026-10-29. 구현: apps/demo-web/app/utils/idle-session.ts(코어) · plugins/session-idle.client.ts(앱 결합) · composables/useSessionIdle.ts · 데모 /session.

1. 백엔드 확인 결과

  • 세션은 슬라이딩 만료: AuthenticationFilter 가 인증 요청마다 SessionStoreService.touch() 로 만료를 미룬다.
  • 만료 시간 ongo.framework.session.timeout(데모 30m), 사용자당 최대 max-per-user: 2(초과 시 EXPIRE_OLDEST — 중복 로그인은 FE-08).
  • 로그인 응답(POST /api/auth/login → bearer·userId·roles)에는 만료 시각이 없다 → FE 는 같은 값을 설정으로 갖는다.
  • 연장용 API 는 새로 만들지 않는다. 기존 보호 경로 GET /api/me 를 부르면 세션이 연장된다.

2. 무엇을 추적하나

FE 가 세는 것은 서버 세션 만료 시각 = 마지막 서버 갱신(lastTouch) + timeout.

신호 출처 효과
서버 갱신(touch) 로그인, 인증 헤더가 붙은 성공 응답(FE-02 response 훅), 연장 호출 lastTouch = now, 경고 중이면 닫음
사용자 활동(activity) pointerdown·keydown·wheel·touchstart·scroll·탭 다시 보기(1초 스로틀) lastActivity = now (서버는 모름)

3. 상태와 판정 (1초마다)

stopped(비로그인) → active → warning → expired

  • active 에서
  • 마지막 갱신 뒤 활동이 있었고, 갱신 후 keepAliveAfter 가 지났으면 → 조용히 연장(auto) — 쓰는 중인 사용자는 경고를 보지 않는다.
  • 남은 시간 ≤ warnBefore 인데 갱신 뒤 활동이 있었으면 → 조용히 연장, 없으면 → warning(연장 다이얼로그).
  • warning 에서는 활동만으로 연장하지 않는다(사용자가 연장 버튼을 눌러야 함). 다른 탭의 연장·API 응답이 오면 active 로 돌아가고 다이얼로그를 닫는다.
  • 남은 시간 ≤ 0 → expired: 다이얼로그를 닫고 FE-06 terminateSession('expired', { redirect }) → 로그인 화면 + "세션이 만료되었습니다" 안내.
  • 연장 실패(네트워크 등)는 경고를 유지한다. 401 이면 FE-02 auth-error 가 이미 만료 처리한다.

4. 멀티탭 동기화

BroadcastChannel('ongo-session')(없으면 FE-34 storage 이벤트로 대체)로 touch·activity(5초 스로틀)·expired 를 주고받는다. 한 탭에서 쓰고 있으면 다른 탭도 유휴로 보지 않고, 한 탭에서 연장하면 다른 탭 경고가 닫히며, 만료는 모든 탭이 함께 로그아웃한다.

5. 연장 호출

keepAlive() = GET {apiBase}{keepAlivePath}(기본 /me, 오류 팝업·전역 로딩 끔). 목업 로그인(mock-token-*)은 BE 에 없는 토큰이라 호출하지 않고 성공으로 친다(데모).

6. 설정 (runtimeConfig.public.session, env)

키 env 기본 설명
timeoutSec NUXT_PUBLIC_SESSION_TIMEOUT_SEC 1800 BE ongo.framework.session.timeout 과 같게
warnBeforeSec NUXT_PUBLIC_SESSION_WARN_BEFORE_SEC 120 만료 몇 초 전에 연장 다이얼로그
keepAliveAfterSec NUXT_PUBLIC_SESSION_KEEP_ALIVE_AFTER_SEC 300 활동 중 자동 연장 최소 간격
keepAlivePath NUXT_PUBLIC_SESSION_KEEP_ALIVE_PATH /me apiBase 기준. 비우면 연장 호출 없이 FE 시각만 갱신

warnBefore 는 timeout 보다 작게, keepAliveAfter 는 timeout - warnBefore 이하로 보정한다.

7. 화면 API (useSessionIdle())

state(phase·expiresAt·remainingMs·lastTouch·lastActivity) · extend() · configure({ timeoutSec, warnBeforeSec, keepAliveAfterSec })(데모용 일시 변경) · resetConfig() · setActivityTracking(on) · events(최근 판정 로그).

FE-11 이중 제출 방지·로딩 락·멱등 키 설계 docs/DOUBLE-SUBMIT.md

기준일 2026-10-30. 구현: apps/demo-web/app/utils/action-lock.ts(버튼·작업 락) · utils/http.ts(요청 락·멱등 키) · composables/useActionLock.ts·useIdempotencyKey.ts · 목업 이체 서버 utils/transfer-mock.ts · 데모 /submit.

1. 세 겹 방어

층 무엇을 막나 사용
버튼(작업) 락 같은 버튼을 연타해 핸들러가 두 번 도는 것 const lock = useActionLock(); lock.run('transfer', async () => …) + <UiButton :loading="lock.pending('transfer')">
요청 락(FE-02) 진행 중인 같은 요청(메서드+URL 또는 키)을 다시 보내는 것 http.post(url, body, { lock: true }) — 결과를 공유
멱등 키 응답을 못 받아 다시 보낸 요청을 서버가 두 번 처리하는 것 http.post(url, body, { idempotencyKey: idem.key }) — 성공하면 idem.reset()
  • 버튼 락은 진행 중이면 같은 Promise 를 돌려주고 핸들러를 다시 부르지 않는다. 끝나면(성공·실패 모두) 풀린다.
  • 요청 락은 화면이 여러 곳에서 같은 요청을 동시에 보낼 때(자동 저장·버튼·단축키) 한 번만 보낸다.
  • 멱등 키는 논리적 제출 한 건마다 하나다. 응답이 끊겨 사용자가 다시 누르면 같은 키가 다시 가서 서버가 처음 결과를 돌려준다(이중 이체 방지). 성공하거나 사용자가 입력을 바꾸면 새 키.
  • idempotent: true 는 호출마다 새 키(그 호출 안의 재시도만 같은 키) — 기존 FE-02 동작 유지.
  • idempotencyKey 를 주면 그 키를 Idempotency-Key 헤더로 보내고 POST 도 retry 를 허용한다.

2. 백엔드

현재 백엔드는 Idempotency-Key 를 처리하지 않는다(신규 API·필터 만들지 않음). 데모는 브라우저 안 목업 이체 서버(/mock-api/transfer)가 키별 결과를 기억해 재전송을 재생(replayed: true)한다. 실제 적용 시 백엔드가 키를 저장·재생해야 이중 처리가 완전히 막힌다 — 그 전까지 FE 의 버튼·요청 락이 1차 방어다.

FE-03 메시지 카탈로그 설계 docs/MESSAGE-CATALOG.md

화면에 보이는 오류·안내 문구를 어디서 가져올지와 어떤 순서로 고를지를 정한다. 기준일 2026-10-29. 구현: apps/demo-web/app/utils/message-catalog.ts(코어) · plugins/message-catalog.ts(적재·설정) · composables/useMessageCatalog.ts(화면) · 데모 /messages.

1. 원천(Source)

단계 무엇 어디서
override 카탈로그 항목 중 override: true 인 FE 표준 문구 내장 + 정적 JSON
server 오류 응답에 실려 온 서버 문구(message) 백엔드 MessageSourceService (응답 시점)
remote 서버 메시지 조회 API 로 받아 둔 문구 기존 API GET /api/demo/messages/{code} (캐시)
catalog FE 카탈로그 문구 내장 BUILTIN_MESSAGES < 정적 JSON(public/mock/message-catalog.json)
default 오류 종류별 기본 문구(미정의 코드) DEFAULT_MESSAGES(utils/http.ts)
  • 같은 code 가 내장·정적 JSON 에 다 있으면 정적 JSON 이 이긴다. 제목(title)·톤(tone)은 단계와 관계없이 카탈로그 항목에서 가져온다.
  • 백엔드 API 는 새로 만들지 않는다. 기존 GET /api/demo/messages/{code}?name= 응답 {code, text} 를 쓰고, text === code 면 서버에 없는 코드로 본다. Accept-Language(기본 ko-KR)를 따르고, {0} 같은 MessageFormat 자리표시는 FE 가 채운다('' → ').

2. 결정 정책(MessagePolicy)

정책 = 위 단계의 순서. 앞 단계에서 값이 나오면 멈춘다. default 는 항상 맨 끝(빠져 있으면 붙인다).

프리셋 순서 쓰임
server-first (기본) override → server → remote → catalog → default 백엔드 문구가 정본. FE 는 빈 곳만 채우고 override 코드만 덮는다
catalog-first override → catalog → server → remote → default FE 가 문구를 통제(금융권 문구 심의 등). 카탈로그에 없는 코드만 서버 문구
server-only server → remote → default 서버 문구만. FE 코드(NETWORK 등)도 기본 문구
catalog-only catalog → default 오프라인·목업 시연

바꾸는 방법 (위에서 아래로 범위가 좁아진다)

  1. 환경 프로파일별: .env.<profile> 또는 실행 시 NUXT_PUBLIC_MESSAGE_CATALOG_POLICY=catalog-first. 프리셋 이름 대신 순서를 직접 적어도 된다: override,catalog,server,default(모르는 단계는 무시).
  2. 코드별: 카탈로그 JSON 항목에 "override": true — 정책에 override 단계가 있으면 그 코드만 FE 문구로 덮는다.
  3. 실행 중(데모·테스트): useMessageCatalog().setPolicy('catalog-first').

3. 캐시·버전

  • 백엔드(확인 결과): Refreshable + MetaLoadRunner 가 메타 적재 뒤 코드 캐시·메시지 테이블 캐시(JdbcMessageSource)·로컬 캐시를 비운다(실행: meta-load.on-startup·cron·runNow(), 데모 POST /api/demo/meta/reload). 단 데모 앱은 MetaLoadService 를 등록하지 않아 reload 가 demo.meta.not-configured 이고, 메시지 테이블(message.jdbc.enabled)도 꺼져 있다. 버전·ETag 개념은 없다.
  • FE: 서버 버전이 없으므로 remote 문구는 TTL 캐시(기본 300초, 없는 코드도 같은 TTL 로 기억해 반복 호출을 막는다). reload() 가 TTL 캐시를 비우고 정적 JSON 을 다시 읽는다. 정적 JSON 의 version 은 표시용.
  • 제안(이번 범위 밖): 백엔드가 메타 적재 시각이나 버전을 응답 헤더로 주면 FE 는 TTL 대신 버전이 바뀔 때만 비울 수 있다.

4. 설정 (runtimeConfig.public.messageCatalog, env)

키 env 기본 설명
policy NUXT_PUBLIC_MESSAGE_CATALOG_POLICY server-first 프리셋 이름 또는 쉼표 순서
url NUXT_PUBLIC_MESSAGE_CATALOG_URL /mock/message-catalog.json 정적 카탈로그. 비우면 내장 문구만
remotePath NUXT_PUBLIC_MESSAGE_CATALOG_REMOTE_PATH /demo/messages/{code} apiBase 기준 조회 경로. 비우면 remote 단계 없음
remoteTtlSec NUXT_PUBLIC_MESSAGE_CATALOG_REMOTE_TTL_SEC 300 remote 캐시 TTL
prefetch NUXT_PUBLIC_MESSAGE_CATALOG_PREFETCH (빈 값) 기동 시 미리 받을 code 목록(쉼표)

5. 화면 API (useMessageCatalog())

  • text(code, args?) — 동기. 정책대로 고른 문구 + {0} 치환. 받아 둔 remote 문구가 있으면 반영.
  • fetchText(code, args?, { force? }) — 비동기. remote 를 (TTL 안이면 캐시로) 확인한 뒤 text.
  • resolve({ kind, code, serverMessage }) → { message, source, entry } — 오류 팝업과 같은 판정.
  • policy · setPolicy(p) · reload() · status · list() · lookup(code).

공통 HTTP 오류 팝업(FE-02)은 resolve 를 쓰고, 다이얼로그 제목·톤·필드 오류 문구도 카탈로그를 따른다.

설계 메모 — 응답 envelope(ApiResponse) 사이트별 커스텀 docs/ENVELOPE-CUSTOM-DESIGN.md

상태: 설계(구현 전) · 추적: TODO-LAYOUT-CUSTOM.md

현재

  • 래핑: ApiResponseBodyAdvice(core) — 빈 ongoApiResponseBodyAdvice, @ConditionalOnMissingBean, ongo.framework.web.envelope-enabled(기본 true)일 때만 등록.
  • 필드명·레이아웃: ApiResponse(common) code/message/data/traceId 코드 고정. yml 로 못 바꾼다.
  • 가능한 우회: envelope-enabled: false, @NoEnvelope, Advice 빈 교체.
  • 헤더(상관ID)는 별개: ongo.framework.web.correlation-header.
  • 비교: 공통코드·메시지는 yml(컬럼·from·where·query·mode)로 스키마 커스텀 가능.

목표

사이트 규격(예 resultCode/resultMsg/body/guid, 또는 성공 코드 0000)에 Advice 전체 교체 없이 맞춘다.

안 A — yml 필드 매핑(단순 이름 변경)

ongo.framework.web.envelope:
  fields: { code: resultCode, message: resultMsg, data: body, trace-id: guid }
  success-code: "0000"        # 기본 OK
  omit-null: true
  extra: { timestamp: true }   # 선택 필드
  • 구현: Advice 가 ApiResponse 대신 LinkedHashMap 을 매핑 순서대로 생성(또는 Jackson PropertyNamingStrategy/MixIn).
  • 장점: 대부분 사이트를 설정만으로. 단점: 중첩 구조(header{}/body{})는 표현 한계.

안 B — SPI ApiResponseWriter(가칭)

public interface ApiResponseWriter {
    Object success(Object data, String traceId);
    Object error(String code, String message, Object detail, String traceId);
}
  • Advice·예외 핸들러·Idempotency 재생·Outbound unwrap-envelope(역방향 read) 가 모두 이 SPI 를 경유.
  • 기본 구현 = 안 A 매핑을 읽는 MappedApiResponseWriter. 중첩 구조 사이트는 빈 교체.

권장

B 를 뼈대로, 기본 구현이 A(yml)를 읽는 2단 구조. 기본값은 현재 JSON 과 바이트 동일하게(회귀 테스트). 기본 필드 레이아웃은 확정 대기(원우) — 확정 후 기본 매핑만 바꾼다.

영향 범위

ApiResponseBodyAdvice, 전역 예외 핸들러, IdempotencyKeyFilter 재생, Outbound decoder(unwrap), 프런트 공통 응답 파서, 개발 가이드 #web.

FE-18 파일 업로드/다운로드 공통 설계 (File Upload & Download) docs/FILE-TRANSFER.md

기준일: 2026-10-30
구현 위치:
- 코어 유틸: apps/demo-web/app/utils/file-transfer.ts
- 컴포저블: apps/demo-web/app/composables/useFileTransfer.ts
- 목업 파일 API: apps/demo-web/app/utils/file-mock.ts (/mock-api/files/**)
- 데모 화면: apps/demo-web/app/pages/files.vue
- 단위 테스트: apps/demo-web/tests/file-transfer.test.ts
연동: 백엔드 공통 파일 업로드/다운로드 API 부재로 브라우저 목업 핸들러 연동, 실제 BE 예시 연계 지원


1. 요구사항 및 목표

  1. 파일 검증 (Validation) - 용량 제한 (maxSizeBytes, 기본 10MB) - 허용 확장자/MIME 타입 검증 (allowedExtensions, allowedMimeTypes) - 다중 파일 개수 제한 (maxFiles) - 검증 실패 시 명확한 에러 코드/메시지 반환 (FILE_TOO_LARGE, INVALID_EXTENSION, TOO_MANY_FILES)

  2. 멀티파트 업로드 (Multipart Upload) - FormData 기반 멀티파트 바디 구성 (File / Blob / 추가 폼 필드) - 실시간 업로드 진행률(progress: 0~100%) 계산 및 상태(status: idle | validating | uploading | success | error) 관리 - XHR 또는 커스텀 업로더 어댑터를 통한 진행률 이벤트 추적 - 취소(abort) 지원

  3. 다운로드 (Blob Download) - Content-Disposition 헤더 파싱: filename*=UTF-8''... (RFC 5987/6266) 및 filename="..." 형식 지원, 한글 파일명 완벽 디코딩 - 브라우저 다운로드 트리거: URL.createObjectURL(blob) 임시 <a> 태그 생성 후 자동 클릭 및 메모리 해제(revokeObjectURL) - 다운로드 진행률 및 오류 처리 (오류 발생 시 FE-02 HttpError 공통 예외 체계 연동)

  4. 프레임워크 규격 준수 - 코어 로직(utils/file-transfer.ts)은 DOM/Nuxt에 최소한으로 의존하며 테스트 용이성 확보 - 컴포저블(composables/useFileTransfer.ts)을 통해 반응형 상태 제공 - 프레임워크(TS) / 업무 화면(JS, pages/files.vue) 분리 원칙 준수

커스터마이징 가이드 docs/CUSTOMIZATION.md

기능별 코드 예제(SPI 한눈에 보기 표·복붙용 Java/yml·데모 위치·흔한 실수)는 CUSTOMIZATION-EXAMPLES.md, 코드생성 화면 단계별 사용법·REST API 표·트러블슈팅은 DEVTOOLS-GUIDE.md.

프레임의 모든 기능은 같은 세 단계로 바꾼다. 위에서부터 가벼운 방법이다.

  1. yml — ongo.framework.<기능>.* 값으로 on/off·순서·코드·상태·크기를 바꾼다.
  2. 슬롯(SPI) 빈 추가 — 프레임이 주워 쓰는 타입의 빈을 앱에 등록한다(기본은 그대로 두고 덧붙임).
  3. 기본 빈 교체 — 같은 타입(또는 같은 이름) 빈을 앱에 등록하면 프레임 기본은 @ConditionalOnMissingBean 으로 물러난다. 후보가 둘 이상이면 쓸 빈에 @Primary.

데모 앱에 모든 예제가 있다. - yml 예제: ongo-framework-sc-demo/src/main/resources/application-custom.yml - 빈 예제: ongo-framework-sc-demo/src/main/java/com/ongosoft/framework/demo/custom/DemoCustomizations.java - 앱 인터셉터 예제: .../demo/DemoChainInterceptors.java - 확인 테스트: .../demo/DemoCustomizationTest.java (custom 프로필) - 실행: ./gradlew :ongo-framework-sc-demo:bootRun --args='--spring.profiles.active=custom'

예제 값·문구는 모두 임의의 데모 값이다. 앞으로 추가되는 기능도 이 문서에 같은 표 형식(yml / 슬롯 / 교체 / 데모 위치)으로 덧붙인다.


1. JSON 공통 (envelope·상관ID) — ongo.framework.web

무엇을 어디서 어떻게
envelope 자동 래핑 끄기 yml ongo.framework.web.envelope-enabled: false
특정 응답만 래핑 제외 코드 컨트롤러/메서드에 @NoEnvelope
상관ID 헤더명 yml ongo.framework.web.correlation-header: X-Sample-Trace (인바운드·응답·Feign 아웃바운드 모두 이 이름)
상관ID 형식 빈 교체 CorrelationIdGenerator 빈 (데모: demo-000001 순번)
상관ID 필터 자체 빈 교체 이름 ongoCorrelationIdFilter 인 FilterRegistrationBean
상관ID 필터 순서 yml ongo.framework.interceptor.correlation-filter-order
envelope 래퍼 빈 교체 ApiResponseBodyAdvice 빈

2. 예외·메시지 — ongo.framework.exception, ongo.framework.message

무엇을 어디서 어떻게
업무/검증/시스템/미처리 HTTP 상태 yml business-status, validation-status, system-status, unhandled-status (데모: 업무 409)
envelope code 문자열 yml validation-code, unhandled-code, malformed-code, not-found-code, method-not-allowed-code, unsupported-media-code
예외 하나만 다른 상태 코드 BusinessException.withStatus(code, HttpStatus.X)
공통 핸들러 끄기 yml ongo.framework.exception.enabled: false
공통 핸들러 교체 빈 교체 ApiExceptionHandler 빈 (상속해 메서드 재정의 가능)
문구 번들 위치·로케일 yml ongo.framework.message.basename, default-locale, encoding
코드별 문구 덮어쓰기 yml ongo.framework.message.messages."[code]": 문구
문구 저장소 교체(DB 등) 빈 교체 MessageSourceService 빈 (데모: [sample-msg] <code>). 여러 테이블·사이트 SQL·캐시는 §9 · EXAMPLES F3

3. 트랜잭션 — ongo.framework.transaction

무엇을 어디서 어떻게
기본 전파·격리·timeout yml propagation, isolation, timeout (데모: 15s). 메서드의 @Transactional 속성이 있으면 그쪽이 우선
롤백할/안 할 예외 타입 yml rollback-for, no-rollback-for (FQCN 목록)
메시지 코드로 롤백 yml rollback-on-codes
롤백 판단 전체 빈 교체 RollbackRuleService 빈 (데모: 체크 예외 DemoCheckedException 도 롤백)
DataSource 선택 빈 교체 DataSourceService 빈
트랜잭션 매니저 빈 교체 PlatformTransactionManager(transactionManager) 또는 TransactionManagerService 빈
끄기 yml enabled: false

4. 아웃바운드 REST (Feign) — ongo.framework.outbound

무엇을 어디서 어떻게
타임아웃·재시도·리다이렉트 yml connect-timeout, read-timeout, retry.max-attempts/period/max-period, follow-redirects
오류 code 문자열 yml client-error-code, server-error-code, unavailable-code
상대 envelope 풀기 yml unwrap-envelope: false 면 응답 본문 그대로
상관ID 전파 끄기 yml propagate-correlation-id: false
헤더·인증 추가 슬롯 feign.RequestInterceptor 빈 추가 (데모: X-Demo-Client). 상관ID 헤더는 프레임 CorrelationIdRequestInterceptor 가 넣으므로 앱에서 다시 넣지 않는다(호출부가 이미 넣으면 덮어쓰지 않음)
오류 해석 빈 교체 ErrorDecoder 빈
응답 디코딩 빈 교체 Decoder 빈
타임아웃 객체·재시도기 빈 교체 Request.Options, Retryer 빈
연결 실패 응답 빈 교체 OutboundExceptionAdvice 빈
클라이언트별 설정 Spring Cloud @FeignClient(configuration = ...) 또는 spring.cloud.openfeign.client.config.<name>.*

5. MyBatis — ongo.framework.mybatis (+ 표준 mybatis.*)

무엇을 어디서 어떻게
snake→camel·timeout·fetch·null setter yml map-underscore-to-camel-case, default-statement-timeout, default-fetch-size, call-setters-on-nulls
페이징 기본/상한 yml paging.default-size, paging.max-size (데모: 5/10)
느린 쿼리 로그(프레임 Plugin) yml plugins.slow-query-threshold: 200ms (지정할 때만 켬, SQL 본문·파라미터는 남기지 않음)
XML 매퍼 위치 등 표준 설정 yml mybatis.mapper-locations, mybatis.type-aliases-package …
Plugin 추가 슬롯 org.apache.ibatis.plugin.Interceptor 빈 (데모: DemoQueryCounter). 여러 개면 @Order
TypeHandler 추가 슬롯 TypeHandler 빈
Configuration 추가 설정 슬롯 ConfigurationCustomizer 빈 (데모: fetch size 42). 프레임 기본값 뒤에 돌게 하려면 @Order
페이징 규칙 빈 교체 Paging 빈
SqlSessionFactory 전체 빈 교체 SqlSessionFactory 빈 (이 경우 프레임 기본값은 직접 반영)
끄기 yml enabled: false

6. 요청 체인 (Filter·HandlerInterceptor) — ongo.framework.interceptor

체인: 상관ID 필터 → 접근로그 필터 → DispatcherServlet → MDC 인터셉터(order 0) → 앱 인터셉터…

무엇을 어디서 어떻게
필터 순서 yml correlation-filter-order, access-log.order (상관ID 가 먼저여야 접근로그에 traceId)
접근로그 제외 경로 yml access-log.exclude-patterns (Ant 패턴)
접근로그·MDC 개별 off yml access-log.enabled, handler-mdc.enabled
전체 off yml enabled: false (상관ID 필터는 web 소관이라 남음)
앱 인터셉터 추가 슬롯 OngoHandlerInterceptor 빈 — order(), includePatterns(), excludePatterns() (데모: DemoChainInterceptors)
앱 인터셉터 순서 yml orders.<빈이름>: 5 (데모: timing 을 audit 앞으로)
접근로그 출력처 빈 교체 AccessLogWriter 빈 (데모: 메모리 버퍼 DemoAccessLogBuffer)
접근로그 필터 자체 빈 교체 이름 ongoAccessLogFilter 인 FilterRegistrationBean
MDC 인터셉터 빈 교체 HandlerMdcInterceptor 빈
일반 Spring 방식 Spring WebMvcConfigurer.addInterceptors 도 그대로 쓸 수 있다(프레임 순서 yml 은 적용 안 됨)

MDC 키: traceId(상관ID), handler(컨트롤러#메서드). 로그 패턴 예: %5p [%X{traceId:-}] [%X{handler:-}].

7. 마스킹·XSS — ongo.framework.masking, ongo.framework.xss

체인: 상관ID 필터(H+10) → 접근로그 필터(H+20, 쿼리 마스킹) → XSS 파라미터 필터(H+30) → DispatcherServlet → [JSON 본문 XSS 정화(읽기 컨버터)] → 컨트롤러 → [응답 마스킹(쓰기 컨버터)]. 마스킹·XSS 컨버터는 MVC 에만 붙는다. 공용 ObjectMapper 와 Feign 인코더/디코더는 그대로라 아웃바운드 본문은 가려지거나 정화되지 않는다. 접근로그는 정화 전 원본 쿼리를 마스킹해서 남긴다.

무엇을 어디서 어떻게
마스킹 규칙 yml masking.rules[n].fields(대소문자 무시) + strategy: FULL\|PARTIAL\|EMAIL + keep-start/keep-end. 비우면 기본 password·secret·token FULL. 지정하면 기본 규칙을 대체 (데모 custom: sampleCode PARTIAL 3/2, email EMAIL)
마스킹 문자 yml masking.mask-char (데모 custom: #)
마스킹 화이트리스트 yml / 코드 masking.whitelist-fields (데모 custom: nickname) / 필드에 @Unmasked (데모: SampleProfile.demoKey)
로그 값 패턴 yml masking.value-patterns 정규식 (데모 custom: SC-\d{4}-\d{4})
응답·로그 개별 off yml masking.response-enabled, masking.log-enabled; 전체 masking.enabled
로그 메시지 마스킹 logback logback-spring.xml 에 conversionRule maskedMsg → com.ongosoft.framework.starter.MaskingLogbackConverter, 패턴 %msg → %maskedMsg (데모: ongo-framework-sc-demo/src/main/resources/logback-spring.xml)
마스킹 방식 전체 빈 교체 MaskingService 빈 (shouldMask/mask/maskText) — 응답·접근로그·logback 모두 이 빈을 쓴다
XSS 화이트리스트 경로 yml xss.whitelist-paths Ant 패턴 — 파라미터·본문 모두 건너뜀 (데모: /api/demo/secure/raw/**)
XSS 제외 필드 yml / 코드 xss.excluded-fields (파라미터 이름·JSON 필드 이름, 데모 custom: body) / 필드에 @XssSafe (데모: NoteForm.trustedHtml)
XSS 범위·순서 yml xss.parameters, xss.json-body, xss.order(접근로그보다 뒤 권장), 전체 xss.enabled
XSS 정화 정책 빈 교체 XssFilterService 빈 (기본 HtmlEscapeXssFilter = HTML 이스케이프; 데모 custom: 태그 제거 demoTagStrippingXssFilter)
XSS 파라미터 필터 자체 빈 교체 이름 ongoXssFilter 인 FilterRegistrationBean

데모 엔드포인트: GET /api/demo/secure/profile(마스킹), GET /api/demo/secure/search?q=·POST /api/demo/secure/notes(XSS), /api/demo/secure/raw/**(화이트리스트). 값은 모두 임의 샘플.

8. 검증·OpenAPI·해시 — ongo.framework.validation, ongo.framework.openapi, ongo.framework.hash

검증 흐름: @Valid/@Validated(그룹) → 위반 → 공통 예외 처리(ApiExceptionHandler) → 400 VALIDATION_FAILED + errors[{field, code, message}]. 메시지 코드는 MessageService 로 다국어 문구로 바뀐다. 본문(MethodArgumentNotValidException)과 파라미터 제약(HandlerMethodValidationException, ConstraintViolationException) 모두 같은 envelope 형식으로 응답한다. OpenAPI 는 앱이 springdoc-openapi-starter-webmvc-ui 를 넣었을 때만 켜진다. 노출은 활성 프로필이 openapi.profiles(기본 local, dev)에 있을 때만이며, prod 에서는 /v3/api-docs·Swagger UI 가 404 다.

무엇을 어디서 어떻게
비밀번호 규칙 값 yml validation.password.min-length/max-length/require-letter/require-digit/require-special/max-repeat (기본 8/64/true/true/true/3; 데모 custom: 12자·연속 2자)
비밀번호 규칙 자체 빈 교체 PasswordRuleService 빈 — 위반 메시지 코드 목록 반환. @ValidPassword 가 이 빈을 쓴다 (데모: 금지어 sample 추가 demoPasswordRule)
비밀번호 검증 사용 코드 필드에 @ValidPassword — 규칙별 코드(validation.password.length\|letter\|digit\|special\|repeat)가 각각 errors[] 항목
검증 그룹 코드 ValidationGroups.Create/Update (둘 다 Default 포함) + @Validated(Create.class) (데모: 등록 시에만 memberId 필수)
검증 문구 messages 앱 messages*.properties 에 같은 코드로 덮어쓰기, 또는 MessageService 빈 교체 (데모 custom: [sample-msg] <코드>)
OpenAPI 노출 프로필 yml openapi.profiles (데모 custom: local, dev, custom). 앱이 springdoc.api-docs.enabled 등을 직접 쓰면 그 값이 우선
OpenAPI 문서 정보 yml openapi.title/version/description/paths-to-match (데모: /api/**; custom: Sample Custom API, v0-sample)
OpenAPI 전체 off yml openapi.enabled: false
OpenAPI 문서 덧붙이기 슬롯 springdoc GlobalOpenApiCustomizer 빈 (데모: 확장 x-sample-owner: demo-team)
OpenAPI 문서·그룹 교체 빈 교체 OpenAPI 빈 / 이름 ongoGroupedOpenApi 인 GroupedOpenApi 빈
해시 알고리즘·반복 yml hash.algorithm(기본 SHA-512), hash.salt-bytes(16), hash.iterations(1), hash.encoding: HEX\|BASE64
HMAC yml hash.hmac-algorithm(기본 HmacSHA512), hash.hmac-key — 키는 환경변수로 (데모: ${ONGO_HASH_HMAC_KEY:placeholder-demo-hmac-key}, 운영 기본값 금지)
해시 방식 전체 빈 교체 HashService 빈 (hash/matches/digest/hmac/verifyHmac; 기본 DigestHashService, 저장 형식 알고리즘$반복$솔트$해시) (데모 custom: SHA-256·1000회·BASE64 demoHashService)

HashService 는 일반 데이터 해시·무결성용이다. 사용자 비밀번호 저장은 BCrypt/Argon2 같은 전용 해시를 HashService 빈 교체로 쓰는 것을 권장한다.

데모 엔드포인트: POST /api/demo/validation/members(Create)·PUT /api/demo/validation/members/{id}(Update)·GET /api/demo/validation/range?n=(1~100), POST /api/demo/hash, local/dev 프로필의 /swagger-ui/index.html·/v3/api-docs. 값은 모두 임의 샘플.

9. 공통 코드·메시지·메타 적재 — ongo.framework.code, ongo.framework.message.jdbc, ongo.framework.meta-load

조회 흐름: 앱 → CodeLookupService(기본 CachingCodeLookup → JdbcCodeLookup 또는 yml MapCodeLookup). 메시지: MessageSourceService(테이블 켜면 JdbcMessageSource → 없으면 yml 맵 → 번들 → 폴백 → 코드). 공통 예외 문구도 같은 서비스를 쓰므로 메시지 표에 넣은 코드는 오류 응답 문구에도 바로 반영된다. 적재: 앱(고객사 팩)이 MetaLoadService 를 등록했을 때만 MetaLoadRunner 가 켜진다. 적재가 끝나면 Refreshable 구현(코드 캐시·메시지 표 캐시)을 모두 비운다. 같은 시점에 두 번 돌지 않는다. 코드 캐시는 프로세스 내 TTL 캐시(CachingCodeLookup)다. 공유 캐시가 필요하면 §10 의 DistributedCacheService 를 쓰는 CodeLookupService 구현으로 교체한다. 메타 적재 끝에는 §10 로컬 캐시도 비워진다.

무엇을 어디서 어떻게
코드 저장소 선택 yml 기본 yml code.groups.<그룹>[n].code/name/sort-order/parent-code/attributes. code.jdbc.enabled: true 면 단일 코드 테이블 (데모: on)
코드 테이블·컬럼명 yml code.jdbc.table/group-column/code-column/name-column/sort-column/parent-column/use-column/use-value (기본 ongo_common_code·code_group·code·code_name·sort_order·parent_code·use_yn·Y, 모두 placeholder). parent-column·use-column 을 비우면 계층·사용여부 필터 없음. 식별자 형태가 아니면 기동 실패
코드 확장 컬럼 yml code.jdbc.attribute-columns.<키>: <컬럼> → CodeItem.attributes (데모: extra: extra_1)
코드 캐시 yml code.cache.enabled, code.cache.ttl(기본 10m, 0s = 만료 없음·적재 때만 비움; 데모 custom: 0s)
코드 조회 전체 빈 교체 CodeLookupService 빈 (codes/find/children/name) — 다중 테이블·원격 조회 등. 둘 이상이면 @Primary. 캐시가 필요하면 new CachingCodeLookup(내구현, ttl) 로 감싼다
코드 기능 off yml code.enabled: false
메시지 표 yml message.jdbc.enabled, table/code-column/locale-column/text-column (기본 ongo_common_message·msg_code·locale·msg_text). 로케일 탐색: ko-KR → ko → 기본 로케일 → 빈 값(공통) → 번들 폴백
메시지 전체 빈 교체 MessageSourceService 빈 (기존 §2 와 동일; 데모 custom 은 고정 접두어 구현이라 메시지 표를 쓰지 않음)
메타 적재 구현 슬롯 MetaLoadService 빈 (load() → MetaLoadResult(codes, messages)) (데모 custom: 원천 표 demo_meta_code → ongo_common_code MERGE demoMetaLoad)
적재 시점 yml meta-load.on-startup(기동 완료 시 1회; 데모 custom: true), meta-load.cron(+ zone), 전체 meta-load.enabled. 수동은 MetaLoadRunner.runNow()
적재 후 추가 갱신 슬롯 Refreshable 빈 — 적재 끝에 refresh() 가 불린다 (앱 자체 캐시 비우기 등)

yml code.jdbc / message.jdbc 는 테이블 하나만 받는다. 표가 여러 개이거나 조인·조건이 사이트마다 다르면 그 yml 을 늘리지 않고 SPI 빈으로 바꾼다. 수신 MESSAGE 기본 코드(INB_OK 등, §12.1) 문구도 같은 MessageSourceService 를 탄다. 기간계 코드 → 채널 메시지코드는 코드 표가 아니라 §18 맵핑 → 나온 키로 다시 이 메시지 표를 찾는다.

케이스 어디를 바꾸나 예제
1. 단일 코드·메시지 테이블 위 yml (code.jdbc.*, message.jdbc.*) 이 절 표. 복붙 yml 은 EXAMPLES F3.1 · H2.1
2. 코드 표 여러 개 / 메시지 표 여러 개 CodeLookupService / MessageSourceService 빈. 쿼리로 합치고, 캐시하고, Refreshable.refresh() 에서 비운다 EXAMPLES H2.2 · F3.2
3. 사이트 SQL (조인·조건이 다름) 같은 SPI. yml 컬럼 매핑으로는 조인을 표현하지 않는다 EXAMPLES H2.3 · F3.3
4. 캐시 프로세스 안은 new CachingCodeLookup(구현, ttl). 인스턴스 간은 DistributedCacheService 로 감싼다 (§10) EXAMPLES H2.4
5. 채널 ↔ 기간계 메시지코드 ChannelMessageCodeMappingService(코어) 또는 ScMessageCodeMappingRepository(삼카). 반환 값은 메시지 표 키 EXAMPLES C6 · I2 · 아래 맵핑 절
9.x 코드·메시지 모드별 yml 샘플 (SPI 없이 JDBC 내장)
# (A) yml 모드(기본) — DB 없이 yml/번들만
ongo.framework:
  code.groups.COLOR:
    - { code: R, name: 빨강, sort-order: 1 }
  message:
    default-locale: ko
    messages:
      "[order.partial-fail]": "{0}님 {1}건 중 {2}건 실패"   # 다중 인자 {0}{1}{2} — MessageFormat
---
# (B) JDBC 단일 테이블 모드
ongo.framework:
  code:
    jdbc: { enabled: true, table: ongo_common_code, group-column: code_group, code-column: code, name-column: code_name, use-column: use_yn, use-value: Y }
    cache: { enabled: true, ttl: 10m }       # 0 = 만료 없음(refresh/메타 적재 시에만 갱신)
  message:
    jdbc: { enabled: true, table: ongo_common_message, code-column: msg_code, locale-column: locale, text-column: msg_text }
    cache: { enabled: true, ttl: 10m }       # 코드 캐시와 같은 규칙: TTL 만료·Refreshable.refresh() 시 재조회, enabled:false 면 매 조회
---
# (C) JDBC 조인/사이트 SQL 모드 (SPI 빈 없이)
ongo.framework:
  code.jdbc: { enabled: true, mode: QUERY, query: "select d.cd c_code, d.nm c_name, d.ord c_sort from cm_grp g join cm_dtl d on d.grp_id = g.grp_id where g.grp_cd = ? order by d.ord" }
  message.jdbc:
    enabled: true
    from: "msg_m m join msg_d d on d.id = m.id"
    code-column: m.msg_cd
    locale-column: d.lang
    text-column: d.txt
    use-column: m.use_yn
    # 또는 query: "select ... m_code, ... m_locale, ... m_text from ..."

다중 인자: BusinessException(code, new Object[]{a, b, c}, ...) → ApiExceptionHandler → MessageSourceService.resolve 에서 yml·번들·메시지 표 문구 모두 {0}{1}{2} 치환(데모 ?args=a&args=b 와 같은 경로). 작은따옴표는 MessageFormat 규칙대로 '' 로 쓴다.

9.y 토큰 갱신·멱등 키
ongo.framework.idempotency:
  enabled: true
  header: Idempotency-Key
  ttl: 24h
  paths: ["/api/**"]      # 비면 전체 변경 요청
  • POST /api/auth/refresh (AuthSessionService.refresh): 유효 세션 → 새 토큰 발급·기존 토큰 무효화. 만료면 401 auth.token-expired.
  • 같은 키 재요청 → 저장 응답 재생(Idempotent-Replayed: true), 처리 중 → 409 idempotency.in-progress, 다른 본문 → 422 idempotency.payload-mismatch. 저장소 교체: IdempotencyStore 빈(기본 메모리).

데모 엔드포인트: GET /api/demo/codes/{group}(?parent= 하위), GET /api/demo/codes/{group}/{code}(없으면 404 + 메시지 표 문구), GET /api/demo/messages/{code}?name=(Accept-Language), POST /api/demo/meta/reload(기본 프로필은 422, custom 은 적재 결과). 표·데이터: ongo-framework-sc-demo/src/main/resources/schema.sql·data.sql — 값은 모두 임의 샘플.


10. 캐시·세션 — ongo.framework.cache, ongo.framework.session

저장소(Redis 등)는 정하지 않는다. 프레임은 서비스만 고정하고 기본 구현은 프로세스 내 맵이다(단일 인스턴스·데모용). 다중 인스턴스 운영에서는 DistributedCacheService·SessionStoreService 빈을 Redis 등 구현으로 등록해 교체한다 — 프레임 코드·의존성은 그대로.

  • LocalCacheService — 파드 안 캐시(TTL + 최대 개수, 오래 안 쓴 항목부터 버림). 메타 적재 뒤 비움(기본 on).
  • DistributedCacheService — 인스턴스 간 공유 캐시. 계약: get/put(기본 TTL 또는 지정, 0s = 만료 없음)/evict/clear/getOrLoad. 공통코드 그룹을 이 캐시에 싣는 예는 EXAMPLES H2.4. clear() 는 캐시 전체이므로 코드 키만 evict 한다.
  • SessionStoreService — 서버 세션: create(동시 세션 한도 적용)/touch(유휴 만료 연장)/find/updateAttributes/invalidate/invalidateUser(강제 로그아웃)/sessionsOf. 인증 기능은 이 서비스로 로그인 세션을 다룬다.
무엇을 어디서 어떻게
로컬 캐시 yml cache.local.ttl(기본 10m; 데모 custom: 2m), cache.local.max-size(기본 10000, 0 = 무제한), cache.local.clear-on-meta-load(기본 true)
분산 캐시 기본값 yml cache.distributed.ttl(기본 30m), max-size, key-prefix(기본 ongo:; 데모: demo:)
분산 캐시 저장소 빈 교체 DistributedCacheService 빈 (데모 custom: DemoRemoteCache — 원격 저장소 흉내, 접두어 sample:). 둘 이상이면 @Primary
로컬 캐시 구현 빈 교체 LocalCacheService 빈 (Caffeine 등)
캐시 기능 off yml cache.enabled: false (두 캐시 빈 모두 없음)
세션 타임아웃 yml session.timeout(마지막 접근 기준, 기본 30m; 데모 custom: 15m)
동시 세션 yml session.max-per-user(0 = 무제한; 데모 2, custom 1), session.overflow: EXPIRE_OLDEST(기본, 가장 오래된 세션 끊음) — 밀려난 토큰의 다음 요청은 401 auth.duplicate-login(SessionStoreService.wasDisplaced, 기록은 timeout 동안 유지; 커스텀 저장소는 이 메서드 구현 필요) / REJECT(새 로그인 거절 → SessionLimitExceededException)
세션 정책 전체 빈 교체 SessionPolicy 빈 — 교체 저장소도 이 빈을 주입받아 같은 정책을 쓴다
세션 저장소 빈 교체 SessionStoreService 빈 (Redis 등). 기본 InMemorySessionStore
세션 기능 off yml session.enabled: false

데모 엔드포인트: PUT|GET|DELETE /api/demo/cache/{local|distributed}/{key}(본문 {"value":...}, 없으면 404 + 메시지 표 문구, 응답 impl 에 구현 이름), POST /api/demo/sessions({"userId":..,"attributes":{..}}, 한도 초과 REJECT 시 업무 오류 — custom 409), GET /api/demo/sessions/{id}(연장, 만료면 401), DELETE /api/demo/sessions/{id}, GET|DELETE /api/demo/sessions/users/{userId}(목록·강제 로그아웃). 코드: .../demo/CacheSessionDemoController.java.

11. 인증·인가·암호 — ongo.framework.auth, ongo.framework.crypto

프레임은 서비스·필터·가드 골격과 데모 구현만 둔다. 로그인 방식·사용자 저장소·역할/권한 표·IAM/SSO 연동은 앱 몫이고, 교체는 서비스 빈으로 한다(프레임 JAR 수정 없음). Spring Security 는 강제하지 않는다.

  • AuthService — 자격 확인(authenticate). IdentityService — 사용자 ID → 주체(역할·클레임) 재조회. 기본 InMemoryUserAuth(yml 사용자, 비밀번호는 기동 시 HashService 로 해시 보관) 가 둘 다 구현한다.
  • AuthorizationService — 권한 코드 확인. 기본 RoleBasedAuthorization(역할 → 권한 표, * = 전체).
  • AuthSessionService — 로그인 = AuthService 검증 → SessionStoreService 세션 발급(세션 ID 가 토큰). 동시 세션 거절은 409 auth.session-limit.
  • 인증 필터: 헤더(기본 Authorization: Bearer <토큰>)·쿠키 → SessionStoreService.touch → IdentityService → AuthContext.current(). 차단은 하지 않는다.
  • 인가 가드: protected-paths 는 로그인 필요, @RequirePermission("코드") 는 로그인 + 권한. 미로그인 401 auth.unauthenticated, 권한 없음 403 auth.forbidden, 로그인 실패 401 auth.bad-credentials — 모두 공통 예외 → 공통 응답.
  • FieldCryptoService — 필드 암복호. 기본 AesGcmFieldCrypto(AES-256-GCM, 키가 있을 때만 빈 생성). ChannelCryptoService — 구간 암호, 기본 NoOpChannelCrypto(TLS 위임). 비밀번호는 HashService.
무엇을 어디서 어떻게
토큰 위치 yml auth.header(기본 Authorization), auth.scheme(기본 Bearer, 빈 값 = 헤더 값 전체; 데모 custom: X-Sample-Token), auth.cookie-name
보호 경로 yml auth.protected-paths, auth.public-paths(Ant 패턴)
역할 → 권한 yml auth.role-permissions.<역할>: [코드…]
데모 사용자 yml auth.users[].id/name/password/roles — 비밀번호는 환경변수로 주입 권장
순서 yml auth.filter-order, auth.interceptor-order(기본 10) 또는 interceptor.orders.ongoAuthorizationInterceptor
인증·주체 빈 교체 AuthService + IdentityService 빈(IAM·SSO·회원 DB). 하나라도 있으면 기본 인메모리 빈은 안 올라오므로 둘 다 제공
인가 빈 교체 AuthorizationService 빈 (데모 custom: DemoCustomizations.demoAuthorization — sample.report.* 접두어 와일드카드, @Primary)
로그인 방식 빈 교체 AuthSessionService 빈(JWT 등), 필터·가드는 이름 ongoAuthenticationFilter/ongoAuthorizationInterceptor 빈
필드 암호 yml / 빈 교체 crypto.field.key(데모: ${ONGO_FIELD_CRYPTO_KEY:placeholder…}) / FieldCryptoService 빈 (데모 custom: DemoFieldCrypto)
구간 암호 빈 교체 ChannelCryptoService 빈
끄기 yml auth.enabled: false, crypto.enabled: false

데모 엔드포인트(POC 흐름): POST /api/auth/login({"id","password"} → data.bearer), GET /api/me, GET /api/demo/auth/reports(sample.report.read), DELETE /api/demo/auth/admin/users/{userId}/sessions(sample.admin.session, 강제 로그아웃), POST /api/auth/logout, POST /api/demo/crypto/field({"value"} 암복호 왕복). 샘플 계정: sample-user / sample-admin(비밀번호는 application.yml placeholder). 코드: .../demo/AuthDemoController.java.

12. 채널·표준전문 — ongo.framework.channel

기간계·대외(MCI / EAI / FEP) 고정길이 전문 송신 골격이다. 아웃바운드 REST(Feign)와는 별도 스택이며 섞지 않는다. 업무 코드는 ChannelService.send(채널, 인터페이스ID, 값 맵) 만 호출하고, 레이아웃·코덱·전송·구간 암호는 각각 서비스라 따로 교체한다(프레임 JAR 수정 없음).

  • 흐름: TelegramLayoutService(레이아웃 조회) → TelegramCodecService.encode → ChannelCryptoService.encrypt → PhysicalTransportService.exchange → decrypt → decode → ChannelResponse(값·채널 유형·전송 이름·바이트 수).
  • 기본 코덱 FixedLengthTelegramCodec: CHAR 왼쪽 정렬·공백 채움, NUM 오른쪽 정렬·0 채움, 길이는 문자셋 바이트 기준(MS949 한글 = 2바이트). 넘치면 자르지 않고 400 channel.encode.
  • 기본 전송: http(전문 바이트를 application/octet-stream POST — MCI HTTP 형태), loopback(받은 전문 그대로 회신, 로컬·테스트용). TCP·WebT·AnyLink 등은 PhysicalTransportService 빈을 추가하고 yml 에 name() 을 적는다.
  • TCP 수신(EAI 가 tcp.port 로 밀어 넣는 방식): 코어에 TCP 서버는 없다. 앱/선택 어댑터가 소켓을 열고 받은 바이트를 InboundTelegramProcessor.process(채널, 인터페이스ID, bytes) 로 넘기면 HTTP 수신과 같은 파이프라인을 탄다. 예제: 데모 DemoEaiTcpInboundListener(custom 프로필) — docs/CUSTOMIZATION-EXAMPLES.md §M5, 개발가이드 #eaitcp.
  • 오류(공통 응답): 미등록 채널 404 channel.unknown, 미등록 인터페이스 404 channel.interface.unknown, 응답 길이 부족 502 channel.decode, 전송 실패 502 channel.send-failed.
무엇을 어디서 어떻게
문자셋 yml channel.charset(기본 UTF-8; 데모 custom: MS949) — 전 채널 공통 기본
채널별 문자셋 yml channel.channels.<이름>.charset > channel.charset-defaults.<type 또는 이름 소문자>(기본 fep: EUC-KR) > channel.charset. 삼카 관례: MCI/EAI = UTF-8, FEP = EUC-KR. 송신 encode/decode·수신 decode/응답 encode 모두 같은 채널 문자셋(TelegramCodecService.withCharset)
채널 yml channel.channels.<이름>.type(MCI/EAI/FEP…), .transport(기본 loopback), .url, .timeout(기본 10s)
레이아웃 yml channel.layouts.<인터페이스ID>.request[]/response[] = {name, length, type: CHAR|NUM} (response 비우면 request 와 같음)
레이아웃 원천 빈 교체 TelegramLayoutService 빈 (메타 DB·레이아웃 파일). 둘 이상이면 @Primary
전문 형식 빈 교체 TelegramCodecService 빈 (헤더+바디, 가변길이, 표준 헤더 등)
전송 솔루션 빈 추가 PhysicalTransportService 빈 + channels.<이름>.transport: <name()> (데모 custom: DemoCustomizations.demoHostTransport = sample-host, FEP 에 연결)
구간 암호 빈 교체 ChannelCryptoService 빈 (11장, 기본 no-op)
송신 전체 빈 교체 ChannelService 빈 (라우팅·재시도·전문 로그 정책)
끄기 yml channel.enabled: false

데모 엔드포인트: POST /api/demo/channel/{channel}/{interfaceId}(본문 = 값 맵). 기본 프로필은 mci/eai/fep 모두 loopback 이고 샘플 인터페이스 SAMPLE0001(요청 acctNo 12·holder 20·amount NUM 13 → 응답 balance 자리에 amount 가 그대로 돌아옴). custom 프로필은 fep 이 sample-host(잔액 임의 고정값 회신)·MS949. 응답 data.response(값·transport·requestBytes), data.codec. 코드: .../demo/ChannelDemoController.java.


12.1 동기·비동기 송신 · 공통 수신 endpoint
  • 비동기 송신: ChannelService.sendAsync(채널, 인터페이스ID, 값) → CompletableFuture<ChannelResponse>. 기본 DefaultChannelService 는 채널 전용 Executor ongoChannelExecutor(고정 풀, channel.async.pool-size 기본 4)에서 송신한다. 동기 send 는 그대로.
  • 공통 수신: 앱이 InboundHandlerService 빈(interfaceId(), 선택 supports(channel), handle(InboundTelegram))을 하나 이상 등록하면 프레임이 수신 endpoint 를 연다. 흐름: 구간 복호 → 요청 레이아웃 decode → 처리기 → 응답 레이아웃 encode → 암호.
  • 동기(기본): POST {path}/{channel}/{interfaceId} 본문 = 전문 바이트(application/octet-stream) → 응답 전문 바이트.
  • 비동기: 같은 경로에 ?mode=async 또는 헤더 X-Ongo-Channel-Mode: async → 202 + 접수(receiptId, status=ACCEPTED). 처리는 ongoChannelExecutor 에서, 결과는 GET {path}/receipts/{receiptId}(DONE·응답 값 / FAILED·오류).
  • 오류(공통 응답): 레이아웃 없음 404 channel.interface.unknown, 처리기 없음 404 channel.inbound.unknown, 접수 번호 없음 404 channel.inbound.receipt-unknown.
  • 고객사 전용 수신 VO·어노테이션 파싱은 확장 모듈에서 InboundHandlerService·TelegramLayoutService 구현으로 얹는다(코어 금지).
무엇을 어디서 어떻게
비동기 풀 yml / 빈 교체 channel.async.pool-size, 또는 이름 ongoChannelExecutor 인 Executor 빈
수신 경로 yml channel.inbound.path(기본 /framework/channel/inbound)
수신 끄기 yml channel.inbound.enabled: false (처리기 빈이 없어도 열리지 않음)
접수 보관 yml channel.inbound.max-receipts(기본 1000, 오래된 것부터 버림)
수신 처리 빈 추가 InboundHandlerService 빈 (데모: ChannelInboundDemoHandler — SAMPLE0001 잔액 = 금액×2)
수신 전체 빈 교체 InboundTelegramProcessor / OngoChannelInboundController 빈 (영속 접수 저장·회신 송신 등)
수신 공통 메시지 yml channel.inbound-message.* (아래). 끄기: enabled: false

수신 응답 MESSAGE: 업무가 message.stdMsgC/message.stdMsgCn 을 안 넣으면 공통 정상 코드를 채운다. BusinessException(또는 프레임 패키지 밖 CodedException)은 그 코드·문구(문구가 있으면 테이블이 덮지 않고, 없으면 MessageSourceService)를 쓴다. 그 외는 DB·타임아웃·검증·공통 에러로 나눈다. 코드·기본 문구·결과코드(header.procsRsDvC 기본 0/1/8/9)는 시스템마다 바꾼다. 삼카 코덱은 건수가 1 이상일 때만 코드·내용 반복부(기본 10+100, samsung-card.standard-telegram.message-code-length/message-text-length)를 붙인다. 건수 0이면 기존 HEADER+MESSAGE 2바이트+BODY+@@ 길이를 유지한다.

ongo.framework.channel.inbound-message:
  enabled: true
  success-code: INB_OK          # 기본은 삼카 반복부 코드 길이(10) 안에 둔다. 더 긴 체계면 message-code-length 도 늘린다
  error-code: INB_ERR
  data-access-code: INB_DB
  timeout-code: INB_TMO
  validation-code: INB_VAL
  success-text: 정상 처리되었습니다
  error-text: 처리 중 오류가 발생했습니다
  data-access-text: 데이터 처리 중 오류가 발생했습니다
  timeout-text: 처리 시간이 초과되었습니다
  validation-text: 입력값을 확인해 주세요
  result-field: header.procsRsDvC    # 비우면 결과코드를 넣지 않음
  success-result: "0"
  business-result: "1"
  timeout-result: "8"
  error-result: "9"

데모: POST /api/demo/channel/{channel}/{interfaceId}/async(비동기 송신), POST /framework/channel/inbound/{channel}/SAMPLE0001(수신 동기·?mode=async). 테스트: ChannelAsyncInboundDemoTest.

채널별 송신 예제(MCI / EAI / FEP) — 업무 코드는 채널 이름만 바꾼다. 문자셋은 채널 설정으로 자동.

ongo.framework.channel:
  charset: UTF-8                 # mci/eai
  charset-defaults: { fep: EUC-KR }
  channels:
    mci: { type: MCI, transport: http, url: http://mci-host/gw }
    eai: { type: EAI, transport: http, url: http://eai-host/gw }
    fep: { type: FEP, transport: http, url: http://fep-host/gw }   # charset: MS949 처럼 개별 지정 가능
channel.send("mci", "SCIF0001", Map.of("cardNo", no, "header.rcvSysDvC", "MCI"));          // UTF-8
channel.send("eai", "SCIF0002", Map.of("cardNo", no, "header.rcvSysDvC", "EAI"));          // UTF-8
ScTelegramContext.with(Map.of("rcvSysDvC", "FEP", "stdIfId", "SCIF0003"), Map.of(),
        () -> channel.send("fep", "SCIF0003", Map.of("cardNo", no)));                       // EUC-KR

데모: POST /api/demo/channel/{mci|eai|fep}/{interfaceId}(응답 data.charset), 수신 시연 POST /api/demo/channel/{mci|eai|fep}/SAMPLE0001/inbound(값 → 채널 문자셋 요청 전문 → 공통 수신 처리기 → 응답 decode). 쇼케이스 화면(/) 「삼카 확장」 카드에 채널별 버튼. 테스트: ChannelPerChannelDemoTest.

채널별 수신 URL: 전용 컨트롤러 없이 공통 경로 하나로 MCI/EAI/FEP 를 받는다 — POST /framework/channel/inbound/mci/{interfaceId}, .../eai/{interfaceId}, .../fep/{interfaceId} ({channel} = InboundHandlerService.supports(channel) 판정값). 동기 응답은 인코더 byte[](application/octet-stream), ?mode=async 는 JSON 접수. TCP 수신·채널별 별도 path 는 운영 연동 스펙 확정 후.


12.2 채널·전문 규칙 레퍼런스 (2026-10-28, #115·#116 기준)

역할 분담: 이 절은 규칙·키·우선순위·케이스 를 정리한다. 복붙용 완전한 코드는 CUSTOMIZATION-EXAMPLES.md §K 에 있다.

12.2.1 방향 용어 — 「송신」과 「수신 응답」
구분 누가 시작 프레임 진입점 코덱 방향 헤더 기본값 묶음
송신(우리가 요청) 우리 업무 코드 ChannelService.send/sendAsync @direction 없음(= 요청) standard-telegram.header / .message
송신 응답 decode 상대가 회신 같은 호출의 반환값 decode —
수신(타시스템이 요청) 상대 시스템 POST {inbound.path}/{channel}/{interfaceId} decode —
수신 응답(우리가 회신) 우리 InboundHandlerService InboundTelegramProcessor @direction=response 자동 standard-telegram.response-header / .response-message + 요청 에코
12.2.2 채널별 문자셋(charset) — MCI / EAI / FEP

결정 순서(먼저 찾은 값): channel.channels.<이름>.charset → channel.charset-defaults.<type 소문자 또는 이름> → channel.charset.

채널 삼카 관례 기본 설정으로 얻는 값 비고
mci UTF-8 charset(기본 UTF-8) 한글 1자 = 3바이트
eai UTF-8 charset 한글 1자 = 3바이트
fep EUC-KR charset-defaults.fep: EUC-KR(코어 기본) 대외기관이 MS949 확장 한글을 보내면 channels.fep.charset: MS949
  • 같은 채널 문자셋이 송신 encode → 응답 decode, 수신 decode → 수신 응답 encode 네 단계 모두에 쓰인다(TelegramCodecService.withCharset). 수신 응답만 다른 문자셋으로 나가는 일은 없다.
  • 필드 길이는 바이트 기준이다. 같은 holder 20바이트 필드에 한글 이름은 UTF-8 6자 / EUC-KR 10자까지 들어간다. 넘치면 잘리지 않고 400 channel.encode.
  • 데모 응답 data.charset 으로 실제 적용 문자셋 확인.
12.2.3 HEADER/MESSAGE 값 병합 — null·"" vs 공백 " "

우선순위(위가 이김):

순위 원천 송신 수신 응답
1 전문 values 의 header.x / message.x ✓ ✓(처리기 반환 맵)
2 ScTelegramContext(업무단, 스레드 범위) ✓ ✓
3 요청 HEADER 에코(§12.2.4) + stdEtxtPrgNo +1 — ✓
4 yml standard-telegram.header/message 또는 response-header/response-message ✓ ✓
5 내장 기본값 ScStandardHeaderDefaults(언어 KR, 버전 D12, 일련번호, 시각, 호스트…) ✓ ✓
상위 값 결과 예
null 하위(디폴트) 사용 header.rcvSysDvC 를 맵에 안 넣음 / null
"" (빈 문자열) 하위(디폴트) 사용 화면 HEADER JSON 에서 칸을 비움
" " (공백 1자 이상) 공백 그대로 덮어씀 → 전문에 공백 패딩 상대가 "공백이어야 함" 을 요구하는 필드
"MCI" 그 값 —

주의: 「값을 지우고 싶다」 는 "" 가 아니라 " " 다. "" 는 디폴트가 다시 들어간다. stdEtxtLnth(전체 길이) 는 항상 자동 계산되어 덮을 수 없다.

12.2.4 수신 응답 헤더 에코 — 「받은 값을 그대로 돌려준다」

InboundTelegramProcessor 가 응답 encode values 에 @direction=response(처리기가 이미 넣었으면 유지) 와 @request(원 요청 decode 맵)를 넣고, 표준전문 코덱이 아래를 응답 HEADER 에 복사한다.

응답 필드 값 의미
stdEtxtSn 요청 그대로 표준전문일련번호 — 거래 추적 키. 새로 만들지 않는다
stdEtxtCrtDt 요청 그대로 전문 생성일자
fstAkSysDvC, fstAkDtm 요청 그대로 최초 요청 시스템·일시
stdIfId 요청 그대로 표준 인터페이스 ID
akRcvSvId, rspRcvSvId, mciAkNodeId 요청 그대로 요청/응답 수신 서비스·MCI 노드
rcvSysDvC 요청의 sendSysDvC 응답 수신 시스템 = 원 요청 송신 시스템
stdEtxtPrgNo 요청 + 1 표준전문진행번호(아래)

에코는 순위 3이므로 처리기 반환 맵의 header.x(순위 1) 로 개별 필드를 다시 덮을 수 있다(특수 연동만).

12.2.5 표준전문진행번호 stdEtxtPrgNo — hop 규칙 (#116)

전문이 전문을 호출하는 중첩(nested) 호출 에서 몇 번째 hop 인지 표시한다.

상황 값 구현
우리가 최초 송신 0 (NUM 2 → 00) ScStandardHeaderDefaults.header()
수신 응답 요청값 + 1 ScStandardTelegramCodec.nextPrgNo
요청값이 비었거나 숫자 아님 0 으로 보고 1 동일

예: A(송신 00) → 우리 수신 → 우리 응답 01. 우리 처리기가 안에서 또 송신하면 그 송신은 새 거래이므로 00 에서 시작한다(상위 hop 을 이어 붙이려면 ScTelegramContext.with(Map.of("stdEtxtPrgNo", …)) 로 명시).

12.2.6 실 적용 케이스
케이스 데모에서 쓰는 방식 고객 앱에서 교체하는 방식 주의 / 안티패턴
채널 3종 송신 ChannelDemoController → POST /api/demo/channel/{mci\|eai\|fep}/{id}, 전송 loopback yml channels.<이름>.transport/url 만 바꾸고 업무 코드는 그대로 업무 코드에 new String(bytes, "EUC-KR") 같은 직접 변환 금지 — 채널 문자셋에 맡김
FEP 문자셋 charset-defaults.fep: EUC-KR, custom 프로필 MS949 channels.fep.charset 개별 지정 공통 charset 을 EUC-KR 로 바꿔 MCI/EAI 까지 깨뜨리는 것
수신 ChannelInboundDemoHandler(SAMPLE0001), /api/demo/channel/{ch}/SAMPLE0001/inbound InboundHandlerService 빈 추가(인터페이스별 1개), supports(channel) 로 채널 한정 채널별 전용 컨트롤러 신설 — 공통 경로 /framework/channel/inbound/{channel}/… 사용
헤더 디폴트 yml standard-telegram.header 몇 개 기관별 값은 yml, 인터페이스별 값은 ScTelegramContext, 건별 값은 header.x 빈 칸을 "지움" 의미로 "" 사용(디폴트가 들어감)
응답 에코 자동 손대지 않음 처리기에서 stdEtxtSn 을 새로 생성해 덮기(추적 끊김)
진행번호 자동 0 / +1 손대지 않음 수신 응답에 header.stdEtxtPrgNo=0 하드코딩
일반(비삼카) 앱 generic-demo: 코어 FixedLengthTelegramCodec(BODY 만) 자체 TelegramCodecService 표준전문 헤더 규칙(에코·진행번호)은 삼카 코덱 기능 — 코어 코덱에는 없음
Boot2(JDK 8/11) ongo-framework-boot2-sc-demo(AA14/15) 같은 yml 키·SPI(Boot2 자동구성 PoC10) Boot2 에서 Map.of 등 Java 9+ API 사용 금지

13. 로깅·관측 — ongo.framework.logging

앱 로그는 logback 기본 그대로 두고, 그 위에 슬롯 로그(접근·감사·오류·업무 등 이름 붙은 로그)와 외부 연계 브리지, 앱별 로거 레벨 관리를 얹는다. k8s(ELK/Loki/Grafana)와 IaaS(파일·콘솔)를 같은 업무 코드로 커버한다 — 업무 코드는 StructuredLogService.log(슬롯, 메시지, 필드 맵) 만 호출.

  • 흐름: 슬롯 조회(미등록 404 logging.slot.unknown) → 꺼진 슬롯이면 생략 → MDC 상관ID(traceId, 1장) 첨부 → 등록된 모든 LoggingBridgeService 로 송출. 한 브리지가 실패해도 경고만 남기고 업무는 계속.
  • 기본 브리지 slf4j: 로거 ongo.log.<슬롯> 에 메시지 k=v ... — logback appender(파일·콘솔·ELK 등)와 기존 %maskedMsg 마스킹(7장)이 그대로 적용.
  • 기본 슬롯: access·audit·error(필수), biz(꺼짐). 필수 슬롯은 yml 로 enabled: false 를 줘도 꺼지지 않는다(고객사 필수 로그 목록 = required-slots).
  • 레벨: levels 는 기동 시 적용, LogLevelService 로 재기동 없이 조회·변경(Spring Boot LoggingSystem). 잘못된 레벨 400 logging.level.invalid.
무엇을 어디서 어떻게
앱별 로거 레벨 yml logging.levels.<로거>: DEBUG(데모 custom: com.ongosoft.framework.demo: DEBUG)
슬롯 추가·레벨·끄기 yml logging.slots.<이름>: {level, enabled, required}(데모 custom: biz 켜기·DEBUG, sample-trade WARN 추가, audit 끄기 시도 → 무시)
필수 슬롯 목록 yml logging.required-slots(기본 [access, audit, error])
슬롯 로거 이름 yml logging.logger-prefix(기본 ongo.log)
외부 연계 빈 추가 LoggingBridgeService 빈 (ELK·Loki·Kafka·파일 등). 데모 custom: DemoCustomizations.demoLogCollector = sample-collector
브리지 선택·순서 yml logging.bridges: [slf4j, sample-collector](비우면 전부) · 기본 slf4j 끄기 logging.slf4j-bridge: false
레벨 관리 빈 교체 LogLevelService 빈 (중앙 설정 서버 연동 등)
슬롯 로그 전체 빈 교체 StructuredLogService 빈 (둘 이상이면 @Primary) — 비동기·샘플링·필드 마스킹 정책
끄기 yml logging.enabled: false

데모 엔드포인트: GET /api/demo/logging/slots, POST /api/demo/logging/slots/{slot}(본문 = 필드 맵, 응답 data.bridges = 실제 송출 브리지), GET|PUT /api/demo/logging/levels/{logger}?level=DEBUG. 운영에서는 레벨 변경 엔드포인트를 11장 인가 뒤에 둔다. 코드: .../demo/LoggingDemoController.java.


14. 개발 지원 웹툴·코드생성 — ongo.framework.devtools

모듈 ongo-framework-devtools 를 의존하면 앱 안에 /framework-devtools(화면)·/framework-devtools/api(API)가 붙는다. profiles(기본 local·dev) 중 하나가 활성일 때만 AutoConfig 가 켜지고, 화면도 정적 리소스가 아니라 컨트롤러가 내주므로 prod 에는 아무것도 노출되지 않는다.

  • 흐름: 정의서 원문 → SpecParseService(유형별) → 공통 SpecModel/FieldSpec → CodeGenService → CodeGenDecorator 들이 import·클래스/필드 어노테이션 덧붙임 → 미리보기 또는 GeneratedSourceWriter 가 소비 앱 소스 트리에 쓰기.
  • 기본: 유형 json(항상 남음), 생성기 = Java record(또는 getter/setter 클래스), 데코레이터 validation(required → @NotNull, 문자열 length → @Size(max)).
  • 쓰기 보호: 출력 루트 밖 경로 거부, 기존 파일은 overwrite=true 일 때만 덮어씀(아니면 SKIPPED_EXISTS). 필드명·패키지는 Java 식별자 검사.
  • FieldSpec.attributes/SpecModel.attributes 는 프레임이 해석하지 않는 자유 속성 — 고객사 파서가 채우고 고객사 데코레이터가 읽는다.
무엇을 어디서 어떻게
켜는 프로필 yml devtools.profiles: [local, dev](데모 custom: custom 추가)
끄기 yml devtools.enabled: false
출력 위치·쓰기 허용 yml devtools.codegen.output-dir(기본 src/main/java), write-enabled: false 면 미리보기만(쓰기 요청 403 devtools.codegen.write-disabled)
기본 kind·패키지 yml codegen.default-kind(DTO), codegen.default-package
생성 형태·접미사·머리주석 yml codegen.style: RECORD\|CLASS, codegen.suffixes: {DTO: Dto, VO: Vo}, codegen.header
Lombok(8/11 클래스) yml·화면·API codegen.lombok: true / 화면 「Lombok 사용」(대상 JDK 8/11일 때만 표시) / POST /api/generate/{type}?targetJava=8&lombok=true → getter/setter 대신 @Getter @Setter(@Data 는 equals/hashCode 부작용 때문에 안 씀). record 로 생성되는 17/21 에선 무시. 프로젝트에 lombok 의존성 필요
정의서 유형 추가 빈 추가 SpecParseService 빈 (고객사 테이블정의서·IO·전문 양식 등, 이름 = type()). 데모 custom: sample-csv
고객사 어노테이션 빈 추가 CodeGenDecorator 빈 (데모 custom: sample-layout → @SampleLayout·@SampleLayout.Field(offset, length))
데코레이터 선택·순서 yml codegen.decorators: [validation, sample-layout](비우면 등록된 전부, @Order 순) · 기본 끄기 codegen.validation-decorator: false
생성기 전체 빈 교체 CodeGenService 빈 (템플릿 엔진 등)
쓰기 정책 빈 교체 GeneratedSourceWriter 빈
기본 JSON 파서 빈 교체 이름 ongoJsonSpecParser 인 SpecParseService 빈

순수 코어만 쓰는 앱은 기본 설정(json·validation)으로, 고객사 확장 모듈을 의존하는 앱은 그 모듈이 등록한 파서·데코레이터가 함께 뜬다(앱 코드 변경 없음).

JSON 정의서 예: {"name":"SampleOrder","kind":"DTO","package":"com.example.sample","fields":[{"name":"orderNo","type":"STRING","length":12,"required":true,"description":"주문번호","attributes":{}}]} — 타입 별칭 STRING·INT·LONG·DECIMAL·NUM·BOOLEAN·DATE·DATETIME 또는 FQCN. 배열이나 {"specs":[...]} 로 여러 개.

화면 배치·버튼별 동작은 DEVTOOLS-GUIDE.md 가 정본이다(공통 I/O 정의서 카드: 입력 유형 → 불러오기·펼침·편집 → 카드 하단 덮어쓰기/스웨거/미리보기/소스에 쓰기. 업무단위 C/S/R 은 메소드 미정의 시 list·get·create 기본 뼈대 — 가이드 §8.2).

데모 엔드포인트(local/dev, 또는 custom): GET /framework-devtools(화면), GET /framework-devtools/api(유형·데코레이터·출력 위치), POST /framework-devtools/api/parse/{type}, POST /framework-devtools/api/generate/{type}?write=false&overwrite=false(본문 = 정의서 원문, 응답 data.files·data.written). 미등록 유형 404 devtools.spec.unknown, 형식 오류 400 devtools.spec.invalid/devtools.codegen.invalid. 코드: ongo-framework-devtools/.../DevtoolsController.java, 데모 .../demo/custom/DevtoolsDemoCustomizations.java.

14.1 정의서 → 동적 VO 생성·컴파일·로드 → 전문 (local/dev 전용)

hyeri_adapter 방식의 코어 파이프라인(com.ongosoft.framework.devtools.dynamic): 정의서 원문 → SpecParseService → JavaSourceGenerator(활성 데코레이터 + TelegramFieldCodeGenDecorator) → DynamicVoCompiler(JDK javax.tools javac, work-dir/<buildId>/{src,classes}, 별도 URLClassLoader) → VoLayoutResolver(VO 어노테이션 length → TelegramLayout) → TelegramCodecService 인코딩 → PhysicalTransportService 송수신 → 디코딩. 코어 기본 해석기는 @TelegramField(order, length[, num]). 고객사는 파서(SpecParseService)·데코레이터·VoLayoutResolver·코덱만 갈아끼운다(코어에 고객사 로직 없음).

  • 동적 생성·컴파일·클래스로더는 운영·일반 런타임 WAS 에서 금지 — 개발툴 프로세스(local/dev)에서만. 결과는 소스로 내려받아 앱/배치에 반영한다.
  • 로컬 = 코드생성 + 전문 테스트, 서버(dev WAS) = 전문 테스트만. 코드생성 쓰기는 codegen.profiles(기본 [local])에서만. 코드생성 codegen.output-dir(프로젝트 src/main/java 직접 또는 안전한 별도 경로 — 둘 다 허용)과 전문 테스트 dynamic.work-dir 은 다른 경로(겹치면 기동 실패). 서버 work-dir 은 런타임 WAS 가 쓸 수 있는 폴더.
  • 기존 VO 우선(dynamic.prefer-existing-vo: true): ① 네이밍 규칙명 ② 정의서 생성 FQCN 순으로 클래스로더 로드 → 있으면 그 VO(컴파일 안 함, 응답 vos[].existing=true), 없으면 동적 생성·컴파일.
  • devtools AutoConfig 와 같이 devtools.profiles 에서만 켜진다. prod 에서 런타임 컴파일은 하지 않는다(운영은 생성 소스를 커밋·빌드한 VO + 14장/16장 레이아웃).
  • JDK 필요(JRE 면 동적 VO 컴파일 실패). 컴파일 classpath = java.class.path + extra-classpath — fat jar 로 띄운 개발 서버는 extra-classpath 에 프레임/고객사 jar 를 넣는다.
  • 개발 서버는 앱 폴더가 읽기 전용일 수 있으므로 work-dir 을 쓰기 가능한 경로로 둔다(환경변수로 주입 권장). 빌드마다 하위 폴더가 쌓이니 주기적으로 지운다.
무엇을 어디서 어떻게
끄기 yml devtools.dynamic.enabled: false
생성·컴파일 출력 폴더 yml devtools.dynamic.work-dir(기본 build/ongo-devtools/dynamic, 데모 ${ONGO_DEVTOOLS_DYNAMIC_WORK_DIR:...})
컴파일 classpath 보강 yml devtools.dynamic.extra-classpath: [jar·폴더]
생성 형태·기본 패키지 yml devtools.dynamic.style: RECORD\|CLASS, devtools.dynamic.default-package
레이아웃 해석 빈 추가 VoLayoutResolver 빈(@Order 먼저 맞는 것 사용, 기본 ongoTelegramFieldLayoutResolver 는 최후)
파이프라인 교체 빈 DynamicTelegramPipeline 빈
기존 VO 우선 yml devtools.dynamic.prefer-existing-vo(기본 true)
전문 IN/OUT 네이밍 규칙 yml devtools.dynamic.vo-naming.package-name·pattern(기본 {package}.{prefix}{interfaceIdCamel}{suffix}, 원형 ID는 {interfaceId})·prefix: ""·in-suffix: In·out-suffix: Out — prefix·suffix는 코드생성 in/out 클래스명에도 동일 적용 — package 비우면 규칙 미사용
코드생성 허용 프로필 yml devtools.codegen.profiles(기본 [local])
14.2 유연 코드생성(코어) — IN/OUT 직접 정의 · OBJECT/ARRAY · 컬럼 기본 속성

코어 JsonSpecParser·JavaSourceGenerator 가 지원한다(삼카 등 고객사는 레이아웃·파서 확장만).

{"name":"CARD01","package":"com.acme.tlg",
 "in":{"fields":[{"name":"cardNo","type":"STRING","length":16,"required":true,"encrypted":true,"masking":"CARD_NO"}]},
 "out":{"fields":[
   {"name":"owner","type":"OBJECT","fields":[{"name":"name"},{"name":"age","type":"INT"}]},
   {"name":"histories","type":"ARRAY","fields":[{"name":"amount","type":"LONG"}]},
   {"name":"tags","type":"ARRAY<STRING>"},
   {"name":"addr","type":"OBJECT","attributes":{"ref":"com.acme.Addr"}}]}}
  • in/out → CARD01In·CARD01Out(direction REQUEST/RESPONSE, interfaceId=name, 네이밍 규칙과 같은 이름). attributes.className 으로 이름 직접 지정 가능.
  • OBJECT+하위 fields → 중첩 static 타입, ARRAY+fields → List<중첩타입>, ARRAY<T>/T[] → List<T>, attributes.ref → 기존 타입.
  • 컬럼 기본 속성: 자료형(type)·길이(length)·scale·required·encrypted(true|정책명 → @Encrypted)·masking(true|규칙명 → @Masked). 표식 어노테이션은 코어 core.security — 실제 암복호화·마스킹 연결은 앱(TypeHandler·MaskingService 등).
  • 컬럼 속성 처리 SPI core.security.ColumnAttributeService(기본 no-op 빈 ongoColumnAttributeService, @ConditionalOnMissingBean → 앱 빈으로 교체): ColumnMeta.of(field|recordComponent, length) 로 자료형·길이·@Encrypted·@Masked 를 읽어 인터셉터(요청/응답 beforeResponse — 마스킹)·전문 인코더/디코더(beforeEncode 암호화 / afterDecode 복호화)가 별도 처리. 키·규칙은 앱 정책. 호출 지점·순서는 바로 아래 자동 연결 표(ColumnValues) 참고(§16.2).
  • 자동 연결(코어·스타터) — 앱이 ColumnAttributeService 빈 하나만 등록하면 아래가 모두 그 빈을 호출(기본 no-op 이면 동작 불변). 공통 규칙 core.security.ColumnValues: SPI 가 값을 바꾸면 그 값 → 그대로면 @Encrypted 컬럼만 FieldCryptoService → 없으면 원값.
지점 호출 메타(ColumnMeta)
코어 FixedLengthTelegramCodec(빈 ongoTelegramCodecService) 필드마다 beforeEncode / afterDecode FixedLengthTelegramCodec.meta(FieldSpec) — 이름·자료형(NUM=Long)·길이, 표식 없음
삼카 ScStandardTelegramCodec @Encrypted 바디 필드 ColumnValues.outbound/inbound 이름·길이·encrypted
EncryptedFieldInterceptor(INSERT/UPDATE) · EncryptedResultInterceptor(SELECT) @Encrypted 필드 ColumnValues.outbound / inbound ColumnMeta.of(field, null)
응답 MaskingJacksonModule @Masked/@Encrypted 필드 beforeResponse → 값 바뀌면 그 값, 아니면 기존 마스킹 이름·자료형·encrypted·masking

직접 생성 시: new FixedLengthTelegramCodec(cs, () -> cas) / .withColumns(..), new EncryptedFieldInterceptor(crypto, columns), new MaskingJacksonModule(masking, columns). - 엑셀 IN/OUT 업로드는 구현됨: 컬럼↔어노테이션 매핑 → I/O 트리 편집 → 생성 코드 미리보기 → 쓰기(§14.4).

API: POST /framework-devtools/api/dynamic/{type} 본문 {"content":"정의서 원문","interfaceId":"(생략 시 첫 VO)","values":{...},"transport":"loopback"} → data.buildId·data.dir·data.vos[].fields[](offset·length·type)·data.exchange(values 생략 시 로드·레이아웃만). 형식 오류 400 devtools.dynamic.invalid, 컴파일·통신 실패 500 devtools.dynamic.failed.

14.3 DB VO 암호화·응답 DTO 마스킹 (코어 SPI + 기본 동작)

SELECT 결과 자동 복호화: EncryptedResultInterceptor(MyBatis ResultSetHandler.handleResultSets)가 결과 VO(가변 클래스·중첩·List/Map)의 @Encrypted String 을 FieldCryptoService.decrypt. 복호화 실패(평문 레거시)는 원값 유지. 끄기 ongo.framework.mybatis.plugins.field-decryption=false.

코어(starter)만으로 동작하며, 삼카 확장 모듈은 교체·확장 예시만 둔다(데모앱은 호출·확인만).

대상 어노테이션 기본 동작 교체/끄기
DB VO(MyBatis 파라미터) @Encrypted (String, 가변 필드) 추가/수정(Executor.update) 시 EncryptedFieldInterceptor 가 FieldCryptoService 로 암호화 바인딩 → 실행 후 평문 복원. Map(@Param)·Collection 안 VO 도 처리, record 제외 FieldCryptoService 빈(고객사 DB 암호화 솔루션) · 인터셉터 빈 교체 · ongo.framework.mybatis.plugins.field-encryption=false
Controller 응답 DTO @Masked("규칙명") 응답 직렬화 시 MaskingJacksonModule 이 MaskingService.mask(규칙명|필드명); 규칙 없으면 전체 * MaskingService 빈 · ongo.framework.masking.rules · @Unmasked 로 제외
DB VO(MyBatis SELECT 결과) @Encrypted EncryptedResultInterceptor(ResultSetHandler.handleResultSets)가 결과 객체·중첩·List/Map 의 @Encrypted String 을 복호화. 실패하면 원값 유지(평문 레거시 허용) ongo.framework.mybatis.plugins.field-decryption=false · 빈 ongoEncryptedResultInterceptor 교체

전문 코덱 측은 §14.2 ColumnAttributeService · §16.2. 전체 요약은 §18. 예시: ongo-framework-samsung-card security.ScCardHolderRow(@Encrypted) · ScCardHolderView(@Masked) → 데모 POST /api/demo/column-security(ShowcaseDemoTest).

역할 분리: 코어 = SPI + 기본 동작(삼카 없이 동작) · 삼카 확장 = 교체/확장 기본 예시 · 데모앱 = 그 기능 호출·확인. 데모 쇼케이스 페이지 http://localhost:8080/ (코어/삼카/개발툴 카드별 ▶ 실행, 개발툴은 local 프로파일).

14.4 I/O 가져오기·펼침 — 기존 VO / Sample JSON / 정의서 / 엑셀

POST /framework-devtools/api/import/{source} → 공통 I/O 트리(편집 후 코드생성). 화면: devtools “가져오기/펼침”.

source 입력
vo?className= 기존 VO(어노테이션 VO·POJO·record). XIn/XOut 짝, @Encrypted/@Masked, 중첩=OBJECT, List<T>=ARRAY
sample-json?name= 샘플 JSON 본문 → 타입 추론
excel 코어 기본 양식(일반 VO, 시트 1개 = VO 1개)
sc-excel 삼카 확장: 다중시트 인터페이스정의서
기타 등록 파서 SpecParseService.type()

코어 기본 엑셀 양식 (헤더 행 자동 탐지, 선택 #package com.x 행):

항목키 인식 헤더
name 항목명 · 필드명 · 필드명(영문) · 영문명
label 한글명 · 필드명(한글) → 설명 앞에 붙임
type / length / scale 타입·데이터타입 / 길이 / 소수·SCALE
required 필수 · 필수여부 (Y)
encrypted 암호화여부 · 암호화 (Y → @Encrypted)
masking 마스킹여부 · Masking (Y 또는 규칙명 → @Masked)
maskingType 마스킹유형코드 · Masking 유형코드 (masking=Y 일 때 규칙명)
io / level 구분(IN/OUT) / 레벨(중첩)

본문: {"file":"<xlsx base64>","mapping":{"headerRow":1,"columns":{"name":"FIELD","length":"D"},"sheets":["IO*"]}} — columns 값은 헤더명 또는 열 문자, 기본 별칭보다 우선. TSV(#sheet 이름 구분)도 허용.

삼카 sc-excel (ScInterfaceExcelSpecParser extends ExcelSpecParser): 표지·문서관리정보·인터페이스정의서·소스IO정의서·타켓IO정의서 형식. IO 시트의 *** INPUT 항목 ***/*** OUTPUT 항목 *** 구간 → in/out, IO ID(SIO_APC_ISS_00026_I) → SioApcIss00026In/Out, 영문 필드명 FRG_BUR_C → frgBurC(원명은 설명에), 인터페이스ID 는 인터페이스정의서 시트에서. {ID}_IN/_OUT 시트 짝도 지원. 정의서는 BODY 만 정의한다 — 헤더/공통메시지는 코덱이 붙인다(§16.2).

데모 확장·커스텀 카탈로그: 데모의 모든 SPI 교체 예시(DemoCustomizations·DevtoolsDemoCustomizations·DemoExtensionMocks)와 Redis·MQ(Rabbit/Kafka)·ES 목업은 코어 개발가이드 /framework-devtools/guide#demoext·#extmock 및 CUSTOMIZATION-EXAMPLES §M 참고. 코어에는 SPI 계약만 있고 클라이언트 의존성은 없다(일반 공통, 삼카 비의존).

15. 공통 배치 — ongo.framework.batch

ongo-framework-batch(Spring Batch 5, Boot BatchAutoConfiguration 위). 프레임은 골격(런처·실패 정책·모니터링 훅·개발툴 화면), 잡·스텝·Reader/Writer·업무 정책은 앱.

무엇을 어디서 어떻게
끄기 yml ongo.framework.batch.enabled: false
수동 실행 허용 잡 yml launch.allowed-jobs: [..](비우면 전부), launch.recent-limit: 20
실패 정책 yml failure.skip-limit·skippable: [FQCN]·retry-limit·retryable: [FQCN] → 스텝에서 policy.apply(stepBuilder)
실패 정책 교체 빈 BatchFailurePolicyService
실행·조회 교체 빈 BatchJobLaunchService(jobs·launch·recent)
모니터링 훅 빈 추가 BatchMonitorService(beforeJob/afterJob) — 모든 Job 에 자동 부착, 기본 로깅(monitor.logging)
기동 시 자동 실행 yml 표준 spring.batch.job.enabled(데모 false)

개발툴(devtools.profiles 에서만): GET /framework-devtools/batch(화면), GET /framework-devtools/api/batch/jobs, POST .../jobs/{name}/run(본문=잡 파라미터 Map), GET .../jobs/{name}/executions?limit=. 오류: 404 batch.job.unknown, 403 batch.job.not-allowed, 409 batch.job.launch-failed.

잡 스켈레톤 생성(개발툴): POST /framework-devtools/api/generate-batch(?write=true&overwrite= 로 출력 루트에 쓰기, 화면 「배치 잡 스켈레톤 생성」). 본문 {name, packageName, chunk(100), reader: list|custom, itemType(String), outputType, channel, interfaceId} → {Name}JobConfig.java(Job {name}Job·chunk Step·policy.apply·Reader/Processor/Writer TODO). channel+interfaceId 지정 시 Processor 가 ChannelService.send 호출(확장 비의존). 생성 소스는 batch 를 의존한 앱에서 컴파일(데모 테스트가 실제 컴파일 검증). 오류 400 devtools.codegen.invalid.

배치 프로젝트 스켈레톤 생성(개발툴): POST /framework-devtools/api/generate-batch-project(?write=&overwrite=, 화면 「프로젝트 미리보기」, 수정본 저장·zip 은 /api/files/save|download 재사용). 본문 {projectName, packageName(com.example.batch), frameworkVersion(0.1.0-SNAPSHOT), repositoryUrl(사내 Nexus, 선택), samsungCard, job:{generate-batch 와 동일}} → {projectName}/ 아래 settings.gradle·build.gradle(Boot 3.5.6, Java 21 toolchain, ongo-framework-batch [+samsung-card], H2 runtime)·BatchApplication·application.yml(spring.batch.job.name, 실패정책 예)·{Job}JobConfig(패키지 .job)·README.md·.gitignore. - 한계(최소 범위): Gradle 단일 모듈만, Gradle wrapper 미포함(gradle wrapper), 프레임워크 아티팩트는 mavenLocal/사내 Nexus 가정, 채널 transport·운영 DB 는 TODO 주석. 데모 테스트는 생성 Java 소스 컴파일까지 검증(생성 프로젝트의 Gradle 빌드 실행은 미검증).

별도 배치 데모: 모듈 ongo-framework-batch-demo(비웹 Boot 배치, ongo-framework-batch + ongo-framework-samsung-card 의존, WAS 데모와 분리) — scCardLimitSyncJob(SCDEMO01 loopback), 실행 ./gradlew :ongo-framework-batch-demo:bootRun --args='--spring.batch.job.name=scCardLimitSyncJob cards=...', 모듈 README 참고. 배포(publish) 대상 아님.

OnGo DevOps 개발 배포: docker/(앱 저장소 쪽 이미지 설정) — docker/generic-demo(APP ongo-generic-demo, 제니 dev WAS 12011), docker/sc-demo(APP ongo-sc-demo, 제니 dev WAS 12012, 배치 데모는 compose profile batch 로 1회 실행). 포트·환경은 OnGoDevOps config/development/DOCKER/*.properties, docker/README.md 참고.

15.1 배치 잡에서 삼카 전문 통신
  • 공통 배치는 삼카 비의존. 앱(데모)이 ongo-framework-batch + 채널(starter) + ongo-framework-samsung-card 를 함께 의존한다.
  • 배치는 웹이 아니어도 같은 SPI: ChannelService.send/sendAsync + 삼카 TelegramLayoutService(@ScTelegram)·코덱.
  • 데모 demo.batch.ScCardLimitSyncJobConfig: 카드번호 목록 → ChannelService.send("mci","SCDEMO01",..)(loopback) → 결과 Writer, 형식 오류는 skippable 로 건너뜀.

16. 고객사 확장 모듈(삼카) — ongo.framework.samsung-card

모듈 ongo-framework-samsung-card 는 코어·스타터 SPI 위에 삼카 전용 전문 VO 어노테이션·레이아웃·개발툴 훅만 얹는 확장(골격)이다. 코어에는 삼카 VO·레이아웃이 없다. 의존성만 추가하면 SamsungCardAutoConfiguration 이 켜지고, 데모 앱이 이 모듈을 의존한다. 어노테이션 이름·정의서 양식은 placeholder — 실제 삼카 규격은 입사 후 이 모듈 안에서만 바꾼다.

  • 어노테이션 레이아웃: VO 에 @ScTelegram("인터페이스ID")(응답 VO 는 direction = RESPONSE), 필드/레코드 컴포넌트에 @ScField(order, length[, type = NUM][, name]). base-packages 를 스캔해 AnnotatedTelegramLayouts(TelegramLayoutService, 빈 samsungCardTelegramLayoutService, @Primary)를 만든다. 어노테이션에 없는 ID 는 프레임 기본 yml 레이아웃(ongoTelegramLayoutService)으로 넘긴다. 중복 VO·order 중복·응답만 있는 ID·@ScField 없는 VO 는 기동 시 실패.
  • 개발툴 훅(앱이 ongo-framework-devtools 도 의존할 때만): 정의서 유형 samsung-card-io(ScIoSpecParser), 데코레이터 samsung-card(ScCodeGenDecorator) 를 추가 등록 — 기본 json·validation 은 그대로 남는다. 삼카 스펙(속성 interfaceId 있음)이면 생성 VO 에 @ScTelegram·@ScField 를 붙이고, 아니면 생성물을 건드리지 않는다(코어 기본).
무엇을 어디서 어떻게
확장 끄기 yml samsung-card.enabled: false (코어 기본만)
VO 스캔 패키지 yml samsung-card.layout.base-packages: [..] (데모: com.ongosoft.framework.demo.samsungcard)
어노테이션 레이아웃만 끄기 yml samsung-card.layout.enabled: false
yml 레이아웃 fallback yml samsung-card.layout.fallback-to-default: false 면 어노테이션 ID 만
개발툴 훅 끄기 yml samsung-card.devtools.enabled: false
데코레이터 선택 yml devtools.codegen.decorators: [validation, samsung-card]
레이아웃 전체 교체 빈 앱이 TelegramLayoutService 빈을 주면 기본·삼카 둘 다 빠지고 앱 것 사용 · 이름 samsungCardTelegramLayoutService 로 주면 삼카 것만 교체
파서·데코레이터 교체 빈 이름 samsungCardIoSpecParser / samsungCardCodeGenDecorator 빈

samsung-card-io placeholder 양식(파이프 구분, // 주석, 블록 여러 개):

#interface|SCDEMO02|ScDemoReq|VO|com.example.sc
cardNo|STRING|16|Y|카드번호
#interface|SCDEMO02|ScDemoRes|VO|com.example.sc|RESPONSE
resultCode|STRING|2|Y|결과코드
  • 코드조각 정의서(samsung-card-snippet, ScCodeSnippetSpecParser): hyeri 산출물·asis 코드에서 레이아웃을 뽑는다. 블록 헤더 // #interface|ID|Name|kind|pkg[|RESPONSE] 다음에 (A) @ChannelAnnotation(order, dataType, length, scale…)/@ScField 필드 선언(order 정렬, NUMERIC → LONG/DECIMAL) 또는 (B) x = WebtUtil.getString(buf, offset, len)(offset 정렬, 빈틈·겹침은 오류, parseInt/parseLong → LONG). ARRAY(그리드)는 아직 미지원 오류. ScFieldLayoutResolver 가 동적 VO 의 @ScField 로 레이아웃을 만든다(14.1 파이프라인). 이름 samsungCardSnippetSpecParser / samsungCardVoLayoutResolver 빈으로 교체.

데모: ScDemoCardInquiry(SCDEMO01, 요청 cardNo 16 + inquiryDate 8, 응답 cardNo 16 + limitAmount 8 NUM) — POST /api/demo/channel/mci/SCDEMO01 로 loopback 왕복, 기존 yml SAMPLE0001 도 그대로. 코드: ongo-framework-samsung-card/.../SamsungCardAutoConfiguration.java, 데모 .../demo/samsungcard/ScDemoCardInquiry.java, 테스트 SamsungCardExtensionDemoTest.

배포: 17장(OnGoSoft Nexus publish · jar 직접 전달).

16.1 인터페이스 ID → VO 파싱 → TelegramLayout (네이밍 규칙)

AnnotatedTelegramLayouts(삼카 TelegramLayoutService) 조회 순서: ① layout.base-packages 스캔한 @ScTelegram VO ② 인터페이스 ID 네이밍 규칙으로 클래스로더 로드 → @ScField 파싱(캐시) — {package}.{interfaceId}In(요청, 필수)·...Out(응답, 선택) ③ 프레임 기본 yml(fallback-to-default).

무엇을 yml
규칙 조회 패키지 ongo.framework.samsung-card.layout.naming-packages: [..](비면 규칙 조회 안 함)
패턴·접미사 layout.naming-pattern(기본 {package}.{interfaceId}{suffix})·in-suffix: In·out-suffix: Out

개발툴 전문 테스트의 devtools.dynamic.vo-naming 과 같은 규칙을 쓰면, 코드생성 산출물(CARD01In/Out)이 운영 레이아웃·전문 테스트 양쪽에서 그대로 잡힌다.

16.2 삼카 표준전문 코덱 — HEADER + MESSAGE + BODY (hyeri_adapter 동일)

ScStandardTelegramCodec implements TelegramCodecService — hyeri_adapter AbstractMessageBroker 조립 순서: [전문공통헤더부 850B(ChannelHeader 57필드)] + [전문공통메시지부 2B] + [개별메시지부 BODY] + "@@", 헤더 stdEtxtLnth(8) = 전체 길이. fields 인자는 BODY 레이아웃(정의서), 헤더/메시지 값은 header.이름/message.이름 키. decode 결과도 같은 접두. @Encrypted 바디 필드: codec.encrypted(ScStandardTelegramCodec.encryptedOf(Vo.class)) → encode 전 FieldCryptoService.encrypt, decode 후 decrypt. DB 저장 시 암호화는 §14.3 인터셉터 그대로.

바이트 배치 (BODY 47B 예 → 전체 901B):

구간 offset 길이 내용
전문공통헤더부 0 850 57필드(리소스 samsungcard/std-header.layout, 줄 형식 이름:길이:NUM\|CHAR, hyeri ChannelHeader 순서). 0~8 = stdEtxtLnth(전체 길이, 0 채움 → 00000901)
전문공통메시지부 850 2 stdMsgPrntTpc(CHAR1, 기본 0) + stdMsgCt(NUM1)
개별메시지부(BODY) 852 정의서 길이 합 정의서/VO 레이아웃(FieldSpec)
전문종료 끝-2 2 @@

빈 등록 (opt-in, 충돌 없음):

무엇을 어디서 어떻게
표준전문 코덱 켜기 yml ongo.framework.samsung-card.standard-telegram.enabled: true → 빈 samsungCardStandardTelegramCodec(@Primary). 채널(ChannelService·공통 수신)·개발툴 동적 전문테스트가 이 코덱 사용
기본(미설정) — 등록 안 됨 → 코어 ongoTelegramCodecService(FixedLengthTelegramCodec, BODY 만) 단독. 기존 데모·yml 레이아웃 동작 유지
문자셋 yml ongo.framework.channel.charset(기본 UTF-8, 코어와 공유)
교체 빈 이름 samsungCardStandardTelegramCodec 빈을 직접 주면 기본 등록 생략
암호화 연동 빈 ColumnValues 규칙 — ColumnAttributeService(우선) → 값 변화 없고 @Encrypted 면 FieldCryptoService (코어·삼카 코덱, MyBatis 인터셉터 공통)
  • 타입을 아는 호출: 코어 TelegramCodecService.encode(Class, fields, values)/decode(Class, fields, bytes)(기본 = 타입 무시). 삼카 코덱은 VO 클래스의 @Encrypted 필드를 자동 합산하므로 동적 전문테스트에서 따로 지정할 필요 없음.
  • charset() 도 코어 인터페이스 기본 메서드(모르면 null) — 개발툴이 문자열 표시에 사용.
  • 테스트: ScStandardTelegramCodecTest(901B·00000901·암호화·왕복·CAS 연동), SamsungCardAutoConfigurationTest.standardTelegramCodec.

17. 배포 — OnGoSoft Nexus publish · jar 직접 전달

게시 대상은 ongo-framework-core·ongo-framework-starter·ongo-framework-devtools·ongo-framework-samsung-card(데모 제외). 좌표 com.ongosoft.framework:<모듈>:<version>(현재 0.1.0-SNAPSHOT), 본 jar + -sources.jar + POM. POM 에는 Spring Boot/Cloud BOM 으로 정해진 의존성 버전이 실제 값으로 적힌다. 설정: 루트 build.gradle → gradle/publish.gradle.

OnGoSoft Nexus — releases https://nexus.ongosoft.com/repository/maven-releases/, snapshots https://nexus.ongosoft.com/repository/maven-snapshots/ (원우 확인 2026-10-26). OnGoDevOps CI/CD 인벤토리·기존 framework-core 와 같은 Nexus(조회용 maven-public/ 도 있음). 자격증명이 없으면 게시 태스크는 네트워크 접속 전에 안내 메시지로 실패한다.

자격증명·URL 은 저장소에 커밋하지 않는다. ~/.gradle/gradle.properties(개인) 또는 CI 환경변수만:

값 gradle.properties 환경변수
releases URL(기본 위 값, 덮어쓰기용) ongoNexusReleasesUrl ONGO_NEXUS_RELEASES_URL
snapshots URL ongoNexusSnapshotsUrl ONGO_NEXUS_SNAPSHOTS_URL
사용자 nexusUsername (별칭 ongoNexusUsername) NEXUS_USERNAME (별칭 ONGO_NEXUS_USERNAME)
비밀번호/토큰 nexusPassword (별칭 ongoNexusPassword) NEXUS_PASSWORD (별칭 ONGO_NEXUS_PASSWORD)

자격증명 두 경로(값은 어디에도 커밋하지 않음):

  • 로컬 개발: 사용자 폴더 ~/.gradle/gradle.properties 에 nexusUsername / nexusPassword (아래 예시).
  • 개발 빌드(Jenkins): GitHub/Jenkins credential ID nexus-credentials → 환경변수 NEXUS_USERNAME / NEXUS_PASSWORD 로 주입. 예:
// Jenkinsfile
withCredentials([usernamePassword(credentialsId: 'nexus-credentials',
                                  usernameVariable: 'NEXUS_USERNAME', passwordVariable: 'NEXUS_PASSWORD')]) {
    sh './gradlew publishAllPublicationsToOngoNexusRepository'
}
# ~/.gradle/gradle.properties (예시 — 값은 본인 것으로)
# URL 은 기본값이 있으므로 자격증명만 (framework-core 와 같은 키)
nexusUsername=<user>
nexusPassword=<token>
  • 게시: ./gradlew publishAllPublicationsToOngoNexusRepository — 버전이 -SNAPSHOT 이면 snapshots, 아니면 releases URL. 자격증명이 없으면 실패(네트워크 접속 전).
  • 소비 앱:
repositories {
    mavenCentral()
    maven {
        url = uri('https://nexus.ongosoft.com/repository/maven-releases/')
        credentials { username = findProperty('nexusUsername'); password = findProperty('nexusPassword') }
    }
}
dependencies {
    implementation 'com.ongosoft.framework:ongo-framework-starter:0.1.0'
    implementation 'com.ongosoft.framework:ongo-framework-samsung-card:0.1.0'   // 삼카 확장
    developmentOnly 'com.ongosoft.framework:ongo-framework-devtools:0.1.0'      // 또는 local/dev 프로필 전용
}
17.1 고객사 Nexus 가 없을 때 — jar 직접 전달
  1. 권장: 파일 Maven 저장소째 전달. ./gradlew publishAllPublicationsToLocalDistRepository → build/dist-repo/ (POM 포함, 전이 의존성 유지). 이 폴더를 그대로 넘기고 앱에서:
repositories { mavenCentral(); maven { url = uri("$rootDir/libs/ongo-repo") } }   // dist-repo 를 libs/ongo-repo 로 복사
dependencies { implementation 'com.ongosoft.framework:ongo-framework-samsung-card:0.1.0-SNAPSHOT' }
  1. 최후: files(...). jar 만 받는 경우(./gradlew jar → 각 모듈 build/libs/*.jar). POM 이 없어 전이 의존성이 안 따라오므로 앱이 Spring Boot 스타터 등을 직접 선언해야 한다.
dependencies {
    implementation files('libs/ongo-framework-core-0.1.0.jar', 'libs/ongo-framework-starter-0.1.0.jar',
                         'libs/ongo-framework-samsung-card-0.1.0.jar')
    implementation 'org.springframework.boot:spring-boot-starter-web'   // 프레임이 api 로 끌던 것들 직접 선언
}

데모 앱은 같은 저장소라 project(':...') 로 의존한다(위 두 방식은 별도 앱용 예시).

17.2 데모 배포 — Boot3/Boot2 · JDK 21/17/11/8 (AA12~15)

Nexus 게시와 별개로, 데모는 OnGoDevOps CI/CD 가 저장소 소스로 이미지를 빌드해 개발 서버(제니)에 올린다. 상세: docker/README.md, OnGoDevOps README 4.4·6.1.

JDK / Boot AA generic WAS sc WAS 모듈
21 / Boot3 3.5.x 12 12011 12012 (+ sc-custom 12014) ongo-framework-generic-demo / ongo-framework-sc-demo
17 / Boot3 3.5.x 13 13011 13012 같은 모듈, RUNTIME_JDK=17
8 / Boot2 2.7.18 14 14011 14012 ongo-framework-boot2-demo / ongo-framework-boot2-sc-demo
11 / Boot2 2.7.18 15 15011 15012 같은 Boot2 모듈, JDK 11
  • Boot2 데모: bootJar 대신 installDist. 로컬 ./gradlew :ongo-framework-boot2-demo:installDist 후 ongo-framework-boot2/demo/build/install/ongo-boot2-demo/bin/ongo-boot2-demo(JAVA_HOME=JDK 8).
  • Boot2 프레임 jar(ongo-framework-boot2-core-web)와 공통 ongo-framework-common 은 현재 Nexus 게시 대상이 아니다(고객사 jar 분리 PoC 는 MULTI-JDK-PORTING TODO).
  • 런타임은 JDK(JRE 아님) — 개발툴 동적 VO 가 javac 를 쓴다. GET /api/runtime 의 javac=true·workDir.writable=true 확인.
  • 공개 도메인(OnGoDevOps #22): devfw21·devfw17·devfw8·devfw11 (+-sc) 8개 + devfw21-sc-custom. 2026-10-30 부터 컨테이너 앞단 nginx 없이 Cloudflare → NPM → WAS 포트(AA01N) 직결. FE 는 devfw-web(12003→sc) · devfw-web-custom(12004→sc-custom).
17.3 사이트별 운영 배포 가이드

케이스별 상세(IaaS 실행형 jar 멀티 인스턴스 · JEUS+WebToB WAR·Boot 버전 결정 · 중앙 관리 시스템+에이전트 쉘 배포 · Dockerfile+Jenkins/GitHub Actions/GitLab CI · Tekton+Jira 승인+ArgoCD GitOps)는 DEPLOYMENT-GUIDE.md(케이스별 운영 플레이북: 디렉터리·유닛·JVM·헬스·롤링·롤백·체크리스트, 파이프라인/YAML 스케치 포함), 요약·핵심 절차는 코어 개발가이드 #deploy.

18. 기능 총정리 기준) — 빠짐없이 한눈에

§1~§17 을 기능 축으로 다시 묶은 장이다. 각 항목의 상세 키·예시는 괄호 안의 장을 본다.

18.1 역할 분리 — 코어만으로 기본 동작, 삼카는 확장, 데모는 확인
층 모듈 책임 삼카 없이?
코어 ongo-framework-core SPI 인터페이스·표식 어노테이션(@Encrypted·@Masked·@Unmasked)·FixedLengthTelegramCodec·FieldSpec·ColumnAttributeService·ColumnValues 예
스타터 ongo-framework-starter AutoConfig + 기본 구현 빈(전부 @ConditionalOnMissingBean) — 암호화·마스킹·MyBatis 인터셉터·채널 예
배치 ongo-framework-batch Spring Batch 5 골격(런처·실패정책·모니터 훅·개발툴 화면) 예
개발툴 ongo-framework-devtools 정의서 파싱·코드생성·I/O 가져오기·동적 전문테스트(local/dev) 예(json·excel·validation)
고객사 확장 ongo-framework-samsung-card @ScTelegram/@ScField·인터페이스ID 레이아웃·sc-excel·스니펫 파서·ScStandardTelegramCodec·교체 예시 —
데모 ongo-framework-sc-demo 삼카 확장 기준 예시 앱 — 기능 호출·확인만, 로직 없음 —

원칙: 코어/스타터에 고객사 로직 없음. 고객사 모듈 의존을 빼면(samsung-card.enabled: false 또는 의존성 제거) 모든 기능이 코어 기본값으로 돌아간다. 확장은 빈 추가/교체 + yml 로만 한다.

18.2 삼카 전문 VO — @ScTelegram/@ScField · 인터페이스 ID 레이아웃 (§16, §16.1)
  1. VO: @ScTelegram("SCDEMO01")(응답은 direction = RESPONSE), 필드 @ScField(order, length[, type = NUM][, name]).
  2. 조회 순서(AnnotatedTelegramLayouts, @Primary TelegramLayoutService): ① layout.base-packages 스캔 ② 네이밍 규칙 {package}.{interfaceId}In/Out 클래스 로드·파싱(캐시) ③ 코어 yml 레이아웃(fallback-to-default).
  3. 기동 검증: 중복 ID·order 중복·응답만 있는 ID·@ScField 없는 VO → 실패.
  4. 개발툴 devtools.dynamic.vo-naming 을 같은 규칙으로 두면 코드생성 산출물이 운영 레이아웃·전문테스트에 그대로 잡힌다.
18.3 컬럼 보안 — @Encrypted/@Masked 와 SPI (§11, §7, §14.3)
SPI(코어) 기본 빈(스타터) 쓰는 곳 교체
FieldCryptoService ongoFieldCryptoService(ongo.framework.crypto.* 키) MyBatis 저장 암호화·SELECT 복호화·삼카 코덱 고객사 DB 암호화 솔루션 빈
MaskingService RuleBasedMaskingService(ongo.framework.masking.rules) 응답 DTO @Masked 직렬화·로그 쿼리스트링 빈 교체
ColumnAttributeService ongoColumnAttributeService(no-op) 자동 연결: 코어·삼카 전문 코덱 beforeEncode/afterDecode, MyBatis Encrypted*Interceptor, 응답 MaskingJacksonModule beforeResponse 빈 하나 등록(컬럼별 정책) — 예시 데모 ColumnAttributeDemoTest
  • ColumnMeta(name, type, length, encrypted, masking) = ColumnMeta.of(Field|RecordComponent, length).
  • @Encrypted 는 String 가변 필드 대상(record 는 저장 암호화 제외 — 불변).
  • 마스킹 규칙명이 없으면 전체 *, @Unmasked 로 개별 제외.
18.4 DB 암·복호화 자동 처리 (§14.3)
시점 인터셉터(빈) 동작 끄기
INSERT/UPDATE EncryptedFieldInterceptor(Executor.update) 파라미터 VO(@Param Map·Collection 포함) @Encrypted → 암호화 바인딩 → 실행 후 평문 복원 mybatis.plugins.field-encryption=false
SELECT EncryptedResultInterceptor / ongoEncryptedResultInterceptor(ResultSetHandler.handleResultSets) 결과 객체·중첩·List/Map 의 @Encrypted 복호화, 실패 시 원값 mybatis.plugins.field-decryption=false

키 접두 ongo.framework.mybatis.plugins.*. 데모: POST /api/demo/column-security(ScCardHolderRow @Encrypted · ScCardHolderView @Masked), 테스트 ColumnSecurityDefaultsTest·ShowcaseDemoTest.

18.5 코드생성 입력 — 다양한 입력 → 공통 I/O → 유형별 파서 (§14, §14.2, §14.4)
기존 VO ─┐
Sample JSON ─┤                 ┌→ I/O 트리 편집(화면) ─→ /api/generate/json → 미리보기/쓰기
정의서(json·samsung-card-io·snippet) ─┼→ SpecParseService(type) → SpecModel/FieldSpec ┤
엑셀 excel / sc-excel(멀티시트) ─┘                 └→ /api/dynamic/{type} → 동적 VO → 전문테스트
  • 파서 = SpecParseService 빈, 이름 = type(). 코어 json·excel, 삼카 samsung-card-io·samsung-card-snippet·sc-excel.
  • 일반 엑셀 IN/OUT 유연 매핑·미리보기(코어): ① .xlsx 선택 → [시트·컬럼 분석] = /api/excel/inspect 가 헤더 행(별칭 최다 일치, 없으면 첫 표 행)·자동 매핑 제안·미매핑 헤더·샘플 3행 반환 ② 화면에서 항목키(name·label·type·length·scale·required·encrypted·masking·maskingType·io·level·description)↔헤더 드롭다운, IN/OUT 시트 패턴, 기본 타입 → mapping JSON 자동 작성 ③ [엑셀 펼치기]/[미리보기] → 생성 코드가 파일별 편집창 ④ 수정 후 수정본 소스에 쓰기 또는 [zip 다운로드].
  • 추가 매핑 키: defaults(빈 셀 기본값, 예 {"type":"STRING","length":10}) · ioSheets({"in":"*_IN","out":"*_OUT"} → ORDER_IN+ORDER_OUT 시트 = ORDERIn/ORDEROut 한 쌍). 한 시트 안 구분 컬럼(io)·level 중첩 OBJECT/ARRAY 는 기존대로. Array/Object 수동 IN/OUT 편집은 공통 I/O 문서(json)에서 그대로.
  • 예시: 데모 DevtoolsExcelPreviewDemoTest(xlsx 를 테스트에서 생성 — 실제 정의서 커밋 금지).
  • 코어 기본 엑셀(ExcelSpecParser): 시트 1 = VO 1, 헤더 행 자동 탐지, 별칭 표 §14.4. 마스킹여부·암호화여부·마스킹유형코드 컬럼 포함 → @Masked(규칙)·@Encrypted. mapping.columns 로 헤더명/열 문자 지정이 별칭보다 우선, sheets 와일드카드.
  • 삼카 멀티시트(ScInterfaceExcelSpecParser): 인터페이스정의서 시트 = 인터페이스 ID, 소스/타켓 IO정의서 시트 *** INPUT/OUTPUT 항목 *** 구간 → In/Out, IO ID → Pascal(SioApcIss00026In), 영문 SNAKE → camel, 병합 셀 처리, _IN/_OUT 시트 짝 fallback. BODY 만 정의(헤더는 코덱).
  • 실제 샘플 회귀 테스트는 환경변수 IF_SPEC_SAMPLE=<xlsx 경로> 일 때만(실명 포함 파일은 저장소에 넣지 않는다).
18.6 개발툴 실행 위치 — 코드생성/컴파일 vs 런타임 WAS (§14.1)
환경 코드생성(쓰기) 동적 컴파일·전문테스트 조건
로컬 PC(local) O → codegen.output-dir O → dynamic.work-dir devtools.profiles·codegen.profiles
개발 서버 WAS(dev) X(미리보기만) O work-dir 는 쓰기 가능한 경로(ONGO_DEVTOOLS_DYNAMIC_WORK_DIR), JDK 필요, fat jar 면 extra-classpath
운영(prod) X X AutoConfig 자체 미등록(화면·API 404)
  • work-dir 분리: dynamic.work-dir(기본 build/ongo-devtools/dynamic/<buildId>/{src,classes})와 codegen.output-dir(기본 src/main/java)가 서로 포함 관계면 기동 실패(... 다른 경로여야 합니다(겹침 금지)). 전문테스트 산출물이 소스 트리에 섞이지 않게 하기 위함.
  • 동적 클래스는 별도 URLClassLoader — 운영 런타임 클래스패스에 섞이지 않음. 결과는 소스로 받아 커밋·빌드한다.
  • prefer-existing-vo: 네이밍 규칙/생성 FQCN 클래스가 이미 있으면 컴파일 없이 그것 사용.
18.7 개발툴 UI · 엔드포인트

화면 GET /framework-devtools — ① 유형 선택 + 미리보기/소스에 쓰기(덮어쓰기 체크) ② 입력 불러오기: 기존 VO(className)·Sample JSON·엑셀(.xlsx 파일 + 매핑 JSON, 유형 excel/sc-excel — sc-excel 은 samsung-card 데코레이터가 있을 때만 노출, 일반 데모에는 없음)·정의서 본문 → I/O 표(필드·타입·길이·암호·마스킹) 편집 → 재생성. 배치 화면 GET /framework-devtools/batch.

메서드·경로 본문/파라미터 응답·오류
GET /framework-devtools/api — specTypes·decorators·outputDir·writeEnabled
POST /api/parse/{type} 정의서 원문 SpecModel[] · 404 devtools.spec.unknown · 400 devtools.spec.invalid
POST /api/generate/{type}?write=&overwrite= 정의서 원문 files·written · 403 devtools.codegen.write-disabled
POST /api/import/vo?className= — I/O 문서 · 404 devtools.import.class-not-found
POST /api/import/sample-json?name= 샘플 JSON I/O 문서 · 400 devtools.import.invalid
POST /api/import/{excel\|sc-excel\|유형} {"file":"<base64>","mapping":{..}} 또는 원문 I/O 문서
POST /api/excel/inspect/{excel\|sc-excel} {"file"\|"text","mapping"?} fieldKeys[]·sheets[]{sheet,headerRow,headers,suggested,unmapped,sample} · 404(엑셀 파서 아님)
POST /api/files/save?overwrite= {"files":[{path,content}]} (미리보기 수정본) WriteResult[] · 403 쓰기 금지 · 400 devtools.codegen.invalid(빈 목록·..·절대경로)
POST /api/files/download 동일 application/zip generated-sources.zip (서버에 쓰지 않음 → dev WAS 에서도 가능)
POST /api/dynamic/{type} {"content","interfaceId","values","transport"} buildId·dir·vos[]·exchange · 400 devtools.dynamic.invalid · 500 devtools.dynamic.failed
GET /api/batch/jobs · POST .../{name}/run · GET .../{name}/executions?limit= 잡 파라미터 Map 404 batch.job.unknown · 403 batch.job.not-allowed · 409 batch.job.launch-failed

(경로 접두 /framework-devtools.) 동적 전문테스트 코덱 = 기본은 컨텍스트의 TelegramCodecService(Primary).

코덱 선택: 화면 「전문 테스트」 드롭다운 또는 POST /api/dynamic/{type} 본문 "codec" / 쿼리 ?codec=(본문 우선). 후보 목록 GET /api/dynamic/codecs.

codec 내용 등록
default(생략) 컨텍스트 Primary 코덱 코어
fixed 코어 FixedLengthTelegramCodec(BODY 만) 코어 devtools
sc-standard 삼카 ScStandardTelegramCodec(HEADER 850+MESSAGE 2+BODY+@@, VO @Encrypted 반영) — standard-telegram.enabled 와 무관 삼카 확장(빈 samsungCardStandardCodecOption)

후보 추가 = DynamicCodecOption(name, label, codec) 빈(코덱 빈이 아니라 채널 by-type 주입과 충돌 없음). 모르는 codec → 400 devtools.dynamic.invalid. 응답 exchange.codec 에 사용 코덱.

표준전문 HEADER/MESSAGE 설정 (sc-standard / 삼카 표준전문 코덱) — VO 는 BODY 만. HEADER/MESSAGE 는 VO 밖에서 채운다.

우선순위(높음→낮음): ① 전문 values header.x/message.x(개발툴 HEADER·MESSAGE 입력칸) ② 업무단 ScTelegramContext(인터페이스별, 스레드 단위) ③ 설정 ongo.framework.samsung-card.standard-telegram.* ④ 인코더 내장 기본값(ScStandardHeaderDefaults, hyeri 이식).

병합 규칙: 상위 값이 null 또는 "" 이면 하위(디폴트) 값을 쓰고, length>0 이면(공백 " " 포함) 그대로 덮는다. 수신 응답(@direction=response)은 InboundTelegramProcessor 가 자동 지정(처리기가 비워 둔 경우)하고 원 요청 decode 맵을 @request 로 넘긴다 → 응답 HEADER 에 요청의 stdEtxtSn·stdEtxtCrtDt·fstAkSysDvC·fstAkDtm·stdIfId·akRcvSvId·rspRcvSvId·mciAkNodeId 에코, 응답 rcvSysDvC = 요청 sendSysDvC, 응답 stdEtxtPrgNo(표준전문진행번호) = 요청값 +1(네스티드 hop; 송신 시작은 내장 디폴트 0, stdEtxtPrgDvNo 는 기존대로)(우선순위: ③ 설정 response-header 보다 높고 ② 업무단보다 낮음).

수신 처리기 응답 맵 예시(삼카 표준전문 — BODY 키 + header./message. 접두 키, @direction 은 생략해도 자동 response):

@Component
class Card01Inbound implements InboundHandlerService {
    public String interfaceId() { return "CARD01"; }
    public boolean supports(String channel) { return "mci".equals(channel) || "fep".equals(channel); }
    public Map<String, ?> handle(InboundTelegram t) {
        String card = t.values().get("cardNo");                 // 요청 BODY
        String reqSn = t.values().get("header.stdEtxtSn");     // 요청 HEADER(자동 에코됨)
        Map<String, Object> out = new HashMap<>();
        out.put("balance", 10000);                              // 응답 BODY
        out.put("header.procsRsDvC", "0");                      // 응답 HEADER 직접 지정(최우선)
        out.put("message.stdMsgPrntTpc", "1");                  // 응답 MESSAGE
        // out.put("@direction", "response");                   // 생략 가능(프로세서 자동)
        return out;
    }
}

송신 쪽 업무단은 channel.send(..., Map.of("header.rcvSysDvC","MCI", ...)) 처럼 values 에 넣거나 ScTelegramContext.with(header, message, supplier) 블록으로 지정. 값을 null/"" 로 두면 디폴트, " " 는 공백 그대로 전송.

// 업무단: 인터페이스별 HEADER 셋팅 (범위 종료 시 이전 값 복원)
ScTelegramContext.with(Map.of("stdIfId", "SCIF0001", "rcvSysDvC", "MCI", "akRspDvC", "S"), Map.of(), () -> channel.send(req));
ongo.framework.samsung-card.standard-telegram:
  enabled: true            # 채널 Primary 코덱으로 등록
  header:                  # 송신(우리가 요청) HEADER 기본값
    sendSysDvC: ONG
    akRspDvC: S
  message: { stdMsgPrntTpc: "0" }
  response-header:         # 수신(타시스템 요청)에 회신할 때 HEADER 기본값 — values "@direction":"response"
    akRspDvC: R
  response-message: { stdMsgPrntTpc: "1" }

개발툴 「전문 테스트」: 방향(송신 / 수신 응답) 선택 + HEADER(JSON)·MESSAGE(JSON) 입력 → header./message. 접두 키와 @direction 으로 values 에 합쳐 전송(코어 fixed 코덱은 무시).

인코더 내장 기본값 (hyeri AbstractMessageBroker.encode — 비어 있을 때만 셋팅, ChannelUtil, ChannelConstants):

필드 기본값 hyeri 출처
stdEtxtLnth 전체 길이(자동, 항상 덮어씀) setStdEtxtLnth
stdEtxtLangDvC KR STD_ETXT_LANG_DV_C / broker config
stdEtxtVerDvC D12 STD_ETXT_VER_DV_C
stdEtxtCrtDt 현재 yyyyMMdd getCurrentTime
stdEtxtCrtSysNm 호스트명 8자리(부족분 _) ChannelUtil
stdEtxtSn WAS인스턴스번호(2)+HHmmssSSS+순번 001~999 (14자리) getNextGuid
stdEtxtIpadr 호스트 IP getStdEtxtIpadr
envrInfDvC P (L 로컬/T 개발/V 검증/P 운영/D DR/E 교육) ENVR_INF_DV_C
fstAkDtm 현재 yyyyMMddHHmmssSSS 최초요청일시
sendNodeId 호스트명8+-+노드번호(1)+WAS인스턴스번호(2) getSendNodeId
stdEtxtSendDtm 현재 yyyyMMddHHmmssSSS 표준전문송신일시
MESSAGE.stdMsgPrntTpc 0 —
종료코드 @@ STD_ETXT_END_C

노드번호/WAS인스턴스번호 = 시스템 프로퍼티 MC_SERVER_NAME 첫 1자리/다음 2자리(없으면 0/00). 그 밖의 HEADER 필드(stdIfId, rcvSysDvC, akRspDvC, akRcvSvId …)는 내장 기본값 없음 → 업무단/설정에서 셋팅. 참고: hyeri 원본은 언어코드를 stdEtxtVerDvC 가 비었을 때만 채우는 버그가 있으나, 여기선 각 필드 독립.

채널 전용 예외 — 업무는 ChannelException(하위 타입) catch, BusinessException 과 분리: ChannelException(채널/인터페이스 미정의 등) ⊃ ChannelTransportException(송신 실패) · TelegramFormatException(encode/decode) · ChannelResponseException(응답 비정상, resultCode()/periodSystemCode()/mapped()). ChannelException 팩토리가 이 타입을 던지고, 안 잡으면 ApiExceptionHandler.handleChannel 이 공통 응답(코드·메시지 표, 기본 502)으로 변환. 업무 @ExceptionHandler(ChannelResponseException.class) 로 별도 처리 가능.

전문 응답 결과 판정 (ChannelResponseOutcomeService) — DefaultChannelService.send() 가 decode 직후·ChannelResponse 반환 직전 호출. 비정상이면 ChannelResponseException channel.response.failed(502, 인자 {0}=채널/인터페이스 {1}=결과코드 {2}=메시지 요약) → 공통 예외 파이프라인 → 공통 응답.

구현 조건 판정
코어 DefaultChannelResponseOutcomeService(빈 ongoChannelResponseOutcomeService) 기본 on, @ConditionalOnMissingBean fields 후보 중 첫 존재 키 값 ≠ success → 예외. 키가 응답에 없으면 no-op
삼카 ScResponseOutcomeService(빈 samsungCardResponseOutcomeService, @Primary) samsung-card.standard-telegram.enabled=true header.procsRsDvC ≠ 0(1 거래처리오류 / 8 타임아웃 / 9 시스템오류). 요약 = message.stdMsgC/stdMsgCn 있으면 [코드] 내용, 없으면 코드별 기본 문구
ongo.framework.channel.response-outcome:
  enabled: true                               # false = 기본 판정 빈 미등록(판정 안 함)
  fields: [procsRsDvC, header.procsRsDvC]     # 결과코드 키 후보
  success: "0"

교체: ChannelResponseOutcomeService 빈을 직접 등록(@Primary 권장; 람다 가능 (ch, ifId, values) -> {...}). 메시지 키 channel.response.failed 는 messages*.properties/DB 메시지로 문구 변경.

기간계→채널 메시지코드 맵핑 (코어 SPI ChannelMessageCodeMappingService, 금융권 공통): 코드·메시지 표 교체(§9, EXAMPLES F3·H2)와 별개다. 맵핑이 돌려주는 문자열은 메시지 표의 키이고, 화면 문구는 그 키로 MessageSourceService 가 찾는다. 복붙 예제는 EXAMPLES C6(코어) · I2(삼카 저장소). map(channel, 기간계코드) → Optional<채널 메시지코드>. outcome 판정 흐름(코어 DefaultChannelResponseOutcomeService 는 결과코드, 삼카 ScResponseOutcomeService 는 message.stdMsgC → header.stdEtxtErrC)이 비정상 시 호출 → 있으면 그 코드(메시지 표 키)로 ChannelResponseException(mapped()=true, 인자 채널/인터페이스, 결과코드, 요약, 기간계코드), 없으면 channel.response.failed. 코어 기본 빈 ongoChannelMessageCodeMappingService = no-op(NONE). 삼카 samsungCardMessageCodeMappingService(ScMessageCodeMappingService, @Primary) = 앱의 ScMessageCodeMappingRepository 빈에 위임(없으면 빈 결과). 실 적용은 SPI 구현만 하면 됨: ScMessageCodeMappingRepository.findChannelCode(channel, 기간계코드) 빈 1개 등록(맵핑 테이블 조회, 샘플 SQL SELECT CHNL_MSG_C FROM <맵핑테이블> WHERE CHNL = ? AND PERIOD_MSG_C = ? — 인터페이스 Javadoc, 캐시 권장). 코어만 쓰면 ChannelMessageCodeMappingService 빈 직접 구현. 데모/테스트용 InMemoryScMessageCodeMappingRepository(키 채널:기간계코드, *:코드 = 전 채널). 샘플 위치: 데모 demo.samsungcard.ScDemoMessageCodeMappingConfig(mci:E0001→sc.limit.exceeded, *:E0099→sc.system.busy), 시연 테스트 ScMessageCodeMappingDemoTest(hit → 채널 코드 ChannelResponseException, miss → channel.response.failed). 삼카 standard-telegram.enabled=true 면 HEADER+MESSAGE+BODY+@@ 로 왕복, VO @Encrypted 자동 반영.

18.8 프로퍼티 전체 표 추가·관련분)
키 (ongo.framework. 생략) 기본 설명
devtools.enabled true 개발툴 전체
devtools.profiles [local, dev] 켜지는 프로필
devtools.codegen.profiles [local] 코드생성 쓰기 허용 프로필
devtools.codegen.output-dir src/main/java 코드생성 출력
devtools.codegen.write-enabled true false 면 미리보기만
devtools.codegen.default-kind / default-package DTO / ""
devtools.codegen.style / suffixes / header RECORD / {DTO:Dto,VO:Vo} / Generated by…
devtools.codegen.decorators / validation-decorator / true
devtools.dynamic.enabled true 동적 전문테스트
devtools.dynamic.work-dir build/ongo-devtools/dynamic output-dir 와 겹침 금지
devtools.dynamic.extra-classpath [] fat jar 서버용
devtools.dynamic.style / default-package RECORD / ongo.dynamic
devtools.dynamic.prefer-existing-vo true 기존 VO 우선
devtools.dynamic.vo-naming.package-name·pattern·in-suffix·out-suffix -·{package}.{prefix}{interfaceIdCamel}{suffix}·""·In·Out
samsung-card.enabled true 삼카 확장
samsung-card.layout.enabled·base-packages·fallback-to-default true·[]·true
samsung-card.layout.naming-packages·naming-pattern·in-suffix·out-suffix []·…·In·Out
samsung-card.devtools.enabled true 삼카 파서·데코레이터
samsung-card.standard-telegram.enabled false ScStandardTelegramCodec @Primary
channel.charset UTF-8 코덱 문자셋(공통)
channel.charset-defaults {fep: EUC-KR} 채널 type/이름별 기본 문자셋
channel.inbound-message.* 공통 정상/에러 코드·문구·결과코드 업무 시스템 코드 체계로 덮어씀. DB·타임아웃·검증 코드 포함
samsung-card.standard-telegram.message-code-length / message-text-length 10 / 100 반복 MESSAGE 1건 길이
channel.channels.<이름>.charset (비움) 채널 문자셋 지정(최우선)
mybatis.plugins.field-encryption true 저장 암호화
mybatis.plugins.field-decryption true SELECT 복호화
masking.rules / masking.log-enabled - / true
batch.enabled·launch.allowed-jobs·launch.recent-limit·failure.*·monitor.logging true·[]·20·-·- §15
18.9 데모 = 삼카 확장 기준 예시
  • 데모 앱은 ongo-framework-samsung-card 를 의존하고 samsung-card.layout.base-packages: com.ongosoft.framework.demo.samsungcard — 레이아웃·파서·데코레이터가 삼카 기준으로 뜬다.
  • 쇼케이스 http://localhost:8080/(코어/삼카/개발툴 카드별 ▶ 실행, 개발툴은 local 프로필): 채널 loopback POST /api/demo/channel/mci/SCDEMO01, 컬럼보안 POST /api/demo/column-security, 배치 ScCardLimitSyncJobConfig.
  • 표준전문(HEADER+BODY+MESSAGE) 왕복을 데모에서 보려면 ongo.framework.samsung-card.standard-telegram.enabled=true 로 띄운다(기본은 BODY 고정길이 — 기존 테스트 기준).
  • 데모 custom 프로필 = 코어 확장 예시(sample-csv·sample-layout, codegen.output-dir: build/generated-devtools).
기능별 커스터마이징 코드 예제 docs/CUSTOMIZATION-EXAMPLES.md

CUSTOMIZATION.md가 설정 키·동작을 설명한다면, 이 문서는 바꾸고 싶을 때 쓰는 코드를 기능별로 보여준다. 원칙: 코어 기본 빈은 대부분 @ConditionalOnMissingBean 이다 → 같은 타입의 빈을 앱에 등록하면 교체된다. 확장형 SPI(인터셉터·인바운드 핸들러·파서 등)는 여러 개 등록하면 모두 적용된다. 패키지 접두사 com.ongosoft.framework.core 는 아래에서 core 로 줄인다.

목차: 0 SPI 한눈에 보기 · A 웹·인터셉터 · B 배치 · C 채널·코덱 · D 코드생성 · E 암호·마스킹 · F 예외·메시지 · G 트랜잭션 · H 인증·코드·캐시·로깅 · I 삼성카드 확장 · J 교체가 안 될 때 점검표


0. SPI 한눈에 보기 — 목적 · 교체 지점 · 데모 · 흔한 실수

읽는 법: 교체형(단일 빈, @ConditionalOnMissingBean) = 같은 타입 빈 1개 등록 시 기본 빈이 빠진다. 확장형(다중 빈) = 등록한 것 전부가 적용된다. 기본 빈이 이름 조건(@ConditionalOnMissingBean(name=…))이면 그 이름으로 등록하거나 @Primary 로 우선한다.

SPI 목적 형태 · 기본 빈 데모 위치(ongo-framework-sc-demo/src/main/java/com/ongosoft/framework/demo/…) 흔한 실수
OngoHandlerInterceptor 요청 전후 공통 처리 확장형. 순서 order()(기본 100) 또는 yml interceptor.orders.<빈이름> custom/DemoChainInterceptors(audit 10, timing 20) WebMvcConfigurer 로 또 등록해 2번 실행
CorrelationIdGenerator 상관ID 형식 교체형 custom/DemoCustomizations(demo-%06d) 헤더로 들어온 ID 까지 덮어쓰기
AccessLogWriter 접근로그 출력처 교체형 custom/DemoCustomizations(버퍼) 동기 원격 호출로 요청 지연
BatchFailurePolicyService skip/retry 정책 교체형 ongoBatchFailurePolicyService batch-demo job/ScCardLimitSyncJobConfig(yml skip) 스텝에서 policy.apply(...) 를 안 감쌈
BatchMonitorService 잡 시작/종료 훅 확장형(기본 LoggingBatchMonitorService) — 훅 안에서 예외 → 잡 실패
TelegramCodecService 전문 encode/decode 교체형 ongoTelegramCodecService 삼카 ScStandardTelegramCodec ColumnAttributeService 훅 호출 누락 → 암호화 안 됨
PhysicalTransportService 물리 송수신(TCP/MQ…) 확장형. name() 을 yml transport 로 선택 custom/DemoCustomizations(sample-host) yml 에 endpoints 로 씀(정답 channels)
InboundHandlerService 수신 전문 업무 처리 확장형. interfaceId() 매칭 ChannelInboundDemoHandler(SAMPLE0001) 같은 ID 핸들러 2개 → 시작 오류/모호
ChannelResponseOutcomeService 응답 성공/실패 판정 교체형(기본 DefaultChannelResponseOutcomeService) 삼카 ScResponseOutcomeService 실패를 예외 대신 값으로 리턴
ChannelMessageCodeMappingService 상대 코드 → 우리 메시지코드(메시지 표 키) 교체형(기본 NONE) samsungcard/ScDemoMessageCodeMappingConfig · §C6 · §I2 * 기본 매핑 누락. 반환 코드를 메시지 표에 안 넣음
TelegramLayoutService 레이아웃 원천(yml/어노테이션/DB) 교체형 삼카 AnnotatedTelegramLayouts 길이 합 ≠ 전문 길이
SpecParseService 정의서 유형 추가 확장형. type() 이 드롭다운 ID devtools/DevtoolsDemoCustomizations(sample-csv) type() 중복
CodeGenDecorator 생성 코드에 import/어노테이션 확장형. name() 을 codegen.decorators 로 선택 devtools/DevtoolsDemoCustomizations(sample-layout) decorators 목록에 이름 안 넣음(목록이 비어있지 않을 때)
VoLayoutResolver 기존 VO → 정의서 확장형 삼카 samsungCardVoLayoutResolver 해당 없으면 Optional.empty() 가 아니라 예외
DynamicCodecOption 전문 테스트 코덱 목록 확장형 삼카 samsungCardStandardCodecOption —
FieldCryptoService 컬럼 암복호화 엔진 교체형 custom/DemoCustomizations(SAMPLE-ENC: Base64 — 데모 전용) 키를 코드/yml 평문에 둠
MaskingService 마스킹 규칙 교체형 — 원본 길이 노출 정책 미고려
ColumnAttributeService 컬럼별 암호/마스킹 정책 분기 교체형(전부 default 메소드) — null 값 처리 누락
XssFilterService XSS 정화 교체형 custom/DemoCustomizations @XssSafe 필드까지 정화
HashService/ChannelCryptoService 해시·HMAC / 채널 구간 암호 교체형 custom/DemoCustomizations(DigestHashService) 상수시간 비교 안 함
MessageSourceService 메시지 원천 교체형 custom/DemoCustomizations · §F3 defaultMessage 무시. 빈 교체 뒤 yml·번들 폴백을 안 넘김
RollbackRuleService 롤백 여부 교체형 custom/DemoCustomizations byType 결과 무시
AuthService/AuthorizationService 인증/인가 교체형(@Primary 데모) custom/DemoCustomizations 비밀번호 평문 비교
CodeLookupService 공통코드 교체형 §H2 (단일 yml·여러 표·사이트 SQL·캐시) 매 호출 DB 조회(CachingCodeLookup / DistributedCacheService 로 감싸지 않음)
LoggingBridgeService 외부 로그 수집기 연동 확장형 custom/DemoCustomizations(sample-collector) 민감정보 그대로 전송

데모 커스텀은 --spring.profiles.active=custom 에서만 켜진다(@Profile("custom"), application-custom.yml).


A. 웹 공통

A1. 요청 인터셉터 추가 — OngoHandlerInterceptor

빈으로 등록하면 자동으로 MVC 에 붙는다. order() 작을수록 먼저.

@Component
public class TenantInterceptor implements OngoHandlerInterceptor {
    @Override public int order() { return 50; }
    @Override public List<String> includePatterns() { return List.of("/api/**"); }
    @Override public List<String> excludePatterns() { return List.of("/api/public/**"); }

    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        String tenant = req.getHeader("X-Tenant");
        if (tenant == null) throw BusinessException.withStatus("E_TENANT", HttpStatus.BAD_REQUEST);
        MDC.put("tenant", tenant);
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object h, Exception ex) {
        MDC.remove("tenant");
    }
}

설정으로 끄거나 순서만 바꿀 땐 ongo.framework.interceptor.*(CUSTOMIZATION §6).

A2. 상관ID 형식 교체 — CorrelationIdGenerator
@Bean
CorrelationIdGenerator correlationIdGenerator() {
    return () -> "ONG-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + UUID.randomUUID().toString().substring(0, 8);
}

들어온 X-Correlation-Id 헤더가 있으면 그대로 쓰고, 없을 때만 생성기를 호출한다.

A3. 접근 로그 출력처 교체 — AccessLogWriter

기본은 로거. DB/Kafka 로 보내려면 같은 타입 빈 등록(메서드는 인터페이스 정의 참고).


B. 배치

B1. 설정만으로 skip/retry
ongo.framework.batch:
  failure:
    skip-limit: 10
    skippable: [org.springframework.dao.DataIntegrityViolationException]
    retry-limit: 3
    retryable: [java.net.SocketTimeoutException]
  launch:
    allowed-jobs: [cardLimitSyncJob]   # 화면/REST 수동 실행 허용
    recent-limit: 20
  monitor.logging: true
B2. 실패 정책 코드로 — BatchFailurePolicyService
@Bean
BatchFailurePolicyService batchFailurePolicyService() {
    return new BatchFailurePolicyService() {
        public SkipPolicy skipPolicy() { return (t, cnt) -> t instanceof ValidationException && cnt < 100; }
        public int retryLimit() { return 2; }
        public boolean retryable(Throwable t) { return t instanceof ChannelTransportException; }
    };
}

스텝에 적용:

@Bean
Step syncStep(JobRepository repo, PlatformTransactionManager tx, BatchFailurePolicyService policy) {
    var step = new StepBuilder("syncStep", repo).<CardDto, CardDto>chunk(100, tx)
            .reader(reader()).writer(writer());
    return policy.apply(step).build();
}
B3. 모니터링 훅 — BatchMonitorService
@Component
public class SlackBatchMonitor implements BatchMonitorService {
    @Override public void afterJob(JobExecution e) {
        if (e.getStatus() == BatchStatus.FAILED) notifier.send(e.getJobInstance().getJobName() + " 실패: " + e.getAllFailureExceptions());
    }
}
B4. 수동 실행 — BatchJobLaunchService
@RestController @RequiredArgsConstructor
class OpsController {
    private final BatchJobLaunchService launcher;
    @PostMapping("/ops/batch/{job}") Object run(@PathVariable String job, @RequestBody Map<String,String> p) { return launcher.launch(job, p); }
}
B5. 배치에서 전문 송신 — 아래 C1 의 ChannelService 를 Writer 에서 그대로 주입해 사용.

C. 채널·전문

C1. 송신 — ChannelService
ChannelResponse r = channelService.send("mci", "CARD01", Map.of("cardNo", no, "amount", 10000));
CompletableFuture<ChannelResponse> f = channelService.sendAsync("eai", "CARD02", values);
C2. 코덱 교체/추가 — TelegramCodecService
@Component
public class Utf8PipeCodec implements TelegramCodecService {
    @Override public String name() { return "pipe"; }
    @Override public Charset charset() { return StandardCharsets.UTF_8; }
    @Override public byte[] encode(List<FieldSpec> fields, Map<String, ?> v) {
        return fields.stream().map(f -> String.valueOf(v.getOrDefault(f.name(), ""))).collect(Collectors.joining("|")).getBytes(charset());
    }
    @Override public Map<String, String> decode(List<FieldSpec> fields, byte[] p) {
        String[] t = new String(p, charset()).split("\\|", -1);
        Map<String, String> m = new LinkedHashMap<>();
        for (int i = 0; i < fields.size(); i++) m.put(fields.get(i).name(), i < t.length ? t[i] : "");
        return m;
    }
}

기본은 코어 FixedLengthTelegramCodec. 삼카 표준전문(HEADER+MESSAGE+BODY)은 ongo.framework.samsung-card.standard-telegram.enabled=true 로 @Primary 교체(§16.2).

C3. 물리 전송 추가 — PhysicalTransportService
@Component
public class TcpTransport implements PhysicalTransportService {
    @Override public String name() { return "tcp"; }
    @Override public byte[] exchange(ChannelEndpoint ep, byte[] payload) throws Exception {
        URI u = URI.create(ep.url());
        try (Socket s = new Socket(u.getHost(), u.getPort())) {
            s.setSoTimeout((int) ep.timeout().toMillis());
            s.getOutputStream().write(payload);
            return s.getInputStream().readAllBytes();
        }
    }
}
ongo:
  framework:
    channel:
      channels:
        eai:                        # ChannelService.send("eai", …) 의 채널명
          type: EAI
          transport: tcp            # = TcpTransport.name()
          url: tcp://eai.example.internal:9000   # placeholder 호스트
          timeout: 5s

흔한 실수: 키는 channel.channels.<이름> 이다(endpoints 아님). transport 값과 name() 불일치 시 송신 시점에 미등록 전송 오류. 응답을 다 읽기 전에 소켓을 닫지 않도록 상대 프로토콜(길이 헤더 등)에 맞춰 읽을 것 — 위 readAllBytes() 는 상대가 연결을 끊는 경우에만 맞다.

C4. 수신 처리 — InboundHandlerService
@Component
public class Card01Inbound implements InboundHandlerService {
    @Override public String interfaceId() { return "CARD01"; }
    @Override public boolean supports(String channel) { return "mci".equals(channel); }
    @Override public Map<String, ?> handle(InboundTelegram t) {
        return Map.of("resultCode", "0000", "cardNo", t.values().get("cardNo"));
    }
}
C5. 응답 성공/실패 판정 — ChannelResponseOutcomeService
@Bean
ChannelResponseOutcomeService outcome() {
    return (channel, id, v) -> {
        if (!"0000".equals(v.get("resultCode"))) throw BusinessException.of("E_CH_" + v.get("resultCode"));
    };
}

기본 구현 DefaultChannelResponseOutcomeService 는 설정만으로 쓸 수 있다(코드 불필요):

ongo.framework.channel.response-outcome:
  enabled: true
  fields: [procsRsDvC, header.procsRsDvC]   # 먼저 찾은 필드로 판정
  success: "0"

실패 시 ChannelException.responseFailed(...)(매핑 있으면 responseMapped). 흔한 실수: 실패를 예외 대신 값으로 넘겨 업무 코드가 성공으로 처리.

C6. 상대 시스템 코드 → 우리 메시지코드 — ChannelMessageCodeMappingService
@Bean
ChannelMessageCodeMappingService codeMap() {
    Map<String, String> m = Map.of("E901", "E_CARD_LIMIT", "E902", "E_CARD_STOP");
    return (channel, code) -> Optional.ofNullable(m.get(code));
}

반환 문자열은 메시지 표의 키다. 그 키의 문구는 §F3 MessageSourceService 가 고른다. 공통코드 목록은 §H2. 삼카 모듈은 이 SPI 를 직접 등록하는 대신 §I2 ScMessageCodeMappingRepository 빈을 등록한다. 판정 흐름·기본 빈 이름은 CUSTOMIZATION 맵핑 절.

C7. 레이아웃 원천 교체 — TelegramLayoutService

기본은 어노테이션 VO(삼카 @ScTelegram/@ScField)·맵 레이아웃. DB 메타에서 읽으려면 같은 타입 빈 등록.


D. 코드생성(devtools)

D1. 새 정의서 유형 — SpecParseService
@Component
public class CsvSpecParser implements SpecParseService {
    @Override public String type() { return "my-csv"; }
    @Override public String description() { return "우리 CSV 정의서(name,type,length,required,desc)"; }
    @Override public List<SpecModel> parse(String content) {
        List<FieldSpec> fs = content.lines().skip(1).map(l -> l.split(",", -1))
            .map(c -> new FieldSpec(c[0], c[1], Integer.valueOf(c[2]), null, "Y".equals(c[3]), c[4], Map.of())).toList();
        return List.of(new SpecModel("MyVo", "DTO", "com.example", null, fs, Map.of()));
    }
}

등록 즉시 화면 유형 드롭다운에 나타난다(데모 DevtoolsDemoCustomizations 참고).

D2. 레이어 네이밍 규칙
ongo.framework.devtools.codegen:
  type-packages: [com.example]            # 타입 색인 범위(좁힐수록 빠름)
  layer:
    controller: { package: web,  suffix: Controller }
    service:    { package: service, suffix: Service }
    repository: { package: repository, suffix: Mapper }
  suffixes: { DTO: Dto, VO: Vo }
  write-enabled: false                     # 개발계는 zip 다운로드만
D3. 생성 코드 꾸미기 — CodeGenDecorator

목적: 생성되는 VO/DTO 에 고객사 어노테이션·import 를 자동으로 붙인다. 교체 지점: CodeGenDecorator 빈(확장형, 모든 메소드 default 라 필요한 것만 구현).

package com.example.devtools;

import com.ongosoft.framework.devtools.codegen.CodeGenDecorator;
import com.ongosoft.framework.devtools.spec.FieldSpec;
import com.ongosoft.framework.devtools.spec.SpecModel;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class AuditColumnDecoratorConfig {
    /** label 이 있으면 @Comment("label"), 정의서 attributes.audit=true 면 클래스에 @Audited. */
    @Bean
    CodeGenDecorator auditColumnDecorator() {
        return new CodeGenDecorator() {
            public String name() { return "audit-column"; }
            public List<String> imports(SpecModel s) {
                return List.of("com.example.common.Comment", "com.example.common.Audited");
            }
            public List<String> classAnnotations(SpecModel s) {
                return Boolean.TRUE.equals(s.attributes().get("audit")) ? List.of("@Audited") : List.of();
            }
            public List<String> fieldAnnotations(SpecModel s, FieldSpec f) {
                Object label = f.attributes().get("label");
                return label == null ? List.of() : List.of("@Comment(\"" + label.toString().replace("\"", "\\\"") + "\")");
            }
        };
    }
}
ongo.framework.devtools.codegen:
  decorators: [validation, audit-column]   # 순서 = 적용 순서. 비우면 등록된 전부

데모: demo/custom/DevtoolsDemoCustomizations.java 의 sample-layout(필드 오프셋 누적 → @SampleLayout.Field(offset, length)), 삼카 samsungCardCodeGenDecorator(@ScTelegram/@ScField), 스웨거 체크 시 OpenApiCodeGenDecorator(@Schema). 흔한 실수: decorators 를 지정했는데 새 이름을 빠뜨림 → 적용 안 됨 · 어노테이션 문자열에 따옴표 이스케이프 누락 → 생성 소스 컴파일 오류 · import 에 존재하지 않는 클래스.

D4. 기존 VO → 정의서 — VoLayoutResolver

목적: 자체 어노테이션 VO 를 [불러오기]·전문 테스트에서 인식. resolve(Class<?>) → 내 형식이 아니면 Optional.empty().

@Bean
VoLayoutResolver myVoLayoutResolver() {
    return type -> {
        if (!type.isAnnotationPresent(MyTelegram.class)) return Optional.empty();
        List<FieldSpec> fields = Arrays.stream(type.getDeclaredFields())
            .filter(f -> f.isAnnotationPresent(MyField.class))
            .sorted(Comparator.comparingInt(f -> f.getAnnotation(MyField.class).order()))
            .map(f -> new FieldSpec(f.getName(), "STRING", f.getAnnotation(MyField.class).length(), null, false,
                                    null, Map.of("label", f.getAnnotation(MyField.class).label())))
            .toList();
        return Optional.of(fields);
    };
}
D5. 전문 테스트 코덱 추가 — DynamicCodecOption
@Bean
DynamicCodecOption myCodecOption(MyTelegramCodec codec) {
    return new DynamicCodecOption("my-std", "자사 표준전문", codec);   // name = API codec 파라미터, label = 화면 표시
}

E. 암호화·마스킹

E1. 어노테이션
public record CardVo(@Encrypted("card") String cardNo, String name) {}       // DB 저장 시 암호화, 조회 시 복호화
public record CardRes(@Masked("card") String cardNo, @Unmasked String memo) {} // 응답 직렬화 시 마스킹 / 제외
E2. 암호 엔진 교체 — FieldCryptoService
@Bean
FieldCryptoService fieldCryptoService(KmsClient kms) {
    return new FieldCryptoService() {
        public String encrypt(String p) { return kms.encrypt("card-key", p); }
        public String decrypt(String c) { return kms.decrypt("card-key", c); }
    };
}
E3. 마스킹 규칙 교체 — MaskingService
@Bean
MaskingService maskingService() {
    return new MaskingService() {
        public boolean shouldMask(String f) { return f.toLowerCase().contains("cardno") || f.endsWith("Phone"); }
        public String mask(String f, String v) {
            if (v == null || v.length() < 8) return v;
            return v.substring(0, 4) + "*".repeat(v.length() - 8) + v.substring(v.length() - 4);
        }
        public String maskText(String t) { return t.replaceAll("\\d{4}-?\\d{4}-?\\d{4}-?(\\d{4})", "****-****-****-$1"); }
    };
}
E4. 컬럼 단위 훅 — ColumnAttributeService

정책명(@Encrypted("card")의 "card")별로 다르게 처리:

@Bean
ColumnAttributeService columnAttributeService(FieldCryptoService crypto, MaskingService masking) {
    return new ColumnAttributeService() {
        public String beforeEncode(ColumnMeta c, String v) { return c.isEncrypted() ? crypto.encrypt(v) : v; }
        public String afterDecode(ColumnMeta c, String v)  { return c.isEncrypted() ? crypto.decrypt(v) : v; }
        public Object beforeResponse(ColumnMeta c, Object v) {
            return c.isMasked() && v instanceof String s ? masking.mask(c.name(), s) : v;
        }
    };
}
E5. 해시·채널 암호 — HashService, ChannelCryptoService 동일 방식으로 빈 교체.

F. 예외·메시지

F1. 업무 예외 던지기
throw BusinessException.of("E_ORDER_NOT_FOUND", orderId);                     // 메시지 = messages.properties 코드 + 인자
throw BusinessException.withStatus("E_DUP", HttpStatus.CONFLICT, key);
# messages_ko.properties
E_ORDER_NOT_FOUND=주문 {0} 을(를) 찾을 수 없습니다.
F2. 자체 예외에 코드 붙이기 — MessageCoded
public class PartnerException extends RuntimeException implements MessageCoded {
    private final String code;
    public PartnerException(String code) { super(code); this.code = code; }
    @Override public String code() { return code; }
}

표준 오류 응답(봉투)에 이 코드·메시지가 들어간다.

F3. 메시지 원천 교체 — MessageSourceService (기본 파일, ongo.framework.message.jdbc 로 DB). 별도 구현은 같은 타입 빈.

MessageSourceService 빈을 하나 등록하면 기본 JdbcMessageSource / DefaultMessageSource 는 올라오지 않는다. 빈이 둘 이상이면 @Primary. 이때 message.jdbc 는 적용되지 않는다. 단일 표는 §F3.1. yml 맵·번들 폴백이 필요하면 new DefaultMessageSource(OngoMessageProperties) 를 직접 만들어 넘긴다(OngoMessageProperties 빈은 남는다). 수신 MESSAGE 기본 코드(INB_OK 등, CUSTOMIZATION §12.1) 문구도 이 서비스를 탄다. 기간계→채널 맵핑의 반환 값은 여기 키다(§C6 · §I2 · CUSTOMIZATION 맵핑). 설정 키 설명은 CUSTOMIZATION §9. 아래 표·컬럼 이름은 임의 샘플이다.

F3.1 단일 메시지 테이블 yml

테이블이 하나이고 컬럼이 코드·로케일·문구 이면 yml 만으로 된다. 코드 표는 §H2.1.

ongo.framework.message:
  basename: classpath:messages,classpath:ongo/framework/messages
  default-locale: ko
  jdbc:
    enabled: true
    table: ongo_common_message
    code-column: msg_code
    locale-column: locale
    text-column: msg_text
  # 코드에 점이 있으면 대괄호 키. 테이블에 없을 때 이 맵이 번들보다 우선한다.
  # messages:
  #   "[demo.order.rejected]": "앱에서 덮어쓴 문구 {0}"

로케일 탐색은 ko-KR → ko → 기본 로케일 → 빈 값(공통 행) → DefaultMessageSource(yml 맵 → basename 번들 → defaultMessage → 코드). 표가 여러 개이거나 조인이 있으면 F3.2 · F3.3.

F3.2 메시지 테이블 여러 개

두 표를 코드 → 로케일 → 문구 맵으로 합친다. 같은 코드·로케일이 양쪽 다 있으면 나중에 읽은 채널 표 문구를 쓴다. 처음 조회 때 전체를 메모리에 올리고, refresh() 가 그 맵을 비운다. MetaLoadRunner 는 적재가 끝나면 컨텍스트의 Refreshable 빈을 모두 부른다. 조회 값(코드)은 ? 에 바인드한다. 표·컬럼 이름은 소스에 적은 상수다.

public final class MultiTableMessageSource implements MessageSourceService, Refreshable {
    private static final String[] SQL = {
        "select msg_code, locale, msg_text from site_msg_common",
        "select msg_code, locale, msg_text from site_msg_channel"
    };
    private final JdbcTemplate jdbc;
    private final Locale defaultLocale;
    private final MessageSourceService fallback;
    private volatile Map<String, Map<String, String>> cache;

    public MultiTableMessageSource(JdbcTemplate jdbc, Locale defaultLocale, MessageSourceService fallback) {
        this.jdbc = jdbc;
        this.defaultLocale = defaultLocale;
        this.fallback = fallback;
    }

    @Override
    public String resolve(String code, Object[] args, Locale locale, String defaultMessage) {
        if (code != null && !code.trim().isEmpty()) {
            Map<String, String> byLocale = messages().get(code);
            if (byLocale != null) {
                Locale l = locale != null ? locale : defaultLocale;
                String text = first(byLocale, l.toLanguageTag(), l.getLanguage(),
                        defaultLocale.toLanguageTag(), defaultLocale.getLanguage(), "");
                if (text != null) {
                    return format(text, args, l);
                }
            }
        }
        return fallback.resolve(code, args, locale, defaultMessage);
    }

    @Override
    public void refresh() {
        cache = null;
    }

    private Map<String, Map<String, String>> messages() {
        Map<String, Map<String, String>> c = cache;
        if (c == null) {
            Map<String, Map<String, String>> loaded = new HashMap<>();
            for (String sql : SQL) {
                jdbc.query(sql, rs -> {
                    String loc = rs.getString("locale");
                    loaded.computeIfAbsent(rs.getString("msg_code"), k -> new HashMap<>())
                            .put(loc == null ? "" : loc.trim().replace('_', '-'), rs.getString("msg_text"));
                });
            }
            cache = c = loaded;
        }
        return c;
    }

    private static String first(Map<String, String> m, String... keys) {
        for (String k : keys) {
            String v = m.get(k);
            if (v != null) return v;
        }
        return null;
    }

    private static String format(String template, Object[] args, Locale locale) {
        if (args == null || args.length == 0) return template;
        try {
            return new MessageFormat(template, locale).format(args);
        } catch (IllegalArgumentException ex) {
            return template;
        }
    }
}

@Bean
MessageSourceService messageSourceService(JdbcTemplate jdbc, OngoMessageProperties props) {
    Locale def = StringUtils.parseLocale(props.getDefaultLocale());
    return new MultiTableMessageSource(jdbc, def == null ? Locale.KOREAN : def, new DefaultMessageSource(props));
}

import: MessageSourceService, Refreshable, DefaultMessageSource, OngoMessageProperties, JdbcTemplate, StringUtils, MessageFormat, HashMap, Locale, Map.

F3.3 사이트 메시지 SQL

조인·사용 여부처럼 사이트마다 다른 SQL 은 yml 컬럼 매핑으로 표현하지 못한다. F3.2 와 같은 타입의 빈이고, 적재 쿼리만 사이트 SQL 로 바꾼다.

private static final String SQL =
        "select m.msg_code, l.locale, l.msg_text "
                + "from site_msg m "
                + "join site_msg_locale l on m.msg_id = l.msg_id "
                + "where m.use_yn = 'Y'";

use_yn = 'Y' 는 상수다. 요청에서 온 값은 ? 에 바인드한다. 빈 등록은 F3.2 와 같다.


G. 트랜잭션

G1. 롤백 규칙 — RollbackRuleService

기본: RuntimeException 롤백. 업무 예외는 커밋하고 싶다면:

@Bean
RollbackRuleService rollbackRuleService() {
    return (byTypeRules, ex) -> !(ex instanceof BusinessException) && (byTypeRules || ex instanceof SystemException);
}
G2. 데이터소스·다중 트랜잭션 — DataSourceService, TransactionManagerService, DistributedTransactionManagerService 를 빈으로 교체(라우팅 DS, XA 등). 전파 기본값은 ongo.framework.transaction.*(CUSTOMIZATION §3).

H. 기타

H1. 인증 — AuthService
@Bean
AuthService authService(UserMapper users, PasswordEncoder enc) {
    return (id, pw) -> users.find(id).filter(u -> enc.matches(pw, u.hash())).map(u -> new AuthPrincipal(u.id(), u.name(), u.roles(), Map.of()));
}

인가는 AuthorizationService, 신원 조회는 IdentityService.

H2. 공통코드 — CodeLookupService

codes(group) 만 구현하면 find / children / name 은 인터페이스 기본 메서드가 채운다. 목록은 정렬 순서, 없으면 빈 목록(null 금지). 이 타입 빈을 등록하면 프레임의 CachingCodeLookup → JdbcCodeLookup(또는 yml MapCodeLookup) 도 빠지고, code.jdbc · code.cache 는 적용되지 않는다. 단일 표는 §H2.1. 프로세스 안 캐시가 필요하면 구현을 new CachingCodeLookup(구현, ttl) 로 감싸 그 래퍼를 빈으로 등록한다. 적재 끝의 refresh() 는 빈에만 오므로, 안쪽 구현은 new 로 감싸고 래퍼가 Refreshable 이면 이어서 부른다(CachingCodeLookup 이 이미 그렇게 한다). 설정 키는 CUSTOMIZATION §9. 메시지 표는 §F3.

매퍼가 그룹 목록을 이미 주면 한 줄로도 된다. 매 호출 DB 를 치면 §H2.4 로 감싼다.

@Bean
CodeLookupService codeLookupService(CodeMapper m) { return group -> m.findByGroup(group); }
H2.1 단일 코드 테이블 yml

표가 하나이면 yml 만으로 된다. jdbc.enabled: true 이면 yml code.groups 는 무시된다. 프레임이 만드는 SQL 은 select code, name, sort[, parent, 확장컬럼] from table where group = ? [and use = ?] order by sort, code 이다. 그룹 값은 ? 바인드. 표·컬럼 이름이 식별자가 아니면 기동 실패(SqlIdentifiers). parent-column · use-column 을 비우면 계층·사용여부 필터가 빠진다. 메시지 표는 §F3.1.

ongo.framework.code:
  jdbc:
    enabled: true
    table: ongo_common_code
    group-column: code_group
    code-column: code
    name-column: code_name
    sort-column: sort_order
    parent-column: parent_code   # 비우면 계층 없음
    use-column: use_yn           # 비우면 사용여부 필터 없음
    use-value: "Y"
    attribute-columns:
      extra: extra_1             # CodeItem.attributes 키 → 컬럼
  cache:
    enabled: true
    ttl: 10m                     # 0s = 만료 없음. 적재 끝 refresh() 때만 비움
H2.2 코드 테이블 여러 개

공통 표와 채널 표를 한 그룹 목록으로 합친다. 공통 표는 상위 코드·사용 여부를 읽고, 채널 표는 상위 코드가 없다. 정렬은 sortOrder 다음 코드. 이 클래스는 캐시를 갖지 않고, 비우기는 바깥 CachingCodeLookup 이 한다. 그룹 값은 ? 바인드. 표 이름은 상수다.

public final class MultiTableCodeLookup implements CodeLookupService, Refreshable {
    private final JdbcTemplate jdbc;

    public MultiTableCodeLookup(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    @Override
    public List<CodeItem> codes(String group) {
        List<CodeItem> rows = new ArrayList<>();
        rows.addAll(query(
                "select code, code_name, sort_order, parent_code from site_code_common "
                        + "where code_group = ? and use_yn = 'Y'", group));
        rows.addAll(query(
                "select code, code_name, sort_order, null as parent_code from site_code_channel "
                        + "where code_group = ?", group));
        rows.sort(Comparator.comparingInt(CodeItem::sortOrder).thenComparing(CodeItem::code));
        return rows;
    }

    private List<CodeItem> query(String sql, String group) {
        return jdbc.query(sql, (rs, n) -> new CodeItem(
                group, rs.getString(1), rs.getString(2), rs.getInt(3), rs.getString(4)), group);
    }

    @Override
    public void refresh() { /* 캐시는 래퍼가 비운다 */ }
}

@Bean
CodeLookupService codeLookupService(JdbcTemplate jdbc) {
    return new CachingCodeLookup(new MultiTableCodeLookup(jdbc), Duration.ofMinutes(10));
}

MetaLoadRunner 가 CachingCodeLookup.refresh() 를 부르면 그룹 캐시를 비운 뒤, delegate 가 Refreshable 이면 refresh() 를 이어서 부른다.

H2.3 사이트 코드 SQL

테이블 수·조인·조건은 사이트마다 다르다. code.jdbc 는 표 하나의 컬럼 이름만 받는다. 채널은 생성자 상수로 두고, 그룹·채널은 ? 에 바인드한다. 속성이 코드당 여러 행이면 한 CodeItem 으로 모은 뒤 attributes 에 넣는다. 아래는 코드당 한 행(attr_key = 'extra')인 임의 샘플이다.

public final class SiteSqlCodeLookup implements CodeLookupService, Refreshable {
    private final JdbcTemplate jdbc;
    private final String channel;

    public SiteSqlCodeLookup(JdbcTemplate jdbc, String channel) {
        this.jdbc = jdbc;
        this.channel = channel;
    }

    @Override
    public List<CodeItem> codes(String group) {
        String sql = "select c.code, c.code_name, c.sort_order, c.parent_code, a.attr_val "
                + "from site_code c "
                + "join site_code_attr a on a.code_id = c.code_id and a.attr_key = 'extra' "
                + "where c.code_group = ? and c.chnl in ('*', ?) and c.use_yn = 'Y' "
                + "order by c.sort_order, c.code";
        return jdbc.query(sql, (rs, n) -> {
            Map<String, String> attrs = new LinkedHashMap<>();
            String extra = rs.getString(5);
            if (extra != null) attrs.put("extra", extra);
            return new CodeItem(group, rs.getString(1), rs.getString(2), rs.getInt(3), rs.getString(4), attrs);
        }, group, channel);
    }

    @Override
    public void refresh() { /* 쿼리마다 DB 를 읽는다. 캐시 비우기는 H2.4 래퍼 */ }
}
H2.4 코드 캐시

프로세스 안은 CachingCodeLookup 이다. ttl 이 Duration.ZERO(yml 0s)이면 만료가 없고, 적재 끝 refresh() 때만 비운다.

@Bean
CodeLookupService codeLookupService(JdbcTemplate jdbc) {
    return new CachingCodeLookup(new SiteSqlCodeLookup(jdbc, "mci"), Duration.ofMinutes(10));
}

인스턴스 사이는 DistributedCacheService 로 감싼다. 프레임은 Redis 를 하드코딩하지 않는다. 기본 구현은 프로세스 내 맵이고, 운영 저장소는 이 타입 빈으로 교체한다. get(key, List.class) 는 원소 타입이 사라지므로, Redis 구현은 List<CodeItem> 코덱을 그 저장소에 직접 등록한다. clear() 는 캐시 전체라 쓰지 않고, 이 조회가 넣은 키만 evict 한다. 다른 인스턴스가 넣은 같은 키는 그 인스턴스의 TTL 이 지나야 비운다.

public final class DistributedCodeLookup implements CodeLookupService, Refreshable {
    private final CodeLookupService delegate;
    private final DistributedCacheService cache;
    private final Duration ttl;
    private final Set<String> seen = ConcurrentHashMap.newKeySet();

    public DistributedCodeLookup(CodeLookupService delegate, DistributedCacheService cache, Duration ttl) {
        this.delegate = delegate;
        this.cache = cache;
        this.ttl = ttl;
    }

    @Override
    @SuppressWarnings("unchecked")
    public List<CodeItem> codes(String group) {
        String key = "code:" + group;
        Optional<List> hit = cache.get(key, List.class);   // List<CodeItem> 타입은 사라진다
        if (hit.isPresent()) return (List<CodeItem>) hit.get();
        List<CodeItem> loaded = delegate.codes(group);
        cache.put(key, loaded, ttl);                       // Duration.ZERO = 만료 없음
        seen.add(key);
        return loaded;
    }

    @Override
    public void refresh() {
        for (String key : seen) cache.evict(key);
        seen.clear();
        if (delegate instanceof Refreshable) ((Refreshable) delegate).refresh();
    }
}

@Bean
CodeLookupService codeLookupService(JdbcTemplate jdbc, DistributedCacheService cache) {
    CodeLookupService sql = new SiteSqlCodeLookup(jdbc, "mci");
    return new DistributedCodeLookup(
            new CachingCodeLookup(sql, Duration.ofMinutes(10)), cache, Duration.ofMinutes(30));
}

빈은 바깥 DistributedCodeLookup 하나다. 적재 끝 refresh() 가 공유 키를 지운 뒤, 안의 CachingCodeLookup 이 프로세스 캐시를 비우고 SiteSqlCodeLookup.refresh() 까지 잇는다. 캐시 계약은 CUSTOMIZATION §10.

H3. 캐시·세션 — CacheService(LocalCacheService/DistributedCacheService), SessionStoreService 교체로 Redis 등 연결. 공통코드 그룹을 공유 캐시에 넣는 예는 §H2.4.
H4. 로깅 — StructuredLogService, LogLevelService, LoggingBridgeService 교체로 필드 추가·런타임 레벨 변경.
H5. 검증 — PasswordRuleService, XSS 는 XssFilterService(+ @XssSafe 로 필드 제외).

I. 삼성카드 확장(ongo-framework-samsung-card)

공통 프레임워크는 삼카에 의존하지 않는다. 앱이 ongo-framework-samsung-card 를 추가 의존하면 아래 빈이 자동 등록되며, 모두 같은 타입 빈 등록으로 교체 가능하다.

ongo:
  framework:
    samsung-card:
      enabled: true
      standard-telegram:
        enabled: true      # samsungCardStandardTelegramCodec (850B 헤더 + 2B 메시지 + BODY + "@@") — CUSTOMIZATION §16.2
      layout:
        enabled: true      # samsungCardTelegramLayoutService (@ScTelegram/@ScField VO → 레이아웃)
      devtools:
        enabled: true      # sc-excel / samsung-card-snippet / samsung-card-io 파서, VO 리졸버, 코덱 옵션, 데코레이터
빈 이름 타입 교체 예
samsungCardStandardTelegramCodec TelegramCodecService 헤더 기본값·문자셋 변경
samsungCardTelegramLayoutService TelegramLayoutService DB 레이아웃 병행
samsungCardResponseOutcomeService ChannelResponseOutcomeService 성공 판정 필드 변경(기본 header.procsRsDvC)
samsungCardMessageCodeMappingService ChannelMessageCodeMappingService 매핑 원천(DB/파일)
samsungCardSnippetSpecParser·samsungCardIoSpecParser·samsungCardExcelSpecParser SpecParseService 레거시 코드조각 패턴 추가
samsungCardVoLayoutResolver · samsungCardStandardCodecOption · samsungCardCodeGenDecorator devtools —
I1. 전문 VO — @ScTelegram / @ScField
package com.example.card.telegram;

import com.ongosoft.framework.core.channel.FieldSpec;
import com.ongosoft.framework.samsungcard.layout.ScField;
import com.ongosoft.framework.samsungcard.layout.ScTelegram;

@ScTelegram("CARD01")                                   // direction 기본 REQUEST
public record CARD01In(
    @ScField(order = 1, length = 16, label = "카드번호") String cardNo,
    @ScField(order = 2, length = 8,  label = "조회일자") String inquiryDate,
    @ScField(order = 3, length = 13, type = FieldSpec.Type.NUM, label = "금액") String amount) {}

@ScTelegram(value = "CARD01", direction = ScTelegram.Direction.RESPONSE)
public record CARD01Out(
    @ScField(order = 1, length = 4,  label = "결과코드") String resultCode,
    @ScField(order = 2, length = 13, type = FieldSpec.Type.NUM, label = "한도") String limitAmount) {}

type 기본 CHAR(오른쪽 공백 채움), NUM 은 왼쪽 0 채움. order 는 1부터 빈틈 없이. 데모: demo/samsungcard/ScDemoCardInquiry.java(SCDEMO01).

송신: channelService.send("mci", "CARD01", Map.of("cardNo", "1234567812345678", "inquiryDate", "20261027", "amount", "10000")).

I2. 메시지코드 매핑
@Configuration(proxyBeanMethods = false)
public class ScMessageCodeMappingConfig {
    @Bean
    ScMessageCodeMappingRepository scMessageCodeMappingRepository() {
        Map<String, String> m = Map.of(
            "mci:E0001", "sc.limit.exceeded",   // 채널:상대코드 → 우리 메시지코드
            "*:E0099",  "sc.system.busy");      // * = 모든 채널
        return (channel, code) -> Optional.ofNullable(m.getOrDefault(channel + ":" + code, m.get("*:" + code)));
    }
}
# messages.properties
sc.limit.exceeded=한도를 초과했습니다. ({0})
sc.system.busy=상대 시스템이 혼잡합니다. 잠시 후 다시 시도하세요.

데모: demo/samsungcard/ScDemoMessageCodeMappingConfig.java. 매핑 결과는 ChannelException.responseMapped(code, channel, id, rc, summary, period) 로 던져진다.

운영 표 조회. 채널 전용 행(chnl = 요청 채널)이 와일드카드(*)보다 앞에 온다. InMemory 구현은 두 키가 둘 다 맞으면 어느 쪽을 쓸지 정하지 않는다. 채널·기간계 코드는 ? 바인드, 표 이름은 상수다.

@Bean
ScMessageCodeMappingRepository scMessageCodeMappingRepository(JdbcTemplate jdbc) {
    return (channel, code) -> {
        List<String> rows = jdbc.query(
                "select chnl_msg_c from site_chnl_msg_map "
                        + "where (chnl = ? or chnl = '*') and period_msg_c = ? "
                        + "order by case when chnl = '*' then 1 else 0 end",
                (rs, n) -> rs.getString(1), channel, code);
        return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
    };
}

반환 값 sc.limit.exceeded 같은 키는 messages.properties 또는 §F3 메시지 표에 둔다. 코어만 쓰는 앱은 §C6 ChannelMessageCodeMappingService 를 직접 등록한다. 삼카는 Repository 빈만 등록하면 ScMessageCodeMappingService 가 주입받는다. 코드 표 교체와는 별개다(§H2 · CUSTOMIZATION §9 · 맵핑 절).

I3. 표준전문 헤더 기본값·encode/decode 가로채기

코덱을 감싸(위임) 헤더 기본값을 넣거나 로그를 남긴다. 기존 빈을 주입받아야 하므로 다른 이름 + @Primary 로 등록한다.

@Configuration(proxyBeanMethods = false)
public class ScCodecWrapConfig {
    @Bean
    @Primary
    TelegramCodecService scCodecWithDefaults(@Qualifier("samsungCardStandardTelegramCodec") TelegramCodecService delegate) {
        return new TelegramCodecService() {
            public byte[] encode(List<FieldSpec> layout, Map<String, ?> values) {
                Map<String, Object> v = new LinkedHashMap<>(values);
                v.putIfAbsent("header.sendSysDvC", "ONG");     // 화면 HEADER JSON 과 같은 키 규칙
                return delegate.encode(layout, v);
            }
            public Map<String, String> decode(List<FieldSpec> layout, byte[] bytes) {
                return delegate.decode(layout, bytes);
            }
            public Charset charset() { return delegate.charset(); }
            public String name() { return delegate.name(); }
        };
    }
}

흔한 실수: 같은 타입을 그냥 등록 → 원래 삼카 코덱이 @ConditionalOnMissingBean 으로 빠져 위임 대상이 없음(이름 지정·@Qualifier 필수) · 헤더 키에 header. 접두사 누락.

I4. 레거시 코드조각 파서 패턴 추가

기본 snippet 파서는 (A) @ChannelAnnotation(order, dataType, length, scale) 필드, (B) X = WebtUtil.getString(buf, off, len) 오프셋 두 패턴만 안다. 다른 패턴은 새 SpecParseService 로 추가한다(기존 유형은 유지).

@Bean
SpecParseService legacySubstringSpecParser() {
    Pattern p = Pattern.compile("(\\w+)\\s*=\\s*\\w+\\.substring\\((\\d+)\\s*,\\s*(\\d+)\\)");   // cardNo = buf.substring(0, 16);
    return new SpecParseService() {
        public String type() { return "legacy-substring"; }
        public String description() { return "레거시 substring(begin,end) 코드조각"; }
        public List<SpecModel> parse(String content) {
            record Hit(String name, int from, int to) {}
            List<Hit> hits = new ArrayList<>();
            Matcher m = p.matcher(content);
            while (m.find()) hits.add(new Hit(m.group(1), Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3))));
            if (hits.isEmpty()) throw new IllegalArgumentException("substring 패턴이 없습니다");
            hits.sort(Comparator.comparingInt(Hit::from));
            List<FieldSpec> fields = new ArrayList<>();
            int expect = 0;
            for (Hit h : hits) {
                if (h.from() != expect) throw new IllegalArgumentException("오프셋 빈틈/겹침: " + h.name() + "@" + h.from());
                fields.add(new FieldSpec(h.name(), "STRING", h.to() - h.from(), null, false, null,
                        Map.of("offset", h.from())));
                expect = h.to();
            }
            return List.of(new SpecModel("LegacyTelegram", "DTO", "com.example.legacy", null, fields, Map.of()));
        }
    };
}

화면: 공통 I/O 정의서 카드의 입력 유형 legacy-substring → 코드조각을 JSON 정의서 칸에 붙여넣기 → 본문 파싱 → 표 확인 → [전문 송수신]으로 길이 검증.


J. 교체가 안 될 때 점검표

  1. 빈이 스캔되는가 — @SpringBootApplication 하위 패키지인지. 아니면 @Import.
  2. 프로필 — 데모처럼 @Profile("custom") 을 복사했다면 그 프로필을 켰는지.
  3. 교체형/확장형 구분 — 교체형인데 기본 빈도 살아 있으면 이름 조건(ConditionalOnMissingBean(name=…))이다 → 같은 이름 또는 @Primary.
  4. 위임(데코레이터 패턴) — 기본 빈을 주입받아 감쌀 때는 다른 이름 + @Qualifier + @Primary(I3).
  5. yml 키 — ongo.framework.channel.channels.<이름>, devtools.codegen.decorators, batch.failure.skip-limit 등은 CUSTOMIZATION.md 의 키 표와 대조.
  6. 확인 방법 — /actuator/beans(로컬) 또는 시작 로그의 ConditionEvaluationReport(--debug), devtools 화면 정보줄의 데코레이터: 목록.
  7. 시크릿 — 암호 키·토큰은 코드/yml 평문 금지. 환경변수·Vault·KMS 로 주입(${ONGO_CRYPTO_KEY} 형태).

K. 채널·전문 독립 예제 (송신·수신·charset·헤더·에코·진행번호)

규칙·우선순위 표는 CUSTOMIZATION.md §12.2. 아래 스니펫은 각각 단독으로 붙여 넣어 동작하도록 import 까지 적었다. 삼카 표준전문 코덱(standard-telegram.enabled: true) 기준이며, 코어 고정길이 코덱만 쓰는 일반 앱은 K1·K2·K3 만 해당된다.

K0. 공통 yml — 채널 3종 + 문자셋 + 표준전문
ongo:
  framework:
    channel:
      charset: UTF-8                    # 공통 기본(MCI/EAI)
      charset-defaults:
        fep: EUC-KR                     # type/이름이 fep 인 채널 기본
      channels:
        mci: { type: MCI, transport: http, url: http://mci.example.internal/gw, timeout: 10s }
        eai: { type: EAI, transport: http, url: http://eai.example.internal/gw, timeout: 10s }
        fep: { type: FEP, transport: http, url: http://fep.example.internal/gw, timeout: 15s }
        # fep: { ..., charset: MS949 }  # 개별 지정이 최우선
      inbound:
        path: /framework/channel/inbound   # 기본값
    samsung-card:
      standard-telegram:
        enabled: true
        header:            # 송신 HEADER 기관 기본값(null/"" 이면 내장 기본값)
          sendSysDvC: ONG
          envrInfDvC: D
        response-header:   # 수신 응답 HEADER 기본값(에코·업무값이 우선)
          procsRsDvC: "0"
K1. 송신 — MCI (UTF-8)
package com.example.card.service;

import com.ongosoft.framework.core.channel.ChannelResponse;
import com.ongosoft.framework.core.channel.ChannelService;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Service;

@Service
public class CardInquiryService {
    private final ChannelService channel;
    public CardInquiryService(ChannelService channel) { this.channel = channel; }

    public Map<String, String> inquire(String cardNo) {
        Map<String, Object> v = new LinkedHashMap<>();
        v.put("cardNo", cardNo);                    // BODY
        v.put("header.rcvSysDvC", "MCI");          // HEADER 건별 값
        v.put("header.stdIfId", "SCIF0001");
        ChannelResponse r = channel.send("mci", "SCIF0001", v);
        return r.values();                          // header.x / message.x / BODY 키
    }
}

결과 전문: stdEtxtPrgNo=00(최초 송신), stdEtxtSn 새로 채번, 길이는 UTF-8 바이트.

K2. 송신 — EAI (UTF-8) · 비동기
CompletableFuture<ChannelResponse> f = channel.sendAsync("eai", "SCIF0002",
        Map.of("cardNo", cardNo, "header.rcvSysDvC", "EAI"));
f.thenAccept(r -> log.info("eai done bytes={}", r.requestBytes()));

풀 크기 ongo.framework.channel.async.pool-size(기본 4). Boot2/JDK 8 에서는 Map.of 대신 LinkedHashMap.

K3. 송신 — FEP (EUC-KR) · 한글 길이
// holder 가 CHAR 20 이면 EUC-KR 기준 한글 10자까지. 11자면 400 channel.encode (자르지 않음)
ChannelResponse r = channel.send("fep", "SCIF0003",
        Map.of("cardNo", cardNo, "holder", "홍길동", "header.rcvSysDvC", "FEP"));

안티패턴: new String(bytes, "EUC-KR") 로 직접 변환하거나 공통 charset 을 EUC-KR 로 바꾸기(MCI/EAI 깨짐).

K4. 헤더 값 병합 — null / "" / " " 를 각각
Map<String, Object> v = new HashMap<>();
v.put("cardNo", cardNo);
v.put("header.envrInfDvC", null);   // null → yml(D) → 없으면 내장(P)
v.put("header.sendSysDvC", "");     // ""   → yml(ONG) 사용
v.put("header.mciAkNodeId", " ");   // " "  → 공백으로 덮음(전문에 공백 패딩)
v.put("header.rcvSysDvC", "MCI");   // 값   → 그대로
channel.send("mci", "SCIF0001", v);

인터페이스 단위 기본값은 컨텍스트로:

ScTelegramContext.with(Map.of("stdIfId", "SCIF0001", "rcvSysDvC", "MCI"), Map.of(),
        () -> channel.send("mci", "SCIF0001", Map.of("cardNo", cardNo)));   // 블록 종료 시 이전 값 복원
K5. 수신 — 공통 endpoint 하나로 MCI/EAI/FEP
package com.example.card.inbound;

import com.ongosoft.framework.core.channel.InboundHandlerService;
import com.ongosoft.framework.core.channel.InboundTelegram;
import java.util.*;
import org.springframework.stereotype.Component;

@Component
public class ScIf0001Inbound implements InboundHandlerService {
    private static final Set<String> CHANNELS = new HashSet<>(Arrays.asList("mci", "eai", "fep"));
    @Override public String interfaceId() { return "SCIF0001"; }
    @Override public boolean supports(String channel) { return CHANNELS.contains(channel); }
    @Override public Map<String, ?> handle(InboundTelegram t) {
        String cardNo = t.values().get("cardNo");                 // BODY
        String reqSn  = t.values().get("header.stdEtxtSn");       // 요청 HEADER 도 조회 가능
        Map<String, Object> out = new LinkedHashMap<>();
        out.put("resultCd", "00");
        out.put("cardNo", cardNo);
        out.put("header.procsRsDvC", "0");                        // 처리결과 — 업무값
        return out;                                               // @direction/에코/진행번호는 프레임이 채움
    }
}

호출(상대 시스템 입장 URL 은 채널마다 다름):

curl -X POST -H 'Content-Type: application/octet-stream' --data-binary @req_mci.bin  http://host/framework/channel/inbound/mci/SCIF0001 -o res.bin
curl -X POST -H 'Content-Type: application/octet-stream' --data-binary @req_fep.bin  http://host/framework/channel/inbound/fep/SCIF0001 -o res.bin   # EUC-KR
curl -X POST --data-binary @req.bin 'http://host/framework/channel/inbound/eai/SCIF0001?mode=async'   # 202 + receiptId
curl http://host/framework/channel/inbound/receipts/<receiptId>
K6. @direction=response — 응답 기본값 묶음 선택

수신 처리기 경로에서는 자동이다. 수신이 아닌 곳(배치·테스트·개발툴)에서 응답 전문을 직접 조립 할 때만 명시한다.

Map<String, Object> v = new HashMap<>();
v.put(ScStandardTelegramCodec.DIRECTION, "response");          // "@direction"
v.put(ScStandardTelegramCodec.REQUEST, decodedRequest);         // "@request" — 있으면 에코·+1 적용
v.put("resultCd", "00");
byte[] res = codec.withCharset(StandardCharsets.UTF_8).encode(layout.response(), v);

@request 가 없으면 에코는 생략되고 response-header 기본값 + 내장 기본값만 들어간다.

K7. 헤더 에코 확인 테스트
@Test
void echoesRequestHeader() {
    Map<String, String> req = codec.decode(layout.request(), requestBytes);
    Map<String, Object> v = new HashMap<>();
    v.put("@direction", "response"); v.put("@request", req); v.put("resultCd", "00");
    Map<String, String> res = codec.decode(layout.response(), codec.encode(layout.response(), v));
    assertThat(res.get("header.stdEtxtSn")).isEqualTo(req.get("header.stdEtxtSn"));   // 그대로
    assertThat(res.get("header.stdIfId")).isEqualTo(req.get("header.stdIfId"));
    assertThat(res.get("header.rcvSysDvC").trim()).isEqualTo(req.get("header.sendSysDvC").trim());
}

에코 목록: ScStandardTelegramCodec.ECHO_HEADER = stdEtxtSn, stdEtxtCrtDt, fstAkSysDvC, fstAkDtm, stdIfId, akRcvSvId, rspRcvSvId, mciAkNodeId (+ rcvSysDvC ← sendSysDvC).

K8. stdEtxtPrgNo — 송신 0, 수신 응답 +1
// 송신: 아무것도 안 하면 "00"
ChannelResponse r = channel.send("mci", "SCIF0001", Map.of("cardNo", cardNo));

// 수신 응답: 요청 "03" → 응답 "04" (자동). 비었거나 숫자 아니면 "01"
@Test
void prgNoPlusOne() {
    Map<String, Object> req = Map.of("header.stdEtxtPrgNo", "03");
    Map<String, Object> v = Map.of("@direction", "response", "@request", req);
    Map<String, String> res = codec.decode(layout.response(), codec.encode(layout.response(), v));
    assertThat(res.get("header.stdEtxtPrgNo")).isEqualTo("04");
}

안티패턴: 처리기에서 header.stdEtxtPrgNo 를 하드코딩(순위 1이라 +1 규칙을 덮어버림). 중첩 송신에서 hop 을 이어야 하면 ScTelegramContext.with(Map.of("stdEtxtPrgNo", n), …) 로 명시.


L. 실 적용 케이스 — 데모 방식 · 고객 앱 교체 방식 · 주의

기능 / SPI 설정(yml) 빈 교체 예제 데모에서 쓰는 방식 고객 앱(삼카)에서 교체하는 방식 일반 앱 / Boot2 주의·안티패턴
요청 인터셉터 interceptor.* OngoHandlerInterceptor 추가 A1 DemoCustomizations(custom 프로필) 인증·감사 인터셉터 추가 Boot2 인가 인터셉터 자동 등록(PoC5) 순서 getOrder() 미지정
상관ID web.*(CUSTOMIZATION §1) CorrelationIdGenerator A2 UUID 기본 거래번호 체계로 교체 동일 헤더명 변경 시 게이트웨이와 불일치
배치 실패정책 batch.failure.* BatchFailurePolicyService B1·B2 ongo-framework-batch-demo 카드 건별 skip batch-generic-demo / Boot2 Batch 4.3(PoC12) skip 무제한
채널 송신 channel.channels.* ChannelService C1·K1~K3 loopback·sample-host http/TCP 전송 추가(C3) 코어 고정길이 코덱 업무 코드에서 문자셋 변환
코덱 samsung-card.standard-telegram.* TelegramCodecService C2·I3·K6 삼카 표준전문(sc-demo) 위임 래퍼 @Primary generic: FixedLengthTelegramCodec 같은 타입 단순 등록(원 코덱 사라짐)
수신 channel.inbound.* InboundHandlerService C4·K5 ChannelInboundDemoHandler 인터페이스별 처리기 동일 채널별 컨트롤러 신설
헤더/에코/진행번호 standard-telegram.header/response-header (코덱 내부) K4·K7·K8 자동 yml 기관값, 건별 header.x 삼카 코덱 전용 "" 로 지우기, PrgNo 하드코딩
응답 판정 channel.response-outcome.* ChannelResponseOutcomeService C5 header.procsRsDvC 기관별 성공코드 동일 실패를 값으로 반환
코드 매핑 — ChannelMessageCodeMappingService C6·I2 ScDemoMessageCodeMappingConfig DB 매핑 동일 * 와일드카드 누락
코드생성 devtools.codegen.* SpecParseService·CodeGenDecorator D1~D5·I4 데모 /devtools 레거시 snippet 파서 추가 codegen.target-java = 실행 JDK(PoC22) 운영 프로필에서 devtools 켜기
암호화·마스킹 crypto.*, masking.* FieldCryptoService·MaskingService E1~E5 데모 키(로컬) KMS 키 주입 Boot2 MyBatis 인터셉터(PoC6·7) 키 평문 커밋
예외·메시지 message.* MessageSourceService F1~F3 파일 message.jdbc DB 동일 코드 없는 RuntimeException
트랜잭션 transaction.* RollbackRuleService 등 G1·G2 /api/demo/tx 라우팅 DS Boot2 자동구성(PoC9) 전파 기본값 무시
인증·FE auth.* AuthService H1 demo-web FE-06(#114) SSO 연동 Boot2 인증 필터(PoC4) 토큰 로깅

M. 외부 솔루션 연계 목업 — Redis · MQ(Rabbit/Kafka) · ES/ELK

원칙: 코어 JAR 은 SPI 계약만 제공한다. Redis·Rabbit·Kafka·Elasticsearch 클라이언트 의존성은 코어·데모 어디에도 없으며, 실제 연계는 같은 SPI 타입의 빈을 선택 어댑터 모듈 또는 고객사 팩에서 등록해 교체한다. 데모(custom 프로필)는 프로세스 내 목업으로 교체 지점만 보여 준다 — ongo-framework-sc-demo/.../custom/DemoExtensionMocks.java.

custom 프로필 전용: /api/demo/ext/* 는 --spring.profiles.active=custom 에서만 등록된다(그 외 404 가 정상). 쇼케이스 카드·FE 참조 화면(apps/demo-web /sc-custom, 기본 삼카 데모와 별도)에 같은 안내가 있다. CI 는 multi-jdk.yml 의 sc-demo-custom 잡(JDK 21 단일)이 :ongo-framework-sc-demo:test 로 회귀 확인한다.

케이스 SPI(코어) 데모 목업 빈 활성 방법 시연 API 실제 교체
세션 저장소 SessionStoreService DemoRedisLikeSessionStore (키 sample:session:{id}·sample:user:{uid}, 키별 TTL, maxPerUser/overflow 준수) 빈 등록만 (기본 InMemorySessionStore 는 @ConditionalOnMissingBean 로 양보) GET /api/demo/ext/session/keys Redis HASH/JSON + EXPIRE, 사용자 SET 인덱스. 동시 세션 판정은 Lua/트랜잭션
공용 캐시 DistributedCacheService DemoCustomizations.DemoRemoteCache (sample: 접두어, put(k,v,ttl) TTL 지원) 빈 등록만 PUT/GET /api/demo/cache/distributed/{key} SET key value PX ttl
MQ (범용 Messaging SPI 없음 — 후속 설계) DemoInMemoryBroker (토픽 pub/sub + 큐 poll) 빈 등록 · 데모 전용 POST /api/demo/ext/mq/{topic}, GET /api/demo/ext/mq/{topic}, GET /api/demo/ext/mq spring-rabbit / spring-kafka 선택 어댑터. 기간계 전문은 PhysicalTransportService(C3)로 붙이는 것도 가능
로그 색인 LoggingBridgeService DemoElasticsearchBridge (sample-elasticsearch, 최근 200건 링버퍼) yml ongo.framework.logging.bridges 에 sample-elasticsearch 추가 GET /api/demo/ext/logs?slot=&traceId=&size= ES Bulk / Logstash / Kafka 수집기로 비동기 전송

흔한 실수: 데모 브로커(DemoInMemoryBroker)를 업무 코드에서 직접 주입하지 말 것 — 코어 SPI 가 아니다. 로그 브리지는 emit 이 업무 흐름을 막지 않도록 실제 구현에서 큐잉·드롭 정책을 둘 것. 세션 목업은 단일 JVM 전용이라 다중 인스턴스 검증엔 실제 Redis 필요. 테스트: ExtensionMockTest(custom 프로필).

M5. EAI TCP 수신 목업 — 솔루션이 tcp.port 로 밀어 넣는 경우

원칙: 코어·스타터에는 TCP 서버가 없다. 내장 수신은 HTTP POST /framework/channel/inbound/{channel}/{interfaceId} 뿐이고, 처리 파이프라인은 코어 InboundTelegramProcessor(복호 → decode → InboundHandlerService → encode → 암호)다. TCP 수신은 앱/선택 어댑터가 소켓을 열고 받은 바이트를 같은 처리기에 넘기는 일반 코어 확장 패턴으로 붙인다(고객사 확장 의존 없음).

항목 내용
클래스 ongo-framework-sc-demo demo/custom/DemoEaiTcpInboundListener — @Profile("custom"), @ConditionalOnProperty(demo.eai-tcp.enabled=true), SmartLifecycle
프레임(데모 임의) 요청 [길이 4B BE][인터페이스 ID 10B ASCII][본문] → 응답 [길이 4B][결과 "00"/"99"][응답 전문 또는 오류 문구], keep-alive, 최대 1MB
처리 processor.process("eai", id, body) → ChannelInboundDemoHandler(잔액=금액×2)
yml demo.eai-tcp.enabled(true), port(19090, 0=임의), bind(127.0.0.1), channel(eai)
확인 API POST /api/demo/ext/eai-tcp/SAMPLE0001 {"acctNo":"110000000001","holder":"sample","amount":21} → 컨트롤러가 채널 코덱 + SAMPLE0001 요청 레이아웃으로 표준전문을 조립(삼카 표준: HEADER std-header.layout + MESSAGE + BODY + @@, 897B 이상)해 소켓으로 보내고, code=00 이면 응답 레이아웃으로 decode 해 decoded 에 담는다(codec·requestBytes 포함). 본문만 45B 로 보내면 전문 길이 부족: 45 < 897 이 난다(수정 전 버그)
테스트 EaiTcpInboundDemoTest (port=0, 왕복·keep-alive·미등록 ID 99·확인 API). 데모 build.gradle 이 테스트 JVM 에 demo.eai-tcp.port=0 지정
// 핵심 — HTTP 수신과 같은 코어 파이프라인
byte[] res = processor.process(channel, interfaceId, body);
writeFrame(out, "00", res);
호출 흐름 (Bean 경로 · lookup 키)

demo-web /sc-custom 화면의 각 케이스 "호출 흐름 보기" 와 같은 내용이다(정본 apps/demo-web/app/utils/sc-flows.ts).

HTTP 공통 수신 — URL 경로 변수가 그대로 lookup 키가 된다:

flowchart TD
  A["상대 시스템<br/>POST /framework/channel/inbound/{channel}/{interfaceId}"] --> B["OngoChannelInboundController<br/>@RequestMapping(ongo.framework.channel.inbound.path)"]
  B -->|"channel, interfaceId (경로 변수)"| C["InboundTelegramProcessor.process(channel, interfaceId, raw)"]
  C -->|"interfaceId"| D[("TelegramLayoutService.find<br/>yml channel.layouts.{id}")]
  C --> E["복호(설정 시) → TelegramCodecService.decode<br/>ScStandardTelegramCodec HEADER+MESSAGE+BODY+@@"]
  E -->|"h.interfaceId()==id && h.supports(channel)"| F[("List&lt;InboundHandlerService&gt; 빈<br/>stream().filter().findFirst()")]
  F --> G["ChannelInboundDemoHandler.handle(InboundTelegram)"]
  G --> H["encode(response 레이아웃) → 암호 → HTTP 200 바이트"]

EAI TCP 수신 목업 — 프레임 앞 10바이트가 interfaceId 키, 이후는 HTTP 와 같은 Processor:

flowchart TD
  A["POST /api/demo/ext/eai-tcp/SAMPLE0001"] --> B["ExtensionMockDemoController.eaiTcp<br/>codec.encode(layout.request) = 표준전문"]
  B --> C["Socket 127.0.0.1:19090<br/>[len4][ID10][전문]"]
  C --> D["DemoEaiTcpInboundListener.readFrame<br/>ID10 → interfaceId"]
  D --> E["InboundTelegramProcessor.process(eai, id, body)<br/>(위 HTTP 흐름 C~G 와 동일)"]
  E --> F["writeFrame [len4][00|99][응답 전문] → decode(layout.response)"]

수신 시연 POST /api/demo/channel/{channel}/{id}/inbound: ChannelDemoController.inbound 가 값→표준전문을 만들어 같은 InboundTelegramProcessor.process 를 호출한다. MQ 목업: DemoInMemoryBroker.publish → Map<topic, Queue>·Map<topic, List<Consumer>> lookup(코어 SPI 아님, 운영은 리스너에서 Processor 호출 자리).

운영 교체 포인트: ① readFrame/writeFrame 을 솔루션 규격(ASCII 길이, ID 위치, ACK/NAK, 하트비트)으로 ② 스레드 풀 상한·타임아웃 ③ 비동기는 processor.accept(접수번호) ④ 허용 IP·TLS ⑤ 채널별 리스너 빈. 솔루션 라이브러리 수신 콜백이 있으면 그 안에서 processor.process 만 호출. 송신 TCP 는 PhysicalTransportService 로.

  • 일반: 본문 = 코어 고정길이 레이아웃 바이트.
  • 삼카: 채널 코덱이 표준전문이면 TCP 본문도 표준전문 그대로, 응답 HEADER 에코·@direction 규칙은 HTTP 수신과 동일.
Multi-JDK 포팅 설계 (Java 8 / 11 / 17+) — 원소스 전략 docs/MULTI-JDK-PORTING.md

상태: PoC1~22 핵심 완료(2026-10-28 기준 develop) · 기준: Java 21 toolchain, Spring Boot 3.5.6(Boot3) / 2.7.18(Boot2)

이 문서는 처음 설계안(§1~7)과 PoC 진행 기록(§8 이후)을 함께 담는다. 「구현 전」 상태는 지났다. 현재 상태 요약:

항목 상태 근거
공통 코어 ongo-framework-common(Java 8 바이트코드) 완료 PoC1~8 — 예외·응답·auth·session·security·ColumnValues 이동
Boot2 어댑터 ongo-framework-boot2-core-web(javax, 생성 소스) 완료 PoC2~12 — Web·인터셉터·Crypto·MyBatis·Validation·Cache·Tx·Logging·Code·MetaLoad·Channel·OpenApi·Feign·Batch
devtools·samsung-card Java 8 문법 + Boot2 생성 완료 PoC15a·15b·16·17
런타임 스모크(Boot2 devtools HTTP·동적 전문·sc-standard 코덱 왕복) 완료 PoC18~20
동적 VO style / codegen.target-java 기본 = 실행 JDK 완료 PoC21·22
실 JDK 8/11 테스트 매트릭스 + GitHub Actions 완료 PoC13·14 (jdkMatrixTest, .github/workflows/multi-jdk.yml)
데모 배포 4라인(JDK 21/17/8/11, AA12~15) 완료 docker/README.md, OnGoDevOps 4.4
고객사 제공용 jar 분리(Boot3/Boot2 세트) 대기 문서 끝 TODO
Boot2 프레임 jar Nexus 게시 미정 현재 이미지 안 소스 빌드

1. 결론

레포를 버전별로 쪼개지 않는다. 한 레포 = 공통 코어 1벌 + Boot 라인별 어댑터 2벌.

타깃 Spring Boot Servlet/Validation 산출물
Java 8, 11 2.7.x (Spring 5.3) javax.* *-boot2 아티팩트 (bytecode 8)
Java 17+ 3.x (Spring 6) jakarta.* *-boot3 아티팩트 (bytecode 17)

Boot 3은 Java 17 최소라 8/11은 Boot 2.7 외 선택지가 없다. 따라서 "자바 3라인"이 아니라 "Boot 2라인" 문제다. 11은 8용 jar를 그대로 쓴다.

2. 현황 조사 (src/main, import 기준 분류)

분류: A=순수 Java(Spring/javax/jakarta 없음), S=spring-core/web만(HttpStatus 등), B=jakarta 의존, C=Boot/Batch/Feign 의존

모듈 A S B C 계
ongo-framework-core 101 16 10 0 127
ongo-framework-starter 11 11 0 47 69
ongo-framework-devtools 30 3 0 3 36
ongo-framework-samsung-card 22 1 0 2 25
ongo-framework-batch 3 1 0 9 13
합계 167 32 10 61 270
  • jakarta import: jakarta.servlet 26회, jakarta.validation 25회.
  • core의 B(jakarta) 10개: security/XssRequestFilter, auth/AuthorizationInterceptor, auth/AuthenticationFilter, web/CorrelationIdFilter, web/ApiExceptionHandler, web/HandlerMdcInterceptor, web/AccessLogFilter, validation/ValidPassword, validation/ValidationGroups, validation/ValidPasswordValidator.
  • core의 S 16개: 예외 계층(exception/*Exception, CodedException, ExceptionResponsePolicy, channel/*Exception, auth/AuthExceptions, logging/LoggingExceptions)은 HttpStatus/HttpStatusCode만 사용. 나머지: transaction/*ManagerService, security/HtmlEscapeXssFilter, web/ApiResponseBodyAdvice, web/OngoHandlerInterceptor.
  • Java 9+ 문법 사용(파일 수): record 51, List/Map/Set.of 57, Stream.toList() 21, var 18, instanceof 패턴 14, text block 3, switch -> 3. Java 8 소스 호환을 막는 주요인.

3. 무엇이 공통 코어로 가나

  • core의 A 101개 + devtools/samsung-card/batch의 순수 로직 → ongo-framework-common (Spring 무의존, Java 8 문법).
  • 예외 계층(S 중 HttpStatusCode 사용분): HttpStatusCode는 Spring 6 전용 → 정수 status code(int)로 교체하고 Spring 어댑터에서 변환. 그러면 예외 계층 전체가 common으로 이동.
  • transaction, HtmlEscapeXssFilter 등 spring-core만 쓰는 것: Spring 5.3/6 공통 API면 ongo-framework-spring-common(Spring 5.3 기준 컴파일, Java 8)으로. 6 전용 API 쓰면 어댑터로.

4. 어댑터 내용

boot3 (17+): 현 코드 그대로. core의 jakarta 10개 + starter 47 AutoConfiguration + batch(Batch 5) + Feign(Spring Cloud 2025.x). META-INF/spring/...AutoConfiguration.imports 유지.

boot2 (8/11): 같은 클래스를 javax.servlet/javax.validation로 변환한 버전 + Boot 2.7 / Batch 4 / Spring Cloud 2021.x. - 소스 중복을 피하려고 boot2 소스는 손으로 쓰지 않는다: boot3 어댑터 소스를 빌드 시 jakarta. → javax. 치환(Gradle copy+filter 또는 Eclipse Transformer 역방향)으로 생성. 차이가 치환으로 안 풀리는 것만 src/boot2-overrides에 둔다. - 알려진 수동 차이: Batch 5→4(JobBuilderFactory, JobRepository 생성자 API), HttpStatusCode→HttpStatus, Boot 2.7은 AutoConfiguration.imports와 spring.factories 둘 다 지원(2.7부터 imports 지원 → 그대로 OK), Spring Security 6→5 DSL.

5. 제안 모듈 레이아웃

ongo-framework-common            # Java 8 bytecode, 무의존: core A + 예외계층 + devtools/samsung-card 순수부
ongo-framework-spring-common     # Java 8, Spring 5.3 API로 컴파일 (5.3/6 공용 부분)
ongo-framework-boot3/            # Java 17
  core-web  (jakarta 10개)  starter  batch  devtools  samsung-card
ongo-framework-boot2/            # Java 8, boot3 소스 jakarta→javax 생성 + overrides
  core-web  starter  batch  devtools  samsung-card
demos: 기존 demo들은 boot3 라인에 연결, boot2 smoke demo 1개 추가

CI: common은 JDK 8/11/17/21 매트릭스 테스트, boot2는 JDK 8·11, boot3는 17·21.

6. 리스크 / 기존 사용자 영향

  1. common을 Java 8로 낮추면 record 51개·toList()·var·패턴매칭 다운그레이드 필요 — 가장 큰 작업. record는 equals/hashCode/accessor 이름(x()) 유지해 API 호환 맞출 것. (대안: Lombok @Value 금지, 수동 클래스)
  2. 패키지/아티팩트 좌표 변경 → 기존 소비자(demo, nanuer 등) 의존성 교체 필요. boot3 아티팩트는 기존 artifactId 유지하고 신규 common을 transitive로 끌어오면 소비자 영향 최소.
  3. HttpStatusCode → int 변경은 예외 public API 변경. 생성자 오버로드 유지(boot3 어댑터에 deprecated 브리지)로 완화.
  4. Feign/Spring Cloud, Batch, springdoc(OpenAPI) 버전 매트릭스가 boot2 쪽에서 별도로 관리됨.
  5. 자동 치환은 문자열 리터럴 내 jakarta 등 오탐 가능 → 생성 소스도 컴파일+테스트로 검증.

7. 다음 PoC 단계

  1. ongo-framework-common 모듈 생성, core A 중 record 없는 패키지(paging, code, crypto) 이동 + release=8 컴파일 통과.
  2. 예외 계층 HttpStatusCode → int 전환 PoC, 기존 demo 테스트 green.
  3. boot2 core-web 모듈: jakarta→javax 치환 생성 태스크로 10개 파일 컴파일(Boot 2.7.18, JDK 8).
  4. starter 중 1개 AutoConfiguration(OngoXssAutoConfiguration)을 boot2로 생성·smoke 테스트.
  5. 결과 보고 후 record 다운그레이드 일괄 진행 여부 결정.

8. PoC 진행 기록

8.1 1단계 (#71)

ongo-framework-common(release 8) 생성, paging·code·crypto(+PageResult/PageMeta) 이관.

8.2 2단계 — 예외 계층 int 전환 + Boot2 core-web 생성 + XSS smoke

예외 계층 HttpStatusCode → Integer/int (호환 깨짐, 소스 수정 필요) - CodedException/BusinessException/SystemException/ValidationException/FieldViolation/MessageCoded/ExceptionResponsePolicy, channel.ChannelException/ChannelResponseException/ChannelTransportException/TelegramFormatException → ongo-framework-common 이동(패키지명 동일). - 생성자 상태 인자 HttpStatusCode → Integer(null=기본값), withStatus(code, int, args...), httpStatus() 는 Integer 반환. - ExceptionResponsePolicy·FieldViolation record → 불변 클래스(접근자 이름·equals 유지). *StatusCode() 제거 → businessStatus() 등 int 사용. - 정수 상수·검증: core.exception.HttpStatusCodes. Spring 변환은 Boot3 ApiExceptionHandler 가 담당. - 이전 가이드: withStatus("x", HttpStatus.CONFLICT) → withStatus("x", HttpStatus.CONFLICT.value()). 점진 이전용 @Deprecated core.web.HttpStatusBridge (of(HttpStatusCode), httpStatus(CodedException)) 를 Boot3 어댑터에 둠. 레포 내 호출부는 모두 .value() 로 이전 완료.

Boot2 ongo-framework-boot2/core-web (Gradle :ongo-framework-boot2-core-web) - Boot 2.7.18 BOM(Spring 5.3.31), --release 8. 소스는 generateBoot2Sources 가 core/starter 의 boot3 소스를 복사하며 jakarta.(servlet|validation|annotation|persistence|transaction). → javax. 치환, ElementType.RECORD_COMPONENT 제거. 산출: build/generated/boot2/java. - 치환으로 안 되는 파일은 ongo-framework-boot2/src/boot2-overrides/java 에 같은 경로로 두면 덮어쓴다(현재 없음). - 생성 대상(PoC): CorrelationId/CorrelationIdFilter/CorrelationIdGenerator, validation(ValidPassword·Validator·Groups·PasswordRule), security(XssFilterService·HtmlEscapeXssFilter·XssRequestFilter·XssSafe), starter OngoXssAutoConfiguration/OngoXssProperties/XssJacksonModule. 이를 위해 boot3 원본에서 List/Set.copyOf·var·instanceof 패턴·List.of() 를 8 문법으로 내림(동작 동일). - AutoConfiguration.imports 는 XSS 한 줄만 생성. - smoke Boot2XssSmokeTest: Boot 2.7.x 확인, ongoXssFilter·ongoXssBodyConfigurer 등록, <script> 파라미터 정화, enabled=false 비활성, 생성 클래스 major=52(Java 8).

아직 안 된 것 - core B 10개 중 ApiExceptionHandler(→ ApiResponse·MessageSourceService 등 core S/A 의존, toList()/List.of 다수), AccessLogFilter, HandlerMdcInterceptor, AuthenticationFilter(session), AuthorizationInterceptor(OngoHandlerInterceptor) 는 의존 패키지를 common/spring-common 으로 먼저 내린 뒤 생성 목록에 추가. - 실제 JDK 8 런타임 테스트 미실시(로컬에 JDK 8 없음, JDK 21 toolchain + --release 8). CI 매트릭스(JDK 8/11)에서 boot2 테스트 필요. - ongo-framework-spring-common 미생성, boot2 starter/batch(Batch 4)/Feign(2021.x)/Security 5 미착수, boot2 게시 설정 미적용.

PoC3 (ApiResponse · ApiExceptionHandler · 접근로그)

  • common 이관: web/ApiResponse, web/ApiFieldError(record→불변 클래스, record 식 접근자 + bean getter), web/CorrelationId(MDC), message/MessageSourceService. common 의존은 slf4j-api(api) + jackson-annotations(compileOnly, @JsonInclude)까지만 허용.
  • Boot2 생성 목록 추가: AccessLogFilter·AccessLogEntry(record→클래스)·AccessLogWriter·HandlerMdcInterceptor·OngoHandlerInterceptor — boot3 원본 자체를 8 문법으로 내림(List.of→Collections, 패턴 instanceof 제거).
  • ApiExceptionHandler 는 src/boot2-overrides 사본(Spring 5.3): HttpStatusCode→int, HandlerMethodValidationException·NoResourceFoundException(6.1) 핸들러 없음. 원본 수정 시 사본도 맞출 것. 런타임에 javax.validation 필요(앱은 spring-boot-starter-validation).
  • Boot2 smoke: Boot2ApiExceptionSmokeTest(비즈니스 409·미처리 은닉·파라미터 누락 errors[]·AccessLogFilter).
  • 남은 jakarta: auth/AuthenticationFilter(세션), auth/AuthorizationInterceptor → auth/* 의존 정리 후. starter 쪽 web/interceptor AutoConfiguration 의 Boot2 생성도 미착수. 실 JDK 8 CI 없음.

PoC4 (auth · session → common, Boot2 인증 필터/인가 인터셉터)

  • common 이관(패키지명 동일): session/*(SessionData·SessionPolicy record→불변 클래스, InMemorySessionStore toList()→Collectors), auth/* 중 서블릿 무관한 것 전부 — AuthPrincipal(record→클래스), AuthContext, AuthService/IdentityService/AuthorizationService, RequirePermission, RoleBasedAuthorization, InMemoryUserAuth(User record→클래스), AuthSessionService(LoginResult record→클래스), AuthExceptions(HttpStatus→HttpStatusCodes). 모두 record 식 접근자(userId()) 유지 + bean getter 추가(JSON 직렬화 호환).
  • core 에 남은 jakarta 2개(AuthenticationFilter·AuthorizationInterceptor)는 원본을 8 문법으로 내리고(isBlank→trim().isEmpty(), List.copyOf, instanceof 패턴) Boot2 생성 목록에 추가. overrides 불필요.
  • Boot2 starter 생성 추가: OngoHash/OngoSession/OngoAuth AutoConfiguration+Properties, imports 에도 노출. (OngoAuthAutoConfiguration 의 Set.copyOf 제거)
  • smoke Boot2AuthSmokeTest: 자동구성 빈 등록·enabled=false, Bearer/쿠키 토큰 → 401/403/200, 생성 클래스 major=52.
  • spring-common 은 아직 불필요(이번 이관분은 Spring 무의존). core B(jakarta) 10개는 전부 Boot2 생성/override 완료.

남은 것 / 다음 - OngoInterceptorAutoConfiguration(인터셉터 등록기 — Boot2 앱에서 AuthorizationInterceptor 를 MVC 에 자동 등록하는 부분): OngoWebAutoConfiguration·OngoMaskingAutoConfiguration 의존 + var/toArray(String[]::new) → 해당 체인 같이 생성해야 함. 그 전까지 Boot2 앱은 WebMvcConfigurer 로 직접 등록. - ApiResponseBodyAdvice·OngoWebAutoConfiguration/OngoExceptionAutoConfiguration Boot2 생성(후속, Web 체인과 함께). - 나머지 record(약 40개)·toList() 일괄 다운그레이드, starter 나머지, Batch 4, Feign 2021.x, 실 JDK 8/11 CI 매트릭스.

PoC5 (Web · Interceptor 체인 Boot2 — 인가 인터셉터 자동 등록)

  • Boot2 생성 추가(starter): OngoWeb/OngoMasking/OngoInterceptor/OngoMessage/OngoException AutoConfiguration+Properties, MaskingJacksonModule·MaskingLogbackConverter, DefaultMessageSource·JdbcMessageSource·SqlIdentifiers. imports 에도 노출. → Boot2 앱도 OngoHandlerInterceptor 빈(= AuthorizationInterceptor)이 WebMvcConfigurer 없이 자동 등록됨.
  • Boot2 생성 추가(core): ApiResponseBodyAdvice·NoEnvelope, security 마스킹 순수부(MaskRule·MaskStrategy·MaskingService·RuleBasedMaskingService·MaskingLogConverter·ColumnValues·Masked/Unmasked/Encrypted).
  • 원본 8 문법 다운그레이드: var, Collection.toArray(String[]::new)→new String[0], getPackageName()→getPackage().getName(), instanceof 패턴, switch 식→switch 문, String.repeat, isBlank, List.of/List.copyOf/Map.copyOf/toList()/toUnmodifiableSet, Matcher.appendReplacement(StringBuilder)→StringBuffer. MaskRule record→불변 클래스(접근자 fields() 등 유지).
  • override 1개 추가: core/security/ColumnAttributeService — ColumnMeta record·of(RecordComponent, …) 가 Java 16 API 라 Boot2 사본은 불변 클래스 + Field 팩토리만. 원본 바꿀 때 같이 맞출 것.
  • generateBoot2Sources: overrides 에 같은 경로가 있으면 원본을 제외(Sync 는 먼저 복사된 쪽이 남아 덮어쓰기가 안 됐음).
  • spring-jdbc·logback-classic 은 compileOnly(메시지 JDBC·로그 변환기는 앱 런타임에 있을 때만).
  • smoke Boot2WebInterceptorSmokeTest: Web/Exception/Masking/Interceptor/Auth 빈 등록, MVC 실제 체인에서 401(필터)·403(자동 등록 인가 인터셉터)·200 envelope+응답 마스킹, interceptor.enabled=false, 생성 클래스 major=52.

남은 것 / 다음 (PoC6 후보) - security 를 common 으로 이관(현재는 core 원본을 Boot2 생성) + OngoCryptoAutoConfiguration·EncryptedField/ResultInterceptor(MyBatis) Boot2. - 나머지 starter(Outbound/Feign 2021.x, Cache, Transaction, SlowQuery 등), 남은 record·toList() 일괄, Batch 4, 실 JDK 8/11 CI 매트릭스.

PoC6 (security → common, Boot2 Crypto · MyBatis @Encrypted 인터셉터)

  • common 이관(패키지명 동일 core.security): MaskRule·MaskStrategy·MaskingService·RuleBasedMaskingService·MaskingLogConverter·Masked/Unmasked/Encrypted·XssFilterService·XssSafe. Boot2 생성 목록에서는 제외(common jar 로 공급).
  • 애너테이션(Masked/Unmasked/Encrypted)의 ElementType.RECORD_COMPONENT 타깃 제거(Java 8). record 컴포넌트에 붙여도 FIELD·METHOD 로 전파되므로 기존 사용처 컴파일·동작 유지. 대신 core ColumnMeta.of(RecordComponent, …) 는 c.getAccessor() 의 애너테이션을 읽도록 변경.
  • core 에 남은 것: HtmlEscapeXssFilter(Spring HtmlUtils)·XssRequestFilter(서블릿) — 기존대로 Boot2 생성. ColumnAttributeService(record·RecordComponent → override 유지)·ColumnValues(ColumnMeta 의존) — core 원본/override 로 Boot2 생성.
  • Boot2 생성 추가(starter): OngoCryptoAutoConfiguration·OngoCryptoProperties(imports 노출), MyBatis EncryptedFieldInterceptor·EncryptedResultInterceptor. 원본 8 문법 다운그레이드: instanceof 패턴, Class#isRecord()→상위 타입 java.lang.Record 이름 비교 헬퍼.
  • org.mybatis:mybatis:3.5.16 은 boot2 compileOnly(앱이 mybatis-spring-boot-starter 2.3.x 를 넣을 때만). OngoMyBatisAutoConfiguration 은 PoC7 에서 Boot2 생성됨(인터셉터 자동 등록).
  • smoke Boot2CryptoSmokeTest: 키 있을 때 Field/Channel 암호 빈·키 없으면 Field 없음·enabled=false, 인터셉터 저장 시 암호화+호출자 평문 복원·조회 시 복호화, 생성/common 클래스 major=52.

남은 것 / 다음 (PoC7 후보) - OngoMyBatisAutoConfiguration(MyBatis 3.5/mybatis-spring 2.x) Boot2 생성 → 인터셉터 자동 등록, ColumnAttributeService 를 common 으로 옮길지(ColumnMeta 를 불변 클래스로 통일) 결정. - 나머지 starter(Outbound/Feign 2021.x, Cache, Transaction, SlowQuery 등), 남은 record·toList() 일괄, Batch 4, 실 JDK 8/11 CI 매트릭스.

PoC7 (Boot2 MyBatis 자동구성 → @Encrypted 인터셉터 자동 등록)

  • Boot2 생성 추가(starter, 원본 그대로 — 이미 8 문법): OngoMyBatisAutoConfiguration·OngoMyBatisProperties·SlowQueryInterceptor. imports 노출 목록에 OngoMyBatisAutoConfiguration 추가. override 없음.
  • 의존: boot2 compileOnly/testImplementation 을 org.mybatis:mybatis:3.5.16 → org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.2(Boot 2.7·Java 8 라인, ConfigurationCustomizer·MybatisAutoConfiguration 동일 패키지). 테스트는 H2.
  • 결과: Boot2 앱은 mybatis-spring-boot-starter 2.3.x 만 넣으면 EncryptedFieldInterceptor·EncryptedResultInterceptor·Paging·(임계값 설정 시) SlowQueryInterceptor 가 자동 등록되고 SqlSessionFactory 플러그인에 들어간다. PoC6 의 "인터셉터 직접 빈 등록" 안내는 폐기.
  • 버그 수정: starter 의 AutoConfiguration.imports 가 CRLF 라 generateBoot2Resources 필터가 \r 때문에 대부분 줄을 걸러 Boot2 jar 에 OngoXssAutoConfiguration 만 노출되던 문제 → 줄을 trim 후 비교. 이제 PoC2~7 자동구성 11개가 모두 노출된다(필터 클로저 변경은 Gradle up-to-date 에 안 잡히므로 바꾸면 --rerun-tasks).
  • smoke Boot2MyBatisSmokeTest(3): DataSource+Crypto+OngoMyBatis+MybatisAutoConfiguration 컨텍스트에서 인터셉터 빈·SqlSessionFactory 플러그인 등록·mapUnderscoreToCamelCase, field-encryption=false·slow-query-threshold·enabled=false 토글, imports 노출·class major=52.

남은 것 / 다음 (PoC8 후보) - ColumnAttributeService common 이관(ColumnMeta 불변 클래스 통일) 여부 결정. - 나머지 starter(Outbound/Feign 2021.x, Cache, Transaction, DataSourceService, Code/MetaLoad, Validation, Logging, Channel, OpenApi), 남은 record·toList() 일괄, Batch 4, 실 JDK 8/11 CI 매트릭스.

PoC8 (ColumnAttributeService · ColumnMeta · ColumnValues → common, Boot2 Validation 자동구성)

  • common 이관(패키지 동일 core.security): ColumnAttributeService·ColumnMeta·ColumnValues. ColumnMeta 는 record → 불변 final class(접근자 name()/type()/length()/encrypted()/masking() 이름 유지, equals/hashCode/toString). 기존 사용처(생성자·접근자 호출) 그대로 컴파일.
  • ColumnMeta.of(String name, Class<?> type, AnnotatedElement source, Integer length) 추가 — of(Field, Integer) 는 위임. Boot3 record 는 of(c.getName(), c.getType(), c.getAccessor(), len) 로 접근자 애너테이션을 읽는다(common 은 Java 8 이라 RecordComponent 미참조).
  • Boot2 override ColumnAttributeService 삭제, Boot2 생성 목록(coreFiles)에서 두 파일 제외(common jar 로 공급).
  • Boot2 생성 추가(starter, override 없음): OngoValidationAutoConfiguration·OngoValidationProperties, imports 노출.
  • 테스트: common ColumnMetaTest(불변·equals·애너테이션 해석), core ColumnMetaRecordTest(record 접근자 경유), boot2 Boot2ValidationSmokeTest(PasswordRuleService 빈·enabled=false·class major=52). ./gradlew build --rerun-tasks 통과.

남은 것 / 다음 (PoC9 후보) - 나머지 starter(Outbound/Feign 2021.x, Cache, Transaction, DataSourceService, Code/MetaLoad, Logging, Channel, OpenApi), 남은 record·toList() 일괄, Batch 4, 실 JDK 8/11 CI 매트릭스.

PoC9 (Boot2 Cache · Transaction · Logging · Code · MetaLoad · DataSourceService 자동구성)

  • Boot2 생성 추가(starter, override 없음): OngoCacheAutoConfiguration·OngoTransactionAutoConfiguration(+TransactionSettings·TransactionAttributeCustomizer·FrameworkTransactionAttribute(Source)·DefaultRollbackRule·Framework(DataSource|Jdbc)TransactionManager·DataSourceTransactionManagerService)·OngoLoggingAutoConfiguration(+LoggingSystemLogLevels)·OngoCodeAutoConfiguration(+JdbcCodeLookup)·OngoMetaLoadAutoConfiguration(+MetaLoadRunner)·OngoDataSourceServiceAutoConfiguration 와 각 Properties. imports 노출 6개 추가(총 18개).
  • core 생성 추가: core/cache(CacheService·Local/Distributed·InMemoryCache), core/transaction(DataSourceService·TransactionManagerService·DistributedTransactionManagerService·RollbackRuleService), core/logging(StructuredLogService·LogLevelService·LoggingBridgeService·Slf4jLoggingBridge·DefaultStructuredLogService·LogEvent·LogSlot·LoggingExceptions). core/code 는 이미 common.
  • 원본 8 문법 다운그레이드: instanceof 패턴, List/Set/Map.of()·copyOf→Collections, String#isBlank→trim().isEmpty(), Duration#toSeconds→getSeconds, 익명 LinkedHashMap<> 타입 명시, private record(InMemoryCache 엔트리·트랜잭션 속성 캐시 키)→불변 클래스.
  • LogEvent·LogSlot: public record → 불변 final class(접근자·equals/hashCode/toString 동일, Jackson 용 getX()/isX() 추가 — 데모 /slots JSON 필드 유지).
  • Slf4jLoggingBridge: slf4j 2.x atLevel → 레벨별 분기(Boot2 의 slf4j 1.7 호환, 모르는 레벨은 기존처럼 IllegalArgumentException).
  • OngoTransactionAutoConfiguration: Boot 3.2+ 전용 TransactionManagerCustomizationAutoConfiguration 를 after 클래스 대신 afterName 문자열로 → Boot2 에서 컴파일되고 없으면 무시.
  • smoke Boot2CoreServicesSmokeTest(4): Cache·Logging 빈/슬롯/slf4j 송신·끄기, DataSourceService→DataSource→DataSourceTransactionManager(timeout 5s)·RollbackRule·끄기, Code·MetaLoad 러너 runNow, imports 노출·class major=52.

남은 것 / 다음 (PoC10 후보) - Outbound/Feign(Spring Cloud 2021.x 매트릭스), Channel, OpenApi(springdoc 1.x), Batch 4 — 버전 매트릭스 작업 필요해 보류. - 남은 record·toList() 일괄, 실 JDK 8/11 CI 매트릭스.

PoC10 (Boot2 Channel · OpenApi)

  • Channel 은 Feign 과 독립(표준전문 스택, http/loopback 전송)이라 그대로 진행. 피벗(일괄 record 변환) 불필요.
  • Boot2 생성 추가: OngoChannelAutoConfiguration·OngoChannelProperties·OngoChannelInboundController, OngoOpenApiAutoConfiguration·OngoOpenApiProperties·OpenApiProfileEnvironmentPostProcessor(spring.factories 도 생성 리소스에 포함). imports 노출 2개 추가(총 20개).
  • core 생성 추가: core/channel/*. 원본 8 문법 다운그레이드:
  • public record 6종(ChannelEndpoint·ChannelResponse·FieldSpec·InboundReceipt·InboundTelegram·TelegramLayout) → 불변 final class(접근자·검증·기본값·equals/hashCode/toString 동일, Jackson getX() 추가로 JSON 필드 유지).
  • List/Map.copyOf·List.of→Collections.unmodifiable*, var→명시 타입, isBlank→trim().isEmpty(), strip()→trim(), stripTrailing()→replaceAll("\\s+$","")(ASCII 공백 기준), ByteArrayOutputStream#writeBytes→write(b,0,len), 익명 LinkedHashMap<> 타입 명시, starter .toList()·toArray(String[]::new) 제거.
  • override 1개: HttpPhysicalTransport — 원본은 Java 11 java.net.http.HttpClient → boot2-overrides 사본은 HttpURLConnection(POST octet-stream, timeout=connect/read, 2xx 외 IllegalStateException).
  • OpenApi: Boot 2.7 은 springdoc 1.8.0(springdoc-openapi-ui, compileOnly). 2.x→1.x 차이는 GroupedOpenApi 패키지(org.springdoc.core.models.→org.springdoc.core.)뿐 → 생성 filter 한 줄로 해결(override 없음).
  • smoke Boot2ChannelOpenApiSmokeTest(5): loopback 송수신 왕복(19바이트·NUM 0채움 복원)·끄기, HttpURLConnection 전송(JDK HttpServer echo), springdoc 1.x OpenAPI/GroupedOpenApi 빈, 프로필 게이트(dev ON / prod OFF), imports·spring.factories 노출. class major=52.

남은 것 / 다음 (PoC11 후보) - Outbound/Feign(Spring Cloud 2021.x 매트릭스), Batch 4(Spring Batch 4.3 API 차이). - 남은 record·toList() 일괄(core 의 미생성 패키지), 실 JDK 8/11 CI 매트릭스.

PoC11 (Boot2 Outbound / OpenFeign — Spring Cloud 2021.0.x)

  • 버전 매트릭스: Boot2 core-web 에 spring-cloud-dependencies:2021.0.9 BOM 을 Boot 2.7.18 BOM 다음에 import(루트의 2025.0.0 을 덮음). 해석 결과 spring-cloud-openfeign-core 3.1.9 · feign-core 11.10 · spring-cloud-commons/context 3.1.8(전부 Java 8). openfeign 은 compileOnly(앱이 넣을 때만 켜짐) + testImplementation.
  • Boot2 생성 추가: OngoOutboundAutoConfiguration·OngoOutboundProperties·OutboundCallException·OutboundErrorDecoder·OutboundExceptionAdvice·EnvelopeUnwrappingDecoder·CorrelationIdRequestInterceptor. imports 노출 1개 추가(총 21개).
  • API 차이(2025.0 / feign 13 ↔ 2021.0 / feign 11): 프레임워크가 쓰는 범위에서는 없음 → override 0개, 생성 filter 추가 없음.
  • Request.Options(long, TimeUnit, long, TimeUnit, boolean), Retryer.Default/NEVER_RETRY, Response.charset()·toBuilder().body(byte[]), Util.toByteArray, RequestTemplate.headers()/header(), RetryableException — feign 11 에 모두 존재.
  • SpringDecoder(ObjectFactory<HttpMessageConverters>)·ResponseEntityDecoder — openfeign 3.1 에 동일 시그니처. HttpMessageConverters 는 Boot 2.7 org.springframework.boot.autoconfigure.http 그대로.
  • 원본 8 문법 다운그레이드만: isBlank→trim().isEmpty()(3곳), var→Collection<String>, instanceof 패턴→명시 캐스트(EnvelopeUnwrappingDecoder#candidate), private record RemoteError→private static final class.
  • smoke Boot2OutboundSmokeTest(3): 빈 등록·yml(read-timeout·retry)·끄기, 실제 Feign 호출(JDK HttpServer) — envelope data 언랩, 상관ID 헤더 전파, 4xx envelope→BusinessException(원격 code), 5xx 비envelope→SystemException(OUTBOUND_SERVER_ERROR), imports 노출. Boot2 smoke 전체 10 클래스/35 테스트 green.

남은 것 / 다음 (PoC12 후보) - Batch 4(Spring Batch 4.3 — JobBuilderFactory/StepBuilderFactory, JobRepository 빌더 API 차이). - 남은 record·toList() 일괄(아직 Boot2 생성 대상이 아닌 core·devtools 패키지), 실 JDK 8/11 CI 매트릭스.

PoC12 (Boot2 Batch — Spring Batch 4.3)

  • 버전: Boot 2.7.18 BOM → spring-batch-core 4.3.10(Java 8). spring-boot-starter-batch 는 compileOnly(앱이 넣을 때만 켜짐) + testImplementation.
  • Boot2 생성 추가: ongo-framework-batch 의 com/ongosoft/framework/batch/*.java 전부(OngoBatchAutoConfiguration·OngoBatchProperties·BatchFailurePolicyService·RuleBasedBatchFailurePolicyService·BatchJobLaunchService·DefaultBatchJobLaunchService·BatchMonitorService·LoggingBatchMonitorService·OngoBatchJobListener·BatchTimes). imports 에 OngoBatchAutoConfiguration 덧붙임(총 22개, generateBoot2Resources doLast).
  • 제외: batch/devtools(개발툴 배치 화면) — devtools 모듈이 아직 Boot2 미생성.
  • API 차이(Batch 5 ↔ 4.3)와 처리:
  • JobExecution#getStartTime()/getEndTime(): 5 = LocalDateTime, 4.3 = java.util.Date → 원본에 BatchTimes(패키지 전용 헬퍼) 하나로 가두고 override 1개(boot2-overrides/.../batch/BatchTimes.java: Date→LocalDateTime, 시스템 시간대). 외부 API(ExecutionInfo.start/end)는 둘 다 LocalDateTime. 원본 바꿀 때 사본도 맞출 것.
  • SkipPolicy#shouldSkip(Throwable, int|long): 람다 (t, count) -> count < limit 가 양쪽에 그대로 맞음.
  • SimpleStepBuilder·FaultTolerantStepBuilder·skipPolicy/retryPolicy, spring-retry RetryPolicy(1.3 ↔ 2.x), AbstractJob#registerJobExecutionListener, JobExplorer·JobLauncher, JobParameters#getParameters() 의 getValue() — 프레임워크 사용 범위에서 동일.
  • JobBuilderFactory/StepBuilderFactory·new StepBuilder(name, repo) 차이는 앱 쪽(잡 정의)만 해당 — 프레임워크는 잡을 만들지 않고 policy.apply(SimpleStepBuilder) 만 받으므로 어댑터 불필요. 앱 가이드:
    • Boot 2.7 은 @EnableBatchProcessing 필요(JobRepository·JobLauncher·JobExplorer 생성 → 그래야 ongoBatchJobLaunchService 가 붙는다). Boot 3 은 붙이지 말 것(자동구성 꺼짐).
    • Batch 4: policy.apply(steps.get("s").<I,O>chunk(n).reader(..).processor(..).writer(..)). Batch 5: policy.apply(new StepBuilder("s", repo).<I,O>chunk(n, tx)...).
    • Batch 4.3 은 processor(...) 가 ItemProcessor/Function 오버로드라 람다는 (ItemProcessor<I,O>) x -> .. 캐스트 필요.
  • 원본 8 문법 다운그레이드: BatchJobLaunchService public record 3종(JobInfo·StepInfo·ExecutionInfo) → 불변 final class(접근자 name() 등·equals/hashCode/toString 유지, Jackson getX()/isX() 추가 → 개발툴 배치 화면 JSON 필드 동일), toList()→Collectors.toList()(4곳), List.of()→Collections.emptyList(), instanceof 패턴→캐스트.
  • smoke Boot2BatchSmokeTest(3): @EnableBatchProcessing + JobBuilderFactory/StepBuilderFactory 잡(H2) 을 BatchJobLaunchService 로 실행 → yml skip 정책(1건 skip, read 3/write 2), 모니터 훅 before/after, 실행 이력(start/end LocalDateTime, 파라미터+ongo.run.id), recent(), JSON 필드명; allowed-jobs·enabled=false; imports 노출(devtools 화면 제외)·class major 52.

남은 것 / 다음 (PoC13 후보) - 개발툴(devtools) Boot2 생성 → batch/devtools 배치 화면 포함(text block·record 다수). - 남은 record·toList() 일괄(아직 Boot2 생성 대상이 아닌 core·devtools·samsung-card 패키지), 실 JDK 8/11 CI 매트릭스.

PoC13 (실 JDK 8 / 11 런타임 테스트 매트릭스)

  • 지금까지는 --release 8 컴파일 + class major=52 확인까지였고, 테스트는 JDK 21 런처로만 돌았다. 이제 실제 JDK 8·11 JVM 위에서 common·Boot2 테스트를 돌린다.
  • gradle/jdk-matrix.gradle: test8·test11(Test 태스크, javaLauncher = javaToolchains.launcherFor(8|11), 기존 test 클래스·런타임 클래스패스 재사용) + 묶음 jdkMatrixTest. 컴파일 툴체인은 21 그대로(--release 8).
  • 적용: ongo-framework-common, ongo-framework-boot2-core-web(각 build.gradle 끝 apply from). Boot3 모듈(core/starter/devtools 등)은 Java 17+ 라 대상 아님.
  • JDK 는 foojay(settings.gradle)가 없으면 ~/.gradle/jdks 에 자동 설치(Temurin 8u504·11.0.32). Apple Silicon 은 Temurin 8 arm64 가 없어 x64(Rosetta) 로 받는다.
  • 실행: ./gradlew :ongo-framework-common:jdkMatrixTest :ongo-framework-boot2-core-web:jdkMatrixTest. 결과: common 2, Boot2 smoke 11 클래스/38 테스트 — JDK 8·11 모두 green(기본 test 는 영향 없음).
  • 의미: Java 9+ API(List.of·isBlank·HttpClient 등)가 다운그레이드에서 빠졌다면 --release 8 에서 이미 컴파일 실패하지만, 런타임 리플렉션/서비스 로딩/Boot 2.7·Feign·Batch 4.3·springdoc 1.x 의 JDK 8 동작은 이 매트릭스로만 확인된다.

남은 것 / 다음 (PoC14 후보) - CI 파이프라인(GitLab/웹훅 빌드)에 jdkMatrixTest 연결. - 개발툴(devtools) Boot2 생성 → batch/devtools 배치 화면(text block·record 다수, 전문 테스트 UI 작업과 겹치므로 그 PR 이후). - samsung-card 의 record·toList() 일괄.

PoC14 (multi-JDK 매트릭스 CI — GitHub Actions)

  • 잡 sc-demo-custom: :ongo-framework-sc-demo:test 를 JDK 21 단일로 실행(custom 프로필 확장 목업·SPI 교체 테스트 포함). sc-demo 는 Boot3 라 8/11 매트릭스 대상이 아니며, 매트릭스(jdk-matrix)는 common·boot2-core 만 유지.
  • 저장소에 CI 설정이 없어서(GitHub Actions·Jenkins·GitLab CI 모두 없음, 이미지는 develop 웹훅 빌드) .github/workflows/multi-jdk.yml 을 새로 추가.
  • 트리거: develop/main 대상 PR, develop push, 수동(workflow_dispatch). 같은 ref 는 이전 실행 취소.
  • 매트릭스 jdk: [8, 11](fail-fast 끔): setup-java(temurin) 로 해당 JDK + 21 설치(21 이 JAVA_HOME → Gradle 실행·--release 8 컴파일) → ./gradlew :ongo-framework-common:test{8|11} :ongo-framework-boot2-core-web:test{8|11}. Gradle 툴체인이 설치된 8/11 을 자동 감지해 foojay 다운로드 생략. gradle/actions/setup-gradle 캐시.
  • 실패 시에도 테스트 리포트를 artifact(test8-reports/test11-reports)로 올린다.
  • 기존 Boot3 빌드·웹훅 이미지 빌드와 무관(추가 job 만). 필요하면 브랜치 보호에서 jdk-matrix (8)·jdk-matrix (11) 을 필수 체크로.

남은 것 / 다음 (PoC15 후보) - samsung-card Java 8 준비(main 25파일 중 15파일에 record·toList()·List.of 등) — starter·devtools 에 기대므로 Boot2 devtools 생성 이후가 순서. - 개발툴(devtools) Boot2 생성(전문 테스트 UI 작업 이후), HttpStatusBridge 정리.

PoC15a (record·toList() 등 Java 9+ 일괄 → 8 문법 — devtools codegen/spec · samsung-card)

  • 범위: Boot2 생성 대상이 아닌 공통 소스 중 전문(telegram) 송신·인코딩 영역을 뺀 나머지. 전문테스트 화면 작업과 충돌을 피하려고 아래 「남은 것」 파일은 손대지 않음.
  • devtools: OnDevtoolsProfileCondition, codegen/*(13), spec/*(7). samsung-card: 텔레그램 코덱·레이아웃·헤더·자동구성 4개 제외 전부.
  • core: 남은 블로커 없음(XssRequestFilter 는 이미 Boot2 생성·8 컴파일, ApiExceptionHandler 는 boot2-overrides 사본).
  • record 16개 → 불변 final class(nested 는 static final, 메서드 지역 record 는 지역 클래스):
  • 접근자 x() 그대로 + Jackson getter getX()/isX(), equals/hashCode/toString(record 형식 Name[a=.., b=..]).
  • 정규 생성자에 @java.beans.ConstructorProperties → @RequestBody 역직렬화·Spring 바인딩이 record 처럼 생성자로(-parameters 불필요). 보조 생성자·compact 생성자 로직(Map.copyOf·JavaTarget.normalize 등) 유지.
  • 컴포넌트 애너테이션(@XssSafe·@Masked)은 필드+접근자+getter 에(@XssSafe 는 생성자 파라미터에도) → JSON 필드명·XSS 예외·마스킹 동작 동일.
  • 대상: GeneratedFile, CodeGenOptions, TypeResolver.Resolved, GeneratedSourceWriter.WriteResult, LayerCodeGenerator.{Rule,Request,Method,Result}, Batch{Job,Project}SkeletonGenerator.Request, ExcelSpecParser.SheetInspection, SpecModel, FieldSpec, ScCardHolderView, ScCodeSnippetSpecParser 지역 F 2개.
  • API/문법 치환(문자열 리터럴·주석 안은 제외 — 생성 코드 템플릿의 List.of 등은 targetJava 9+ 출력용이라 그대로):
  • toList()→Collectors.toList(), List.of→Collections.emptyList()/Arrays.asList, Set.of→LinkedHashSet, List/Set/Map.copyOf→unmodifiable*(new ArrayList/LinkedHashSet/LinkedHashMap), Map.of(k,v)→singletonMap, Map.of(..)/Map.ofEntries/Map.entry→Stream.of(SimpleImmutableEntry..).collect(toMap(.., LinkedHashMap::new))+unmodifiableMap(순서 보존), isBlank→trim().isEmpty(), Optional::stream→filter(isPresent).map(get), Files.writeString→Files.write(bytes).
  • text block 4개(배치 스켈레톤 생성기 — 생성되는 Java/Gradle 텍스트) → 문자열 연결(JLS 들여쓰기 제거 규칙 그대로, .formatted→String.format), var→명시 타입, instanceof 패턴→캐스트, switch 식→고전 switch, 인터페이스 private static→package 기본(static).
  • IoSpecImporter: Class#isRecord/getRecordComponents(16+) → 리플렉션 조회(8~15 에선 건너뜀, 17+ 에선 기존과 동일하게 record 컴포넌트 애너테이션 수집).
  • 검증: javac --release 8 문법 검사(대상 파일 0 에러 — 클래스패스가 Java 17/21 클래스라 API 는 정규식 스캔으로 0건 확인), Java 21 전체 빌드 green, Java8RecordCompatTest(4: @RequestBody 생성자 바인딩·직렬화 키=record 컴포넌트·라운드트립 equals/hashCode·compact 생성자 의미).

남은 것 / 다음 (PoC15b+ 후보) - 전문(telegram) 영역 — 전문테스트 화면 작업과 겹쳐 보류: devtools dynamic/*(DynamicTelegramController·DynamicTelegramPipeline·DynamicVoCompiler·TelegramFieldCodeGenDecorator·TelegramFieldLayoutResolver·TelegramVoNamingRule·DynamicCodecOption), DevtoolsController, OngoDevtoolsAutoConfiguration·OngoDevtoolsProperties; samsung-card ScStandardTelegramCodec·AnnotatedTelegramLayouts·ScStandardHeaderDefaults·SamsungCardAutoConfiguration; batch batch/devtools/BatchDevtoolsController(text block). - devtools·samsung-card 의 Boot2 생성(coreFiles 식 목록 + Boot2 클래스패스에서 실제 8 컴파일 — 이번 검사는 문법+스캔이라 API 누락 가능성 0 은 아님), 실 JDK 8/11 CI 매트릭스.

PoC15b (samsung-card Java 8 문법 정리 — 비 devtools 패키지)

  • HttpStatusBridge 는 Boot3 전용(Spring 6 HttpStatusCode) @Deprecated 어댑터라 Boot2 로 옮길 대상이 아님 → 그대로 두고(다음 메이저 제거) samsung-card 로 진행.
  • Boot2 생성은 아직 안 함(모듈 build 그대로 Boot3). 생성할 때 치환만으로 끝나도록 원본을 8 문법으로 내림. 대상: 루트·channel·layout·security(9파일). samsungcard/devtools(정의서 파서·코드생성 데코레이터)는 devtools 모듈 Boot2 생성과 같이 하려고 이번에 제외.
  • public record → 불변 final class(생성자·접근자 이름·equals/hashCode/toString 유지):
  • AnnotatedTelegramLayouts.Naming(compact 생성자 기본값 로직 그대로, 이전 5인자 생성자 유지), ScStandardTelegramCodec.Parts(NONE, null→빈 맵).
  • ScCardHolderView: @Masked 를 필드로, Jackson 용 getX() 추가 → 응답 JSON 필드명·마스킹 동일.
  • local record F → private static 클래스.
  • List/Map/Set.of·copyOf→Arrays.asList·Collections.unmodifiable*/empty*, .toList()→Collectors.toList(), isBlank→trim().isEmpty(), var→명시 타입, switch 식→switch 문.
  • 차이: Map.copyOf 는 null 키/값을 거부했지만 이제 허용(설정 기본값 맵 등 — 더 관대해질 뿐).
  • 검증: samsung-card 26, demo 117 테스트 green(JDK 21). 남은 9+ 문법은 samsungcard/devtools 4파일뿐.

남은 것 / 다음 (PoC16 후보) - devtools Boot2 생성(전문 테스트 UI 작업 이후) + samsungcard/devtools 8 문법. - samsung-card Boot2 생성(ongo-framework-boot2 에 samsungcard/** 추가, @ConditionalOnClass(devtools) 빈은 devtools 생성 전까지 제외).

PoC16 (devtools 전문(dynamic)·컨트롤러·자동구성 + samsungcard/devtools + batch devtools — Java 8 문법)

  • PoC15a/15b 와 같은 규칙으로 남은 Java 9+ 문법/API 를 원본에서 8 로 내림. Boot2 생성은 아직 안 함(모듈 build 는 Boot3 그대로).
  • 대상: devtools dynamic/*(DynamicTelegramController·DynamicTelegramPipeline·DynamicVoCompiler·TelegramFieldCodeGenDecorator·TelegramFieldLayoutResolver·TelegramVoNamingRule·DynamicCodecOption), DevtoolsController, OngoDevtoolsAutoConfiguration, OngoDevtoolsProperties(+ ExcelSpecParser 의 List::copyOf 한 곳); samsung-card samsungcard/devtools(ScIoSpecParser 의 String.lines()); batch BatchDevtoolsController.
  • record → 불변 final class: 필드·정식 생성자(@ConstructorProperties — Jackson 역직렬화)·x() 접근자(기존 호출 호환)+getX()(Jackson 직렬화 이름 동일)·equals/hashCode/toString(record 형식 Name[a=..]). compact 생성자 로직(TelegramVoNamingRule 기본값 등)은 정식 생성자 앞부분으로, 보조 생성자 그대로. 요청 DTO DynamicTelegramController.Request 의 @XssSafe 는 필드로. local record F → local final class.
  • List/Set/Map.of·copyOf → Collections.empty*/singleton*/unmodifiable*·Arrays.asList, .toList() → Collectors.toList(), isBlank → trim().isEmpty(), var → 명시 타입, switch 식 → switch 문, instanceof 패턴 → 캐스트.
  • Java 9/11 API: Path.of → Paths.get, Files.writeString → Files.write(bytes), InputStream.readAllBytes → StreamUtils.copyToString, Optional.or → isPresent() 분기, String.lines() → split("\\R").
  • batch 배치 화면 HTML text block → 줄 단위 문자열 연결(공통 들여쓰기 제거·끝 공백 제거·줄마다 \n — text block 과 같은 값).
  • 차이: Map.of/List.copyOf 는 null 을 거부했지만 이제 허용(더 관대해질 뿐). 코드생성 템플릿이 targetJava 9+ 일 때 내보내는 List.of 문자열(JavaTarget·BatchJobSkeletonGenerator)은 생성물 문법이라 범위 밖.
  • 검증: ./gradlew build green(JDK 21). devtools·samsung-card·batch src/main 에 9+ 문법 스캔 0건(생성 템플릿 문자열 제외).

남은 것 / 다음 (PoC17 후보) - devtools·samsung-card Boot2 생성(ongo-framework-boot2 에 소스 목록 추가 + Boot2 클래스패스에서 실제 8 컴파일 — 문법 스캔만으로는 API 누락 가능성 0 아님). HttpStatusBridge 는 Boot3 전용이라 제외. - 실 JDK 8/11 CI 매트릭스에 devtools·samsung-card 포함.

PoC17 (devtools · samsung-card Boot2 생성)

  • ongo-framework-boot2/core-web/build.gradle 의 generateBoot2Sources 에 devtoolsFiles(devtools/**)·samsungFiles(samsungcard/**) 추가, batchFiles 에 batch/devtools/*(PoC12 에서 제외했던 배치 화면) 포함. 같은 jakarta→javax 필터·boot2-overrides 규칙(새 override 파일 없음).
  • 리소스: ongo/devtools/**(UI)·samsungcard/**(표준 헤더 레이아웃) 복사, imports 에 OngoBatchDevtoolsAutoConfiguration·OngoDevtoolsAutoConfiguration·SamsungCardAutoConfiguration 덧붙임. 두 모듈을 같이 생성하므로 samsung-card 의 @ConditionalOnClass(devtools) 빈도 제외 없이 포함(순환·순서 문제 없음).
  • 의존: poi-ooxml:5.4.1 compileOnly(+test) — devtools 정의서 파서. POI 5.x 는 Java 8 지원.
  • 실제 Boot2/--release 8 컴파일에서 나온 Java 9+ API(PoC16 문법 스캔이 못 잡은 것)를 원본에서 수정:
  • instanceof 패턴 3곳(DevtoolsController) → 캐스트.
  • toArray(String[]::new)(Java 11) → toArray(new String[0]) (OnDevtoolsProfileCondition·OngoDevtoolsAutoConfiguration·DynamicVoCompiler).
  • List::of 공급자 → Collections::emptyList (DynamicTelegramController).
  • ByteArrayOutputStream.toString(Charset)(10) → new String(bytes, UTF_8); URLClassLoader(String name, …)(9) → 리플렉션으로 있으면 이름 있는 로더, 없으면(8) 이름 없는 로더 (DynamicVoCompiler). Boot3 동작·테스트(ongo-dynamic- 로더명) 그대로.
  • Class#getPackageName(9) → 클래스명에서 계산, Record.class(16) → "java.lang.Record".equals(k.getName()) (IoSpecImporter).
  • 제외: 없음(HttpStatusBridge 는 원래 Boot3 전용이며 devtools·samsung-card 트리에 없음).
  • 검증: ./gradlew :ongo-framework-boot2-core-web:build build 그린, :ongo-framework-boot2-core-web:jdkMatrixTest(실 JDK 8/11) 그린. Boot2BatchSmokeTest 가 세 자동구성 노출·생성 클래스 major 52·리소스 존재를 확인. multi-jdk.yml 은 boot2 모듈 test8/test11 을 그대로 돌리므로 변경 불필요.

남은 것 / 다음 (PoC18 후보) - devtools·samsung-card 의 Boot2 런타임 테스트(현재는 컴파일·바이트코드·노출만 검사). 특히 DynamicVoCompiler 는 실행 JDK 의 javac(ToolProvider) 가 필요 — JRE 8 만 있는 환경에서는 동적 VO 컴파일 불가(문서화 필요). - devtools 모듈 테스트를 Boot2 클래스패스에서 돌리는 미러(test8/test11).

PoC18 (Boot2 devtools · samsung-card 런타임 스모크 + javac 런타임 요구 문서화)

  • ongo-framework-boot2/core-web 에 Boot2DevtoolsSmokeTest 추가(Boot 2.7 WebApplicationContextRunner, 작게):
  • local 프로필: OngoDevtoolsAutoConfiguration + SamsungCardAutoConfiguration → DevtoolsController·CodeGenService·파서(ongoJsonSpecParser·ongoExcelSpecParser·samsungCardIoSpecParser)·sc-standard DynamicCodecOption 등록.
  • prod 프로필: devtools OFF, 삼카 자동구성은 정상 기동.
  • OngoBatchDevtoolsAutoConfiguration: @EnableBatchProcessing(Batch 4.3)+H2 위에서 BatchDevtoolsController 등록.
  • 리소스 ongo/devtools/index.html·samsungcard/std-header.layout 존재.
  • DynamicVoCompiler 로 VO 1개 컴파일·로드(실행 JDK 에 javac 없으면 assumption 으로 건너뜀).
  • jdkMatrixTest: test8/test11 은 boot2 모듈 test 소스 전체를 실 JDK 8·11 로 돌리므로 새 스모크도 자동 포함(추가 배선 불필요). multi-jdk.yml 변경 없음.
런타임 요구: DynamicVoCompiler 는 JDK(javac) 필요
  • 동적 전문 VO 는 ToolProvider.getSystemJavaCompiler() 로 컴파일한다. JRE 만 있으면 null → IllegalStateException("javac 없음…").
  • Java 8: JRE 에는 tools.jar(javac) 가 없다 → JRE-only Java 8 에서는 동적 VO 컴파일 불가. Java 9+ 는 jdk.compiler 모듈이 들어간 런타임(JDK 또는 jlink 에 jdk.compiler 포함)이어야 한다.
  • devtools 는 local/dev 전용이므로 개발 서버 이미지를 JDK(예: eclipse-temurin:8-jdk, -jre 아님)로 쓰면 된다. 운영(prod)은 devtools OFF 라 영향 없음.

남은 것 / 백로그 - devtools · samsung-card 모듈 자체 테스트(Boot3) 를 Boot2 클래스패스에서 돌리는 미러(test8/test11) — 전체 포팅은 범위 밖, 필요한 것만 선별. - devtools HTTP 엔드포인트(MockMvc) · 동적 전문 파이프라인(엑셀 정의서 → 코덱 왕복) 의 Boot2 통합 테스트. - Boot2 라인에서 devtools.dynamic.style·codegen.style 기본값을 런타임 JDK<16 이면 CLASS 로 자동 전환할지 결정. - 실제 JRE 8 런처에서 DynamicVoCompiler 오류 메시지 확인(현재 매트릭스 툴체인은 JDK 라 assumption 분기만 문서로 보장).

PoC19 (Boot2 devtools HTTP · 동적 전문 파이프라인 스모크)

  • Boot2DevtoolsHttpSmokeTest(3개): Boot 2.7 컨텍스트(JacksonAutoConfiguration+devtools+삼카, local)에서 생성된 DevtoolsController 를 MockMvc(standalone) 로 호출.
  • GET /framework-devtools → index.html.
  • GET /framework-devtools/api → specTypes 에 json·excel.
  • POST /api/parse/json·/api/generate/json → 스펙 필드·생성 소스(demo/p19/…, orderNo).
  • Boot2DynamicTelegramSmokeTest(1개): JsonSpecParser in/out 정의서 → 동적 VO 컴파일 → @TelegramField 레이아웃(cardNo CHAR10 · amount NUM5) → FixedLengthTelegramCodec encode("94001 00042", hex 고정) → LoopbackPhysicalTransport → 응답 VO decode(cardNo=94001, echo=42). Boot3 DynamicTelegramPipelineTest 의 exchange 케이스를 Java 8 문법으로 옮긴 것(텍스트 블록·var 제거, 테스트 전용 lines 파서 대신 실제 JSON 파서).
  • javac 없으면 assumption 으로 건너뜀(PoC18 과 동일).
  • DynamicVoCompiler javac 부재 메시지 단위 검증은 보류: ToolProvider 는 static 이라 Boot 2.7 기본 mockito-core(inline 아님)로 목킹 불가. 코드상 IllegalStateException("javac 없음: …") 한 줄이라 문서로만 보장.
  • test8/test11 은 boot2 test 소스 전체를 돌리므로 새 스모크 자동 포함.

남은 것 / 백로그 (PoC19 이후) - 엑셀(excel) 정의서·sc-standard 코덱 경로의 Boot2 왕복(POI 픽스처 생성 필요) — 현재는 JSON+고정길이만. - DynamicTelegramController HTTP(load/exchange) MockMvc 스모크. - 실제 JRE 8 런처(또는 mockito-inline)로 DynamicVoCompiler 오류 메시지 확인. - devtools · samsung-card Boot3 테스트의 선별 미러(전체 포팅은 범위 밖).

PoC20 (Boot2 sc-standard 코덱 왕복 · DynamicTelegramController HTTP)

  • 엑셀 픽스처는 Boot3 테스트에도 없어(POI 생성 필요) 이번엔 보류하고 sc-standard 코덱 왕복으로 대체.
  • Boot2ScStandardCodecSmokeTest(1개): 생성된 ScStandardTelegramCodec 으로 std-header.layout(850)+MESSAGE(2)+BODY(47)+@@ encode → 길이필드 00000901 자동, @Encrypted 바디(cdnoe) 암호화(E4321) → decode 왕복(header.stdEtxtLnth=901, stdEtxtLangDvC=KO, message.stdMsgPrntTpc=0). samsung-card ScStandardTelegramCodecTest 첫 케이스의 Java 8 사본(var·List.of/Map.of 제거).
  • Boot2DynamicTelegramHttpSmokeTest(2개): Boot 2.7 컨텍스트(Jackson+devtools+삼카, local, LoopbackPhysicalTransport 빈)에서 자동구성된 DynamicTelegramController 를 MockMvc(standalone) 로 호출.
  • GET /framework-devtools/api/dynamic/codecs → default·fixed.
  • POST /framework-devtools/api/dynamic/json(PoC19 JSON 정의서 + values, codec=fixed) → VO 로드 + simulate/loopback 교환(request="94001 00042", values.echo=42). javac 없으면 assumption 으로 건너뜀.
  • 동적 VO 기본 style 이 RECORD 라 JDK 8/11 javac(test8·test11)에서 컴파일 실패 → 테스트에서 ongo.framework.devtools.dynamic.style=CLASS 지정.
  • 참고: 자동구성은 loopback transport 를 기본 등록하지 않으므로(앱에서 채널 starter 가 등록) 테스트에서 빈을 직접 넣음.

남은 것 / 백로그 (PoC20 이후) - 엑셀(excel) 정의서 Boot2 왕복(POI 픽스처 생성 필요). - sc-standard 코덱을 DynamicTelegramController 의 codec=sc-standard 경로로 HTTP 교환(삼카 DynamicCodecOption 등록 확인 포함). - 실제 JRE 8 런처(또는 mockito-inline)로 DynamicVoCompiler 오류 메시지 확인. - devtools · samsung-card Boot3 테스트의 선별 미러(전체 포팅은 범위 밖).

PoC21 (동적 VO 기본 style = 실행 JDK 기준)

  • 문제(PoC20): ongo.framework.devtools.dynamic.style 기본 RECORD → JDK 8/11 런타임 javac 에서 동적 VO 컴파일 실패. Boot2 앱이 설정 없이 쓰면 같은 버그.
  • 수정: OngoDevtoolsProperties.Dynamic.style 기본값을 JavaTarget.runtimeDefaultStyle() 로 — java.specification.version 기준 16+ → RECORD, 미만 → CLASS. 명시 설정은 그대로 우선(바인딩이 필드를 덮어씀). 새 플래그 없음. codegen.style(소스 생성, targetJava 로 이미 CLASS 강제)은 변경 없음.
  • 테스트: RuntimeDefaultStyleTest(devtools, JDK21: 기본 RECORD·버전 파싱·명시 우선), Boot2DynamicStyleDefaultTest(test/test8/test11: 미지정 시 런타임 기준 CLASS, 명시 RECORD 우선).
  • Boot2DynamicTelegramHttpSmokeTest 의 dynamic.style=CLASS 강제 제거 — test8/test11 에서도 통과.

남은 것 / 백로그 (PoC21 이후) - ~~동적 VO 생성 시 targetJava 실행 JDK 기준~~ → PoC22 완료. - 엑셀(excel) 정의서 Boot2 왕복(POI 픽스처 생성 필요). - codec=sc-standard 경로 HTTP 교환(삼카 DynamicCodecOption 등록 확인 포함). - 실제 JRE 8 런처(또는 mockito-inline)로 DynamicVoCompiler 오류 메시지 확인.

PoC22 (codegen.target-java 기본값 = 실행 JDK 기준)

  • ongo.framework.devtools.codegen.target-java 미설정 시 JavaTarget.runtimeTarget() — runtimeFeature() 와 같은 파싱으로 실행 JDK 이하 최대 지원값(8|11|17|21; 예 16→11, 25→21).
  • 명시 설정은 항상 우선. 화면·API 요청의 targetJava 도 그대로 우선(다른 JDK 대상 UI 코드생성 영향 없음).
  • 버그 수정: Boot2 소스 필터(jakarta.→javax.)가 JavaSourceGenerator 의 import 치환 리터럴까지 바꿔 legacy 대상에서 javaxalidation.* 를 생성하던 문제(기본 21 일 때 숨어 있었음) — 접두어 분리로 해결.
  • Boot2 테스트: target-java 강제 없이 매트릭스 통과(Boot2DynamicStyleDefaultTest 에 기본/명시 케이스 추가).

핵심 포팅 완료. 잔여(선택) 백로그 - Excel(POI) Boot2 왕복 fixture - JRE-only(javac 없음) 환경 오류 메시지 확인 - Boot3 테스트 일부 선택 이식(mirror)

TODO: 고객사 제공용 jar 분리 PoC (Boot3 / Boot2 버전별)

  • 상태: 대기 — 원우 요청 시 진행 (2026-10-28 등록)
  • 목표: 고객사에 프레임워크를 물리 jar 파일로 전달해 flatDir·libs/ (implementation files(...)) 등으로 의존성 주입. Nexus 없이 동작 검증.
  • Boot3 세트 (jakarta): common · core · starter · devtools · samsung-card · batch library jar.
  • Boot2 세트 (javax): boot2-core-web(생성본) + common jar를 미리 빌드해 묶는 단계 추가 (현재는 이미지 빌드 시 소스 생성).
  • 원칙: Spring Boot fat jar(bootJar) 금지 — 얇은 library jar만. 두 세트는 섞지 않고 고객 Boot·Java 버전과 짝 맞춤.
  • 확인할 것: 전이 의존성(Boot BOM·MyBatis·Feign·springdoc) 처리 방식(고객 BOM vs 동봉), AutoConfiguration imports / spring.factories 동작, 라이선스·내부 API 노출 범위.
docker/ — OnGo DevOps dev deployment of the demos docker/README.md

Image build config only (OnGoDevOps README 6.1). Ports, env and target server live in OnGoDevOps config/<env>/DOCKER/*.properties.

포트 규칙: AA N — JDK 마다 AA 블록(21→12, 17→13, 8→14, 11→15), 끝자리 N = 1 generic / 2 sc / 3 Nuxt FE / 4 sc-custom. 백엔드 쇼케이스는 WAS 전용(2026-10-30 원우: 컨테이너 앞단 nginx docker/web 폐지) — WAS AA01N 만 publish 하고 Cloudflare → NPM → WAS 포트로 직결. 옛 WEB 포트 AA00N(1~2) 은 비움. Nuxt FE 는 nginx 웹서버 그대로(docker/demo-web), 사이트 2개: AA003(기본) · AA004(삼카 커스텀).

JDK / Boot AA generic WAS sc WAS sc-custom WAS Nuxt FE
21 / Boot3 12 ongo-generic-demo 12011 ongo-sc-demo 12012 ongo-sc-custom-demo 12014 (dev,custom) ongo-demo-web 12003 (/api→12012) · ongo-demo-web-custom 12004 (/api→12014)
17 / Boot3 13 ongo-boot3-j17-demo 13011 ongo-boot3-j17-sc-demo 13012 - -
8 / Boot2 14 ongo-boot2-demo 14011 ongo-boot2-sc-demo 14012 - -
11 / Boot2 15 ongo-boot2-j11-demo 15011 ongo-boot2-j11-sc-demo 15012 - -

공개 도메인(Cloudflare A Proxied → NPM Proxy Host → 제니 192.168.0.101:포트, 정본 OnGoDevOps README 4.4):

도메인 NPM → 내용
devfw-web 12003 Nuxt FE 기본 — /api/** → java21-sc WAS 12012
devfw-web-custom 12004 Nuxt FE 삼카 커스텀 — /api/** → java21-sc-custom WAS 12014
devfw21 · devfw21-sc · devfw21-sc-custom 12011 · 12012 · 12014 Boot3/JDK21 쇼케이스 WAS
devfw17 · devfw17-sc 13011 · 13012 Boot3/JDK17
devfw8 · devfw8-sc 14011 · 14012 Boot2/JDK8
devfw11 · devfw11-sc 15011 · 15012 Boot2/JDK11
OnGoDevOps APP compose module
ongo-demo-web docker/demo-web apps/demo-web (Nuxt 4 FE 웹서버, /api/** → :12012 java21-sc)
ongo-demo-web-custom docker/demo-web-custom (빌드 컨텍스트 ../demo-web) apps/demo-web 같은 소스, /api/** → :12014 java21-sc-custom
ongo-generic-demo docker/generic-demo ongo-framework-generic-demo (no samsung-card)
ongo-sc-custom-demo docker/sc-custom-demo ongo-framework-sc-demo 를 SPRING_PROFILES_ACTIVE=dev,custom 으로(확장 목업 /api/demo/ext/*·SPI 교체). 기본 ongo-sc-demo 와 별도, 도메인 https://devfw21-sc-custom.ongosoft.com
ongo-sc-demo docker/sc-demo ongo-framework-sc-demo (Samsung showcase) + ongo-framework-batch-demo (profile batch)
ongo-boot3-j17-demo docker/boot3-j17-demo (RUNTIME_JDK=17) ongo-framework-generic-demo
ongo-boot3-j17-sc-demo docker/boot3-j17-sc-demo (RUNTIME_JDK=17) ongo-framework-sc-demo (+batch)
ongo-boot2-demo docker/boot2-demo (docker/app-boot2, JDK 8) ongo-framework-boot2-demo
ongo-boot2-j11-demo docker/boot2-j11-demo (docker/app-boot2, JDK 11) ongo-framework-boot2-demo
ongo-boot2-sc-demo docker/boot2-sc-demo (docker/app-boot2, JDK 8) ongo-framework-boot2-sc-demo
ongo-boot2-j11-sc-demo docker/boot2-j11-sc-demo (docker/app-boot2, JDK 11) ongo-framework-boot2-sc-demo

모든 이미지는 런타임도 temurin JDK(JRE 아님, 동적 VO javac) + 쓰기 가능한 /var/tmp/ongo-devtools/dynamic(ONGO_DEVTOOLS_DYNAMIC_WORK_DIR). Boot2 이미지 WORKDIR 도 이 트리이고, 데모 yml devtools.dynamic.work-dir 가 같은 환경변수를 읽는다. GET /api/runtime → line·java·javaSpec·springBoot·spring·javac(true=JDK)·workDir{path,exists,writable} (Boot3 는 devtools OngoRuntimeInfoAutoConfiguration 공용).

  • 베이스 이미지(eclipse-temurin·nginx·node)는 Dockerfile ARG BASE_REGISTRY(기본 docker.io)로 받는다. 제니 배포는 OnGoDevOps image.base-registry=docker.ongosoft.com(Nexus docker-group) 경유, 빌드 결과는 image.registry=docker-push.ongosoft.com(docker-hosted)에 push. 로컬은 그대로 Docker Hub.
  • 백엔드 compose 는 app(WAS) 만 — 앞단 nginx 없음. 헬스/스모크는 GET /api/runtime(또는 /index.html). app = Spring Boot jar built from the repo clone (APP_SRC) with SPRING_PROFILES_ACTIVE=${DEPLOY_ENV} (dev → devtools on).
  • docker/demo-web = 분리된 Nuxt 프론트엔드 전용 웹서버 (ongo-demo-web, :12003, https://devfw-web.ongosoft.com). /healthz 및 SPA 라우트(/, /ui, /guide 등) 직접 서빙, /api/** 요청만 제니 WAS(host.docker.internal:12012)로 프록시. docker/demo-web-custom = 같은 이미지 빌드로 :12004, https://devfw-web-custom.ongosoft.com, /api/** → :12014.
  • Batch demo (no web, exits after the job), on Jennie: cd /opt/apps/ongo-sc-demo/docker/sc-demo && docker compose -p ongo-sc-demo-dev --profile batch run --rm batch 'cards=9400000000000001;BAD' inquiryDate=20261026
  • Local try: cd docker/generic-demo && APP_SRC=$(git rev-parse --show-toplevel) docker compose up --build → http://localhost:12011/index.html

버전별 라인 (JDK / Spring Boot)

라인 런타임 JDK Boot 확인 URL
Boot3 21 3.5.x :12011 · :12012 · :12014 /api/runtime
Boot3 17 (RUNTIME_JDK=17 → -PongoJavaRelease=17) 3.5.x :13011 · :13012
Boot2 8 2.7.18 :14011 · :14012
Boot2 11 2.7.18 :15011 · :15012
  • Boot2 데모는 루트 Boot3 Gradle 플러그인과 충돌을 피해 bootJar 대신 installDist(lib/*.jar) 로 패키징한다. 로컬: ./gradlew :ongo-framework-boot2-demo:installDist && JAVA_HOME=<jdk8> ongo-framework-boot2/demo/build/install/ongo-boot2-demo/bin/ongo-boot2-demo.
  • Boot2 프레임 jar(ongo-framework-boot2-core-web) 는 별도 게시 없이 같은 저장소 소스로 이미지 안에서 빌드한다(Nexus 게시 대상 아님 — 후속).
  • Boot3/JDK 17: docker/app 의 ARG RUNTIME_JDK(기본 21)가 런타임 베이스 이미지와 바이트코드 타깃(-PongoJavaRelease)을 함께 정한다. 로컬 확인: ./gradlew :ongo-framework-generic-demo:bootJar -PongoJavaRelease=17 && JAVA_HOME=<jdk17> java -jar ongo-framework-generic-demo/build/libs/*.jar.
금융 범용 프레임워크 · 백엔드 코어 — 아키텍처 (전체 디테일 정본) docs/architecture.md

JSON REST 요청/응답 → 공통 예외 → 코드·메시지 조회 SPI → 캐시/세션 SPI(Redis 등 교체). 채널·보안·관측. 업무 도메인·UI 제외.

  • 행동 규범·전략·Do/Don’t: AGENT-HANDBOOK.md — 반드시 함께 읽을 것
  • 출처: Cursor Canvas「금융 범용 프레임워크 아키텍처 초안」의 표·단계·대조를 빠짐없이 옮김 + 핸드북 합의
  • 작성일: 2026-10-26 · 기준선: Java 21 / Spring Boot 3.x
  • Canvas 근거(원우 2026-10-26 합의):
  • C1 JSON REST → C2 예외
  • 공통 코드·메시지 — 전사 메타 배치 적재·조회 SPI·프레임 잠금 시 외부 커스텀
  • 내부/외부 캐시·세션 SPI · Redis 미확정·대체 가능
  • 참고 자료는 기능만 · 네이밍·패턴·구조는 신규
  • 로깅 — k8s는 ES/Kibana/Grafana, IaaS는 별도 연계 없음 · 둘 다 커버
  • 참고 소스: OnGoSoft/framework-core-backend-springboot tip c4bf8aa · OnGoSoft/hyeri · OnGoSoft/hyeri_adapter_1.5

목차

방향 · 코어 꼭지 · C1 · C2 · C2b · C2c · C2d · C2e · C2f · C2g · C5 인증·권한·POC · C3 · C4 · 플랫폼·채널 · 서비스·SPI · Java·Boot · 런타임 · 구축 순서 · 개발 지원 웹툴 · 열린 결정 · framework-core 대조 · 참고 저장소


1. 방향 (north)

백엔드 우선순위 — API JSON REST 포맷 → 공통 예외 → 공통 코드·메시지 → 캐시·세션·암복호·로깅·채널 등. 각 기능은 AutoConfiguration으로 기본 구현을 심어 데모가 되게 하고, 삼카 투입 시 그 AutoConfig를 삼카용(예: Penta D'Amo·Redis·ELK)으로 바꿔 넣는 것이 최선이다. 그 작업이 안 되어도 앱이 의존성만 받고 SPI로 커스텀할 수 있어야 한다. 이 사상은 앞으로 추가할 기능에도 동일 적용. 참고 소스는 기능만 보고 패턴은 신규.

항목 값
범위 백엔드 프레임만 (도메인·UI 제외)
스택 백엔드 Spring Boot · JSON REST · Java 21→17→11→8 / 프론트 Nuxt(프레임워크 공통=TypeScript, 업무 화면=JavaScript) · 제품 앱은 BE·배치·FE 별도 프로젝트 · POC는 모노레포 데모
공통 배치 Spring Batch · 모노레포 모듈 · 백엔드 동일 사상 · /framework-devtools 배치 툴 · 코드젠이 배치 프로젝트/잡 커버 · POC 샘플 잡 · 앱은 BE/배치/FE 별도 프로젝트 (2026-10-26 원우) · 골격 구현됨(CUSTOMIZATION 15장, 삼카 비의존 · 샘플 잡 scCardLimitSyncJob)
최우선 C1 요청/응답 → C2 예외 → C3 코드·메시지 → C4 캐시·세션
확장 조회·캐시·세션·채널 모두 SPI · 프레임 잠금 시 외부 JAR
네이밍 DTO/VO (BO 불사용) · 구조·패턴 신규
참고 자료 기능만 · 레거시 패턴 비답습 · 개선된 구조로 신규
배포 형태 스타터 의존성으로 앱에 부착 · 핵심 SPI 커스텀·이식
교체 사상 전 기능(향후 포함): AutoConfig 기본 → 삼카 투입 시 AutoConfig를 삼카용으로 교체(최선) → 못 하면 앱 SPI(최악)

모듈 예정

모듈 역할
ongo-framework-core C1~C7 서비스·AutoConfig·기본 데모
ongo-framework-starter 앱 의존성 스타터
ongo-framework-batch Spring Batch 공통 골격·런처·실패 정책·모니터링 훅
ongo-framework-devtools local/dev 개발 지원 웹툴 (배치 화면 포함)
ongo-framework-samsung-card 삼카 고객사 확장(골격) — @ScTelegram VO 레이아웃 · 삼카 SpecParseService/CodeGenDecorator · 데모가 의존
ongo-framework-sc-demo POC 데모 WAS (로그인·권한 예제)
ongo-framework-sc-demo-batch POC 샘플 배치 잡
apps/demo-web Nuxt POC 데모 (제품 FE는 별도 프로젝트)

2. 코어 꼭지 (번호 = 우선순위)

꼭지 이름 상세
C1 JSON REST 요청/응답 공통 envelope · application/json · 래핑·페이징·상관ID
C2 공통 예외 처리 RestControllerAdvice · 예외 → C1 포맷 · 메시지 코드 해석
C2b 공통 트랜잭션 선언적 TX(@Transactional) · AutoConfig 기본 · 전파/격리/롤백 규칙 · DataSource/TM SPI · yml·빈 교체
C2c 아웃바운드 REST (B2B) OpenFeign 공통 · 인코더/디코더·에러디코더 · 타임아웃·재시도 · C1 envelope 정합 · 클라이언트로 교체 SPI
C2d MyBatis 공통 SqlSession/매퍼 스캔 AutoConfig · 타입핸들러·플러그인 · 페이징 · DataSource SPI · yml 교체
C2e 인터셉터 공통 HTTP Filter/HandlerInterceptor 체인 · MDC·상관ID·감사 · MyBatis Plugin 훅 · 순서 yml · 앱 추가 SPI
C2f 마스킹 · XSS 응답/로그 마스킹 규칙 AutoConfig · XSS 요청 필터 · 규칙·화이트리스트 yml · 삼카 정책 SPI
C2g 검증 · OpenAPI · 해시 Bean Validation 그룹/공통 제약 · OpenAPI(local/dev) · Hash/HMAC SPI (데모 SHA-512 → 삼카)
C3 공통 코드·메시지 CodeLookupService · MessageSourceService · 배치 적재 SPI · 캐시 연동 · 외부 커스텀
C4 캐시·세션 LocalCacheService · DistributedCacheService · SessionStoreService · Redis 등 교체
C5 인증·권한·암호 SPI AuthService·AuthorizationService·IdentityService · 세션 · FieldCrypto · E2E · POC 데모 앱 예제
C6 채널·표준전문 ChannelService · Codec · PhysicalTransport · MCI/EAI/FEP
C7 플랫폼·로깅·관측 AutoConfig · 마스킹·암호 · Feign · OpenAPI · logback(앱별 로거·레벨·필수 로그 슬롯) · LoggingBridgeService · 헬스·감사

3. C1 API 요청/응답 (JSON REST만)

최우선 — 공통 요청/응답 래퍼(DTO)를 정의하고 컨트롤러는 업무 페이로드만 담는다. framework-core ResponseDto 참고, 필드명은 새 계약으로 고정.

구분 역할 포함 후보
공통 요청 envelope traceId · locale · page/size · body(T)
공통 응답 성공/실패 동일 골격 success · code · message · data · errors[] · meta
미디어 입출력 application/json · UTF-8
핸들링 프레임 ArgumentResolver · ResponseBodyAdvice · Jackson

4. C2 공통 예외 처리

층 역할
예외 타입 Business / Validation / System + code·args·HTTP 힌트
전역 핸들러 @RestControllerAdvice → C1 envelope (success=false)
메시지 해석 MessageSourceService (C3)로 코드→문구
검증 Bean Validation → errors[]

5. C2b 공통 트랜잭션

AutoConfig로 기본 TX 심기 · yml/SPI로 교체 · 삼카 투입 시 맞춤.

항목 기본(데모) 교체·삼카
선언적 TX @Transactional + AOP 전파·격리·timeout yml
롤백 규칙 RuntimeException·지정 예외 체크예외·업무코드 매핑
트랜잭션 매니저 DataSourceTransactionManager JPA / JTA / 고객사 TM
다중 리소스 단일 DS 기본 필요 시 분산 TX SPI
  • AutoConfiguration이 PlatformTransactionManager·예외 롤백 어드바이저를 기본 등록
  • 채널(표준전문) 호출은 보통 TX 밖 또는 짧은 단위 — 정책은 yml로
  • 프레임 잠금 시에도 TransactionManager 빈·@Primary로 앱에서 교체

6. C2c 아웃바운드 REST (B2B)

백엔드→백엔드 JSON REST 공통. 기간계 표준전문(C6)과 분리.

항목 기본(데모) 교체
클라이언트 OpenFeign AutoConfig WebClient/RestClient SPI
계약 C1 envelope 인코더·디코더 고객사 응답 포맷 어댑터
오류 ErrorDecoder → C2 예외 상태·코드 매핑 yml
운영 connect/read timeout · 재시도 서킷·벌크헤드 선택 모듈
관측 상관ID 헤더 전파 · logback LoggingBridgeService
  • 앱은 @FeignClient 인터페이스만 두고, 공통 인터셉터·디코더는 프레임 AutoConfig
  • 표준전문 MCI/EAI/FEP는 C6 — Feign과 섞지 않음
  • 삼카·기타 솔루션 교체는 yml 프로파일 또는 Feign 설정 빈 오버라이드

7. C2d MyBatis 공통

영속화 공통. 기본·권장 MyBatis(풀 SQL·검수). JPA는 허용하되 지양.

항목 기본(데모) 교체
부트스트랩 MyBatis AutoConfig · 매퍼 스캔 패키지·팩토리 빈 오버라이드
DataSource 단일 DS + C2b TX 멀티 DS · 고객사 풀
타입/인터셉터 공통 TypeHandler · 암호화 인터셉터 훅 삼카·벤더 핸들러
페이징·유틸 공통 페이지 헬퍼 고객사 페이징 규칙
SQL 관측 slow query logback LoggingBridgeService
  • 앱은 Mapper 인터페이스·XML만 두고, SqlSessionFactory·플러그인은 프레임 AutoConfig
  • C3 코드/메시지 Mapper도 이 공통 위에 올린다
  • JPA는 막지 않으나 지양: 삼카는 쿼리 풀 등록·검수 표준 → 기본·권장은 MyBatis(명시 SQL). JPA는 선택 SPI

8. C2e 인터셉터 공통

웹·영속 횡단. AutoConfig 기본 체인 · yml 순서 · 앱에서 추가/교체.

층 기본(데모) 교체·확장
HTTP Filter + HandlerInterceptor (상관ID·MDC·접근로그) 삼카 필수 감사·보안 인터셉터
MVC 공통 pre/post 훅 등록기 앱 HandlerInterceptor @Order
MyBatis Plugin/Interceptor 슬롯 (암복호·마스킹) 고객사 플러그인
Feign RequestInterceptor (헤더·상관ID) 인증 토큰 주입 SPI
  • 프레임이 체인 골격·순서를 AutoConfig로 심고, 앱은 인터셉터 빈만 추가
  • 켜고 끄기·순서는 application.yml
  • 삼카 필수 로그/감사 인터셉터는 팩으로 끼우고, 프레임 잠금 시에도 SPI로 등록

9. C2f 마스킹 · XSS

개인정보 마스킹과 XSS 방어. AutoConfig 기본 · yml 규칙 · 삼카 정책 교체.

기능 기본(데모) 교체
마스킹 필드 어노테이션·규칙 엔진 (응답/로그) 삼카 마스킹 정책·해제 감사
XSS 필터 요청 파라미터/바디 sanitize Filter 화이트리스트·제외 경로 yml
연동 C2e 체인에 등록 · C1 응답 Advice와 정합 앱 MaskingService / XssFilterService
  • 마스킹 해제·열람은 감사 로그(C7)와 묶는다
  • XSS는 기본 ON, API 문서·파일 업로드 경로는 yml 제외
  • 프레임 잠금 시에도 MaskingService·XssFilterService 빈 오버라이드

10. C2g 검증 · OpenAPI · 해시

  • 검증: Bean Validation 그룹/공통 제약 (framework-core ValidPassword 등 공통 검증 기능 참고) → 실패 시 C2 → C1 errors[]
  • OpenAPI: 스웨거 문서는 local/dev 프로파일에서만 (framework-core OpenApiConfig 참고) · 개발 지원 웹툴 API 영역·POC와 연동
  • 해시: Hash/HMAC SPI (HashService) — 데모 SHA-512 → 삼카 솔루션으로 교체 · C5 비밀번호 해시·검증이 HashService 사용
  • framework-core 대조에서 "핵심으로 추가 반영"된 항목(OpenAPI · 공통 Validation · Hash/HMAC)

11. C5 인증·권한 · POC 예제

프레임 = 서비스·필터 골격·기본 데모. 정책·역할·메뉴는 앱. POC 데모 앱이 사용법을 끝까지 보여 준다.

역할 분리 (1) — 프레임워크: AuthService / IdentityService / AuthorizationService / SessionStoreService(C4) 계약, Security Filter 체인 AutoConfig, 미인증·권한없음 → C2 예외 → C1 envelope. 애플리케이션: 실제 로그인 방식(폼·토큰·SSO), 사용자 저장소, 역할·권한 매핑, URL/메서드 인가 규칙. 삼카 IAM/SSO는 투입 시 AuthService 구현 교체.

역할 분리 (2) — 프레임워크는 인증·인가 계약(SPI), 보안 필터 체인 골격, 세션 연동, 데모용 기본 구현만 가진다. 실제 IdP/SSO·역할·메뉴·화면 권한 매핑은 애플리케이션(및 삼카 팩)이 구현한다. POC 앱이 그 앱 쪽 예제를 제공한다.

11.1 프레임 vs 앱 (표 A — 프레임 코어 / 앱·POC 데모)
층 프레임 (코어) 앱 / POC 데모
인증 AuthService.authenticate / 토큰·세션 발급 훅 데모: ID/PW → 인메모리 사용자 · 실무: IAM·SSO·자체회원
주체 IdentityService · SecurityContext 홀더 UserDetails/Principal 매핑 · 부서·기기 클레임
인가 AuthorizationService · @RequirePermission 골격 역할·메뉴·API 권한 테이블 · 데모 ROLE_USER/ADMIN
세션 SessionStoreService 연동 · 타임아웃 yml 데모: 로컬/JDBC · 실무: Redis 등 교체
웹 체인 Filter AutoConfig · 제외 경로 yml 로그인/로그아웃/리프레시 컨트롤러는 앱
11.2 프레임 vs 앱 (표 B — 계층 상세)
층 프레임 앱 / POC
인증 (Authentication) AuthService · SecurityFilterChain AutoConfig · 토큰/세션 훅 로그인 API · IdP/자체회원 연동 · 토큰 발급 정책
주체 (Identity) IdentityService · SecurityContext 홀더 사용자·조직·기기 조회 · Principal 매핑
인가 (Authorization) AuthorizationService · @PreAuthorize 훅 · 메서드/URL 가드 역할·권한 코드 · 메뉴/API 매핑 · 거부 시 C2 예외
세션 SessionStoreService(C4) · 타임아웃·동시접속 정책 슬롯 로그인 시 세션 생성 · 로그아웃·강제만료
암호 FieldCryptoService · ChannelCryptoService · HashService 필드 암복호 사용 · 비밀번호 해시 정책

교체 사상 동일: AutoConfig 데모(인메모리 유저·간단 JWT/세션) → 삼카 투입 시 IAM/SSO AutoConfig 교체(최선) → 못 하면 앱이 AuthService·AuthorizationService @Primary(최악).

11.3 POC 데모 앱

프레임 위에 붙는 샘플 앱에서 로그인→권한 검사→보호 API까지 한 바퀴를 예제로 남긴다. 삼카 실제 IdP는 입사 후 팩으로 갈아 끼운다.

  1. 데모 로그인 — POST /api/auth/login (id/pw) → AuthService 검증 → 세션 또는 JWT 발급 → C1 응답
  2. 보호 API — Authorization 헤더/세션 쿠키 → 필터가 IdentityService 채움 → @RequirePermission 또는 AuthorizationService
  3. 권한 거부 — AuthorizationService 실패 → C2 BusinessException → C1 success=false · 메시지 코드
  4. 역할·메뉴 예제 — 앱 DB에 ROLE/PERM 시드 · /api/me · 관리자 전용 API 1개 · 일반 유저 거부 시나리오
  5. 로그아웃·만료 — SessionStoreService invalidate · 토큰 블랙리스트(선택) · 재로그인
  • POC 데모 앱: starter 의존 + 샘플 로그인 API · JWT(또는 세션 쿠키) · ROLE 기반 보호 API 2~3개 · application.yml 권한 매핑 예제
  • 삼카 투입: AuthService를 SSO/IAM 구현으로 갈아끼우고, 앱의 역할 매핑만 현장 정책에 맞춤 — 프레임 JAR 수정 없이도 SPI로 가능
  • 비밀번호 해시·검증은 C2g HashService 연동 · 필드 암호는 FieldCryptoService와 분리
  • 프레임에 넣는 것: AuthService / AuthorizationService / IdentityService · Security 필터 골격 · 데모 InMemoryUserDetails · 세션·토큰 유틸
  • 프레임에 넣지 않는 것: 삼카 IAM/SSO 실연동 · 업무 역할 트리 · 메뉴·화면 권한 매트릭스 · 회원 도메인 테이블
  • POC README에 ‘앱에서 이렇게 붙인다’ 순서·yml 샘플·교체 지점(@Primary)을 문서화

12. C3 공통 코드·메시지

삼성카드 전사 메타관리 배치 적재 → 로컬 테이블 조회 패턴. 테이블 개수·상세는 현장 확인.

커스터마이즈 — 최선은 프레임 소스에서 메타 배치·테이블에 맞춰 구현하는 것. 최악은 프레임 JAR을 못 고치는 경우이므로 CodeLookupService / MessageSourceService / MetaLoadService를 SPI로 두고 외부 JAR·@Primary·spring.factories로 갈아 끼운다. framework-core는 단일 코드 테이블·메시지 테이블을 프로퍼티로 조회하도록 설계돼 있다(CodeMapper·MessageMapper·Caffeine).

층 하는 일 프레임 고객사 팩
적재 전사 메타 → 코드/메시지 테이블 배치 MetaLoadService (스케줄 훅) 메타시스템 I/F·테이블 매핑
코드 조회 그룹/값/자식 목록 CodeLookupService 스키마·SQL·캐시 키
메시지 조회 코드+locale → 문구 MessageSourceService DB/리소스/원격 소스
캐시 조회 결과 캐시 CacheService 사용 (C4) 로컬 vs 분산 선택
  • framework-core 참고: ongo-framework.code / .message 프로퍼티로 테이블·컬럼명 설정, DatabaseMessageSource + ResourceMessageSource 체인, @Cacheable
  • 현장 메타 테이블 개수·컬럼은 내부에 숨겨져 기억 불명확 → 입사 후 스키마 확인 후 기본 구현 또는 팩으로 맞춤
  • C2 예외 메시지도 MessageSourceService를 통한다
  • 구현 현황: CodeLookupService(yml/단일 테이블 SQL + 그룹 TTL 캐시) · JdbcMessageSource(메시지 테이블 → 번들 폴백) · MetaLoadService + MetaLoadRunner(기동·cron·수동, 적재 후 Refreshable 갱신). 코드 캐시는 프로세스 내 TTL, 메타 적재 시 로컬 캐시도 비움. 설정표는 docs/CUSTOMIZATION.md §9

13. C4 캐시·세션

  • 구현 현황: LocalCacheService·DistributedCacheService(기본 프로세스 내 TTL·LRU 맵, 키 접두어) · SessionStoreService(기본 InMemorySessionStore, 유휴 만료·사용자당 동시 세션 EXPIRE_OLDEST/REJECT) · SessionPolicy. Redis 등은 빈 교체. 설정표는 docs/CUSTOMIZATION.md §10

내부(로컬) 캐시와 외부(분산) 캐시·세션 모두 SPI. Redis는 후보일 뿐 확정 아님.

⚠️ 솔루션 교체 — 삼성카드에 Redis가 있어 공통데이터 캐시·세션에 쓸 수 있으나 사용 가능 여부는 미확정이다. Redis에 하드코딩하지 말고 DistributedCacheService / SessionStoreService 구현체로 Redis·Ehcache·Hazelcast·DB 등을 붙일 수 있게 한다. 프레임 잠금 시에도 외부 모듈로 교체.

서비스 범위 기본 후보 교체 예
LocalCacheService 프로세스 내 (Caffeine 등) Caffeine Guava · 단순 Map(테스트)
DistributedCacheService 공통데이터·공유 캐시 미정 (SPI만) Redis · 기타 IMDG · 없음(로컬만)
SessionStoreService 로그인 세션 저장 미정 (SPI만) Redis · JDBC · 서버 세션
CacheManagerFacade 이름 공간·TTL·무효화 API 코어 계약 구현체별 어댑터
  • 코드·메시지(C3)는 CacheManagerFacade 경유 — 로컬만/분산 혼용을 설정으로
  • 세션 정책(타임아웃·동시접속)은 C5, 저장소 구현은 C4 SessionStoreService
  • Spring Cache 추상화 + 커스텀 CacheManager 빈 오버라이드 경로를 문서화

14. 플랫폼·채널

영역 역할
C5 인증·보안 Auth/Identity/Authorization 서비스 · 앱 로그인·역할 · POC 예제 · D'Amo · 구간암호 SPI
C6 채널 JSON API와 분리된 표준전문 SPI (MCI/EAI/FEP)
C7 로깅·관측 앱 로그는 logback 기본 · 앱별 로거 추가/수정·레벨 변경 쉽게 · 고객사 필수 로그 슬롯 · MDC/상관ID · 외부 연계 SPI (k8s ELK/Grafana, IaaS 파일·콘솔)
C7 기타 AutoConfig · 마스킹·@DBEncrypt · Feign · OpenAPI · 헬스
채널 오류 BusinessException → C2 → C1 JSON
FEP WebT/AnyLink 등 PhysicalTransportService로 교체

15. 서비스·SPI

코어는 계약만. 프레임 잠금 시 외부 JAR 오버라이드.

서비스 하는 일 교체 단위
CodeLookupService 공통코드 조회 테이블·SQL·원격
MessageSourceService 메시지 코드→문구 DB·리소스·원격
MetaLoadService 메타 배치 적재 메타시스템 I/F
LocalCacheService 로컬 캐시 Caffeine 등
DistributedCacheService 분산 캐시 Redis·기타·없음
SessionStoreService 세션 저장 Redis·JDBC·서버
ChannelService 표준전문 send 채널 라우팅
TelegramCodecService DTO↔byte[] 코덱 규칙
PhysicalTransportService 바이트 전송 HTTP·EAI·WebT·AnyLink
AuthService / IdentityService 인증·주체 IdP·인사
FieldCryptoService 필드 암복호 데모 AES → 삼카 Penta D'Amo
ChannelCryptoService 종단간 구간 암복호 데모 no-op/TLS → 삼카 E2E 솔루션
MaskingService 응답/로그 마스킹 규칙·삼카 정책
XssFilterService XSS sanitize 화이트리스트·필터 구현
LoggingBridgeService 로그·메트릭·트레이스 송신 ELK/Grafana · 파일/콘솔 · no-op

(본문 기타 언급 서비스: AuthorizationService(C5) · HashService(C2g) · SpecParseService(개발툴) · CacheManagerFacade(C4))

AutoConfiguration이 기본 빈을 심는다. 삼카 가면 삼카용 AutoConfig/구현으로 교체하는 프레임워크를 만든다. 그게 안 되면 ServiceLoader / spring.factories / @Primary로 앱·외부 JAR만 등록해 덮는다.

16. Java·Boot 매트릭스

타깃 Java Boot 비고
기준선 21 3.x 먼저 구현
동계열 17 3.x 포팅
분기 11 2.7 javax↔jakarta
최저 8 2.7 문법 제약

17. 런타임

flowchart LR
  client["클라이언트 JSON"] --> api["C1 envelope"]
  api --> app["앱 / 코어 밖"]
  app -.-> ex["C2 예외"]
  ex --> cm["C3 코드·메시지"]
  cm --> cache{"C4 캐시·세션"}
  app --> cache
  cache --> adp["외부 Adapters"]
  adp --> redis["Redis/기타"]
  app --> ch["C6 채널"]
  ch --> adp
  adp --> mef["MCI/EAI/FEP"]
  meta["전사 메타"] -. 배치 .-> adp
  adp -. 적재 .-> cm

18. 구축 순서

  1. C1 요청/응답 — JSON envelope · Advice · OpenAPI
  2. C2~C2f 기반 — 예외 · TX · Feign · MyBatis · 인터셉터 · 마스킹·XSS
  3. C3 코드·메시지 SPI — Lookup/Message/MetaLoad 서비스 · 기본 DB 구현 · 외부 오버라이드 경로
  4. C4 캐시·세션 SPI — Local + Distributed + SessionStore · Redis 어댑터는 선택 모듈
  5. C5 인증 SPI + POC 데모 앱 — 서비스·필터 골격 · 샘플 로그인/권한 예제 앱
  6. C6~C7 · 채널 · Java 멀티라인 — 표준전문 · 관측 · 17/11/8

19. 개발 지원 웹툴 (devtools)

프레임 사용 시 붙는 웹 기반 개발 지원. hyeri 유사 기능 · 코어와 분리된 툴 앱.

방향 — 별도 서비스로 띄우지 않는다. 프레임 스타터 의존성만 받으면 앱 안에 개발 지원 웹 페이지가 붙고, local·dev 프로파일에서만 활성화한다(운영 OFF). hyeri 유사 메타·전문·매핑 지원. 운영 업무 UI 아님.

영역 hyeri 유사 후보 비고
메타/코드 코드·메시지 조회·캐시 갱신 C3 연동
전문/채널 표준전문 레이아웃·매핑·테스트 송신 C6 Codec/Channel
API OpenAPI(스웨거) · 샘플 호출 C2g · POC
코드생성 DTO/VO 등 자동생성 · 입력 다종 기본 JSON · 데모 엑셀 유지 · SpecParseService 다수 등록(삼카 테이블/IO/전문 등)
설정 서비스 구현체·프로파일 확인 SPI 진단
로그/추적 상관ID·샘플 요청 추적 C7
  • 상세 화면은 hyeri를 기능 레벨로만 참고 · UI/네이밍은 신규
  • spring.profiles: local,dev 에서만 AutoConfig 등록 · prod 기본 미노출
  • 경로 예: /framework-devtools/** · 보안 제외 또는 개발자 전용
  • 코드생성은 현재 앱 워크스페이스에 DTO/VO 등 출력
  • 입력 다종: 기본은 JSON 스키마/샘플로 파싱·생성 · 추가로 엑셀(IO·DB테이블·전문 정의서). SpecParseService로 양식 파서 교체 (데모 엑셀 파서는 계속 유지, 삼카 양식은 SpecParseService 다수 등록)
  • 문서유형·파싱규칙은 DB 저장 금지(헬퍼 툴) · 프로젝트 파일(yml/json 등)로 커밋·형상공유. 테이블·IO는 단순 매핑, 전문 레이아웃은 규칙 설계 분리
  • 원우 확인: hyeri에서 꼭 살릴 메뉴/기능 · 정의서 컬럼 매핑 상세는 추후
19.0 구현 구조 반영)
[입력] 기존VO · SampleJSON · 정의서 · 엑셀(excel / sc-excel 멀티시트 — sc-excel 은 삼카 확장, 일반 데모 미노출)
   └ SpecParseService(type) → SpecModel/FieldSpec(+attributes: encrypted·masking)
        ├ 코드생성: JavaSourceGenerator + CodeGenDecorator(validation·samsung-card·telegram-field) → GeneratedSourceWriter → codegen.output-dir   [local]
        └ 전문테스트: DynamicVoCompiler(javac, URLClassLoader, dynamic.work-dir) → VoLayoutResolver → TelegramCodecService(Primary) → PhysicalTransportService  [local·dev WAS]
코덱: 코어 FixedLengthTelegramCodec(BODY) | 삼카 ScStandardTelegramCodec(HEADER 850 + MESSAGE 2 + BODY + "@@", standard-telegram.enabled)
보안: @Encrypted → EncryptedFieldInterceptor(저장) · EncryptedResultInterceptor(SELECT) · 코덱(코어 FixedLength·삼카 Sc) — 모두 ColumnValues(ColumnAttributeService 우선 → @Encrypted 면 FieldCryptoService) / @Masked·@Encrypted 응답 → ColumnAttributeService.beforeResponse → MaskingService
  • 실행 위치: 코드생성(쓰기)=local, 동적 컴파일·전문테스트=local·dev WAS, prod=개발툴 미등록. output-dir 와 work-dir 겹침 시 기동 실패.
  • 코어만으로 기본 동작, 삼카 모듈은 빈 추가/opt-in 교체, 데모는 삼카 기준 예시. 상세 키·엔드포인트는 CUSTOMIZATION §14~§18.
19.1 구현 현황 · 후속 메모 (2026-10-26)
  • 화면: 입력 유형·불러오기·JSON 정의서↔표·덮어쓰기/스웨거·미리보기/소스에 쓰기가 공통 I/O 정의서 카드 하나에 모이고, 업무단위 C/S/R·배치·전문 테스트는 별도 카드. 사용법은 DEVTOOLS-GUIDE.
  • 구현됨: ongo-framework-devtools 모듈 — SpecParseService(기본 json) · SpecModel · CodeGenService(기본 Java record/class) · CodeGenDecorator(기본 validation) · GeneratedSourceWriter. 상세는 CUSTOMIZATION 14장.
  • 후속(원우 합의, 미구현):
  • (구현됨, CUSTOMIZATION 12.1) 채널 인터페이스 동기·비동기 송수신 · 공통 인터페이스 수신 endpoint.
  • (구현됨 골격, CUSTOMIZATION 16장 · ongo-framework-samsung-card) 고객사 확장 모듈(예: 삼카용) = 코어 SPI 위에 고객사 레이아웃 VO·어노테이션·TelegramLayoutService(어노테이션 파싱) 구현·SpecParseService/CodeGenDecorator 를 얹는 별도 모듈. 데모 앱은 이 확장 모듈을 의존. 코어만 쓰면 개발툴이 기본 설정, 확장 의존 시 고객사 설정으로 뜬다.
  • (구현됨, CUSTOMIZATION 17장 · Nexus https://nexus.ongosoft.com) 배포: OnGoSoft Nexus publish 를 기본으로 개발하되, 고객사 Nexus 불확실 시 jar 직접 배포(files(...)/로컬 repo)도 가능하게 둔다.

20. 열린 결정 (임의 확정 금지)

  • C1 envelope 필드명 확정
  • 요청도 envelope vs 응답만 래핑
  • 메타 코드/메시지 테이블 개수·스키마 (입사 후 확인)
  • Redis 사용 가능 여부 · 대안 분산 캐시/세션
  • 프레임 잠금 확장: spring.factories vs 설정 빈 이름
  • FEP AnyLink 잔존 여부
  • 로깅: k8s(ELK/Grafana) vs IaaS · 앱별 logback 로거/레벨 DSL · 삼성 필수 로그 목록(입사 후)
  • 종단간 구간암호: 삼카 솔루션명·적용 구간(채널/API) 입사 후 확정
  • 개발툴 파싱규칙: yml 매핑 vs 규칙 DSL vs Java 플러그인 — 전문 레이아웃(복잡 엑셀) 대응 방식 추후 확정
  • 개발 지원 웹툴: hyeri 유사 메뉴 범위·필수 화면 (원우 지정)

C1 envelope 필드표를 확정한 뒤 C3·C4 서비스 IDL을 박으면 된다.

21. framework-core 대조 (tip c4bf8aa)

기능 레벨만 대조. 네이밍·패턴은 신규.

framework-core 판정 새 코어
ResponseDto · Exception · ErrorController 핵심 C1 · C2
TransactionConfig 핵심 C2b
FeignConfig 핵심 C2c
MybatisConfig · EncryptInterceptor 핵심 C2d (JPA 지양)
Filter · HandlerInterceptor · AOP Logging 핵심 C2e · C7
Masking · XSS(HTMLCharacterEscapes) 핵심 C2f
Code · Message · Cache 핵심 C3 · C4
AES Encryptor · @DBEncrypt 핵심 FieldCryptoService
OpenApiConfig (local/dev) 핵심(선택 프로파일) C2g
Validation(ValidPassword 등) 핵심(공통 검증) C2g
SHA-512 / HMAC Hash 핵심(암호 유틸) C2g HashService
CmnMetController(메타 API) 앱/샘플에 가깝 도메인 밖 · 필요 시 옵션 모듈
Date/Convert/Binding Util 지원 유틸 공통 유틸 모듈(핵심 SPI 아님)
GlobalBeanFactory · static 컨텍스트 레거시 패턴 신규에서 지양 · DI 우선
ServletListener · TomcatContext 인프라 훅 C2e/C7 옵션
채널 MCI/EAI/FEP 없음 → 별도(hyeri) C6
  • 핵심으로 추가 반영: OpenAPI · 공통 Validation · Hash/HMAC (C2g)
  • 메타 REST 컨트롤러·static BeanFactory는 핵심에서 제외하거나 개선 후 옵션
  • 구간암호·세션 Redis·ELK는 framework-core에 없음 → 이미 SPI로 신규 추가

22. 참고 저장소 (기능 레벨만 참고 · 패턴 비답습)

저장소 용도
OnGoSoft/framework-core-backend-springboot (tip c4bf8aa) 공통 기능 대조 (§21)
OnGoSoft/hyeri 채널·표준전문(C6) · 개발 지원 웹툴 기능 참고 (org 미러)
OnGoSoft/hyeri_adapter_1.5 채널 어댑터 참고 (org 미러)

원우 개인 저장소(RunningMusician/*)는 원본일 뿐, 팀 참조는 위 OnGoSoft 조직 저장소를 기준으로 한다.

ongo-framework README.md

온고소프트 금융 범용 백엔드 프레임워크 (Java 21 / Spring Boot).

동료·에이전트는 작업 전에 아래를 반드시 읽는다.

  1. docs/AGENT-HANDBOOK.md — 방향·전략·Do/Don’t (숙지용 정본) 2-0. docs/FRONTEND-AGENT-CONTEXT.md — 프론트(Nuxt) 에이전트 공통 컨텍스트: 백엔드 모듈·DevOps 배포(OnGoDevOps 참조)·도메인·Nuxt↔백엔드 연계
  2. docs/FRONTEND-COMMON-WORKLIST.md — 프론트 공통 작업 리스트(레이어 A–E, FE-01~FE-36, SEO·AEO 포함, P0/P1/P2)
  3. docs/architecture.md — 꼭지(C1~C7) 설계 요약

브랜치·배포 (2026-10-27~)

  • 개발 전용 저장소: 기본 브랜치 develop, master 는 삭제됨. 작업 브랜치는 develop 에서 따고 PR 은 develop 으로 머지한다.
  • develop push(머지) → GitHub 웹훅 → Jenkins WEBHOOK-Dispatcher → config/webhook-map/development.csv → RELEASE-Pipeline(DOCKER) 으로 데모 ongo-generic-demo·ongo-sc-demo 개발계(제니 Docker) 자동 재배포.
  • 운영 배포 없음: OnGoDevOps production.csv 에 이 저장소 행이 없어 PR merge 운영 경로에서 제외된다. 상세: OnGoDevOps README 1.4·2.4.

한 줄 방향

  • 범위: 백엔드 코어만 (업무 도메인·UI 제외)
  • 교체 사상: AutoConfiguration 기본 데모 → 삼카 투입 시 AutoConfig 교체(최선) → 앱 SPI·yml(최악)
  • 참고(framework-core, hyeri 등): 기능만. 네이밍·패턴·구조는 신규. DTO/VO (BO 금지)
  • 핵심 기능은 데모로 전부 동작 + 커스텀 가능

모듈 (settings.gradle 기준)

모듈 역할
ongo-framework-common JDK 8 바이트코드 공통 코어(예외·응답·auth·session·security 등) — Boot2/Boot3 공용 (MULTI-JDK PoC)
ongo-framework-core C1~C7 서비스·AutoConfig·기본 데모 구현
ongo-framework-starter 앱 의존성 스타터
ongo-framework-devtools local/dev 전용 개발 지원 웹툴 (별도 서비스 아님)
ongo-framework-samsung-card 삼카 고객사 확장 모듈(골격): 어노테이션 전문 레이아웃·개발툴 훅
ongo-framework-sc-demo POC 데모 앱 — 삼카 확장 기준 예시, 쇼케이스 /(port 8080)
ongo-framework-generic-demo 삼카 비의존 일반 데모(starter/core + devtools), 쇼케이스 /(port 8081)
ongo-framework-batch Spring Batch 공통 골격·런처·실패 정책·모니터링 훅
ongo-framework-batch-demo 삼카 확장 기준 샘플 배치 잡(scCardLimitSyncJob)
ongo-framework-batch-generic-demo 삼카 비의존 일반 샘플 배치
ongo-framework-boot2-core-web Boot 2.7(javax) 어댑터 — JDK 8/11 라인, 소스 생성(ongo-framework-boot2/)
ongo-framework-boot2-demo / ongo-framework-boot2-sc-demo Boot2 일반/삼카 데모(installDist, AA14·15)
apps/demo-web Nuxt POC 데모 (제품 FE는 별도 프로젝트)

요구 사항

  • 빌드: JDK 21 toolchain(Gradle). 하위 라인은 --release 로 컴파일.
  • 런타임 라인(2026-10-28, 데모 배포 AA 포트 = WEB AA00N / WAS AA01N, N=1 generic·2 sc):
라인 JDK Spring Boot AA 모듈
Boot3 21 (주 타깃) 3.5.x 12 core·starter·devtools·samsung-card·batch
Boot3 17 3.5.x 13 같은 jar, -PongoJavaRelease=17
Boot2 8 2.7.18 14 common + boot2-core-web
Boot2 11 2.7.18 15 8 용 jar 그대로

상세: docs/MULTI-JDK-PORTING.md, docker/README.md.

최근 반영 (2026-10-27~28)

PR 내용 문서
#113 저장소명 ongo-framework-21 → ongo-framework 전체
#114 FE-06 로그인(인증)·권한 공통(demo-web) FRONTEND-COMMON-WORKLIST FE-06
#115 표준전문 헤더 병합(null/"" → 디폴트, 공백은 덮음), 수신 @direction=response·요청 헤더 에코, 채널별 charset(MCI/EAI UTF-8 · FEP EUC-KR) CUSTOMIZATION §12.2, EXAMPLES §K
#116 표준전문진행번호 stdEtxtPrgNo — 송신 0, 수신 응답 요청+1 CUSTOMIZATION §12.2.5, EXAMPLES §K8

라이선스

Private — OnGoSoft

빌드·실행

  • 요구: JDK 17+ 설치되어 있으면 됨 (Gradle toolchain 이 Java 21 을 자동 다운로드). Spring Boot 3.5.x.
  • 모듈: ongo-framework-core(envelope·상관ID·예외 타입·MessageSourceService·트랜잭션 서비스) · ongo-framework-starter(AutoConfiguration·기본 메시지·트랜잭션) · ongo-framework-sc-demo(샘플 앱)
./gradlew build                          # 전체 빌드 + 테스트
./gradlew :ongo-framework-sc-demo:test      # 데모 테스트만
./gradlew :ongo-framework-sc-demo:bootRun   # http://localhost:8080
curl -H 'X-Correlation-Id: t-1' 'localhost:8080/api/demo/hello?name=ongo'
curl 'localhost:8080/api/demo/items?page=0&size=5'
curl -X POST -H 'Content-Type: application/json' -d '{"text":"hi"}' localhost:8080/api/demo/echo
curl -i -H 'X-Correlation-Id: t-err' localhost:8080/api/demo/errors/business
curl -i -H 'Accept-Language: en' localhost:8080/api/demo/errors/business
curl -i -H 'Content-Type: application/json' -d '{"title":"","qty":0}' localhost:8080/api/demo/errors/items
curl -i localhost:8080/api/demo/errors/unhandled
데모 쇼케이스 화면 (/)

ongo-framework-sc-demo 의 static/index.html. 카드별 ▶ 실행으로 실제 API 를 호출하고 응답을 카드 안 <pre> 에 보여 준다. 그룹: 코어(웹·예외·검증 / 데이터·공통), 삼카 확장(전문·컬럼 보안), 개발툴(local 프로파일).

일반 데모 ongo-framework-generic-demo 도 같은 형식의 쇼케이스를 http://localhost:8081/ 에 둔다(삼카 확장 그룹 없음, 코어·개발툴만 — 섹션별 API 는 모듈 README).

  • 응답은 textContent 로만 넣는다(XSS 데모 응답이 HTML 로 해석되지 않음). 마스킹 데모는 서버가 마스킹한 값을 그대로 표시한다.
  • 모바일: viewport 메타, 폭 760px 이하 1열, 그리드 minmax(0,1fr) + 응답 pre-wrap/overflow-wrap:anywhere 로 긴 응답(해시·암호문 등)이 있어도 화면 폭이 늘지 않는다.
응답 envelope (잠정 — 필드명은 열린 결정, 변경 가능)

필드명 success · code · message · data · errors · page · traceId · timestamp 는 확정이 아니다. 스캐폴딩에서 쓰던 이름을 유지하고, 검증 실패 항목만 아키텍처 후보명이던 errors 를 잠정으로 추가했다. 이름과 포함 여부는 바뀔 수 있다. 요청을 envelope 으로 감쌀지도 아직 열린 결정이라 구현하지 않았다.

성공:

{ "success": true, "code": "OK",
  "data": { },
  "page": { "page": 0, "size": 5, "totalElements": 23, "totalPages": 5 },
  "traceId": "t-1", "timestamp": "2026-10-26T14:39:50+09:00" }

실패:

{ "success": false, "code": "VALIDATION_FAILED", "message": "입력값을 확인해 주세요.",
  "errors": [ { "field": "title", "code": "NotBlank", "message": "제목은 필수입니다." } ],
  "traceId": "t-err", "timestamp": "2026-10-26T14:39:50+09:00" }
  • message·data·errors·page 는 null 이면 생략. 컨트롤러는 DTO / PageResult 를 그대로 반환하면 프레임이 감싼다. @NoEnvelope 로 제외.
  • 상관ID: 요청 X-Correlation-Id 수용(없거나 형식 불량 시 생성) → 응답 헤더·MDC traceId·envelope traceId. 실패 응답도 같은 traceId 를 쓴다.
  • 성공 래핑 교체: CorrelationIdGenerator·ApiResponseBodyAdvice 빈을 앱이 등록하면 기본값 후퇴(@ConditionalOnMissingBean, 복수면 @Primary). yml ongo.framework.web.envelope-enabled / correlation-header.
공통 예외 (잠정 매핑)

ApiExceptionHandler(@RestControllerAdvice)가 예외를 위 envelope(success=false)으로 바꾼다. 성공 래핑을 꺼도 실패 응답은 같은 골격을 직접 만든다. 문구는 MessageSourceService 가 코드와 로케일(Accept-Language, 없으면 spring.web.locale)로 고른다. 미처리·시스템 예외의 내부 문구와 스택은 응답에 넣지 않고 로그만 남긴다.

예외 HTTP (잠정 기본) envelope code
비즈니스 BusinessException 422. 예외의 HTTP 힌트가 있으면 그 값 예외에 실은 메시지 코드
검증 ValidationException, Bean Validation, 타입 불일치, 파라미터 누락 400. 검증 예외의 HTTP 힌트 우선 VALIDATION_FAILED (검증 예외에 코드가 있으면 그 코드)
본문 파싱 실패 400 MALFORMED_REQUEST
시스템 SystemException 500. HTTP 힌트 우선 예외의 메시지 코드
미처리 500 UNHANDLED
없는 경로 404 NOT_FOUND
메서드 불일치 405 METHOD_NOT_ALLOWED
지원하지 않는 미디어 타입 415 UNSUPPORTED_MEDIA_TYPE

Bean Validation 의 message 에는 문장 대신 메시지 코드를 적는다(demo.item.title.required 또는 {demo.item.qty.positive}). 핸들러가 서비스로 다시 해석한다. 앱의 Spring MessageSource 빈은 바꾸지 않는다.

422 를 비즈니스 기본으로 둔 이유는, 형식은 맞지만 업무 규칙을 통과하지 못한 경우와 입력 오류(400)를 잠정으로 가르기 위해서다. 앱 정책이 다르면 yml 로 바꾼다.

교체 경로 (메시지·예외)
  1. SPI: MessageSourceService 빈을 앱이 등록하면 기본 구현(DefaultMessageSource)은 올라오지 않는다(@ConditionalOnMissingBean). 구현이 둘 이상이면 @Primary.
  2. 핸들러: ApiExceptionHandler 빈을 등록하거나 ongo.framework.exception.enabled=false. 끈 뒤에도 메시지 서비스는 남는다.
  3. yml: ongo.framework.exception.business-status 등 상태, validation-code 등 envelope code. ongo.framework.message.messages.[코드] 가 번들보다 우선(코드에 점이 있으면 "[demo.order.rejected]"). ongo.framework.message.basename 앞에 앱 번들을 두면 그 파일의 같은 코드가 우선하고, 프레임 기본 번들은 항상 마지막 폴백으로 남는다. 기본 문구까지 없애려면 서비스 빈을 교체한다.

예외에 실은 HTTP 힌트는 yml 기본 상태보다 우선한다.

공통 트랜잭션

@Transactional 은 Boot 가 켜고, 스타터는 그 어드바이저에 기본 전파·격리·timeout·롤백 규칙을 얹는다. 어드바이저를 하나 더 만들지 않는다. DataSource 가 하나면 기본 매니저는 JdbcTransactionManager(DataSource 트랜잭션 + SQLException 변환)다. spring.dao.exceptiontranslation.enabled=false 이면 변환 없는 DataSource 매니저다.

기본 롤백은 Spring 과 같다. RuntimeException·Error 는 롤백하고, 체크 예외는 커밋한다. BusinessException 은 런타임 예외이므로 롤백된다. 체크 예외를 롤백하거나 특정 런타임 예외를 커밋으로 두려면 yml 에 클래스 이름을 적는다. rollback-on-codes 가 비어 있으면 메시지 코드는 보지 않는다. 코드가 있으면 그 코드를 가진 예외만 롤백에 보태고, 목록에 없다고 런타임 예외를 커밋으로 바꾸지 않는다.

데모는 인메모리 H2 다. POST /api/demo/tx/entries 가 행을 넣은 뒤 fail(runtime·business·checked)에 따라 예외를 던진다. POST /api/demo/tx/composed 는 바깥 행을 넣은 뒤 안쪽 트랜잭션이 실패한다. 기본 전파 REQUIRED 에서는 바깥까지 롤백되고, yml 을 REQUIRES_NEW 로 두면 안쪽만 롤백된다.

curl -s -H 'Content-Type: application/json' -d '{"name":"a","amount":10}' localhost:8080/api/demo/tx/entries
curl -s 'localhost:8080/api/demo/tx/entries?name=a'
curl -s -H 'Content-Type: application/json' -d '{"name":"b","amount":10,"fail":"runtime"}' localhost:8080/api/demo/tx/entries
curl -s 'localhost:8080/api/demo/tx/entries?name=b'
교체 경로 (트랜잭션)
  1. 매니저 빈: PlatformTransactionManager 를 등록하면 기본 매니저는 올라오지 않는다. JPA·JTA·고객사 매니저는 이 경로다. 후보가 둘 이상이면 @Primary.
  2. 단일 DataSource 서비스: TransactionManagerService 빈이 DataSource 로 매니저를 만든다. 매니저 빈이 있으면 서비스는 호출되지 않는다.
  3. 분산 서비스: DistributedTransactionManagerService 빈이 있으면 단일 DataSource 매니저 대신 그 매니저를 쓴다. 매니저 빈보다는 뒤다. 기본 구현은 없다.
  4. DataSource: 앱의 DataSource 빈, 또는 spring.datasource·임베디드 DB 가 우선이다. 둘 다 없을 때만 DataSourceService 가 DataSource 를 만든다. DataSource 가 둘 이상이고 분산 서비스가 없으면 프레임은 매니저를 만들지 않는다.
  5. yml: ongo.framework.transaction.propagation · isolation · timeout · rollback-for · no-rollback-for · rollback-on-codes. 격리는 어노테이션이 DEFAULT 일 때만, timeout 은 어노테이션에 없을 때만 채운다. 전파는 어노테이션의 REQUIRED 와 생략을 구분할 수 없어, yml 이 REQUIRED 가 아니면 그 값으로 채워지고 메서드에 적은 다른 전파가 이긴다. timeout 은 1초 이상(30s). enabled=false 면 프레임 매니저·롤백 보정·DataSource 서비스가 빠진다. DataSource 가 있으면 Boot 기본 매니저는 남을 수 있다.
  6. 롤백 판단: RollbackRuleService 빈을 등록하면 타입 규칙 다음의 판단을 앱이 바꾼다. 체크 예외를 항상 롤백하거나 업무 코드를 화이트리스트로 잠그는 정책은 여기에 두고, 기본 구현은 그렇게 하지 않는다.

등록 파일은 Boot 3 의 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 다.

아웃바운드 REST (OpenFeign 공통)

앱이 spring-cloud-starter-openfeign 을 넣고 @EnableFeignClients 를 켜면 스타터가 모든 @FeignClient 의 기본값을 깐다. 표준전문(채널) 연계와는 별개인 일반 REST 호출용이다.

  • 타임아웃·재시도: ongo.framework.outbound.connect-timeout(2s) · read-timeout(5s) · retry.max-attempts(1 = 재시도 없음). 재시도는 연결 실패·타임아웃(I/O)만 하고 HTTP 오류 응답은 재시도하지 않는다.
  • 상관ID 전파: 현재 요청의 상관ID(ongo.framework.web.correlation-header)를 아웃바운드 헤더로 싣는다. 호출 코드가 넣은 헤더는 덮어쓰지 않는다. propagate-correlation-id=false 로 끈다.
  • 디코딩: 상대가 공통 envelope 으로 답하면 data 만 반환 타입으로 바꾼다(unwrap-envelope). 반환 타입이 ApiResponse·String·ResponseEntity 등이면 그대로 둔다. 인코더는 Spring Cloud 기본(메시지 컨버터)이다.
  • 오류 → 공통 예외 → envelope: 4xx + 상대 envelope code → 그 code 의 BusinessException(상대 message 는 기본 문구), 그 밖의 4xx → OUTBOUND_CLIENT_ERROR, 5xx → SystemException(OUTBOUND_SERVER_ERROR 또는 상대 code), 연결 실패·타임아웃 → OUTBOUND_UNAVAILABLE. HTTP 상태는 기존 예외 정책(yml)을 따르고, 상대 본문은 응답에 싣지 않는다. code 는 yml(client-error-code 등)로 바꾼다.

데모: GET /api/demo/outbound/greet?name=원우, POST /api/demo/outbound/orders 가 PartnerClient 로 demo.partner.url(기본 자기 자신의 /api/demo/partner)을 호출한다.

교체 경로 (아웃바운드)
  1. 빈: ErrorDecoder · Decoder · Encoder · Retryer · Request.Options 를 앱이 등록하면 프레임 기본은 물러난다(@ConditionalOnMissingBean). 후보가 둘 이상이면 @Primary.
  2. 클라이언트 하나만: @FeignClient(configuration = ...) 또는 spring.cloud.openfeign.client.config.<name>.* 가 공통 기본값보다 우선한다.
  3. 인터셉터: RequestInterceptor 빈을 더하면 함께 적용된다. 상관ID 인터셉터는 CorrelationIdRequestInterceptor 빈 등록으로 교체한다.
  4. yml: ongo.framework.outbound.*. enabled=false 면 프레임 Feign 빈이 모두 빠진다.

MyBatis 공통 (ongo.framework.mybatis)

  • 의존: 앱에 org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5 를 추가하면 활성화된다 (스타터는 compileOnly). JPA 는 지양하되 함께 써도 막지 않는다.
  • SqlSessionFactory·@Mapper 스캔·mybatis.mapper-locations 는 mybatis-spring-boot 가 만들고, 프레임은 그 앞에서 기본값(ConfigurationCustomizer)과 Paging 빈을 얹는다.
  • DataSource·트랜잭션: Spring 의 단일 DataSource 를 그대로 쓰며 mybatis-spring 이 Spring 트랜잭션에 참여하므로 FrameworkJdbcTransactionManager 의 롤백 정책(업무 예외 포함)이 매퍼 호출에도 그대로 적용된다.
ongo.framework.mybatis:
  enabled: true                      # false 면 프레임 기본값·Paging 빈 제외
  map-underscore-to-camel-case: true # 기본 true
  default-statement-timeout: 10s     # 선택
  default-fetch-size: 100            # 선택
  call-setters-on-nulls: false       # 선택
  paging: { default-size: 20, max-size: 100 }
mybatis.mapper-locations: classpath*:mapper/**/*.xml

페이징: paging.page(paging.normalize(page, size), () -> mapper.count(..), q -> mapper.findPage(.., q.offset(), q.limit())) → PageResult (건수 0·범위 밖이면 목록 쿼리 생략).

교체 경로: - TypeHandler / Interceptor(Plugin): 빈으로 등록하면 SqlSessionFactory 에 자동 등록. - 추가 설정: ConfigurationCustomizer 빈 (프레임 기본값 뒤에 적용하려면 @Order 지정). - 페이징 규칙: Paging 빈을 직접 등록하면 프레임 기본은 물러난다. - SqlSessionFactory 전체 교체: SqlSessionFactory 빈을 직접 등록 (mybatis-spring-boot 가 물러남; 이 경우 프레임 기본값은 직접 반영해야 함). - 데모: DemoItemMapper(+mapper/DemoItemMapper.xml), /api/demo/mybatis/items.

요청 체인 공통 (ongo.framework.interceptor)

  • 체인: 상관ID 필터 → 접근로그 필터 → DispatcherServlet → MDC 인터셉터(handler) → 앱 인터셉터.
  • 순서·on/off 는 yml, 앱 인터셉터는 OngoHandlerInterceptor 빈으로 추가(빈 이름별 순서는 orders.<빈이름>).
  • 접근로그 출력은 AccessLogWriter 빈으로 교체(기본: 로거 ongo.access).
  • Feign 상관ID 는 아웃바운드 CorrelationIdRequestInterceptor 가 같은 MDC 값을 읽어 한 번만 넣는다(체인에서 따로 다루지 않음).
  • MyBatis 프레임 Plugin: ongo.framework.mybatis.plugins.slow-query-threshold.
  • 데모: DemoChainInterceptors, /api/demo/chain/echo.

마스킹·XSS 공통 (ongo.framework.masking, ongo.framework.xss)

  • MaskingService 기본 RuleBasedMaskingService: yml 필드 규칙(FULL/PARTIAL/EMAIL)·화이트리스트·로그 값 패턴. 응답 JSON(MVC 전용 쓰기 컨버터), 접근로그 쿼리, logback %maskedMsg 에 적용.
  • XssFilterService 기본 HtmlEscapeXssFilter: 쿼리·폼 파라미터(필터 H+30)와 JSON 요청 본문(MVC 전용 읽기 컨버터) 정화. 화이트리스트 경로·제외 필드·@XssSafe.
  • 공용 ObjectMapper·Feign 은 건드리지 않는다. 교체·설정 표는 CUSTOMIZATION.md 7절.

커스터마이징 가이드

기능별 "어디서/어떻게 교체"는 docs/CUSTOMIZATION.md 에 표로 정리했다 (JSON 공통·예외/메시지·트랜잭션·Feign·MyBatis·요청 체인·마스킹/XSS).

  • 코드생성 개발툴(/framework-devtools/) 화면·API 사용법: docs/DEVTOOLS-GUIDE.md
  • SPI 별 복붙용 교체 코드(목적·교체 지점·데모 위치·흔한 실수): docs/CUSTOMIZATION-EXAMPLES.md 데모 앱 custom 프로필(application-custom.yml + demo/custom/DemoCustomizations.java)이 모든 교체 예제를 실제로 켜고, DemoCustomizationTest 가 확인한다.
ongo-framework-generic-demo ongo-framework-generic-demo/README.md

Generic OnGo demo with no samsung-card dependency (starter/core + devtools only).

  • Fixed-length channel: layout SAMPLE0001 defined in application.yml, mci channel on loopback transport.
  • Devtools (/framework-devtools, profiles local/dev): basic Excel/JSON spec → code generation.
  • Showcase page: http://localhost:8081/index.html

Run

Test

./gradlew :ongo-framework-generic-demo:test

Not a publish target. For a Samsung batch demo see ongo-framework-batch-demo.

쇼케이스 (삼카 데모와 동일 수준, 삼카 비의존)

http://localhost:8081/ — 카드별 메서드·경로·바디 편집 → ▶ 실행 → 결과(상태+JSON). 범례: 코어 · 개발툴.

섹션 API
코어 — 웹·예외·검증 GET /api/demo/hello, GET /api/demo/errors/business(422), POST /api/demo/validation/members(400), GET /api/demo/secure/search(XSS), GET /api/demo/secure/profile(마스킹)
코어 — 데이터·공통 GET/POST /api/demo/mybatis/items, GET/POST /api/demo/tx/entries, GET /api/demo/codes/{group}, POST /api/demo/hash, GET /api/demo/outbound/greet(OpenFeign → /api/demo/partner)
코어 — 고정길이 전문 POST /api/demo/channel/mci/SAMPLE0001(loopback)
개발툴(local) /framework-devtools/api/batch/jobs, .../genericAcctSyncJob/run, /framework-devtools/api

구현: com.ongosoft.framework.genericdemo.showcase (schema.sql/data.sql 은 데모 샘플 표).

ongo-framework-batch-demo ongo-framework-batch-demo/README.md

별도 배치 데모 앱 — ongo-framework-batch + ongo-framework-samsung-card 를 의존하는 비웹 Spring Boot 배치(WAS 데모 ongo-framework-sc-demo 의 demo/batch 와 분리).

  • 잡: scCardLimitSyncJob — 카드번호 목록 → 삼카 @ScTelegram("SCDEMO01") 레이아웃 → ChannelService.send("mci", ...)(loopback) → Writer. 형식 오류는 yml skippable 로 skip.
  • 실행(기동 시 잡 실행 후 종료): ./gradlew :ongo-framework-batch-demo:bootRun --args="--spring.batch.job.name=scCardLimitSyncJob cards=9400000000000001;BAD;9400000000000002 inquiryDate=20261026"
  • jar: ./gradlew :ongo-framework-batch-demo:bootJar → java -jar build/libs/ongo-framework-batch-demo-*.jar "cards=9400000000000001;BAD" (separate cards with ; — Spring Batch treats , in a job parameter as value,type)
  • OnGo DevOps (Jennie): docker/sc-demo compose profile batch — see docker/README.md
  • 테스트: ./gradlew :ongo-framework-batch-demo:test (JobLauncher 로 직접 실행)
  • 개발툴 배치 화면(/framework-devtools/batch)은 웹이 필요해 여기엔 없음 — WAS 데모에서 사용.
  • 운영 전환: H2 → 실제 DB, channels.mci.transport → 실제 transport.
ongo-framework-batch-generic-demo ongo-framework-batch-generic-demo/README.md

일반(타사 제안용) 배치 데모 — ongo-framework-batch 만 의존하는 비웹 Spring Boot 배치. 삼카(ongo-framework-samsung-card) 비의존(삼카용은 ongo-framework-batch-demo).

  • 잡: genericAcctSyncJob — 계좌번호 목록 → 코어 yml 고정길이 레이아웃 GENBAT01(acctNo 12 + baseDate 8) → ChannelService.send("mci", ...)(loopback) → Writer. 형식 오류는 yml skippable 로 skip.
  • 설정 파일: batch-generic-demo.yml(spring.config.name) — generic-demo WAS 의 application.yml 과 겹치지 않게 분리.
  • jar: ./gradlew :ongo-framework-batch-generic-demo:bootJar → java -jar build/libs/ongo-framework-batch-generic-demo-*.jar "accounts=100000000001;BAD"
  • 개발툴 배치 화면: generic-demo WAS(ongo-framework-generic-demo)가 이 모듈의 잡 설정을 @Import → /framework-devtools/batch 에서 목록·수동 실행·이력.
  • OnGo DevOps(제니): docker/generic-demo compose profile batch.
  • 테스트: ./gradlew :ongo-framework-batch-generic-demo:test
TODO — 기본 내재 레이아웃 확정 · envelope 커스텀 docs/TODO-LAYOUT-CUSTOM.md

상태: 대기(원우 확정 후 진행) · 등록 2026-10-30

1. 기본 내재 레이아웃 확정 대기

  • [ ] 공통코드(ongo_common_code) 기본 테이블·컬럼 레이아웃 확정 — 현재 OngoCodeProperties.Jdbc 기본값은 placeholder.
  • [ ] 메시지(ongo_common_message) 기본 테이블·컬럼 레이아웃 확정 — OngoMessageProperties.Jdbc.
  • [ ] 응답 envelope(ApiResponse) 기본 필드 레이아웃 확정 — 현재 code/message/data/traceId 코드 고정.
  • [ ] 확정 후: 기본값·schema.sql·데모 data.sql·코어 개발가이드(guide.html #web/#code/#message) 동기화.

2. envelope yml/SPI 확장 설계·구현 추적

  • 설계: ENVELOPE-CUSTOM-DESIGN.md
  • [ ] 1단계(SPI): ApiResponseWriter(가칭) 도입, 기본 구현 = 현 ApiResponse 그대로.
  • [ ] 2단계(yml 매핑): ongo.framework.web.envelope.fields.* 필드명 매핑 + 성공 code 값.
  • [ ] 아웃바운드 unwrap-envelope·예외 핸들러·Idempotency 재생·프런트 공통 모듈이 같은 매핑을 읽도록 정리.
  • [ ] 회귀 테스트: 기본 매핑일 때 기존 JSON 바이트 동일.
모듈별 프로그램 명세서 (코어 개발자 인수인계용) docs/specs/README.md

각 문서는 소스(master 기준)에서 클래스·시그니처·설정 키·빈 구성·테스트 목록을 추출하고, 패키지 역할·흐름·확장 방법·예외 코드·주의사항을 보강한 Word(.docx) 문서입니다. 모듈별로 분리했으며 나중에 하나로 합쳐도 됩니다.

문서 모듈 주요 내용
ongo-framework-core-spec.docx core 공통 응답·예외·SPI(Service 접미사) 전체, 채널·인증·로깅 골격
ongo-framework-starter-spec.docx starter AutoConfiguration 21종·등록 빈·ongo.framework.* yml 키 전체
ongo-framework-devtools-spec.docx devtools 정의서 파서·코드생성·동적 전문 테스트(local/dev 전용)
ongo-framework-samsung-card-spec.docx samsung-card 표준전문 코덱(HEADER 850B 57필드)·응답 판정·코드 맵핑·삼카 정의서 파서
ongo-framework-batch-spec.docx batch Spring Batch 5 공통 골격·실패 정책·모니터·수동 실행
ongo-framework-sc-demo-spec.docx demo 기능별 데모 엔드포인트·호출 예·custom 프로필 교체 예
ongo-framework-batch-demo-spec.docx batch-demo 배치 데모 잡
ongo-framework-batch-generic-demo-spec.docx batch-generic-demo 고객사 확장 없는 배치 데모
DEPLOYMENT-GUIDE (md) 배포 사이트·인프라 케이스별 배포 운영 플레이북(2026-10-30 상세화 부록 참조)
ongo-framework-generic-demo-spec.docx generic-demo 고객사 확장 없는 웹 데모
ongo-frontend-demo-web-spec.docx apps/demo-web (FE) 프론트엔드 개발 가이드(README·FRONTEND-AGENT-CONTEXT·FRONTEND-COMMON-WORKLIST 전문)

동일 깊이 동기화: md 정본(DEPLOYMENT-GUIDE·DEVTOOLS-GUIDE·AUTH-TOKEN·SESSION-IDLE·DOUBLE-SUBMIT·MESSAGE-CATALOG·CUSTOMIZATION·CUSTOMIZATION-EXAMPLES 등) 전문을 각 명세서 「부록. 상세 — …」 장에 수록. python3 scripts/sync-docs.py 로 재생성.

공통 목차: 개요 · 의존성과 빌드 · 패키지 맵과 클래스별 역할 · 주요 public API·SPI 시그니처와 확장 포인트 · 설정(yml) 키 전체 · 시퀀스·처리 흐름 · 확장 방법 · 예외·메시지 코드 · 엔드포인트와 호출 예 · 테스트 위치와 실행 · 알려진 한계·주의사항 · 관련 문서 · 부록(공개 타입 시그니처 전체 등).

소스의 package-info.java·SPI 인터페이스·AutoConfiguration Javadoc 에도 같은 기준(사용 시점·교체·확장·주의)으로 주석을 보강했습니다. 코드가 바뀌면 명세서도 함께 갱신해 주십시오.

2026-10-28 현행화

모든 명세서 끝에 「부록. 2026-10-28 현행화」 장을 추가했다(develop 80e2489 기준).

항목 내용
저장소명 ongo-framework-21 → ongo-framework (#113). 문서 본문 표기 일괄 교체
Multi-JDK PoC1~22 완료 — AA12(21)·AA13(17)·AA14(8)·AA15(11), Boot2 라인(boot2-core-web)
채널·전문 #115 헤더 병합 null/"" vs 공백, 에코, 채널 charset / #116 stdEtxtPrgNo 0 → +1
FE #114 FE-06 로그인·권한
모듈별 core: charset·@direction/@request · starter: charset-defaults · samsung-card: ECHO_HEADER·nextPrgNo · devtools: 실행 JDK 기본 · demo/generic-demo: 배포 포트·도메인

docx 는 생성 스크립트 없이 python-docx 로 부록을 덧붙여 갱신했다. Mac Word 에서 복구 안내 없이 열리는 것을 확인했다. 본문의 상세 규칙은 md 가 원본이다(CUSTOMIZATION.md §12.2·§17.2, CUSTOMIZATION-EXAMPLES.md §K·§L).

apps/demo-web — Nuxt POC 데모 apps/demo-web/README.md

핸드북 결정(2026-10-26): 프론트는 Nuxt(Vue). POC는 모노레포 서브폴더, 제품 FE는 별도 프로젝트. 이 앱은 Gradle 빌드와 분리된 Node 프로젝트이며 백엔드 데모의 JSON API를 호출한다.

Nuxt 4(Nuxt 3 는 2026-07-31 지원 종료). 소스는 Nuxt 4 표준 구조대로 app/ 아래(~ = app/), 서버 라우트는 server/, 단위 테스트는 tests/.

npm test           # vitest 단위 테스트 (UI 경계 + TS/JS 언어 경계 검사 포함)
npm run typecheck  # vue-tsc 타입 검사(nuxt typecheck)

언어 분리 설계 — 프레임워크(TypeScript) / 업무 개발(JavaScript)

금융권 현장 업무 개발자가 TypeScript 문법 부담 없이 화면을 개발할 수 있도록 프레임워크(공통) 영역과 업무(화면) 영역의 사용 언어를 명확히 구분한다.

구분 대상 경로 (app/ 기준) 작성 언어 규칙
프레임워크·공통 utils/ · composables/ · plugins/ · middleware/ · stores/ · components/ui/ · components/common/ · app.config.ts TypeScript (.ts, <script setup lang="ts">) 계약 타입·옵션·반환 타입을 명시해 업무 JS 코드에서도 IDE 자동완성(IntelliSense)이 동작하도록 제공
업무 개발(화면) pages/ · layouts/ · (업무 컴포넌트) JavaScript (<script setup>, .js) lang="ts"·.ts 파일·import type·제네릭(<T>)·타입 단언(as) 사용 금지
  • 경계 자동 검사: tests/ui-boundary.test.ts 가 업무 영역(pages/·layouts/)의 lang="ts"·.ts 사용과 프레임워크 공통 영역의 .js 사용을 모두 검출해 실패로 만든다.
  • 타입 검사 설정: nuxt.config.ts 의 typescript.tsConfig.compilerOptions 에서 allowJs: true, checkJs: false 로 두어, 프레임워크 TypeScript 코드는 npm run typecheck 로 엄격히 검증하되 업무 JavaScript 화면은 타입 문법 없이 자유롭게 작성한다.

실행 — 환경 프로파일 (FE-01)

프로파일 고정명은 local-local / local-dev / dev / stg / prod 이다(이름 변경·추가 시 docs/FRONTEND-COMMON-WORKLIST.md FE-01 먼저 수정).

cd apps/demo-web
npm install
npm run dev:local-local   # 로컬 FE → 로컬 BE(삼카 데모 :8080). `npm run dev` 도 동일
npm run dev:local-dev     # 로컬 FE → 개발계 BE(https://devfw21-sc.ongosoft.com)
npm run build:dev         # 개발계 빌드 (build 도 동일)
npm run build:stg         # stg 빌드 — BE 도메인 미정, 실행 시 NUXT_API_TARGET 주입 필수
npm run build:prod        # prod 빌드 — 동일
npm run generate          # 정적 번들(.output/public) 생성 — 제니 Docker web 이미지에서 사용
node .output/server/index.mjs   # 빌드 결과 실행(PORT 로 포트 지정)
파일 NUXT_PUBLIC_PROFILE NUXT_API_TARGET (서버 전용)
.env.local-local local-local http://localhost:8080
.env.local-dev local-dev https://devfw21-sc.ongosoft.com
.env.dev dev https://devfw21-sc.ongosoft.com
.env.stg / .env.prod stg / prod (비어 있음 — 미설정 시 /api/** 는 502)
  • 주입 경로: .env.<profile> → (--dotenv) → nuxt.config.ts runtimeConfig 기본값 → 실행 시 NUXT_* 환경변수가 다시 덮어씀. 코드에서는 useRuntimeConfig()/useProfile()로만 읽는다(URL 하드코딩 금지).
  • 브라우저는 같은 오리진 runtimeConfig.public.apiBase(/api)만 호출하고, Nitro 라우트 server/routes/api/[...path].ts 가 apiTarget 으로 프록시한다(CORS 불필요, 대상 오리진은 브라우저에 노출 안 됨). routeRules 프록시는 빌드 시 대상이 고정돼 런타임 교체가 안 되므로 쓰지 않는다.
  • 일반 데모(:8081)로 붙기: 셸 환경변수가 .env.<profile> 값보다 우선한다 — NUXT_API_TARGET=http://localhost:8081 npm run dev:local-local.
  • 개인 덮어쓰기 파일 .env.*.local 과 .env 는 gitignore. 프로파일 파일에는 시크릿을 넣지 않는다.
  • 화면 헤더에 현재 프로파일 배지가 <FE>-<BE> 형식으로 표시된다(prod 제외): local-local(로컬↔로컬), local-dev, 개발계 배포 dev-dev, stg-stg. Docker 이미지(docker/demo-web)는 WEB_PROFILE=${DEPLOY_ENV}(기본 dev)로 nuxt generate --dotenv .env.<profile> 한다.

스토리지 API (FE-34)

화면·스토어는 localStorage/sessionStorage/document.cookie 를 직접 쓰지 않고 useAppStorage() 만 쓴다. 저장소는 민감도 정책이 고른다(데모: /storage).

const storage = useAppStorage()
storage.set('theme', 'dark', { sensitivity: 'public', scope: 'ui' })              // → localStorage
storage.set('step2', form, { sensitivity: 'internal', scope: 'flow', ttl: 600 })  // → sessionStorage, 10분
storage.set('otp', code, { sensitivity: 'secret', scope: 'auth', ttl: 60 })       // → memory
storage.get('step2', { scope: 'flow' })   // 만료·스키마 버전 불일치면 undefined(자동 삭제)
storage.clear({ scope: 'flow' })          // 신청 완료·취소
storage.clearSensitive()                  // 로그아웃: internal·secret 삭제, public 유지
broadcastStorageEvent('logout'); onStorageEvent('logout', handler)  // 탭 간 전파
민감도 허용 저장소(첫 번째가 기본)
public local · session · memory · cookie
internal session · memory
secret memory · native(FE-13 전까지 memory)
  • 키 = ongo:<profile>:<scope>:<key>(FE-01 프로파일별 분리), 값은 { v, s, e, d } envelope(JSON). 정책 정본은 docs/FRONTEND-COMMON-WORKLIST.md FE-34.
  • 정책 밖 저장소 지정: dev 는 콘솔 경고 후 기본 저장소로, prod 는 StoragePolicyError. 저장 실패(용량·프라이빗 모드)는 memory 폴백. 서버(SSR)에서는 no-op.
  • 액세스 토큰은 secret(memory)이라 새로고침하면 다시 로그인해야 한다(토큰 갱신은 FE-08).
  • 단위 테스트: npm test(vitest).

상태관리 (FE-33)

Pinia setup store 로 통일한다(stores/ 자동 import, 데모: /state). 영속화·초기화는 스토어 옵션으로 선언하고 공통 플러그인(utils/store-persist.ts)이 FE-34 스토리지 경유로 처리한다.

// stores/<이름>.ts — 공통 스토어 템플릿(TypeScript)
export const useXxxStore = defineStore('xxx', () => {
  // state: ref (초기값은 JSON 직렬화 가능하게)
  const items = ref<string[]>([])
  // getters: computed
  const count = computed(() => items.value.length)
  // actions: function
  function add(v: string) { items.value = [...items.value, v] }
  return { items, count, add }
}, {
  persist: { sensitivity: 'internal', ttl: 1800, pick: ['items'] }, // 생략 = 저장 안 함
  resetOnLogout: true, // 사용자·민감 데이터면 true
})
스토어 내용 영속화 로그아웃 초기화
useAuthStore 사용자·권한·토큰(Secret) 안 함(secret → memory) O
useFlowStore 다단계 신청 진행(start/saveStep/finish) session · 30분 O
useAppStore UI 설정 prefs, 로딩 카운터, 다이얼로그 큐 수 local · prefs 만 X
  • 규칙: 스토어 이름 useXxxStore, state=ref·getters=computed·actions=function. 화면에서 localStorage 직접 호출 금지.
  • 민감값은 new Secret(v) 로 감싸 state 에 둔다 → devtools·JSON·로그에 ••••, 원문은 reveal(). Secret 은 영속화되지 않는다(경고). getter 로 원문을 노출하지 않는다(devtools 에 보임).
  • 초기값과 같아지면 저장값을 지운다(finish()·$reset() 시 정리). TTL 은 변경 때마다 다시 계산(슬라이딩).
  • 로그아웃: useAuth().clearSession() = resetAllStores({ onLogout: true }) + useAppStorage().clearSensitive(), 다른 탭은 logout 이벤트로 같은 처리 후 /login.
  • SSR: 스토어는 요청마다 새로 생성되고(setup store, 모듈 전역 state 없음), 서버에서는 영속화가 no-op.

UI 프레임워크 — Nuxt UI(초기) + 교체 레이어 & 퍼블리셔 디자인 가이드

화면은 Ui* 계약 컴포넌트와 useUi* 만 쓴다. 현재 구현은 Nuxt UI 4(Tailwind v4)이고, 다른 UI 프레임워크로 바꿀 때 화면 코드는 고치지 않는다. - 퍼블리셔용 디자인 시스템 & 컴포넌트 카탈로그: /ui (app/pages/ui.vue) — 브랜드 컬러 팔레트, 시맨틱 토큰, 아이콘, 6대 카테고리(Elements, Forms, Data, Navigation, Overlays) 실시간 프리뷰 및 마크업 원클릭 복사 제공. - 업무 개발자(JS) 시작하기 & 실전 CRUD 샘플: /guide (app/pages/guide.vue) — 공식 Nuxt UI Getting Started(https://ui.nuxt.com/docs/getting-started) 연동 안내 및 실전 화면 샘플.

pages · layouts · components/common   ──쓰기──▶  UiButton · UiInput · … · useUiToast   (계약: utils/ui-contract.ts)
                                                        │ 구현(어댑터)
                                                        ▼
                                      Nuxt UI: UButton · UInput · … · useToast        (매핑: utils/ui-adapter-nuxt.ts)
계약 주요 props 현재 구현
UiButton variant(primary·secondary·outline·ghost·danger) size type disabled loading block icon to label UButton
UiInput v-model type placeholder disabled invalid size (+ autocomplete 등 속성 전달) UInput
UiTextarea v-model placeholder rows autoresize disabled invalid size UTextarea
UiSelect v-model options: UiOption[] placeholder disabled invalid USelect
UiRadioGroup v-model options: UiOption[] orientation disabled size URadioGroup
UiCheckbox v-model label disabled UCheckbox
UiSwitch v-model label description disabled size USwitch
UiFormField label hint help error required name UFormField
UiCard title, 슬롯 header·기본·footer UCard
UiAlert / UiBadge tone(info·success·warning·error·neutral) UAlert / UBadge
UiAvatar src alt text icon size UAvatar
UiIcon / UiKbd name size / value size UIcon / UKbd
UiSeparator label icon orientation type USeparator
UiProgress / UiSkeleton value max tone size status UProgress / USkeleton
UiTable columns: UiColumn[] rows loading emptyText, 셀 슬롯 #cell-<key>="{ row, value }" UTable
UiEmpty icon title description, 슬롯 actions UEmpty
UiAccordion items: UiAccordionItem[] type collapsible, 슬롯 body UAccordion
UiTabs v-model items: UiTabItem[] variant content size UTabs
UiBreadcrumb items: UiBreadcrumbItem[] UBreadcrumb
UiStepper v-model items: UiStepItem[] orientation disabled size UStepper
UiPagination v-model:page total itemsPerPage siblingCount showEdges disabled size UPagination
UiModal v-model:open title description dismissible, 슬롯 기본·footer UModal
UiSlideover v-model:open title description side dismissible, 슬롯 기본·footer USlideover
UiTooltip text kbds delayDuration UTooltip
UiProvider 앱 최상위(app.vue) UApp
useUiToast() show({ title, description, tone, timeout }) useToast
UiColorModeToggle 다크모드 켜기/끄기 버튼(헤더) UiButton + useUiColorMode
useUiColorMode() mode·isDark(v-model 가능)·set('light'\|'dark')·toggle() useColorMode(@nuxtjs/color-mode)
  • 경계 강제: tests/ui-boundary.test.ts 가 어댑터 밖의 <U*>·useToast/useOverlay/useColorMode·@nuxt/ui 사용을 실패로 만든다(npm test).
  • 다크모드: 기본 라이트, 헤더 토글로 켜고 끈다. 선택은 localStorage ongo:color-mode 에 남아 새로고침 후에도 유지(비민감 UI 설정 — FE-34 public 허용. 첫 화면 깜빡임 방지 때문에 모듈이 렌더 전에 직접 읽는 예외). 화면 색은 테마 변수(var(--ui-bg)·--ui-text·--ui-bg-muted·--ui-error 등, Tailwind bg-default·text-muted 등)만 쓰고 #fff 같은 고정 색을 넣지 않는다.
  • 정적 페이지와 다크모드 공유: 개발툴 홈·배치실행·코어 개발가이드·개발툴 사용가이드·쇼케이스(boot2·generic-demo·sc-demo)는 같은 토글 스크립트로 html[data-theme] 를 바꾸고, 선택을 cookie ongo-theme(light|dark, path=/, 1년, SameSite=Lax) + localStorage ongo-theme 에 남긴다. demo-web 은 plugins/theme-sync.client.ts(utils/theme-sync.ts)가 시작 시 cookie(없으면 localStorage)를 읽어 color-mode 에 반영하고, 바꿀 때 다시 써 준다. 쿠키는 포트를 구분하지 않아 같은 호스트의 다른 포트(예: demo-web :3000 ↔ 백엔드 :8080)끼리도 공유된다. 다른 호스트(서브도메인)끼리 공유하려면 쿠키에 domain=.상위도메인 을 붙여야 하며 현재는 붙이지 않는다. 한쪽 탭에서 바꾼 값은 다른 쪽을 새로고침해야 반영된다(실시간 동기화 아님).
  • 디자인 토큰: 브랜드 색은 assets/css/main.css 의 --color-brand-* 에만, Nuxt UI 연결은 app.config.ts(primary: 'brand').
  • 폐쇄망 대비: 외부 폰트 다운로드 끔(ui.fonts: false), 아이콘은 로컬 번들(@iconify-json/lucide, Iconify API 호출 안 함). 아이콘 엔드포인트는 백엔드 프록시와 겹치지 않게 /_nuxt_icon.
  • 새 공통 UI 추가: 계약 props 를 프레임워크 중립 이름으로 정하고(utils/ui-contract.ts), components/ui/<이름>.vue 안에서만 Nuxt UI 를 쓴다. /ui 쇼케이스에 상태(비활성·오류·로딩)를 추가한다.
  • UI 프레임워크 교체 절차: ① 새 라이브러리 설치·nuxt.config.ts 모듈 교체 ② components/ui/*.vue·composables/useUi*.ts 를 같은 props/emits/slots 로 다시 구현 ③ utils/ui-adapter-nuxt.ts → ui-adapter-<새것>.ts ④ app.config.ts·main.css 토큰 연결 ⑤ 경계 검사 금지 패턴(tests/ui-boundary.test.ts)을 새 라이브러리 기준으로 갱신 ⑥ /ui 쇼케이스·npm test·npm run typecheck 로 동일 동작 확인. 화면(pages/ 등)은 손대지 않는다.
  • 버전: @nuxt/ui ^4.12.0. 참고 — 테스트 러너를 vitest 3 으로 두면 4.12 가 설치되지 않는다: vitest 3 이 최상위에 vite 7 을 고정하는데, Nuxt UI 4.12 의 선택 피어(@tanstack/charts→octane)가 최상위 vite ^8 을 요구해 충돌한다(Nuxt 자체는 내부에 vite 8 을 따로 씀). vitest 5(vite 8 지원)로 올려 해결.

메시지·컨펌 다이얼로그 공통 (FE-04)

브라우저 alert/confirm 대신 useDialog() 만 쓴다(UiModal 계약 위에 구현, 데모: /dialog).

const dialog = useDialog()
await dialog.alert('저장되었습니다.')
const ok = await dialog.confirm({ title: '신청 확인', message: '신청할까요?', confirmLabel: '신청', onConfirm: () => {} })
const key = await dialog.open({ title: '임시저장', message: '변경사항 처리', buttons: [{ key: 'discard', label: '버리기', variant: 'danger' }, { key: 'save', label: '저장' }] })
const off = dialog.on('show', handler) // 'show' | 'action' | 'hide'
  • 큐·중복 방지: 이미 열려 있으면 FIFO 큐에 쌓여 순서대로 표시된다. 같은 dedupeKey(기본 kind:title:message)가 현재 표시 중이거나 큐에 있으면 중복 추가하지 않고 기존 Promise 를 공유한다(dedupeKey: false 면 중복 허용).
  • 포커스 트랩·복귀(FE-24 연계): UiModal 이 포커스 트랩·ESC 닫기·배경 스크롤 잠금을 처리하고, CommonAppDialogHost 가 열릴 때 기본 액션 버튼 포커스 및 큐 종료 후 원래 트리거 요소 포커스 복귀를 보장한다.
  • FE-02 autoPopup 연동: plugins/http.ts 의 popup 훅이 dialog.alert() 로 오류 코드·필드 검증 상세·traceId 를 표시한다.

HTTP 공통 파이프라인 (FE-02)

화면은 useHttp() 만 쓴다($fetch·fetch 직접 호출 금지). 데모: /http(개발계/로컬 삼카 데모 BE 실제 호출).

// 업무 화면(pages/*.vue)에서는 순수 JavaScript(<script setup>)로 호출한다.
const http = useHttp()
const hello = await http.get('/demo/hello')                            // data 만. 오류는 공통 다이얼로그가 처리
const { data, page } = await http.exchange('GET', '/demo/items')       // page·code·traceId 포함
await http.post('/apply', body, { lock: true, idempotent: true })      // 이중 제출 방지 + Idempotency-Key
try { await http.get('/x', { autoPopup: false }) }                      // 호출부가 직접 처리
catch (e) { if (e instanceof HttpError) show(e.kind, e.code, e.message, e.errors) }
request 훅 ─▶ fetch ─▶ response 훅 ─▶ 분류 ─┬─ SUCCESS → data
                                           └─ 오류 → 메시지(resolveMessage) → error 훅 → autoPopup 이면 popup 훅
                                                                          └─ AUTH_ERROR 면 auth-error 훅
분류 조건 기본 처리(plugins/http.ts)
SUCCESS 2xx (success:false 제외) data 반환
BUSINESS_ERROR envelope 있는 오류(400 검증·403 권한·404·409·422·500) 서버 메시지·필드 오류 알림
AUTH_ERROR 401 알림 + 세션 정리 → /login?redirect=현재경로
NETWORK_ERROR 연결 실패·타임아웃·envelope 없는 5xx 기본 메시지 알림, retry 대상
  • 옵션: autoPopup(기본 true) · loading(기본 true) · lock · idempotent · retry(GET·idempotent 의 NETWORK_ERROR 만) · authEvent(기본 true, 로그인 요청은 false) · query · headers · timeout · signal.
  • 훅: http.on('request'|'response'|'error'|'auth-error'|'popup'|'loading', fn) → 해제 함수. FE-03(메시지 카탈로그)·FE-04(다이얼로그)·FE-05(로딩)·FE-08(토큰 갱신)·FE-11(멱등)은 이 훅에 붙인다.
  • 공통으로 붙는 헤더: Accept-Language: ko-KR(기본, FE-30 에서 전환), Authorization: Bearer(auth 스토어). 응답 X-Correlation-Id 는 useState('lastCorrelationId').
  • popup 훅은 공통 다이얼로그(useDialog().alert, FE-04)로 표시한다. 알린 오류(e.handled)는 catch 하지 않아도 콘솔 오류가 나지 않는다.
  • C1 envelope 필드명은 열린 결정이라, 바뀌면 utils/http.ts 의 classify()·결과 변환만 고친다.

로그인(인증)·권한 공통 (FE-06)

퍼블릭/보호 화면 분리, 미로그인 진입 시 복귀 URL 유지(/login?redirect=), 로그아웃·401 세션 만료·권한 부족 이벤트 훅, 권한별 메뉴·버튼·화면 제어를 제공한다(데모: /auth, /admin-demo).

const { auth, login, loginMock, logout, terminateSession, onAuthEvent } = useAuth()
await login(id, password)                              // 삼카 데모 BE 실제 호출(POST /api/auth/login)
loginMock('manager')                                   // 목업 역할·권한 프리셋 전환(user / manager / admin)
auth.canAccess({ roles: ['ROLE_ADMIN'], permissions: ['admin:manage'] })
const off = onAuthEvent('login', handler)              // 'login' | 'logout' | 'forbidden'
  • 실제 BE 권한 API 호출 위치: 로그인 토큰 발급(POST /api/auth/login)·사용자 조회(GET /api/me)는 삼카 데모 BE 와 연동되어 있으며, 세분화 권한 목록 조회 위치는 composables/useAuth.ts 의 login() 내 주석에 명기되어 있다(auth.setPermissions(...)).
  • 선언적 권한 제어: <CommonCanAccess :roles="[...]" :permissions="[...]"> 로 메뉴·버튼·화면 영역 노출을 제어하고 #denied 슬롯으로 대체 UI 를 표시한다.
  • 라우트 가드(middleware/auth.ts): 미로그인 시 resolveRedirectUrl() 로 검증된 내부 경로만 /login?redirect= 에 담아 이동하고, 권한 부족 시 forbidden 이벤트 발행 및 공통 다이얼로그 안내 후 진입을 차단한다.

페이지 네비게이션 가드 (FE-09)

전역 라우트 미들웨어(middleware/navigation.global.ts)와 useNavigationGuard() 로 URL 직접 진입 화이트리스트와 다단계(3스텝+) 신청 플로우 진입·새로고침·뒤로가기를 제어한다(데모: /apply, /apply/step1~step3, /apply/complete, /apply/internal-only).

// 1) 다단계 신청 화면(pages/apply/step2.vue 등 — 순수 JS)
definePageMeta({
  flow: { id: 'card-apply', step: 2, totalSteps: 3, entryPath: '/apply/step1' },
})

// 2) 앱 내부 진입만 허용하고 주소창 URL 직접 진입을 막는 화면
definePageMeta({
  directEntry: false,
  directEntryRedirect: '/apply',
})
  • URL 직접 진입 화이트리스트(DIRECT_ENTRY_WHITELIST): 최초 진입(from.matched.length === 0) 시 화이트리스트 경로(/, /login, /apply, /apply/step1 등)만 직접 진입을 허용하고, meta.directEntry: false 또는 비허용 심층 경로는 안내 다이얼로그 후 리다이렉트한다.
  • 중간 스텝 직접 진입·건너뛰기 차단: 1단계를 거치지 않거나 이전 단계 저장 없이 /apply/step2, /apply/step3, /apply/complete로 진입 시 공통 다이얼로그 안내 후 /apply/step1로 리다이렉트한다.
  • 새로고침(flow-refresh) & 뒤로가기(flow-back) & 완료 후 방어(already-completed):
  • useFlowStore 가 sessionStorage(internal, 30분)에 현재 도달 스텝을 유지하므로 진행 중 새로고침(F5) 시 현재 스텝을 그대로 유지한다.
  • 미완료 상태에서 이전 스텝으로 뒤로가기는 입력 확인·수정을 허용하되, 최종 제출(flow.complete()) 이후 뒤로가기로 2·3단계 재진입은 중복 제출 방지를 위해 차단한다.

1차 범위

  • 로그인(/api/auth/login) → Bearer 를 auth 스토어에 메모리 보관(FE-33/34) → /api/me, 로그아웃(다른 탭 동시 로그아웃)
  • 권한 API(/api/demo/auth/reports) 허용/403 확인, X-Correlation-Id 표시
  • 샘플 API(hello/items/echo), 공통코드 조회
  • 401 시 로그인 화면 이동(복귀 URL 유지, FE-02 파이프라인)

다음 후보

프론트 공통 워크리스트(docs/FRONTEND-COMMON-WORKLIST.md) P0 후속(FE-13 2단계 실제 네이티브 연동), DevOps 배포(Docker/NPM 도메인), generic-demo 화면.

FE-13 브릿지 목업은 /bridge에서 확인한다. 전송·콜백·푸시·웹 폴백 규약은 BRIDGE-PROTOCOL.md, 변경 및 미정 사항은 WORK-HISTORY.md를 참조한다.

FE-29 XSS 안전 표시는 /xss에서 확인한다. 서버가 이스케이프한 값은 <CommonSafeText :value>(텍스트 노드), 약관·WCMS 같은 서식 HTML 은 <CommonSafeHtml :html>(브라우저 파서 기반 화이트리스트 정화)로만 표시하고, 업무 화면에서 v-html·innerHTML 은 쓰지 않는다(npm test 경계 검사).

민감정보 마스킹·클립보드 제한 (FE-27)

데모 /mask. 화면 표시용 마스킹은 서버 마스킹 다음의 마지막 방어선이다(원문을 DOM 에 두지 않는 것이 1순위).

유형 함수 / kind 예
카드 maskCard / card 9410-12**-****-3456(앞 6·뒤 4)
계좌 maskAccount / account ***-***-**6789(뒤 4, 구분자 유지)
주민(외국인)번호 maskRrn / rrn 900101-1******
휴대폰 maskPhone / phone 010-****-5678
이메일 maskEmail / email ho**********@ongosoft.com
이름 maskName / name 홍*동, 남**수
자유 텍스트 maskText / auto 문장 속 주민·카드·휴대폰·이메일·계좌 패턴 자동 탐지
  • 표시: <CommonMasked :value="v" kind="card" />(allow-reveal 시 보기/가리기 토글 — 원문은 토글 때만 DOM).
  • 복사 제한: v-sensitive(선택·복사·잘라내기·드래그·우클릭 차단, 차단 시 sensitive-blocked 이벤트 버블링) / v-sensitive="'mask'"(복사 허용, 클립보드엔 maskText 결과).
  • 로그: safeLog.info/warn/error(...) — maskObject 로 키 이름(cardNo·acctNo·rrn·mobile·email·userName…)과 값 패턴을 함께 가리고, password·token·pin·cvc·otp 는 ••••. 원본 객체는 바뀌지 않고 순환 참조도 안전.
  • 한계: 화면 캡처·개발자도구는 막을 수 없다. 앱(WebView)에서는 FE-13 브릿지로 캡처 방지(네이티브 FLAG_SECURE 등)를 요청하는 방식으로 확장한다.

금액·일자·계좌 등 포맷·검증 (FE-28)

데모 /format. utils/format.ts 규약: format* = 화면 표시 문자열, parse* = 서버 전송값, is* = true/false 검증, validate* = 오류 메시지(null = 정상).

대상 포맷 검증
금액 formatAmount(v, 소수자릿수) 콤마·음수, toKoreanAmount 한글금액(경까지), parseAmount 숫자 여부
일자 formatDate(v, sep='.'), toYmd(Date) isValidDate(실제 달력·윤년, 1900년 이후), validateDateRange(from, to, maxDays)
카드 formatCard(4-4-4-4, AMEX 4-6-5) isValidCard(14~16자리 + Luhn)
계좌 formatAccount(v, bankCode) — BANK_ACCOUNT_PATTERNS(목업, 실제는 BE 은행코드 API) isValidAccount(은행별 자릿수 / 미등록 10~16자리)
사업자번호 formatBizNo 3-2-5 isValidBizNo(국세청 체크섬)
휴대폰·전화 formatPhone(010-1234-5678, 02 지역번호) isValidMobile

입력은 <CommonFormattedInput kind="amount|date|card|account|bizNo|phone" v-model="form.x" :bank-code required show-korean /> — v-model 에는 숫자(금액)·숫자 문자열(그 외)이 들어가고 화면은 포맷 문자열, 포커스 아웃 시 검증·오류 표시, 제출 전 ref.check() 로 일괄 검증. 내부는 UiInput 계약만 쓰므로 FE-16 입력 컴포넌트가 생기면 그대로 교체된다.

약관 동의 화면 공통 (FE-21)

데모 /terms. 흐름: ① 약관 메타 조회(GET /mock-cms/terms/card-apply.json — 실 BE 약관 메타 API로 교체) ② 항목을 펼치면 contentUrl 에서 본문 비동기 로딩(캐시) ③ 동의 ④ @proceed 로 전송 페이로드 수신.

  • 메타 필드: id·title·required(필수/선택)·type(terms 약관 / manual 설명서)·version·contentUrl·contentType(html/json)·mustRead(본문 끝까지 스크롤해야 동의 가능)·children(마케팅 수신 채널 등 하위 동의).
  • 본문: HTML 은 <CommonSafeHtml>(FE-29 화이트리스트 정화 — 스크립트·이벤트·javascript: 링크 제거), JSON({ sections: [{ heading, paragraphs[] }] })은 <CommonSafeText> 텍스트 바인딩.
  • 규칙: 전체 동의는 열람이 필요한 항목을 건너뛰고 안내, 필수 미동의 시 「동의하고 다음」 비활성, 하위 채널은 부모 동의 시 일괄 체크·하나라도 체크되면 부모 동의.
  • 페이로드(toPayload): [{ termsId, version, required, agreed, agreedAt, children:[{id, agreed}] }] — 실 BE 동의 이력 저장 API로 전송(버전 포함 필수). 마케팅·분석 동의 결과는 useConsentStore().applyTerms() 로 FE-36 태깅 전송 여부에 반영한다.
  • 화면에서는 <CommonTermsAgreement group="card-apply" @proceed="onAgree" />, 로직만 필요하면 useTerms(group)(또는 순수 함수 utils/terms.ts).

SEO·AEO·화면 메타 (FE-20)

데모 /seo(현재 head·JSON-LD·레지스트리 표), /faq(AEO 질문/답 시맨틱 마크업). 화면 메타는 별도 서버에서 받는다고 가정하고 목업 /mock-cms/screen-meta.json(설정 NUXT_PUBLIC_SEO_META_URL)을 조회한다.

  • 원천: server/utils/screen-meta-registry.ts — 화면별 screenId(SCR_…)·title·description·keywords·og·canonical·robots·sitemap(priority/changefreq)·jsonLd·breadcrumb·faq·layout(FE-32 예정)·access(requiresAuth/roles/permissions — auth 미들웨어 폴백). 새 페이지는 여기 등록(미등록 시 테스트 실패).
  • 적용: 플러그인 screen-meta.client.ts 가 라우트 변경마다 useHead 로 title(템플릿 %s | OnGo Demo Web)·meta·og·canonical·JSON-LD(<·>·& 이스케이프) 주입. 런타임 변경 useScreenMeta().set({ title }).
  • 색인 정책: NUXT_PUBLIC_SEO_ALLOW_INDEX(기본 prod 만 true) 거짓이면 모든 화면 noindex + robots.txt 전체 차단. 로그인 필요 화면·신청 단계는 항상 noindex. NUXT_PUBLIC_SEO_ALLOW_AI(기본 true) 로 GPTBot·ClaudeBot·PerplexityBot 등 AI 크롤러 허용/차단.
  • /sitemap.xml·/robots.txt 는 nuxt generate 때 prerender 된다. 절대 URL 은 NUXT_PUBLIC_SITE_URL(없으면 요청 origin).
  • 한계: ssr:false 라 크롤러가 JS 를 실행해야 메타가 보인다(Google 은 실행). 정적 HTML 메타가 필요하면 화면 단위 prerender/SSR 전환 검토.

태깅/클릭코드 수집 (FE-36)

데모 /tracking. 화면마다 수집 코드를 짜지 않고 공통 트래커(utils/tracker.ts, 플러그인 plugins/tracker.client.ts)가 모은다.

이벤트 발생 방법 code
화면진입 pageView 자동(router.afterEach). 화면ID = FE-20 화면 메타 레지스트리(route.meta.screenId 덮어쓰기 → 레지스트리 → route name → path) SCR_*
클릭 click v-track="'CLK_BANNER'" 또는 data-click-code="CLK_BANNER"(+data-click-label) — document 캡처 위임 한 경로, disabled 요소 제외 CLK_*
전환 conversion useTracker().conversion('CNV_CARD_APPLY', { product }) — 신청완료·가입 등 명시 호출 CNV_*
기타 custom useTracker().custom(name, payload) 자유(영문·숫자·_.:/- 1~80자)
  • 공통 필드: type·code·screenId·sessionId(탭 단위, 사용자 식별자 아님)·ts·profile(FE-01)·channel(web/app)·payload.
  • 동의 후 전송: useConsentStore().analytics = pending(메모리 큐 보관, 상한 100) → granted(큐 flush 후 실시간 전송) / denied(큐 폐기, 이후 미수집). 약관 화면 결과는 consent.applyTerms(payload, ['marketing-analytics']) 처럼 해당 termsId 로 반영(실제 동의 정본은 BE 동의 이력).
  • PII 금지: payload 에서 이름·카드·계좌·주민·휴대폰·이메일·주소·생년월일·비밀번호/토큰 키는 키째 제거(dev 경고), 남은 문자열은 maskObject(FE-27)로 패턴 마스킹.
  • 전송: batchSize(기본 5) 도달 또는 flushIntervalMs(3초)·탭 숨김 시. 실패하면 maxRetries(2) 재시도 후 폐기 — 예외를 던지지 않으므로 업무 흐름을 막지 않는다.
  • 어댑터: mock(기본, 데모 패널·콘솔 safeLog) / web(navigator.sendBeacon → 거절 시 fetch keepalive, URL NUXT_PUBLIC_TRACKER_ENDPOINT — 실 자체 로그서버 예 POST /api/collect, GA4 는 같은 인터페이스로 gtag 래퍼 어댑터 추가) / bridge(앱 WebView 감지 시 자동, FE-13 analytics.track 으로 앱 SDK 위임). 설정 NUXT_PUBLIC_TRACKER_ADAPTER·NUXT_PUBLIC_TRACKER_BATCH_SIZE·NUXT_PUBLIC_TRACKER_FLUSH_MS.
  • 감사 로그(FE-31)는 이 트래커로 보내지 않는다(보존·무결성 요건이 다름).

FE-03 메시지 카탈로그는 /messages에서 확인한다. 설계·설정 키는 MESSAGE-CATALOG.md. 문구는 정책(기본 server-first: override → 서버 응답 문구 → 서버 조회 API(TTL 캐시) → 카탈로그 → 기본 문구)대로 고르고, 정책은 NUXT_PUBLIC_MESSAGE_CATALOG_POLICY(프리셋 catalog-first 등 또는 쉼표 순서)로 바꾼다. 화면에서는 useMessageCatalog().text(code, args)·fetchText(code, args) 를 쓴다.

FE-07 세션·유휴 타임아웃은 /session에서 확인한다. 설계·설정 키는 SESSION-IDLE.md. 만료 시간 NUXT_PUBLIC_SESSION_TIMEOUT_SEC 은 백엔드 ongo.framework.session.timeout(데모 30분)과 같게 둔다. 쓰는 중이면 GET /api/me 로 조용히 연장하고, 유휴이면 만료 2분 전에 연장 다이얼로그, 만료 시 로그아웃(탭 간 동기화).

FE-08 토큰 갱신·강제 로그아웃은 /token에서 확인한다(목업 인증 서버). 설계·설정 키는 AUTH-TOKEN.md. 401 auth.token-expired 는 동시 요청이 있어도 한 번만 갱신하고 재시도하며, 중복 로그인·강제 종료 코드는 안내 후 로그아웃한다. 백엔드에 갱신 API 가 생기면 NUXT_PUBLIC_AUTH_REFRESH_PATH 만 넣는다.

FE-10 신청 상태 저장·복원은 /apply에서 확인한다. 스텝 입력은 useFlowStore().saveStep(n, payload) 로 저장하면 sessionStorage(30분)에 남아 새로고침해도 복원되고, 민감 항목(비밀번호·PIN·카드번호·주민번호 등, 또는 { sensitive: [필드] })은 메모리에만 두며 missingSensitive(n) 으로 재입력 대상을 알 수 있다.

FE-11 이중 제출 방지는 /submit에서 확인한다. 이체·신청 버튼은 useActionLock().run(key, fn) + <UiButton :loading="lock.pending(key)">, 같은 요청 동시 전송은 { lock: true }, 응답 끊김 뒤 재전송은 useIdempotencyKey() 의 키를 { idempotencyKey } 로 보내고 성공하면 reset() 한다.

FE-17 페이징은 /paging에서 확인한다. 목록은 usePaging(httpPage('/demo/items', { 조건 }), { size }) 하나로 페이지네이션(<UiPagination v-model:page="list.page.value" :total="list.state.value.total">)과 무한 스크롤({ mode: 'append' } + <CommonInfiniteScroll :pager="feed" />)을 모두 만든다. 조건이 바뀌면 reset().

프론트(Nuxt) 에이전트 공통 컨텍스트 docs/FRONTEND-AGENT-CONTEXT.md

대상: Nuxt 프론트를 구현하는 동료 개발자·에이전트. 이 문서 하나로 "백엔드가 어디 있고, 어떻게 배포되고, 프론트는 무엇을 부르는가"를 맞춘다. 근거: 이 저장소(settings.gradle, docker/README.md, apps/demo-web, 각 데모 application.yml, docs/*)와 OnGoDevOps(README.md, config/development/DOCKER/ongo-*-demo.properties, config/webhook-map/development.csv). 기준일 2026-10-28 KST. 배포 구성(포트·서버·잡·도메인 프록시)의 정본은 OnGoDevOps 프로젝트다. 여기 값과 다르면 OnGoDevOps를 따르고 이 문서를 고친다.

0. 한 장 요약 (직관 — 프론트 웹서버 & 백엔드 WAS 분리 구성)

[Nuxt 웹서버 ongo-demo-web]   devfw-web.ongosoft.com        (제니 :12003) --/api/**--> java21-sc        ongo-sc-demo        WAS :12012 (devfw21-sc)
[Nuxt 웹서버 ongo-demo-web-custom] devfw-web-custom.ongosoft.com (제니 :12004) --/api/**--> java21-sc-custom ongo-sc-custom-demo WAS :12014 (devfw21-sc-custom, dev,custom 프로필)
  ├ /          Nuxt 프론트 홈            (같은 apps/demo-web 빌드 — 두 사이트는 프록시 대상만 다름)
  ├ /ui        퍼블리셔 디자인 시스템 가이드 (Nuxt UI 26종)
  ├ /guide     업무 개발자 시작하기·CRUD 샘플
  └ /auth 등   프론트 공통 쇼케이스
백엔드 쇼케이스는 WAS 전용(앞단 도커 nginx 없음): Cloudflare → NPM → 제니 WAS 포트(AA01N) 직결.
  • 프론트는 Nuxt(Vue) 로 확정(핸드북). 백엔드 데모 WAS 에 합치지 않고 독립 웹서버(docker/demo-web, ongo-demo-web, 포트 12003, 도메인 https://devfw-web.ongosoft.com) 로 분리 기동한다. 삼카 커스텀용 FE 는 같은 빌드를 docker/demo-web-custom(ongo-demo-web-custom, 12004, https://devfw-web-custom.ongosoft.com)로 하나 더 띄운다.
  • 프론트는 백엔드 데모의 JSON API(/api/**)만 부른다. 백엔드 코드를 프론트에 맞춰 바꾸지 않는다(필요하면 원우 확인).
  • develop 머지(push) → GitHub 웹훅 → Jenkins → 제니 Docker 재배포(개발계 자동). 이때 백엔드 데모 WAS 9종과 프론트 웹서버 2종(ongo-demo-web, ongo-demo-web-custom)이 각각 독립 컨테이너로 배포된다.

1. 백엔드 및 프론트 모듈 구성 (settings.gradle + apps/demo-web)

모듈 성격 비고
ongo-framework-core 프레임 코어(C1~C7 서비스·AutoConfig) 고객사 비의존. 설계는 architecture.md
ongo-framework-starter 스타터(의존 묶음) 앱은 이것을 의존
ongo-framework-devtools 코드생성 개발툴(/framework-devtools/) local/dev 프로필에서만. DEVTOOLS-GUIDE.md
ongo-framework-samsung-card 삼카 도메인 팩(유형·표준전문 코덱 등) 코어에 박지 않고 팩으로 분리
ongo-framework-batch 배치 공통
ongo-framework-sc-demo 삼카 데모 WAS(쇼케이스) server.port: 8080
ongo-framework-generic-demo 일반 데모 WAS(삼카 비의존) server.port: 8081
ongo-framework-batch-demo 삼카 배치 데모(비웹, 1회 실행) sc-demo compose --profile batch
ongo-framework-batch-generic-demo 일반 배치 데모
apps/demo-web Nuxt 프론트엔드 웹서버(Gradle과 분리된 Node 프로젝트) 독립 Docker 웹서버 docker/demo-web (ongo-demo-web, :12003)

교체 지점·yml 키는 CUSTOMIZATION.md·CUSTOMIZATION-EXAMPLES.md. 프레임 라이브러리는 Nexus(https://nexus.ongosoft.com/repository/maven-{releases,snapshots}/)로 publish(핸드북·CUSTOMIZATION 17장).

2. DevOps 배포 — OnGoDevOps 참조

배포 구성 상세(Jenkins 잡, 서버, 포트, NPM 프록시, Nexus, 롤백)는 OnGoDevOps 프로젝트 README.md (같은 레벨 ~/workspace/2026/OnGoDevOps, GitHub OnGoSoft/OnGoDevOps)를 본다. 이 저장소의 docker/는 이미지 빌드 설정만 가진다.

흐름(OnGoDevOps README 1.2): 1. OnGoSoft/ongo-framework develop push(머지) → GitHub 웹훅 → Jenkins WEBHOOK-Dispatcher 2. config/webhook-map/development.csv 조회 → 백엔드 데모 9종 및 프론트 웹서버 2종 매핑 3. RELEASE-Pipeline(TYPE=DOCKER) → ssh → 제니(192.168.0.101) WSL Docker Compose 4. 외부 진입: Cloudflare DNS → 공유기 → NAS Nginx Proxy Manager(NPM) → 제니 포트(FE 는 Nuxt nginx 12003/12004, 백엔드는 WAS 포트 직결 AA01N)

OnGoDevOps APP 구분 compose 모듈 제니 포트(NPM 대상) 공개 도메인
ongo-demo-web FE 웹서버(분리) docker/demo-web apps/demo-web (Nuxt 4) 12003 (API→12012) https://devfw-web.ongosoft.com
ongo-demo-web-custom FE 웹서버(삼카 커스텀) docker/demo-web-custom apps/demo-web (같은 빌드) 12004 (API→12014) https://devfw-web-custom.ongosoft.com
ongo-generic-demo BE 데모 docker/generic-demo ongo-framework-generic-demo WAS 12011 https://devfw21.ongosoft.com
ongo-sc-demo BE 데모 docker/sc-demo ongo-framework-sc-demo (+batch-demo) WAS 12012 https://devfw21-sc.ongosoft.com
ongo-sc-custom-demo BE 데모(삼카 커스텀) docker/sc-custom-demo ongo-framework-sc-demo (dev,custom) WAS 12014 https://devfw21-sc-custom.ongosoft.com
ongo-boot3-j17-demo BE 데모 docker/boot3-j17-demo Boot3 / JDK 17 일반 데모 WAS 13011 https://devfw17.ongosoft.com
ongo-boot3-j17-sc-demo BE 데모 docker/boot3-j17-sc-demo Boot3 / JDK 17 삼카 데모 WAS 13012 https://devfw17-sc.ongosoft.com
ongo-boot2-demo BE 데모 docker/boot2-demo Boot2 / JDK 8 일반 데모 WAS 14011 https://devfw8.ongosoft.com
ongo-boot2-sc-demo BE 데모 docker/boot2-sc-demo Boot2 / JDK 8 삼카 데모 WAS 14012 https://devfw8-sc.ongosoft.com
ongo-boot2-j11-demo BE 데모 docker/boot2-j11-demo Boot2 / JDK 11 일반 데모 WAS 15011 https://devfw11.ongosoft.com
ongo-boot2-j11-sc-demo BE 데모 docker/boot2-j11-sc-demo Boot2 / JDK 11 삼카 데모 WAS 15012 https://devfw11-sc.ongosoft.com
  • 백엔드 데모 컨테이너: WAS(app) 하나만. 2026-10-30 원우 결정으로 앞단 도커 nginx(docker/web)를 없애고 NPM 이 WAS 포트(AA01N)에 바로 붙는다(옛 WEB 포트 AA001/AA002 폐지, 이미지 빌드 1개 감소).
  • 프론트엔드 전용 웹서버 컨테이너(docker/demo-web): node:22-alpine에서 apps/demo-web 정적 번들(npm run generate)을 빌드하여 독립 nginx:1.27-alpine(:12003)으로 서빙하며, /api/** 요청만 제니 백엔드 WAS(API_UPSTREAM, 기본 host.docker.internal:12012)로 프록시한다. ongo-demo-web-custom(:12004)은 같은 이미지 빌드에 API_UPSTREAM=host.docker.internal:12014.
  • 설정 정본: OnGoDevOps config/development/DOCKER/ongo-*.properties. 운영(production) 설정은 원우 요청 전까지 만들지 않음.

3. 도메인 및 포트 명명 규칙 (OnGoDevOps 표준)

3.1 도메인 명명 규칙

{환경접두어}{제품군약칭}{JDK버전(백엔드만)}[-{역할/도메인변형}].ongosoft.com

  1. 환경 접두어 ({환경접두어}): - 개발(development): dev (예: devfw-web.ongosoft.com, devfw21-sc.ongosoft.com) - 스테이징(staging, 도입 시): stg (예: stgfw-web.ongosoft.com) - 운영(production): 생략 (예: fw-web.ongosoft.com, nanuer.ongosoft.com)
  2. 백엔드 vs 프론트엔드 분리 규칙: - 백엔드 WAS 데모: devfw{JDK} (일반, N=1) / devfw{JDK}-sc (삼카, N=2) — JDK 버전(21, 17, 8, 11)을 명시 - 프론트엔드 전용 웹서버: devfw-web (N=3, 포트 12003, →sc) / devfw-web-custom (N=4, 포트 12004, →sc-custom) — 프론트엔드는 JDK 런타임과 무관한 단일 Nuxt 웹서버이므로 버전 숫자를 붙이지 않고 -web 역할 접미어를 사용 - 향후 변형 확장 시: 일반 백엔드 연동 전용 프론트가 별도로 필요하면 devfw-web-generic / 삼카 전용 devfw-web-sc, 어드민 분리 시 devfw-admin 규칙을 따른다.
3.2 도메인 목록
도메인 포트 (제니) 용도
devfw-web.ongosoft.com 12003 프론트엔드(Nuxt) 전용 웹서버 (/ 홈, /ui 퍼블리셔 디자인 가이드, /guide 개발 샘플, /auth 등)
devfw-web-custom.ongosoft.com 12004 프론트엔드(Nuxt) 삼카 커스텀 — 같은 화면, /api/** → devfw21-sc-custom WAS 12014 (/sc-custom 확장 목업 실연동)
devfw21-sc.ongosoft.com 12012 (WAS) 삼카 백엔드 데모 개발계 (Java 21 쇼케이스, /api/**, /framework-devtools/)
devfw21-sc-custom.ongosoft.com 12014 (WAS) 삼카 커스텀 백엔드 (sc-demo dev,custom, /api/demo/ext/**)
devfw21.ongosoft.com 12011 (WAS) 일반 백엔드 데모 개발계 (Java 21 쇼케이스, /api/**, /framework-devtools/)
devfw17 / devfw17-sc 13011 / 13012 (WAS) Boot3·JDK17 일반/삼카 백엔드 데모
devfw8 / devfw8-sc 14011 / 14012 (WAS) Boot2·JDK8 일반/삼카 백엔드 데모
devfw11 / devfw11-sc 15011 / 15012 (WAS) Boot2·JDK11 일반/삼카 백엔드 데모
nexus.ongosoft.com / npm.ongosoft.com / docker.ongosoft.com NAS Nexus 저장소(Maven·npm·Docker) / NPM 관리 콘솔
jenkins.ongosoft.com 지수 :8080 Jenkins

4. Nuxt ↔ 백엔드 연계

현재 POC(apps/demo-web): Nuxt 4(Nuxt 3 는 2026-07-31 지원 종료), 소스는 app/ 아래(Nuxt 4 표준 구조), ssr: false. 브라우저는 같은 오리진 runtimeConfig.public.apiBase(/api)만 호출하고, 로컬 개발 시에는 Nitro 라우트 server/routes/api/[...path].ts 가 NUXT_API_TARGET으로 프록시하며, 제니 Docker 배포 시에는 분리된 ongo-demo-web(:12003) nginx 컨테이너가 /api/**를 백엔드 WAS 컨테이너(host.docker.internal:12012)로, ongo-demo-web-custom(:12004)은 :12014 로 프록시한다.

cd apps/demo-web && npm install
npm run dev:local-local                                        # 로컬 FE → 로컬 삼카 데모(:8080). npm run dev 도 동일
npm run dev:local-dev                                          # 로컬 FE → 개발계 삼카 데모(https://devfw21-sc.ongosoft.com)
NUXT_API_TARGET=http://localhost:8081 npm run dev:local-local  # 로컬 FE → 로컬 일반 데모(셸 환경변수가 .env 보다 우선)
npm run build:dev | build:stg | build:prod
npm run generate                                               # 정적 번들(.output/public) 생성 (docker/demo-web 이미지에서 사용)

환경 프로파일 고정명(원우 승인, FRONTEND-COMMON-WORKLIST.md FE-01 구현됨): local-local(로컬 FE→로컬 BE), local-dev(로컬 FE→개발 BE), dev, stg, prod — 값은 apps/demo-web/.env.<profile>. stg/prod 백엔드 도메인은 아직 없음 — NUXT_API_TARGET 을 비워 두었고(미설정 시 /api/** 502), 만들지 말고 실행 시 주입한다. 상세는 apps/demo-web/README.md.

이미 쓰는 API(데모): POST /api/auth/login(Bearer 발급) · POST /api/auth/logout · GET /api/me · GET /api/demo/auth/reports(권한, 허용/403) · 샘플 hello/items/echo · 공통코드. 응답 헤더 X-Correlation-Id 표시. 호출은 공통 파이프라인 useHttp()(FE-02, utils/http.ts·plugins/http.ts)로만 — 401이면 세션 정리 후 /login?redirect= 이동. 오류 응답 데모는 GET /api/demo/errors/{business|business-conflict|system|unhandled|manual-validation|required}(422/409/500/500/400/400).

5. 작업 규칙 (요약)

  1. 시작 전 AGENT-HANDBOOK.md → 이 문서 → FRONTEND-COMMON-WORKLIST.md 순으로 읽는다.
  2. 작업 단위 = 워크리스트 FE-xx(현재 FE-01~FE-36). 우선순위 P0부터, ID 재사용 금지.
  3. develop 에서 브랜치 → PR → develop 머지(머지 = 개발계 백엔드 및 분리된 프론트 웹서버 ongo-demo-web Docker 재배포 트리거임을 기억).
  4. 백엔드 API가 부족하면 목업으로 진행하고 "백엔드 호출 위치"를 명기한다(FE 워크리스트 원칙). 부족/요청 사항은 BACKEND-COLLABORATION.md에 즉시 기록하여 관리한다.
  5. 배포·도메인·포트 변경은 OnGoDevOps 쪽 일이다. 이 저장소에서 추측해 쓰지 않는다.
  6. 프레임 코어에 화면·업무 도메인을 넣지 않는다(핸드북).
  7. UI 는 Ui* 계약 컴포넌트·useUi* 만 쓴다(현재 구현 Nuxt UI, 교체 가능). Nuxt UI 직접 사용은 어댑터 파일에서만 — npm test 의 경계 검사가 막는다. 워크리스트 §1-5.
  8. 프레임워크(TypeScript) / 업무 개발(JavaScript) 분리: 공통 영역(utils/·composables/·plugins/·middleware/·stores/·components/ui/·components/common/)만 TypeScript(.ts, <script setup lang="ts">)로 작성하고, 업무 화면(pages/·layouts/ 등)은 순수 JavaScript(<script setup>, .js)로 작성한다(tests/ui-boundary.test.ts 경계 검사). 워크리스트 §1-6.
  9. XSS 안전 표시(FE-29): 서버가 이스케이프한 값은 <CommonSafeText :value>, 서식 HTML 은 <CommonSafeHtml :html> 로만 그린다. v-html 은 components/common/SafeHtml.vue 한 곳에서만, innerHTML·insertAdjacentHTML·document.write 는 쓰지 않는다(tests/ui-boundary.test.ts). 데모 /xss.
  10. 민감정보 마스킹(FE-27): 카드·계좌·주민·휴대폰·이메일·이름은 <CommonMasked :value kind> 또는 mask()(utils/mask.ts)로만 표시하고, 민감 영역은 v-sensitive(복사·드래그·우클릭 차단) / v-sensitive="'mask'"(클립보드엔 마스킹본). 콘솔·수집기 로그는 console.* 대신 safeLog.*(객체 키·자유 텍스트 자동 마스킹). 데모 /mask.
  11. 포맷·검증(FE-28): 금액·일자·카드·계좌·사업자·휴대폰은 utils/format.ts(format*/parse*/is*/validate*)와 <CommonFormattedInput kind v-model>(v-model = 서버 전송값, 화면 = 포맷 문자열)을 쓴다. 데모 /format.
  12. 약관 동의(FE-21): <CommonTermsAgreement group @proceed> — 메타(필수/선택·약관/설명서·버전·contentUrl·mustRead·children) 조회 후 본문 비동기 로딩, HTML 본문은 FE-29 정화. 목업 /mock-cms/terms/<group>.json(실 BE 메타 API·컨텐츠 서버로 교체). 데모 /terms.
  13. 태깅/클릭코드(FE-36): 화면진입은 자동(화면ID 는 FE-20 화면 메타 레지스트리 값 — definePageMeta({ screenId }) 는 예외적 덮어쓰기만), 클릭은 v-track="'CLK_…'" 또는 data-click-code, 전환은 useTracker().conversion('CNV_…', payload). GA·로그서버를 화면에서 직접 부르지 않는다. 분석·마케팅 동의(useConsentStore) 전에는 전송되지 않고, payload 의 PII 키는 제거된다(이름·카드·계좌·주민·연락처 등을 넣지 말 것). 감사 로그(FE-31)와 분리. 데모 /tracking.
  14. 삼카 확장 · 커스텀 데모: 기본 삼카 데모와 별도 참조 화면 /sc-custom — sc-demo 를 custom 프로필로 띄웠을 때만 있는 /api/demo/ext/*(Redis 흉내 세션 키·MQ 발행/소비·ES 색인 로그·EAI TCP 수신) 호출. custom 아님(404)·BE 미연결이면 화면 내 목업으로 대체하고 「응답 원천」 배지로 표시. 라우팅: 로컬 Nitro 는 /api/demo/ext/** 만 NUXT_API_CUSTOM_TARGET(.env.*) → https://devfw21-sc-custom.ongosoft.com. 배포는 사이트 단위로 나뉜다 — devfw-web(→sc, ext 는 404 → 목업), devfw-web-custom(/api/** 전체 → sc-custom WAS :12014, 실연동). local-local 은 로컬 sc-demo 를 local,custom 으로 띄우면 같은 :8080.
  15. SEO·AEO·화면 메타(FE-20): 화면을 추가하면 server/utils/screen-meta-registry.ts 에 SCR_… 항목(title·description·robots·sitemap·access, 필요 시 jsonLd·faq·breadcrumb)을 반드시 함께 등록한다(테스트가 미등록 페이지를 잡는다). 화면에서 useHead 로 메타를 직접 쓰지 말고 런타임 변경은 useScreenMeta().set({ title }). 로그인 필요 화면은 기본 noindex, 비운영은 NUXT_PUBLIC_SEO_ALLOW_INDEX 미설정 시 전체 noindex·robots 차단. 도메인은 NUXT_PUBLIC_SITE_URL 로만 주입(추측 금지). 데모 /seo, /faq.
프론트엔드 공통 개발 작업 리스트 (금융권 FE 공통) docs/FRONTEND-COMMON-WORKLIST.md

원우 합의(2026-10-27). 채팅 기록 없이 이 파일만 보고 작업할 수 있게 정리한 정본 체크리스트. v2: 두서없이 적은 원본 1–31번을 레이어/파이프라인 기준(A–E)으로 재구성, 안정 ID FE-01…FE-31 부여. v3: 레이아웃 유형별 분기 FE-32 추가. v4: 상태관리 FE-33·스토리지 API FE-34 추가. v5: 외부 JSON 메뉴 FE-35 추가. v6: 태깅/클릭코드 수집 FE-36 추가(신규 항목은 다음 빈 번호로 이어 붙임, 기존 ID 재사용 금지). 원래 번호는 각 항목에 (구 #n)으로 남김.

먼저 읽기: FRONTEND-AGENT-CONTEXT.md — 백엔드 구성·배포(OnGoDevOps)·도메인·Nuxt 연계 공통 컨텍스트.

0. 제약 (반드시)

  • npm 배포용 FE 라이브러리/프레임워크화 금지. 별도 패키지·퍼블리시 없음.
  • 구현 위치: apps/demo-web/app/(Nuxt 4 소스 루트) 등 데모 앱 내부 공통 폴더(composables/, components/common/, utils/, middleware/, plugins/, stores/). 서버 라우트만 apps/demo-web/server/.
  • "실제" 표시 항목은 삼카 데모 백엔드(ongo-framework-samsung-card) 연동. "목업" 표시 항목은 목업 데이터/서버로 시연 OK (실 BE 호출 위치는 주석으로 명기).
  • 시크릿·실계정 정보 커밋 금지. 핸드북 AGENT-HANDBOOK.md 원칙 우선.

1. 설계 결정 (원우 승인 방향)

  1. 환경 프로파일 고정명 — local-local / local-dev / dev / stg / prod. 에이전트가 헷갈리지 않게 이름을 바꾸지 않는다.
  2. 요청 파이프라인 단일 설계 — HTTP 훅·오류 분류·에러 카탈로그·다이얼로그·옵션 자동 팝업·로딩·인증/세션/로그아웃 이벤트·라우트 가드는 따로 만들지 않고 레이어 B 하나의 흐름으로 설계한다. 먼저 FE-02에서 파이프라인 골격(단계·이벤트 이름·옵션)을 확정하고 나머지 B 항목은 그 훅에 붙인다.
  3. 브릿지 목업 우선 — 브라우저용 목업 네이티브 앱으로 규약·데모를 먼저 끝내고, 네이티브는 인터페이스만 맞춘다.
  4. 원우 필수 항목이라도 더 나은 방향이 있으면 PR 설명에 제안으로 남긴다(임의 삭제 금지).
  5. UI 프레임워크 = Nuxt UI(초기), 교체 가능 구조 — 화면은 app/components/ui 의 Ui* 계약 컴포넌트와 useUi* 컴포저블만 쓴다. Nuxt UI(U* 컴포넌트·useToast·@nuxt/ui)를 직접 쓰는 곳은 어댑터 파일(components/ui/·composables/useUi*.ts·utils/ui-adapter-*.ts·app.config.ts·assets/)뿐이고 tests/ui-boundary.test.ts 가 막는다. 새 공통 UI(FE-04 다이얼로그, FE-16 컴포넌트 등)도 먼저 Ui* 계약을 만들고 그 안에서 Nuxt UI 를 쓴다. 교체 절차는 apps/demo-web/README.md「UI 프레임워크」.
  6. 프레임워크(TypeScript) / 업무 개발(JavaScript) 언어 분리 — 프레임워크·공통 영역(utils/, composables/, plugins/, middleware/, stores/, components/ui/, components/common/, app.config.ts)만 TypeScript(.ts, <script setup lang="ts">)로 작성해 타입 계약과 IDE 자동완성을 제공하고, 업무 개발자 영역(pages/, layouts/ 및 업무 컴포넌트)은 진입장벽 없이 순수 JavaScript(<script setup>, .js)로만 작성한다(lang="ts"·.ts·import type 금지, tests/ui-boundary.test.ts 가 자동 검사).

2. 레이어 구조

A 환경 프로파일 ─▶ B 요청 파이프라인 ─▶ D 화면 공통
                     ▲        ▲
        C 브릿지 ────┘        └──── E 보안·데이터 유틸(마스킹·포맷·XSS)

3. 체크리스트 요약

ID 레이어 우선 항목 구# 연동 의존 상태
FE-01 A P0 환경별 빌드·환경변수 주입 1 실제(설정) - [x]
FE-02 B P0 HTTP 요청/응답 공통 모듈 2 실제(삼카 데모 BE) - [x]
FE-03 B P1 에러코드 → 메시지 카탈로그 21 목업 FE-02 [x]
FE-04 B P0 메시지·컨펌 다이얼로그 공통 3 해당없음(UI) FE-02 [x]
FE-05 B P2 글로벌 로딩·스켈레톤·토스트 25 해당없음 FE-02 [x]
FE-06 B P0 로그인(인증)·권한 7 로그인=삼카 데모 BE 가능, 권한=목업 FE-02 [x]
FE-07 B P1 세션·유휴 타임아웃 16 목업/데모 BE FE-06 [x]
FE-08 B P1 토큰 갱신·중복 로그인·강제 로그아웃 17 목업 FE-06, FE-02 [x]
FE-09 B P0 페이지 네비게이션 가드 6 목업 FE-06 [x]
FE-10 B P1 다단계 신청 상태 저장·복원 22 해당없음 FE-09 [x]
FE-11 B P1 이중 제출 방지·로딩 락·멱등 키 20 해당없음 FE-02 [x]
FE-12 B P2 오프라인·재시도 UX 24 해당없음 FE-02 [x]
FE-13 C P0 웹앱 JS 브릿지 공통 15 목업 네이티브 - [x]
FE-14 C P2 보안키패드·생체·카메라/OCR·강제업데이트 23 목업 네이티브 FE-13 [x]
FE-15 C P2 딥링크·앱스킴 진입 28 목업 FE-13, FE-09 [x]
FE-16 D P2 UI 공통 컴포넌트 4 목업 데이터 - [x]
FE-17 D P1 페이징 공통 5 실제 BE 우선, 없으면 목업 FE-02 [x]
FE-18 D P1 파일 업로드/다운로드 공통 8 실제 BE 있으면 연동, 없으면 목업 FE-02 [x]
FE-19 D P2 PDF·문서 뷰어 29 목업 파일 FE-18 [x]
FE-20 D P2 SEO·AEO·화면 메타 공통 관리 9 목업 FE-06 [x]
FE-21 D P1 약관 동의 화면 공통 13 목업(메타+컨텐츠) FE-29 [x]
FE-22 D P2 WCMS 컨텐츠 동적 로딩 14 목업 정적 서버 FE-29 [x]
FE-23 D P2 웹접근성 준수 10 해당없음 - [x]
FE-24 D P2 접근성 보강 30 해당없음 FE-04 [x]
FE-25 D P2 반응형 샘플 11 목업 - [x]
FE-26 D P2 적응형 샘플 12 목업 - [x]
FE-27 E P1 민감정보 마스킹·클립보드 제한 18 해당없음 - [x]
FE-28 E P1 금액·일자·계좌 등 포맷·검증 19 해당없음 - [x]
FE-29 E P0 XSS 디코딩 안전 표시 31 해당없음 - [x]
FE-30 E P2 i18n 훅 26 해당없음 - [x]
FE-31 E P2 감사 로그 훅 27 목업 - [x]
FE-32 D P2 UI 레이아웃 유형별 분기(비로그인/로그인/로그인 후) 32 목업 FE-06, FE-09, FE-20 [x]
FE-33 B P0 상태관리 공통(Pinia/Nuxt state) 패턴 33 해당없음 FE-34 [x]
FE-34 B P0 용도·성격·보관기간별 스토리지 API 34 해당없음(브릿지 보안저장은 FE-13 이후) - [x]
FE-35 D P2 외부(WCMS) JSON 메뉴 정의 로딩·파싱·렌더링 + 목업 메뉴 35 목업(임의 메뉴 JSON) FE-22, FE-32, FE-06, FE-29 [x]
FE-36 B P1 태깅/클릭코드(마케팅·분석 이벤트) 수집 공통 36 목업(수집기) FE-21, FE-34, FE-33, FE-20, FE-13 [x]

우선순위: P0 = FE-01, FE-34, FE-33, FE-02, FE-04, FE-06, FE-09, FE-13, FE-29 → P1 → P2. 같은 우선순위면 레이어 A→B→C→E→D 순.

4. 항목 상세

레이어 A. 환경 프로파일 레이어

FE-01. 환경별 빌드·환경변수 주입 (P0) — 구 #1
  • [x] 완료 — 브랜치 feat/fe-common-FE-01-env-profile · apps/demo-web/.env.{local-local,local-dev,dev,stg,prod}, nuxt.config.ts(runtimeConfig), app/utils/profile.ts(useProfile), server/routes/api/[...path].ts.
  • 목표: local/dev/stg/prod 빌드 스크립트와 런타임 환경변수 분리 주입(runtimeConfig, .env.*).
  • 완료 기준: 로컬 실행 시 로컬 BE / 개발 BE 두 모드 스크립트 제공(아래 고정명 스크립트). stg/prod 빌드 스크립트 존재. 소스에 URL 하드코딩 없음. README에 사용법.
  • 프로파일 고정명(원우 승인): local-local(로컬 FE→로컬 BE), local-dev(로컬 FE→개발 BE), dev, stg, prod. 스크립트 npm run dev:local-local / dev:local-dev / build:dev|stg|prod. 이 이름 외 임의 추가 금지(추가 시 이 문서 먼저 수정).
  • 주입 규칙: .env.<profile> → runtimeConfig.public.apiBase 등. 코드에서는 useRuntimeConfig()로만 접근.
  • 연동: 실제(설정)

레이어 B. 요청 파이프라인 (HTTP·에러·다이얼로그·자동팝업·인증/세션·가드 = 하나의 설계)

FE-02. HTTP 요청/응답 공통 모듈 (P0) — 구 #2
  • [x] 완료 — 브랜치 feat/fe-common-FE-02-http · 코어 apps/demo-web/app/utils/http.ts(createHttpClient·classify·HttpError), 결합 plugins/http.ts, composables/useHttp.ts(useHttp), 데모 pages/http.vue(개발계 삼카 데모 BE 실제 호출), 테스트 tests/http.test.ts. 기존 composables/useApi.ts(api()) 제거.
  • 확정한 골격(나머지 B 항목은 여기에 붙인다): 훅 이름 request / response / error / auth-error / popup / loading(http.on(name, fn) → 해제 함수). 옵션 { autoPopup=true, loading=true, lock?, idempotent?, retry?, authEvent=true } — authEvent 는 로그인 자격 오류처럼 세션 만료가 아닌 401 을 구분하려고 추가. 메시지 결정은 resolveMessage(FE-03 카탈로그 연결 지점).
  • 분류표(classify): 2xx(success:false 제외)=SUCCESS · 401=AUTH_ERROR · envelope 있는 그 외 오류(400 검증·403 권한·404·409·422·500 시스템)=BUSINESS_ERROR · envelope 없는 5xx·연결 실패·타임아웃=NETWORK_ERROR. 403 은 로그아웃 대상이 아니라 BUSINESS_ERROR(code auth.forbidden)로 둔다.
  • 동작 규칙: get/post/... 는 data 만, exchange() 는 page·code·traceId 포함. 재시도는 NETWORK_ERROR 만, GET 또는 idempotent 요청만. lock 은 진행 중 같은 요청의 결과를 공유(실제 요청 1회). 사용자 취소(AbortError)는 팝업 없음. 팝업으로 이미 알린 오류(handled)는 호출부가 catch 하지 않아도 콘솔 오류로 올리지 않는다. 서버 메시지 언어는 Accept-Language: ko-KR 기본(FE-30 에서 전환).
  • 목표: $fetch 래퍼 composable. 삼카 데모 BE 호출 연계.
  • 완료 기준: 요청 전처리/후처리 훅(헤더·토큰·로깅). 성공/비즈니스 실패(공통 JSON 코드)/네트워크 오류 구분. 요청 옵션 autoPopup(기본 true)으로 FE-04 다이얼로그 자동 노출, false면 호출부 처리. 삼카 데모 API 최소 1건 실제 호출 시연.
  • 파이프라인 단계: request hook(토큰·멱등키·로딩) → fetch → response hook → 분류(SUCCESS / BUSINESS_ERROR / AUTH_ERROR / NETWORK_ERROR) → 오류 처리(카탈로그 메시지 → autoPopup이면 다이얼로그) → AUTH_ERROR면 인증 이벤트 발행.
  • 요청 컨텍스트 옵션: { autoPopup=true, loading=true, lock?, idempotent?, retry? }.
  • 연동: 실제(삼카 데모 BE)
FE-03. 에러코드 → 메시지 카탈로그 (P1) — 구 #21
  • [x] 완료 — 브랜치 feat/fe-03-error-message-catalog · 카탈로그 apps/demo-web/app/utils/message-catalog.ts(createMessageCatalog·resolve·resolveField·parseCatalogDocument, 내장 FE 코드 NETWORK·ABORTED·HTTP_502/503/504), 적재 plugins/message-catalog.ts(목업 public/mock/message-catalog.json — 백엔드 호출 위치: 메시지 조회 API 가 생기면 이 URL 만 교체), FE-02 연결 plugins/http.ts(resolveMessage·팝업 제목·톤·필드 문구), 화면용 composables/useMessageCatalog.ts, 데모 pages/messages.vue, 테스트 tests/message-catalog.test.ts. 설계 MESSAGE-CATALOG.md: 결정 정책 = 단계 순서(기본 server-first = override → 서버 응답 문구 → 서버 조회 API(기존 GET /api/demo/messages/{code}, TTL 캐시) → 카탈로그 → 기본 문구), 프리셋 catalog-first·server-only·catalog-only 또는 직접 순서 — NUXT_PUBLIC_MESSAGE_CATALOG_POLICY·코드별 override·setPolicy() 로 변경. 백엔드는 버전 개념 없음(캐시 비우기는 Refreshable·MetaLoadRunner) → FE 는 TTL. 2026-10-29 개발 BE 연동 시연.
  • 목표: 서버 코드와 사용자 메시지 매핑.
  • 완료 기준: 코드 카탈로그(목업 조회 가능), 미정의 코드 기본 메시지, FE-02 자동 팝업에서 사용.
  • 연동: 목업
FE-04. 메시지·컨펌 다이얼로그 공통 (P0) — 구 #3
  • [x] 완료 — (feat/fe-common-FE-04-dialog) · 코어 apps/demo-web/app/utils/dialog.ts(createDialogManager), 플러그인 plugins/dialog.ts, 컴포저블 composables/useDialog.ts, 호스트 components/common/AppDialogHost.vue(UiModal 계약 위 구현, 기존 임시 HttpErrorNotice.vue 제거), FE-02 plugins/http.ts popup 훅 연동, 데모 pages/dialog.vue, 테스트 tests/dialog.test.ts.
  • 목표: 브라우저 alert/confirm 대신 공통 디자인 다이얼로그.
  • 완료 기준: alert/confirm Promise API, 버튼별 클릭 이벤트, show/hide 이벤트 훅, 큐(중복 팝업 처리), FE-02 자동 팝업과 연결, 포커스 트랩(FE-24과 연계).
  • 연동: 해당없음(UI)
FE-05. 글로벌 로딩·스켈레톤·토스트 (P2) — 구 #25
  • [x] 완료 — feat/fe-p2-batch · useUiToast 큐·app.beginLoading 전역 로딩(http loading:false 로 끄기)·UiSkeleton, 데모 /feedback.
  • 완료 기준: 전역 로딩 인디케이터(FE-02 연동, 옵션으로 끄기), 스켈레톤 컴포넌트, 토스트 큐.
  • 연동: 해당없음
FE-06. 로그인(인증)·권한 (P0) — 구 #7
  • [x] 완료 — (feat/fe-common-FE-06-auth) · 코어 apps/demo-web/app/utils/auth.ts(resolveRedirectUrl·evaluateAccess·resolvePermissions·createAuthEventBus·MOCK_AUTH_PRESETS), 스토어 stores/auth.ts, 컴포저블 composables/useAuth.ts(login·loginMock·terminateSession·onAuthEvent + BE 권한 호출 위치 주석 명기), 가드 middleware/auth.ts, 선언적 권한 컴포넌트 components/common/CanAccess.vue, 데모 pages/auth.vue·pages/admin-demo.vue, 테스트 tests/auth.test.ts.
  • 목표: 퍼블릭/인증 필요 화면 구분, 권한별 제어.
  • 완료 기준: 인증 필요 화면 미로그인 진입 시 로그인으로(복귀 URL 유지). 로그아웃(또는 401/세션 만료) 감지 이벤트 훅 → 메인/로그인 리다이렉트. 권한 정보는 BE 호출 위치만 명기하고 목업 권한으로 메뉴/버튼/화면 제어 데모.
  • 연동: 로그인=삼카 데모 BE 가능, 권한=목업
FE-07. 세션·유휴 타임아웃 (P1) — 구 #16
  • [x] 완료 — 브랜치 feat/fe-07-session-idle-timeout · 설계 SESSION-IDLE.md · 코어 apps/demo-web/app/utils/idle-session.ts(서버 세션 만료 시각 = 마지막 서버 갱신 + timeout, active→warning→expired, 활동 중 자동 연장, 멀티탭 메시지), 결합 plugins/session-idle.client.ts(로그인 시작·인증 성공 응답=갱신·DOM 활동·연장 다이얼로그(FE-04)·만료 시 FE-06 terminateSession('expired')·BroadcastChannel), composables/useSessionIdle.ts, 데모 pages/session.vue, 테스트 tests/idle-session.test.ts. 연장 BE 호출 = 기존 GET /api/me(BE 슬라이딩 만료, 신규 API 없음). 설정 runtimeConfig.public.session(BE ongo.framework.session.timeout 30m 과 같게).
  • 목표: 유휴 감지 후 만료 안내.
  • 완료 기준: 만료 N분 전 연장 다이얼로그, 연장 시 BE 호출, 만료 시 FE-06 로그아웃 처리. 멀티탭 동기화(BroadcastChannel) 권장.
  • 연동: 목업/데모 BE
FE-08. 토큰 갱신·중복 로그인·강제 로그아웃 (P1) — 구 #17
  • [x] 완료 — 브랜치 feat/fe-08-token-refresh-forced-logout · 설계 AUTH-TOKEN.md · 갱신·재시도 apps/demo-web/app/utils/http.ts(refreshAuth·shouldRefresh·옵션 refresh, 동시 401 단일 갱신·보낸 뒤 갱신 완료 시 바로 재시도·재시도 1회), 코드 정책 utils/auth.ts(createAuthErrorPolicy·classifyAuthError — refresh auth.token-expired, forced auth.duplicate-login·auth.forced-logout·auth.session-revoked, env NUXT_PUBLIC_AUTH_*), 결합 plugins/http.ts(강제 로그아웃 안내 → terminateSession('forced'), 실제 갱신 호출 위치 refreshPath), 목업 인증 서버 utils/auth-mock.ts(/mock-api/**), 데모 pages/token.vue, 테스트 tests/auth-token.test.ts·tests/http.test.ts. 백엔드에 갱신 API·중복/강제 코드가 없어 목업(신규 API 없음).
  • 완료 기준: 401 시 토큰 갱신 후 재시도(동시 요청 단일 갱신), 중복 로그인/강제 로그아웃 코드 수신 시 안내 후 로그아웃.
  • 연동: 목업
FE-09. 페이지 네비게이션 가드 (P0) — 구 #6
  • [x] 완료 — 브랜치 feat/fe-13-bridge-completion(FE-13 과 한 PR) · 코어 apps/demo-web/app/utils/navigation-guard.ts(DIRECT_ENTRY_WHITELIST·isDirectEntryWhitelisted·evaluateDirectEntry·evaluateFlowGuard·evaluateRouteNavigation), 전역 미들웨어 middleware/navigation.global.ts, 컴포저블 composables/useNavigationGuard.ts, 플로우 스토어 보강 stores/flow.ts(completed·getStepData·complete), 3스텝+완료+내부전용 데모 pages/apply/{index,step1,step2,step3,complete,internal-only}.vue, 테스트 tests/navigation-guard.test.ts.
  • 목표: 라우트 미들웨어로 진입 제어.
  • 완료 기준: URL 직접 진입 허용 페이지 화이트리스트. 단계형 신청 업무에서 중간 스텝 URL 직접 진입 시 첫 스텝으로 리다이렉트(+메시지). 뒤로가기/새로고침 동작 정의. 데모 신청 플로우 3스텝 이상.
  • 연동: 목업
FE-10. 다단계 신청 상태 저장·복원 (P1) — 구 #22
  • [x] 완료 — 브랜치 feat/fe-10-flow-state-persist · 저장/복원은 flow 스토어 영속화(FE-33·FE-34, sessionStorage internal 30분, persist.pick) · 민감정보 저장 금지 apps/demo-web/app/utils/flow-persist.ts(splitSensitive — 이름 규칙 password·pin·cvc·카드번호·주민번호·계좌번호·OTP 등 + 값 패턴 카드번호 Luhn·주민번호 + 화면 지정 항목 → 메모리 Secret, 저장소에는 이름만), stores/flow.ts(secure 메모리 전용·sensitiveFields·missingSensitive(n)·cancel·제출 시 민감 값 즉시 삭제), 데모 pages/apply/step2·step3·index(카드 비밀번호 재입력·신청 취소·저장소 내용 표시), 테스트 tests/flow-persist.test.ts. 완료 확인·취소·로그아웃·TTL 만료 시 정리.
  • 목표: FE-09과 세트.
  • 완료 기준: 스텝 데이터 sessionStorage 저장/복원, 완료·취소 시 정리, 민감정보 저장 금지.
  • 연동: 해당없음
FE-11. 이중 제출 방지·로딩 락·멱등 키 (P1) — 구 #20
  • [x] 완료 — 브랜치 feat/fe-11-double-submit-idempotency · 설계 DOUBLE-SUBMIT.md · 버튼(작업) 락 apps/demo-web/app/utils/action-lock.ts·composables/useActionLock.ts(진행 중 같은 키는 핸들러 재실행 없이 같은 Promise, pending(key) 로 UiButton loading), 요청 락은 FE-02 lock, 멱등 키 http.ts 옵션 idempotencyKey(논리적 제출 한 건 = 키 하나, POST 재시도 허용)·useIdempotencyKey()(성공 시 reset), 목업 이체 서버 utils/transfer-mock.ts(/mock-api/transfer, 키별 재생·응답 끊김 흉내), 데모 pages/submit.vue, 테스트 tests/action-lock.test.ts. 백엔드는 Idempotency-Key 미처리(신규 API·필터 없음).
  • 목표: 이체·신청 등.
  • 완료 기준: 버튼/요청 단위 락, 진행 중 재요청 차단, 요청 헤더 멱등 키 옵션.
  • 연동: 해당없음
FE-12. 오프라인·재시도 UX (P2) — 구 #24
  • [x] 완료 — feat/fe-p2-batch · utils/network.ts(retryDecision — GET/멱등만 자동·POST 수동, backoffMs, withRetry)·composables/useNetworkStatus.ts, 레이아웃 오프라인 배너(net.offline), 데모 /feedback, 테스트 tests/fe-p2.test.ts.
  • 완료 기준: online/offline 감지 배너, 네트워크 오류 시 재시도 버튼, 재시도 정책(GET만 자동 등).
  • 연동: 해당없음

레이어 C. 하이브리드 브릿지 (목업 앱 먼저 → 네이티브)

FE-13. 웹앱 JS 브릿지 공통 (P0) — 구 #15
  • [x] 완료(1단계: 브라우저 목업 네이티브·쇼케이스) — 브랜치 feat/fe-13-bridge-completion · 코어 apps/demo-web/app/utils/bridge.ts(UTF-8 Base64·callbackId·타임아웃·웹 폴백·목업 호스트), plugins/bridge.ts(전역 window.__ONGO_BRIDGE_CALLBACK__·__ONGO_BRIDGE_PUSH__), composables/useBridge.ts, 시연 pages/bridge.vue, 테스트 tests/bridge.test.ts, 규약 docs/BRIDGE-PROTOCOL.md. 2026-10-29 브라우저 시연 확인(단말 정보·UTF-8 왕복·NATIVE_ERROR·TIMEOUT·보안 저장/조회·푸시+토스트·웹 폴백·BRIDGE_UNAVAILABLE·목업 토글). 2단계(실제 iOS/Android 연동)·실앱 action 허용 목록·WebView origin·로그 민감값 제외는 미정.
  • 목표: 네이티브 하이브리드 앱용 브릿지.
  • 완료 기준: JSON IN/OUT, 문자열 인코딩 깨짐 방지(UTF-8/Base64 등 규약 문서화). 동기 패턴: 스크립트는 Promise await, 앱은 반드시 결과를 전역 콜백 함수로 전달 → resolve. 비동기 패턴: 앱 이벤트 푸시 수신. callbackId·타임아웃·앱 미설치(웹) 폴백, 브라우저용 목업 네이티브.
  • 단계(원우 승인): ① 브라우저 목업 네이티브 앱(window 전역 콜백까지 동일 규약) 먼저 완성·쇼케이스 시연 → ② 네이티브는 같은 인터페이스(메시지 스키마)만 맞추면 연동 완료.
  • 연동: 목업 네이티브
FE-14. 보안키패드·생체·카메라/OCR·강제업데이트 (P2) — 구 #23
  • [x] 완료 — feat/fe-p2-batch · utils/native-features.ts(NATIVE_ACTIONS keypad.secure·auth.biometric·camera.ocr·app.version·document.open, compareVersion·updateLevel 강제/선택 업데이트), plugins/bridge.ts 가 목업 핸들러 등록(FE-13 브리지 위), 데모 /native.
  • 목표: FE-13 브릿지 위 기능 API.
  • 완료 기준: 각 기능 브릿지 인터페이스 정의 + 목업 응답, 앱버전 체크 후 강제 업데이트 안내.
  • 연동: 목업 네이티브
FE-15. 딥링크·앱스킴 진입 (P2) — 구 #28
  • [x] 완료 — feat/fe-p2-batch · utils/deeplink.ts(parseDeepLink 허용목록 — 스킴 ongo·호스트·/dl?path=, 그 외 차단, buildAppScheme), 데모 /native, 테스트 tests/fe-p2.test.ts.
  • 완료 기준: 딥링크 파라미터 파싱 → 라우팅(가드 FE-09/FE-06 적용), 앱스킴 호출 유틸.
  • 연동: 목업

레이어 D. 화면 공통 (UI·페이징·파일·SEO·접근성·반응/적응형·약관·WCMS)

FE-16. UI 공통 컴포넌트 (P2) — 구 #4
  • [x] 완료 — feat/fe-p2-batch · components/ui/Banner.vue·Carousel.vue(키보드·자동재생 정지·reduce-motion 존중)·FloatingButton.vue(aria-label), 데모 /ui FE-16 카드.
  • 목표: 인풋, 셀렉트, 그리드(테이블), 배너, 플로팅 버튼/배너, 스와이퍼, 탭, 아코디언, 체크/라디오 등.
  • 완료 기준: 각 컴포넌트 쇼케이스 페이지, v-model 지원, 비활성/에러 상태, 접근성 속성. 외부 UI 라이브러리 도입 시 사유 기록.
  • 연동: 목업 데이터
  • 선행 반영(브랜치 feat/fe-ui-nuxt-ui): UI 라이브러리 = Nuxt UI 4(사유: Nuxt 공식 UI·접근성(Reka UI) 기본 내장·Tailwind 토큰 테마, 원우 결정 2026-10-27). 기본 계약 UiButton·UiInput·UiSelect·UiCheckbox·UiFormField·UiCard·UiAlert·UiBadge·UiTable·UiModal·UiProvider·UiColorModeToggle·useUiToast·useUiColorMode(다크모드 켜기/끄기) + 쇼케이스 /ui. 이 항목은 남은 컴포넌트(배너·플로팅·스와이퍼·탭·아코디언·라디오 등)를 같은 계약 방식으로 추가한다.
FE-17. 페이징 공통 (P1) — 구 #5
  • [x] 완료 — 브랜치 feat/fe-17-paging · 코어 apps/demo-web/app/utils/paging.ts(createPager — pages/append 두 모드가 같은 상태 기계, BE page(0-base)·size·totalElements·totalPages ↔ 화면 1-base, 마지막·빈 목록·오류 retry·범위 밖 보정·늦은 응답 무시·메타 없는 응답 추정), composables/usePaging.ts(usePaging·httpPage(url, query)), 무한 스크롤 components/common/InfiniteScroll.vue(IntersectionObserver·짧은 목록 연속 로딩·로딩/마지막/빈/오류 표시), 데모 pages/paging.vue(실제 BE GET /api/demo/items + 빈 목록 목업·오류 흉내), 테스트 tests/paging.test.ts.
  • 목표: 백엔드 페이징 규격(page/size/total 등 삼카 데모 응답 규격)에 맞춤.
  • 완료 기준: 일반 페이지네이션 컴포넌트 + infinite scroll(IntersectionObserver) 모두 지원, 동일 composable 기반. 마지막 페이지/빈 목록/오류 처리.
  • 연동: 실제 BE 우선, 없으면 목업
FE-18. 파일 업로드/다운로드 공통 (P1) — 구 #8
  • [x] 완료 — 브랜치 feat/fe-18-file-transfer · 설계 FILE-TRANSFER.md · 코어 apps/demo-web/app/utils/file-transfer.ts(용량·확장자·MIME 검증, RFC 5987/6266 Content-Disposition 한글 파일명 파싱, XMLHttpRequest 진행률 추적, Blob 다운로드 트리거), 컴포저블 composables/useFileTransfer.ts, 목업 파일 서버 utils/file-mock.ts(/mock-api/files/**), 데모 pages/files.vue, 테스트 tests/file-transfer.test.ts(19건 통과). 백엔드 전용 업/다운로드 API 부재로 브라우저 목업 핸들러 연동.
  • 목표: multipart 업로드, blob 다운로드.
  • 완료 기준: 진행률, 용량/확장자 검증, 다중 파일, 다운로드 파일명(Content-Disposition, 한글) 처리, 실패 시 FE-02 공통 예외.
  • 연동: 실제 BE 있으면 연동, 없으면 목업
FE-19. PDF·문서 뷰어 (P2) — 구 #29
  • [x] 완료 — feat/fe-p2-batch · components/common/PdfViewer.vue(object 임베드 + 새 창·다운로드 폴백, 앱은 bridge document.open), 목업 public/mock/docs/product-sample.pdf, 데모 /docs-viewer.
  • 목표: 약관/상품설명서.
  • 완료 기준: PDF 인라인 보기(모바일 포함), 다운로드(FE-18) 연계, 앱 환경에서는 브릿지 위임 옵션.
  • 연동: 목업 파일
FE-20. SEO·AEO·화면 메타 공통 관리 (P2) — 구 #9
  • [x] 완료 — 브랜치 feat/fe-20-seo-meta · 순수 함수 apps/demo-web/app/utils/screen-meta.ts(findScreenMeta·resolveScreenId·buildHead·buildSitemap·buildRobots·JSON-LD faqJsonLd/breadcrumbJsonLd/safeJsonLd) · 레지스트리 server/utils/screen-meta-registry.ts(전 화면 SCR_DEMO_*·title·description·og·canonical·robots·sitemap·jsonLd·breadcrumb·faq·layout·access{requiresAuth,roles,permissions}) · 목업 메타 API /mock-cms/screen-meta.json + /sitemap.xml + /robots.txt(AI 크롤러 정책 주석, 비운영은 전체 차단) — ssr:false 정적 배포라 Nitro prerender 로 산출 · useMetaStore(FE-33 meta 스토어) · 플러그인 screen-meta.client.ts(useHead 반응형 적용, NUXT_PUBLIC_SEO_ALLOW_INDEX 거짓이면 noindex 강제) · useScreenMeta().set() 런타임 덮어쓰기 · FE-36 트래커 화면ID를 레지스트리로 대체 · auth 미들웨어가 레지스트리 access 를 폴백으로 사용(FE-06) · 데모 /seo, /faq(h2 질문+첫 문단 답 시맨틱/마이크로데이터) · tests/screen-meta.test.ts 11건(전 pages 등록 검사 포함). 미수행: SSR 이 아니므로 「SSR HTML 에 JSON-LD」 대신 클라이언트 head 주입으로 확인, Rich Results Test 는 비공개 환경이라 미첨부(공개 도메인 배포 후 확인). 실 메타 API 요청은 BACKEND-COLLABORATION #10.
  • 목표: 화면 메타(타이틀·description·og 등)를 별도 서버에서 조회한다고 가정.
  • 완료 기준: 목업 메타 API → useHead/useSeoMeta 적용. 메타에 화면별 접근 권한 정보 포함 가능 구조(FE-06과 연계). SSR 렌더 결과에 메타 반영 확인.
  • AEO(Answer Engine Optimization, 원우 추가): AI 답변/검색엔진이 인용하기 쉽게 — 목업 메타 API에 jsonLd(schema.org Organization·BreadcrumbList·FAQPage·FinancialProduct 등) 필드 포함 → useHead로 application/ld+json 주입. FAQ 목업 데이터로 질문/답변 시맨틱 마크업(h2/h3 질문 + 간결한 첫 문단 답) 페이지 1개 시연. canonical·robots·sitemap.xml·robots.txt(AI 크롤러 허용/차단 정책 주석) 생성. 완료 확인: SSR HTML에 JSON-LD 존재 + Rich Results Test/스키마 검증 결과 PR 첨부.
  • 연동: 목업
FE-21. 약관 동의 화면 공통 (P1) — 구 #13
  • [x] 완료 — 브랜치 feat/fe-27-28-21 · 로직 apps/demo-web/app/utils/terms.ts(순수 함수: initialState·setAgree·setAll·isAllAgreed·missingRequired·toPayload), 컴포저블 composables/useTerms.ts(메타 조회 → 본문은 펼칠 때 비동기 로딩·캐시), 화면 components/common/TermsAgreement.vue(전체/개별/하위 채널 동의, 필수 미동의 시 「동의하고 다음」 비활성, mustRead 항목은 본문 끝까지 스크롤해야 체크 가능, HTML 본문 = CommonSafeHtml(FE-29 정화), JSON 본문 = CommonSafeText 텍스트 바인딩). 목업 서버 server/routes/mock-cms/terms.get.ts(그룹별 메타)·mock-cms/terms/[id].get.ts(HTML·JSON 컨텐츠, 정화 시연용 스크립트 포함). 데모 pages/terms.vue(전송 페이로드 = 약관ID·버전·동의여부·시각·하위 채널). 테스트 tests/terms.test.ts.
  • 목표: 약관 기본정보(필수/선택, 유형=약관/설명서, 컨텐츠 URL) 조회 후 컨텐츠 서버에서 비동기 로딩.
  • 완료 기준: HTML/JSON 컨텐츠 모두 렌더(HTML은 FE-29 정화 후). 전체동의/개별동의, 필수 미동의 시 진행 불가, 본문 열람 여부 체크 옵션.
  • 연동: 목업(메타+컨텐츠)
FE-22. WCMS 컨텐츠 동적 로딩 (P2) — 구 #14
  • [x] 완료 — feat/fe-p2-batch · components/common/WcmsContent.vue(fetch → 스토리지 scope wcms TTL 캐시 → SafeHtml 정제 렌더, 스켈레톤·재시도), 목업 public/mock/wcms/*.html(XSS 벡터 포함), 데모 /wcms.
  • 목표: URL 기반으로 WCMS 자원을 비동기 로딩해 영역에 동적 렌더링(컨텐츠 서버 CORS 허용 가정).
  • 완료 기준: 컴포넌트 <WcmsContent url>, 로딩/실패/캐시 처리, 정화(FE-29) 적용.
  • 연동: 목업 정적 서버
FE-23. 웹접근성 준수 (P2) — 구 #10
  • [x] 완료 — feat/fe-p2-batch · 정적 게이트 tests/a11y-static.test.ts(img alt·target=_blank rel·아이콘 버튼 aria-label), 체크리스트 docs/ACCESSIBILITY-CHECKLIST.md, 데모 /a11y. axe 브라우저 실행(CI)은 보류. 정적 게이트 확장(object·iframe title, 새 화면 메뉴·메타 스모크, 개발툴 헤더 고정색 회귀). 보류 사유: axe 는 헤드리스 브라우저(playwright) 설치·Jenkins 이미지 변경이 필요해 별도 작업.
  • 목표: KWCAG/WCAG 2.1 AA 기준.
  • 완료 기준: 시맨틱 마크업, 대체텍스트, 레이블, 명도대비, 스크린리더 확인 항목 체크리스트 문서화. 자동 점검(axe 등) 결과 첨부.
  • 연동: 해당없음
FE-24. 접근성 보강 (P2) — 구 #30
  • [x] 완료 — feat/fe-p2-batch · utils/focus-trap.ts, 앱 설정 highContrast·reduceMotion(html hc·reduce-motion 클래스), 데모 /a11y.
  • 목표: 포커스 트랩·키보드·고대비.
  • 완료 기준: 다이얼로그 포커스 트랩/복귀, 키보드 탐색, 고대비 모드 토글 샘플.
  • 연동: 해당없음
FE-25. 반응형 샘플 (P2) — 구 #11
  • [x] 완료 — 1차(#162) + 2차: 브레이크포인트 공통 변수 assets/css/main.css(--breakpoint-sm/md/lg = Tailwind 접두어 정본, --bp-* px) ↔ utils/device.ts BREAKPOINTS(640/768/1024, tests/device.test.ts 가 두 값 일치 검사) · 3폭 샘플 pages/responsive.vue(1→2→3열 그리드, 2단 레이아웃, 375/820/1280 확인)
  • 1차 반영(2026-10-30):
  • demo-web 헤더: md(768px) 미만 햄버거 + 좌측 드로어 내비(aria-expanded/aria-controls/aria-current, 터치 영역 ≥44px, ESC·라우트 이동·배경 클릭 시 닫힘). 메뉴 데이터는 FE-35 메뉴 트리 전환 시 그대로 이관.
  • /guide 상세 전문 표(.mdsync-body table)·UiTable 은 가로 스크롤 래퍼(UiTable 최소 폭 560px) — 쇼케이스 표 깨짐 방지.
  • /guide 페이지 목차는 <details> — 모바일 기본 접힘, 문서 탭은 가로 스크롤.
  • 개발툴 guide.html·usage.html(≤900px): 표 display:block; overflow-x:auto, 목차 「목차 열기」 토글 버튼(링크 클릭 시 자동 닫힘). scripts/sync-docs.py CSS/토글에 반영(재실행 멱등).
  • 2차 반영: 브레이크포인트 변수화·3폭 샘플 /responsive·카드형 표(FE-26).
  • 목표: 모바일/태블릿/PC 하나의 마크업 + 브레이크포인트.
  • 완료 기준: 3개 폭에서 깨짐 없는 샘플 페이지, 브레이크포인트 공통 변수.
  • 연동: 목업
FE-26. 적응형 샘플 (P2) — 구 #12
  • [x] 완료 — 순수 함수 utils/device.ts(deviceFromWidth < md mobile / < lg tablet / desktop, deviceFromUserAgent iPadOS 터치 보정, resolveDevice 폭 우선·UA 폴백) · composable useDevice()(width·device·isMobile/isTablet/isDesktop, resize 반영) · UiTable layout='table'|'cards'|'auto'(auto = mobile 카드형 dl 목록, cardTitleKey, 셀 슬롯 공유) · 데모 /responsive(적응형 표 토글, mobile 하단 탭바 ↔ PC 링크 컴포넌트 분기) · 테스트 tests/device.test.ts.
  • 목표: 디바이스별 다른 레이아웃/컴포넌트.
  • 완료 기준: UA 또는 폭 기준 디바이스 판별 composable, 디바이스별 컴포넌트 분기 샘플.
  • 연동: 목업
FE-32. UI 레이아웃 유형별 분기 (P2) — 신규 #32
  • [ ] 완료
  • 목표: 헤더·푸터·메뉴(GNB/LNB/햄버거)·메인 컨텐츠 영역을 레이아웃 단위로 공통화하고, 화면 성격에 따라 레이아웃을 분기한다. 최소 유형: public(비로그인 일반), auth(로그인/인증 화면 — 헤더·메뉴 최소화, 푸터 간소), member(로그인 후 — 사용자 정보·로그아웃·권한별 메뉴), blank(팝업/웹뷰/스텝 신청 전체화면).
  • 완료 기준:
  • 라우트 meta(또는 FE-20 화면 메타)의 layout 값으로 레이아웃 선택, 미지정 시 기본값 규칙 문서화.
  • 같은 URL이라도 로그인 상태(FE-06)에 따라 헤더/메뉴가 달라지는 경우 처리(예: 메인 = 비로그인 헤더 ↔ 로그인 후 헤더), 로그아웃 감지 시 레이아웃 즉시 전환.
  • 영역별 슬롯(header/footer/menu/main) 구조로 화면이 일부 영역만 교체·숨김 가능(예: 신청 스텝은 메뉴 숨김).
  • 권한별 메뉴 노출은 목업 권한(FE-06)으로 제어, 하이브리드 앱 웹뷰(FE-13)에서는 네이티브 헤더 사용 시 웹 헤더 숨김 옵션.
  • 반응형/적응형(FE-25·26)과 충돌 없이 모바일 메뉴(드로어) 동작, 접근성(skip link·landmark) 준수(FE-23).
  • 쇼케이스 데모: 비로그인 → 로그인 화면 → 로그인 후 화면 전환 시 레이아웃 변화 시연.
  • 연동: 목업(메뉴·권한·화면 메타는 목업 데이터, 실제 메뉴 API 호출 위치만 주석 명기)

레이어 E. 금융 보안·데이터 유틸 (보완 항목)

  • 구현(demo-web): layouts/default.vue 가 useMenu().layoutType(= definePageMeta({ layoutType }) → 없으면 로그인 여부 member/public)로 분기. public/member = 헤더+사이드 메뉴(모바일 드로어)+breadcrumb, auth(/login) = 최소 헤더, blank(/apply/step*·complete) = 홈 버튼만. 로그아웃 즉시 public 전환(스토어 반응형). 앱 웹뷰(useBridge native-*)는 웹 헤더 숨김. 본문 바로가기(skip link)·main#main-content. 데모 /layout. 테스트 tests/menu.test.ts.
FE-35. 외부(WCMS) JSON 메뉴 정의 로딩·파싱·렌더링 (P2) — 신규 #35
  • 목표: 메뉴 구조를 코드에 하드코딩하지 않고, WCMS 등 외부 리소스 URL(CORS 허용)에서 JSON으로 정의된 메뉴를 비동기로 받아 파싱해 FE-32 레이아웃의 헤더/GNB/LNB/드로어/푸터 메뉴에 렌더링.
  • 범위:
  • 메뉴 JSON 스키마 정의(예: { version, menus: [{ id, label, url|route, target, icon, order, visible, roles[], layout[], badge, children[] }] }), 깊이 제한 없는 트리, 스키마 검증·알 수 없는 필드 무시.
  • 로더 useMenu(sourceUrl): 환경 프로파일(FE-01)별 URL, 캐시(FE-34, TTL)·실패 시 내장 폴백 메뉴, 로딩/에러 상태.
  • 파서: 정렬(order), 권한(roles — FE-06/08)·로그인 상태·레이아웃 유형(FE-32)별 필터, 외부/내부 링크 구분, label은 FE-29 정화.
  • 렌더러: 재귀 <MenuTree> 컴포넌트, 현재 라우트 활성 표시·breadcrumb 생성, 모바일 드로어/PC 드롭다운(FE-25/26), 키보드·ARIA(nav, aria-expanded, aria-current) 접근성(FE-23/24).
  • 목업: public/mock/menu/*.json에 임의 메뉴 구조(3뎁스 이상, 권한별·비로그인/로그인 차이 포함) 2~3종 제공, 데모 화면에서 소스 전환·권한 토글로 메뉴 변화 시연.
  • 완료 기준: 목업 URL만 바꿔도 메뉴가 다시 그려짐, 권한/로그인 상태 전환 시 메뉴 즉시 갱신, 잘못된 JSON에도 폴백 메뉴로 화면 유지.
  • 의존: FE-22(WCMS 로딩), FE-32(레이아웃), FE-06(권한), FE-29(정화), FE-34(캐시).
  • 구현(demo-web): utils/menu.ts(parseMenuDocument — 깊이 제한·invisible/잘못된 노드 건너뜀·label 디코드·javascript: 등 위험 URL 제거·order 정렬, filterMenu(auth guest/member/any·roles·layout), findTrail), composables/useMenu.ts(URL = NUXT_PUBLIC_MENU_URL, 기본 /mock/menu/default.json, FE-34 스토리지 scope menu public TTL NUXT_PUBLIC_MENU_TTL_SEC(300), 실패 시 내장 폴백 utils/menu-fallback.ts), components/common/MenuTree.vue(재귀, aria-expanded/aria-current, 외부 링크 rel=noopener). 목업 JSON public/mock/menu/{default,card,broken}.json, /layout 에서 소스 전환·캐시 재로딩 시연.
FE-36. 태깅/클릭코드(마케팅·분석 이벤트) 수집 공통 (P1) — 신규 #36
  • [x] 완료 — 브랜치 feat/fe-36-tagging · 코어 apps/demo-web/app/utils/tracker.ts(createTracker = pageView/click/conversion/custom/flush/setConsent, 공통 필드 type·code·screenId·sessionId·ts·profile·channel, 동의 pending=메모리 큐(상한 maxPending, 초과 시 오래된 것부터 폐기)→granted 시 flush / denied 시 폐기, sanitizePayload = PII 키(PII_KEY: 이름·카드·계좌·주민·연락처·이메일·주소·생년월일·비밀값) 제거 + 문자열 maskObject(FE-27), 배치 batchSize·타이머 flushIntervalMs, 재시도 maxRetries 후 폐기·예외 비전파), 어댑터 createMockAdapter/createWebAdapter(sendBeacon 우선 → fetch keepalive)/createBridgeAdapter(FE-13 analytics.track, 목업 네이티브 핸들러 추가). 동의 stores/consent.ts(Pinia, public 영속, applyTerms()로 약관 FE-21 결과 반영). 플러그인 plugins/tracker.client.ts(router.afterEach 진입 = route.meta.screenId → name → path, data-click-code 캡처 위임, v-track 디렉티브, visibilitychange 시 flush, 앱 WebView 면 bridge 자동). 화면 composables/useTracker.ts. 설정 NUXT_PUBLIC_TRACKER_ADAPTER·_ENDPOINT·_BATCH_SIZE·_FLUSH_MS. 데모 pages/tracking.vue(목업 수집기 패널·동의 토글·장애 시뮬레이션). 테스트 tests/tracker.test.ts. 남은 것: 화면ID 정본은 FE-20 화면 메타 도입 시 교체, 실 수집기(GA4·자체 로그서버 POST /api/collect) BE 미존재.
  • 목적: 화면진입·클릭·전환 이벤트를 화면마다 직접 구현하지 않고 공통 훅으로 수집해 분석/마케팅 수집기로 전송.
  • 범위:
  • 이벤트 훅: 화면진입(라우터 afterEach, 화면 메타 FE-20의 화면ID 사용 — FE-20 완료로 레지스트리 값 적용), 클릭(v-track="'CLK_XXX'" 디렉티브 또는 data-click-code 속성 위임), 전환(track.conversion(code, payload) — 신청완료·가입 등 명시 호출).
  • 공통 API useTracker(): pageView / click / conversion / custom, 공통 필드(화면ID·클릭코드·세션ID·타임스탬프·환경 프로파일 FE-01·채널 web/app).
  • 동의 후 전송: 마케팅/분석 동의(약관 FE-21·스토리지 FE-34) 전에는 전송 안 함(필요 시 메모리 큐에 보관 → 동의 시 flush, 거부 시 폐기). 동의 상태는 Pinia(FE-33).
  • 전송: 배치·navigator.sendBeacon 우선, 실패 시 재시도 상한, 수집 실패가 업무 흐름을 막지 않음. 하이브리드는 브릿지(FE-13)로 앱 SDK 위임 가능하게 어댑터 구조(web / bridge / mock).
  • PII 금지: 카드·계좌·주민·이름 등 payload 금지, 마스킹 유틸(FE-27)로 방어. 감사 로그 훅과 분리.
  • 목업 수집기 데모: 어댑터 mock이 이벤트를 화면 하단 패널/콘솔에 표시, 동의 토글로 전송 on/off 시연. 실 수집기(GA·자체 로그서버 등) 호출 위치는 주석으로 명기.
  • 완료 기준: 데모 페이지에서 진입·클릭·전환 이벤트가 동의 전 미전송/동의 후 전송되는 모습을 목업 수집기로 확인.
FE-27. 민감정보 마스킹·클립보드 제한 (P1) — 구 #18
  • [x] 완료 — 브랜치 feat/fe-27-28-21 · 유틸 apps/demo-web/app/utils/mask.ts(maskCard 앞6·뒤4, maskAccount 뒤4, maskRrn 생년월일+성별, maskPhone, maskEmail, maskName, 자유 텍스트 maskText, 객체 키 기반 maskObject(password·token·cvc 등은 ••••, 원본 불변·순환 안전), 로그 safeLog.info/warn/error), 디렉티브 v-sensitive(구현 utils/sensitive.ts, 등록 plugins/sensitive.ts — 기본 = 선택·복사·잘라내기·드래그·우클릭 차단 + sensitive-blocked 이벤트, 'mask' = 복사 허용·클립보드엔 마스킹본), 표시 components/common/Masked.vue(kind, allowReveal 토글). 데모 pages/mask.vue. 테스트 tests/mask.test.ts(jsdom). 한계: 화면 캡처·개발자도구는 막을 수 없으므로 서버 마스킹이 1순위.
  • 목표: 카드·계좌·주민번호 등.
  • 완료 기준: 표시용 마스킹 유틸/디렉티브, 민감 영역 복사 제한 옵션, 로그 출력 시 마스킹.
  • 연동: 해당없음
FE-28. 금액·일자·계좌 등 포맷·검증 (P1) — 구 #19
  • [x] 완료 — 브랜치 feat/fe-27-28-21 · 유틸 apps/demo-web/app/utils/format.ts(규약 format* 표시 / parse* 전송값 / is* 검증 / validate* 메시지: 금액 콤마·소수·음수·toKoreanAmount 한글금액(경 단위까지), 일자 formatDate·isValidDate(실제 달력·윤년)·validateDateRange(최대 일수), 카드 formatCard(AMEX 4-6-5)·Luhn, 계좌 은행별 자릿수 패턴 BANK_ACCOUNT_PATTERNS(목업 — 실제는 BE 은행코드 API), 사업자 국세청 체크섬, 휴대폰·02 지역번호), 입력 연동 테이블 FORMATS, 입력 컴포넌트 components/common/FormattedInput.vue(kind·bankCode·required·showKorean, v-model = 전송값, 화면 = 포맷 문자열, 포커스 아웃 시 검증, check() expose — UiInput 계약만 사용하므로 FE-16 입력 컴포넌트로 교체 가능). 데모 pages/format.vue. 테스트 tests/format.test.ts.
  • 완료 기준: 금액 콤마/한글금액, 일자, 계좌·카드·사업자·휴대폰 포맷터와 검증기, 입력 컴포넌트(FE-16)와 연동.
  • 연동: 해당없음
FE-29. XSS 디코딩 안전 표시 (P0) — 구 #31
  • [x] 완료 — 브랜치 feat/fe-29-xss-safe-display · 유틸 apps/demo-web/app/utils/xss.ts(decodeXssEntities 기본 2회=이중 인코딩까지, sanitizeHtmlAllowlist = 브라우저 파서(DOMParser) 트리를 허용 태그·속성만 재직렬화, DOMParser 없으면 전부 이스케이프), 표시 components/common/SafeText.vue(텍스트 노드)·SafeHtml.vue(v-html 유일 허용), composables/useSafeDisplay.ts, 데모 pages/xss.vue(서버 POST /api/demo/secure/notes 연동). 규칙: 업무 화면 v-html·innerHTML·insertAdjacentHTML·document.write 금지(tests/ui-boundary.test.ts). 테스트 tests/xss.test.ts(jsdom·parse5 기준 공격 벡터 28종 재파싱·재정화 동일성, 이중 인코딩). 2026-10-29 Chrome 시연: 공격 프리셋 스크립트 실행 0회.
  • 목표: 서버에서 XSS 인코딩된 값(&lt; 등)을 디코딩해 읽기용으로만 표시.
  • 완료 기준: 디코딩 유틸 + 표시 시 스크립트 실행 불가 보장(텍스트 바인딩 또는 화이트리스트 정화, v-html 직접 사용 금지 규칙). 이중 인코딩 케이스 테스트.
  • 연동: 해당없음
FE-30. i18n 훅 (P2) — 구 #26
  • [x] 완료 — feat/fe-p2-batch · utils/i18n.ts(ko/en 사전·normalizeLocale·createTranslator) + composables/useI18n.ts(스토리지 locale, <html lang> 갱신, 없는 키는 $messageCatalog.lookup 폴백), 데모 /i18n.
  • 목표: 한/영.
  • 완료 기준: 메시지 키 기반 훅만 우선(전 화면 번역 불필요), FE-03 카탈로그와 연계.
  • 연동: 해당없음
FE-31. 감사 로그 훅 (P2) — 구 #27
  • [x] 완료 — feat/fe-p2-batch · utils/audit.ts(createAuditLog, FE-36 sanitizePayload 로 PII 제거) + plugins/audit.client.ts(화면 진입 자동·v-audit 디렉티브·$audit), 운영 전송 위치 POST /api/audit/events 명기, 데모 /audit.
  • 목표: 화면 진입·버튼 이벤트.
  • 완료 기준: 공통 이벤트 훅 + 전송 위치 명기(목업), PII 제외 규칙.
  • 연동: 목업

레이어 B-보강. 상태·스토리지 (요청 파이프라인의 저장 계층)

FE-33. 상태관리 공통 — Pinia / Nuxt state (P0) — 신규 #33
  • [x] 완료 — 브랜치 feat/fe-common-FE-33-state · @pinia/nuxt 도입(Nuxt 4 업그레이드로 1.x), 공통 플러그인 apps/demo-web/app/utils/store-persist.ts(persist·resetOnLogout 옵션, setup 스토어 $reset, resetStores)·plugins/pinia-store.ts, 민감값 래퍼 utils/secret.ts(Secret), 스토어 stores/auth.ts·flow.ts·app.ts, composables/useStoreReset.ts(resetAllStores), 데모 pages/state.vue, 테스트 tests/store-persist.test.ts. 템플릿은 apps/demo-web/README.md「상태관리」.
  • 구현 해석: 영속화 옵션은 persist: { sensitivity, ttl, storage?, key?, pick? } — 위 storage 직접 지정 대신 FE-34 민감도로 저장소를 고른다(storage 는 선택). meta 스토어는 FE-20 에서 추가(완료 — stores/meta.ts). devtools 마스킹은 Secret 래퍼(직렬화·devtools 에 ••••, 원문은 reveal())로 한다.
  • 목표: 화면·도메인 간 공유 상태를 일관된 패턴으로 관리(데모용 공통 꼭지, 별도 배포 라이브러리 아님).
  • 범위: Pinia setup store 규약(useXxxStore, state/getters/actions 네이밍), SSR 안전(Nuxt useState/Pinia 하이드레이션, 요청 간 상태 누수 금지), 공통 스토어 예: auth(사용자·권한·토큰 메타 — FE-06/08), app(환경 프로파일·로딩·다이얼로그 큐 — FE-01/04/05), flow(다단계 신청 스텝 — FE-09/10), meta(화면 메타 — FE-20).
  • 영속화: 스토어 단위로 persist: { storage: 'memory'|'session'|'local'|'cookie', ttl, key } 선언 → 실제 저장은 FE-34 API 경유(직접 localStorage 호출 금지).
  • 완료 기준: 스토어 템플릿 1개 + 위 공통 스토어 최소 auth/flow 구현, 로그아웃 이벤트(FE-06) 시 resetAll()로 민감 스토어 일괄 초기화, 새로고침 시 정책대로 복원되는 데모, devtools에서 민감값 마스킹.
  • 연동: 해당없음 (FE-02 파이프라인 훅에서 스토어 갱신)
FE-34. 용도·성격·보관기간별 스토리지 API (P0) — 신규 #34
  • [x] 완료 — 브랜치 feat/fe-common-FE-34-storage-api · apps/demo-web/app/utils/storage.ts(정책 테이블 STORAGE_POLICY·createStorage), composables/useAppStorage.ts(useAppStorage·broadcastStorageEvent·onStorageEvent), plugins/storage.client.ts·plugins/auth-sync.client.ts, 데모 pages/storage.vue, 테스트 tests/storage.test.ts(npm test). 액세스 토큰을 localStorage → secret(memory)로 이전.
  • 구현 해석: 정책 위반 저장 요청은 dev = 경고 후 해당 민감도의 기본 저장소로 저장(위반 저장소에는 절대 안 씀), prod = StoragePolicyError 로 차단. 민감도 미지정 기본값 = internal. 저장소 지정은 set(.., { storage }) 로 선택.
  • 목표: 데이터 성격(민감도)과 보관기간(TTL)에 따라 적합한 저장소를 자동 선택하는 단일 API.
  • 저장소 매트릭스:
저장소 수명 탭 공유 용도 예 금지
memory 페이지/앱 수명 X 액세스 토큰, 보안키패드 결과, 일회성 값 -
sessionStorage 탭 종료까지 X 다단계 신청 임시값(FE-10), 화면 복귀 상태 개인식별/금융 원문
localStorage 영구(+TTL 래퍼) O UI 설정, 최근 검색, 약관 버전 캐시 토큰·PII 일체
cookie 만료일/세션 서버 전송 서버가 발급한 세션(HttpOnly/Secure/SameSite — FE는 읽기 X), 비민감 플래그 JS로 민감값 쓰기
native secure (추후) 앱 키체인/키스토어 - 자동로그인 토큰, 생체 키 - (FE-13 브릿지 경유)
  • API 안: storage.set(key, value, { sensitivity: 'public'|'internal'|'secret', ttl, scope }) → 정책 테이블로 저장소 선택(secret은 memory 또는 native만, 허용 안 되는 조합은 dev 경고·prod 차단). get(만료 시 자동 삭제·undefined), remove, clear({ scope }), 키 prefix(앱·환경 프로파일별, FE-01), JSON 직렬화+버전 필드(스키마 변경 시 폐기), 저장 실패(용량·사파리 프라이빗) fallback=memory, SSR에서는 no-op/cookie만.
  • 완료 기준: 정책 테이블 + 래퍼 구현, 탭 간 동기화(storage 이벤트로 로그아웃 전파 — FE-06/08), 로그아웃 시 internal/secret 일괄 삭제, 데모 화면에서 저장소별 저장/만료/삭제 시연, 민감값 localStorage 저장 시도 차단 시연.
  • 연동: 해당없음 (native secure는 FE-13 목업 브릿지 인터페이스만 정의)

5. 동료 에이전트 작업 방법

  1. 새 항목 추가 시 ID는 마지막 번호 다음(현재 다음 = FE-36)을 쓰고 §3 표와 §4 상세를 함께 갱신한다.
  2. master 최신화 후 이 파일에서 미체크 항목 하나(FE-ID)를 고른다(P0 → P1 → P2 순, 의존은 §3 표 의존 열).
  3. 브랜치 feat/fe-common-FE-<NN>-<요약> 생성. 항목 1개 = 작은 PR 1개 원칙.
  4. 구현은 위 §0 제약대로 데모 앱 내부 공통 폴더에. 쇼케이스/데모 페이지로 시연 가능하게 만든다.
  5. 같은 PR에서 이 파일의 해당 항목 [ ]→[x](요약 표 + 상세 둘 다) 체크, 상세에 PR 번호(그리고 ID 유지)·주요 파일 경로를 한 줄 기록.
  6. 범위가 커지면 항목을 하위 단계로 쪼개 이 파일에 추가하고 부분 체크.
  7. 완료 기준이 모호하면 임의 확정하지 말고 PR 설명에 질문을 남긴다.
백엔드 협업 요청 및 연동 현황 (Backend Collaboration & Integration) docs/BACKEND-COLLABORATION.md

기준일: 2026-10-30
관리: FE 담당 (온고소프트 프론트엔드 팀)
목적: FE 공통 기능(FE-01~FE-36) 구축 중 도출된 백엔드 연동·보완 요구사항 및 향후 협업 항목을 기록·추적한다. 백엔드 반영 상태에 따라 지속적으로 업데이트한다.


1. 전체 현황 요약 (Summary Matrix)

# 영역 요청 항목 우선순위 상태 관련 FE 현재 FE 대응 방식
1 인증 토큰 갱신 API (POST /api/auth/refresh) 높음 BE 반영 FE-08 브라우저 내 목업 토큰 갱신 처리
2 인증 중복 로그인·강제 로그아웃 상세 에러 코드 분리 높음 일부 반영: 중복 로그인 401 auth.duplicate-login 연결 완료(e9e1745), 강제 로그아웃·세션 회수 발생 연결은 후속 FE-08 목업 코드로만 시연
3 트랜잭션 멱등 키(Idempotency-Key) 헤더 저장 및 응답 재생 높음 BE 반영 FE-11 FE 버튼·요청 락만 적용 (서버 이중처리 가능)
4 세션 로그인 응답 내 세션 만료 시각(expiresInSec 등) 반환 보통 요청 대기 FE-07 FE 30분 기본값 설정 (BE 설정과 수동 일치)
5 메시지 운영용 메시지 조회 공통 API 및 일괄 조회 지원 보통 요청 대기 FE-03 데모 컨트롤러 단건 조회 임시 사용
6 메시지 메시지·코드 메타 적재 버전(version / ETag) 제공 보통 요청 대기 FE-03 FE에서 300초 TTL로 주기적 재조회
7 데모 BE 데모 앱 MetaLoadService 빈 등록 및 캐시 리로드 지원 낮음 요청 대기 FE-03 demo.meta.not-configured 응답으로 시연 불가
8 데모 BE 메시지 조회 API 다중 파라미터 치환 지원 낮음 BE 반영: 데모 ?args= + 실제 예외/메시지 표 경로 {0}{1}… FE-03 FE 클라이언트에서 {0}, {1} 포맷팅 직접 수행
9 데모 BE 데모 목록 API(GET /api/demo/items) 조건 필터 적용 낮음 요청 대기 FE-17 빈 목록 케이스는 목업 데이터로 분기 시연

확인 환경: 개발계 삼카 데모 https://devfw21-sc.ongosoft.com (2026-10-30 기준 실호출 검증)


2. 상세 요구사항 (Detailed Requirements)

2.1 인증 및 세션 (Authentication & Session)

현재 인증 오류 코드는 auth.unauthenticated(401), auth.forbidden(403), auth.bad-credentials(401), auth.session-limit(409) 4개만 존재합니다 (AuthExceptions, AuthDemoController).

1. 토큰 갱신 API (POST /api/auth/refresh) — 우선순위: 높음
  • 요청 규격:
  • 엔드포인트: POST /api/auth/refresh
  • 요청: 기존 만료 토큰(Authorization 헤더 또는 Refresh Token 쿠키/바디)
  • 응답: { "bearer": "<신규 토큰>", "expiresInSec": 1800 }
  • 에러 코드: 만료되었으나 갱신 가능한 경우 401 auth.token-expired로 명확히 구분
  • 필요 사유: 현재 토큰 갱신 API가 없어 토큰 만료 시 무조건 로그인 화면으로 튕기며 재로그인이 필요합니다. 프론트엔드는 동시 발생 401 요청을 큐잉하여 1회만 토큰을 갱신하고 원래 요청들을 자동 재시도하는 로직이 구현되어 있습니다 (docs/AUTH-TOKEN.md).
2. 중복 로그인 및 강제 로그아웃 상세 코드 — 우선순위: 높음
  • 요청 규격:
  • 동시 세션 초과(max-per-user: 2, EXPIRE_OLDEST 등)로 세션이 만료된 경우: auth.duplicate-login (401 또는 409)
  • 관리자에 의한 세션 종료: auth.forced-logout
  • 보안 정책/권한 변경에 의한 세션 회수: auth.session-revoked
  • 반영 현황, e9e1745): EXPIRE_OLDEST로 밀려난 세션은 SessionStoreService.wasDisplaced 기록 → AuthenticationFilter → AuthorizationInterceptor 경로로 401 auth.duplicate-login 반환. 로그아웃·모르는 토큰은 auth.unauthenticated. auth.forced-logout·auth.session-revoked 발생 연결은 후속.
  • 필요 사유(당초): 기존에는 타 기기 로그인으로 밀려난 세션도 단순 auth.unauthenticated로 내려와, 사용자에게 "다른 기기에서 로그인되어 로그아웃되었습니다"라는 명확한 안내를 노출할 수 없습니다.
4. 로그인 응답 내 세션 만료 시각 반환 — 우선순위: 보통
  • 요청 규격:
  • POST /api/auth/login 응답 페이로드에 expiresInSec(남은 초) 또는 expiresAt(만료 절대시각 ISO-8601) 필드 추가
  • (권장) 인증된 모든 API 응답 헤더에 세션 잔여 시간(X-Session-Remaining: 1800) 전달
  • 필요 사유: 백엔드 세션은 슬라이딩 만료(ongo.framework.session.timeout: 30m)로 동작하나 응답에 만료 정보가 없어 FE 유휴 타이머가 동일 값을 하드코딩 형태로 유지해야 합니다. 양측 설정 불일치 시 세션 연장 팝업 시점과 실제 서버 만료 시점이 어긋납니다 (docs/SESSION-IDLE.md).

2.2 트랜잭션 및 멱등성 (Transactions & Idempotency)
3. 멱등 키(Idempotency-Key) 서버 저장 및 재생 — 우선순위: 높음
  • 요청 규격:
  • 대상: 결제, 이체, 신청 등 상태를 변경하는 주요 POST 요청
  • 동작:
    1. 클라이언트가 Idempotency-Key: <UUID> 헤더 전달
    2. 서버는 (사용자ID, 요청경로, Idempotency-Key)를 키로 24시간 저장
    3. 동일 키로 동일 본문 재요청 시: 새로운 비즈니스 로직을 실행하지 않고 기존 처리 성공 응답을 그대로 반환 (헤더에 Idempotent-Replayed: true 권장)
    4. 처리 중(In-Progress)인 상태에서 동일 키 재요청 시: 409 Conflict 반환
    5. 동일 키인데 요청 본문(Payload)이 다른 경우: 422 Unprocessable Entity 반환
  • 필요 사유: 모바일 네트워크 불안정으로 서버는 처리를 완료했으나 클라이언트가 응답을 받지 못한 경우(Timeout), 사용자의 재전송으로 인한 이중 출금/이중 신청 위험이 존재합니다. 현재 FE에는 버튼 락과 요청 락만 적용되어 있어 서버 레벨의 최종 방어가 필요합니다 (docs/DOUBLE-SUBMIT.md).

2.3 메시지 및 메타데이터 (Messages & Metadata)

오류 및 안내 메시지는 서버 MessageSourceService를 원천으로 하고, 누락 시 조회 API → FE 로컬 카탈로그 순으로 처리합니다 (docs/MESSAGE-CATALOG.md).

5. 운영용 메시지 조회 공통 API 및 일괄 조회 — 우선순위: 보통
  • 현재 상황: 데모 컨트롤러(GET /api/demo/messages/{code})만 존재하며 단건만 지원됨. 미존재 코드 조회 시 text에 코드명을 그대로 반환함.
  • 요청 규격:
  • 공통 엔드포인트: GET /api/common/messages/{code} 및 GET /api/common/messages?codes=a,b,c
  • 응답 규격: { "version": "20261030-01", "entries": [{ "code": "ERR001", "message": "..." }] }
  • 미존재 코드는 404 Not Found 또는 { "found": false }로 식별 가능하게 반환
  • 필요 사유: 데모 컨트롤러는 운영 배포 대상이 아니며, 화면별로 다수 에러코드를 단건 호출하면 네트워크 부하가 증가합니다.
6. 메시지·코드 메타데이터 버전 관리 — 우선순위: 보통
  • 요청 규격: 메타데이터 적재 시각/버전을 응답 내 version 필드 또는 HTTP ETag 헤더로 반환
  • 필요 사유: 현재 백엔드 캐시 리로드(MetaLoadRunner)는 구현되어 있으나 버전 정보가 없어 FE가 300초 주기 TTL로만 캐시를 무효화하고 있습니다. ETag/버전이 지원되면 불필요한 네트워크 트래픽을 방지할 수 있습니다.
10. 화면 메타(SEO·AEO·권한) 조회 API — 우선순위: 낮음 (FE-20)
  • 현재 상황: FE 는 정적 목업 /mock-cms/screen-meta.json(레지스트리 apps/demo-web/server/utils/screen-meta-registry.ts)으로 동작함.
  • 요청 규격:
  • GET /api/meta/screens → { "version": "...", "site": { siteName, titleTemplate, defaultDescription, locale, organization }, "screens": [{ screenId, path, title, description, keywords, og, canonical, robots, sitemap, priority, changefreq, jsonLd, breadcrumb, faq, layout, access: { requiresAuth, roles, permissions } }] }
  • ETag/version 지원(#6 과 동일 방식), 비인증 조회 가능(권한 정보는 화면 노출 제어용이며 서버 인가를 대체하지 않음)
  • 필요 사유: 화면ID(태깅 FE-36)·메타·접근권한을 화면 코드 수정 없이 운영에서 관리하기 위함.

2.4 데모 백엔드 보완 사항 (Demo Backend Improvements)
# 항목 현상 요청 사항
7 MetaLoadService 빈 등록 데모 앱에 빈이 없어 POST /api/demo/meta/reload 호출 시 demo.meta.not-configured 발생 데모용 기본 빈(yml 기반)을 등록하여 캐시 리로드 동작 시연 가능하도록 보완
8 메시지 포맷 인자 확장 현재 name 1개만 받아 치환하며 미전달 시 {0} 노출 args 배열 수신 지원 또는 템플릿 원문 전달 명시 — 반영: GET /api/demo/messages/{code}?args=a&args=b → {0}=a {1}=b (기존 name 은 {0} 하위호환)
9 목록 API 조건 필터 GET /api/demo/items가 category 파라미터를 무시하여 항상 전체 23건 반환 필터 로직을 활성화하여 빈 검색 결과, 조건별 페이징을 실 BE로 테스트할 수 있도록 지원

3. 유관 부서 및 인프라 확인 사항 (Cross-Functional / Infra Notes)

백엔드 코드 변경 외 프론트 연동 과정에서 발견된 인프라·DevOps·앱팀 확인 사항입니다.

영역 담당 내용 및 확인 요청
배포/CI DevOps (제니) RELEASE 빌드(#862, #868, #870 등)에서 간헐적으로 WSL 세션 시작 실패(rc -1)로 재시도 발생. Wsl/Service 상태 점검 필요
프론트 프로파일 DevOps (demo-web) 개발계 배포 시 컨테이너 빌드가 기본값(local-local)으로 패키징되어 스토리지 키가 ongo:local-local:로 생성됨. Dockerfile 빌드 옵션에 --dotenv .env.dev 명시 필요
WAF 차단 정책 인프라 (NAS NPM) GET 쿼리스트링에 <script> 등 스크립트 태그 포함 시 WAF(Block Common Exploits)에서 즉시 403 Forbidden 반환. 정상 차단 정책인지 확인
웹앱 브릿지 앱개발팀 (FE-13) 하이브리드 브릿지 action 허용 화이트리스트, 신뢰할 수 있는 WebView Origin 목록, 로그 시 민감값 마스킹 범위 규정 합의 필요 (docs/BRIDGE-PROTOCOL.md)

4. 백엔드 제공 시 프론트엔드 연동 방법 (FE Integration Guide)

백엔드에서 해당 기능이 제공되면 프론트엔드는 다음 설정(apps/demo-web/.env 또는 nuxt.config.ts)만 변경하여 즉시 연동됩니다.

# 1. 토큰 갱신 경로
NUXT_PUBLIC_AUTH_REFRESH_PATH=/api/auth/refresh

# 2. 강제 로그아웃/중복 로그인 에러 코드 매핑
NUXT_PUBLIC_AUTH_FORCED_LOGOUT_CODES=auth.forced-logout,auth.duplicate-login,auth.session-revoked

# 3. 멱등 키 (FE는 이미 요청 헤더에 'Idempotency-Key'를 전송 중)
# 추가 FE 코드 변경 불필요 (BE 수신/재생 로직만 활성화되면 즉시 동작)

# 4. 공통 메시지 카탈로그 조회 경로
NUXT_PUBLIC_MESSAGE_CATALOG_REMOTE_PATH=/api/common/messages

5. 변경 및 협업 이력 (Change Log & History)

새로운 백엔드 연동 이슈나 회의 결과가 있을 때마다 아래 테이블에 추가하여 히스토리를 관리합니다.

일자 구분 내용 작성/보고
2026-10-30 FE-20 화면 메타 조회 API(#10) 요청 추가 — FE 는 목업 레지스트리로 선구현 FE 담당
2026-10-30 최초 작성 FE 공통(FE-03, 07, 08, 10, 11, 17) 개발 완료에 따른 백엔드 미흡/요청 사항 9건 정리 및 Slack #devops 공유 준비 FE 담당
FE-13 브릿지 연동 규약 docs/BRIDGE-PROTOCOL.md

현재 구현은 브라우저 목업 기준이며 실제 네이티브 연동은 미검증이다.

전송

업무 화면은 useBridge().invoke(action, params, { timeout, fallback })을 await 한다. 기본 전송 문자열은 JSON을 UTF-8 바이트로 인코딩한 Base64이며, encoding: 'json'도 지원한다. 네이티브 전달 대상은 iOS window.webkit.messageHandlers.ongoBridge.postMessage, Android window.OngoNative.postMessage 순으로 판별한다. 네이티브가 없으면 브라우저 목업을 사용한다. 목업을 끄면 fallback을 실행하거나 BRIDGE_UNAVAILABLE로 실패한다.

요청: { v: 1, callbackId, action, params, encoding, timestamp }. 응답: { v: 1, callbackId, ok, code, message?, data?, timestamp }. 앱은 응답을 동일한 UTF-8 Base64로 인코딩하여 window.__ONGO_BRIDGE_CALLBACK__(payload)을 호출한다. 요청별 callbackId를 그대로 반환해야 하며, 동시 요청의 응답 순서는 자유다. 기본 제한 시간은 5000ms이며 시간 초과 후 도착한 응답은 요청을 다시 완료하지 않는다. 전송 예외는 BRIDGE_SEND_FAILED, 앱의 실패 응답은 해당 code를 가진 NATIVE_ERROR로 전달된다.

앱 푸시

앱은 { v: 1, event, data?, timestamp }를 인코딩하여 window.__ONGO_BRIDGE_PUSH__(payload)을 호출한다. 화면은 bridge.onPush(event, handler)가 반환한 함수를 종료 시 호출해 구독을 해제한다. timestamp는 epoch 밀리초다. Base64는 문자열 운반 규약이며 암호화 기능이 없다.

목업 확인

apps/demo-web에서 npm.cmd run dev:local-local 실행 후 /bridge를 연다. 디바이스 정보, 한글·이모지 echo, 오류·타임아웃, 푸시, 목업 비활성화 후 웹 폴백을 시연한다. 목업 보안 저장소는 메모리 Map으로 실제 보안 저장 구현이 아니다. 실앱 action 허용 목록·WebView origin 제한·민감정보 로그 제외 정책은 합의 후 적용한다.

코드생성 개발툴 사용 가이드 (`/framework-devtools/`) docs/DEVTOOLS-GUIDE.md

대상: 프레임워크를 쓰는 업무 개발자. 화면을 위→아래 순서로 따라가며 단계별·필드별로 설명하고, 같은 기능의 REST API 를 §12 에 정리한다. 설정 키는 ongo.framework.devtools.*(→ CUSTOMIZATION.md §14), 확장 코드는 CUSTOMIZATION-EXAMPLES.md §D. 기준 코드: 화면 ongo-framework-devtools/src/main/resources/ongo/devtools/index.html, API DevtoolsController·DynamicTelegramController(devtools), BatchDevtoolsController(batch). 예시 값(카드번호·주문번호 등)은 모두 임의 placeholder 다.

목차 0. 접속·켜는 조건 · 1. 전체 흐름 · 2. 공통 I/O 정의서 카드(유형·옵션·생성) · 3. 샘플 JSON → 정의서 · 4. 기존 VO·엑셀·유형별 본문 파싱 · 5. JSON 정의서 ↔ 펼친 표(양방향) · 6. 암호화/마스킹 · 7. 생성 결과 편집·저장 · 8. 업무단위 C/S/R + 메소드 폼 · 9. 타입 색인 · 10. 배치 스켈레톤 · 11. 전문 테스트(실송신/시뮬레이션) · 12. REST API · 13. 모바일 UX · 14. 트러블슈팅


0. 접속·켜는 조건

환경 URL 비고
로컬(데모) ./gradlew :ongo-framework-sc-demo:bootRun → http://localhost:8080/framework-devtools/ 삼카 확장 포함(데모가 samsung-card 의존)
로컬(일반 데모) 삼카 비의존 — 코어 유형만
로컬(custom 프로필) --args='--spring.profiles.active=custom' sample-csv 유형·sample-layout 데코레이터 추가
개발계(일반) https://devfw21.ongosoft.com/framework-devtools/ 쓰기 금지 → zip 다운로드 사용
개발계(삼카) https://devfw21-sc.ongosoft.com/framework-devtools/ 삼카 유형·표준전문 코덱 포함
배치 실행 화면 /framework-devtools/batch ongo-framework-batch 의존 앱에서만

켜는 조건(모두 yml):

ongo:
  framework:
    devtools:
      enabled: true                 # 기본 true
      profiles: [local, dev]        # 이 중 하나가 활성일 때만 화면·API 노출 (prod 에는 아무 것도 없음)
      codegen:
        profiles: [local]           # "소스에 쓰기"가 의미 있는 프로필. dev 서버는 전문 테스트·미리보기·zip 위주
        output-dir: src/main/java
        write-enabled: true         # false 면 쓰기 요청 403 devtools.codegen.write-disabled
  • 화면은 정적 리소스가 아니라 컨트롤러(GET /framework-devtools)가 내준다. 프로필이 안 맞으면 404 이다(보안상 정상).
  • 공통 I/O 정의서 카드 상단(입력 유형 옆) 정보줄에 데코레이터: … · 출력: … · 쓰기 허용/금지 가 표시된다(GET /api 결과). 쓰기 버튼을 누르기 전에 먼저 확인한다.

1. 전체 흐름

화면은 hash 탭 4개: #vo VO/DTO 생성 · #csr 업무단위 Controller·Service·Repository · #batch 배치 잡 스켈레톤 · #tg 전문 테스트. 공통 I/O 정의서 카드는 #vo에서 편집하고(숨겨도 내용은 유지) #tg 전문 테스트가 그 정의서를 그대로 쓴다. 생성 미리보기(#files)와 하단 로그(pre#out)는 탭마다 따로 둔다. 미리보기·로그는 그 작업을 시작한 탭에만 붙고, 다른 탭으로 갔다가 돌아오면 그 탭에서 보던 파일(고친 경로·본문 포함)과 로그만 다시 보인다. 전문 테스트 탭에는 VO/DTO 탭에서 만든 소스가 남지 않는다. [이 탭 로그 지우기]는 보고 있는 탭의 로그만 비우고 미리보기는 그대로다.

[입력]  샘플 JSON │ 기존 VO(FQCN) │ 정의서 본문(유형별) │ 엑셀(.xlsx)
          └──────────────► JSON 정의서(공통 I/O) ◄──► 펼친 항목 표 (양방향 동기화)
                                     │  암호화/마스킹 체크·정책명, 스웨거 on/off
                    ┌────────────────┼──────────────────────┐
                    ▼                ▼                      ▼
             [미리보기] VO 생성   [전문 송수신] 동적 VO     (업무단위 C/S/R / 배치 는 별도 입력)
                    ▼
        파일별 경로·내용 편집 → [수정본 소스에 쓰기] / [zip 다운로드]

원칙: 생성·전문 테스트는 항상 "공통 I/O 정의서" 카드 안의 "JSON 정의서" 칸 본문 + 같은 카드 상단의 입력 유형 으로 동작한다. 표는 그 본문을 편하게 고치는 편집기일 뿐이다.

화면 배치 이후): 예전 최상단 툴바(유형·덮어쓰기·스웨거·미리보기/소스에 쓰기)는 없어졌고, 모두 강조 테두리(.speccard)의 "공통 I/O 정의서 — 입력 유형 선택 → 불러오기·펼침·편집 → 생성" 카드 하나로 합쳐졌다.

┌ 공통 I/O 정의서 카드 ───────────────────────────────┐
│ 입력 유형 [select]  · 정보줄(데코레이터/출력/쓰기 허용) │
│ ① 샘플 JSON 칸(유형=샘플 JSON 입력일 때)              │
│ 기존 VO · 엑셀 · JSON 정의서 위 [본문 파싱]           │
│ JSON 정의서 칸 ↔ 펼친 항목 표                         │
│ 정의서로 코드 생성: □덮어쓰기 □스웨거 [미리보기][소스에 쓰기] │
└──────────────────────────────────────────────────────┘
업무단위 C/S/R 카드 · 배치 카드 · 전문 테스트 카드(정의서·유형 = 위 카드)

2. 공통 I/O 정의서 카드 — 유형·옵션·생성

요소 설명 주의
입력 유형 드롭다운(카드 상단) 정의서 파서(SpecParseService.type()) 선택. 맨 위 샘플 JSON 입력 은 가상 유형(샘플 칸 표시용) 처음 로드 시 json 이 기본 선택
덮어쓰기(카드 하단 "정의서로 코드 생성") 같은 경로 파일이 있으면 덮어씀. 끄면 SKIPPED_EXISTS 로 건너뜀 업무단위·배치·[수정본 저장]에도 같은 체크가 적용
스웨거(OpenAPI) 주석(카드 하단) 전체 on/off. 켜면 OpenApiCodeGenDecorator 가 클래스·필드에 @Schema(description=…) 부여 필드별 on/off 는 없다. 설명(description/label)이 비면 주석도 비어 있음
미리보기(카드 하단) POST /api/generate/{type}?write=false — 디스크 미변경 결과는 §7 편집 영역에 펼쳐짐
소스에 쓰기(카드 하단) write=true — output-dir 아래에 기록 write-enabled=false 면 403. 출력 루트 밖 경로·.. 는 거부

덮어쓰기·스웨거·미리보기/소스에 쓰기는 이 카드의 DTO/VO 생성 전용이다. 업무단위 C/S/R·배치는 각 카드의 별도 미리보기/쓰기를 쓴다(덮어쓰기 체크는 공유). 폭 700px 이하에서는 카드 안 체크박스는 한 줄씩, 버튼은 전체 폭으로 쌓인다.

2.1 정의서 유형
유형 ID 모듈 입력 형태 화면 흐름
sample-json (화면 전용) 실제 요청/응답 데이터 §3 — 샘플 칸에서 파싱 → json 으로 전환
json 코어 JSON 정의서(§5) 본문 직접 편집 ↔ 표. 기본 유형
excel 코어 코어 기본 양식 .xlsx §4.2 — [기본 양식(xlsx)] 으로 템플릿 받기
sc-excel 삼카 삼카 인터페이스정의서(다중 시트, IN/OUT 시트 짝) §4.2 + IN/OUT 시트 패턴 지정
samsung-card-snippet 삼카 레거시 코드조각(@ChannelAnnotation 필드 / WebtUtil.getString(buf,off,len)) §4.3 — 본문 붙여넣기 → [본문 파싱]
samsung-card-io 삼카 | 구분 텍스트(placeholder 양식) §4.3
sample-csv 데모 custom CSV(첫 줄 이름,kind,패키지) 커스텀 파서 예제

generic/삼카 분기: 화면은 /api 의 decorators·specTypes 를 보고 samsung-card 데코레이터가 있을 때만 sc-excel·삼성 유형 안내와 배치 「삼카」 체크를 보여 준다. 일반 데모(ongo-framework-generic-demo)에서는 코어 유형(json·excel·sample-json)만 보인다. API 경로는 같다.

kind(DTO/VO): 「정의서로 코드 생성」의 kind 칸에서 DTO/VO를 고르거나 직접 입력(codegen.suffixes 키)하면 정의서 JSON의 kind(배열이면 문서마다)가 바뀐다. 삼카 sc-excel은 이 칸을 숨기고 기본 VO로 두며, 클래스명 In/Out 등은 vo-naming 의 prefix·in-suffix·out-suffix(예 InVo/OutVo)로 맞춘다.

유형별 "어떤 입력칸을 쓰는가":

정의서 종류 쓰는 입력칸 버튼
공통 I/O(JSON) JSON 정의서 칸 (자동 동기화) → 미리보기
샘플 데이터 샘플 칸(유형=샘플 JSON 입력 일 때만 보임) [샘플 파싱 → 정의서 생성]
기존 VO 라벨 기존 VO + FQCN [불러오기]
엑셀 계열 파일 선택 + (선택) 매핑 JSON [시트·컬럼 분석] → [엑셀 펼치기]
텍스트 계열(snippet/io/csv) JSON 정의서 칸에 원문 그대로 본문 파싱
업무단위 C/S/R "업무단위" fieldset 미리보기/쓰기(§8)
배치 "배치" fieldset 스켈레톤/프로젝트 미리보기(§10)
전문 정의서 칸 + 전문 테스트 fieldset 전문 송수신

3. 샘플 요청/응답 JSON → JSON 정의서

  1. 유형에서 샘플 JSON 입력 선택 → "① 샘플 요청/응답 JSON" 칸이 나타난다(다른 유형일 때는 숨김).
  2. 실제 데이터(또는 그 모양의 임의 값)를 붙여 넣는다. 요청/응답은 in/out 키로 구분: json {"in":{"cardNo":"1234567812345678","amount":10000}, "out":{"resultCode":"0000","items":[{"seq":1,"name":"a"}]}} 구분 없이 주면 전체가 하나의 VO: {"cardNo":"1234567812345678"}
  3. 이름(CARD01 — 클래스명·인터페이스ID 기준) 입력 → [샘플 파싱 → 정의서 생성] (POST /api/import/sample-json?name=CARD01).
  4. 성공: JSON 정의서·표가 채워지고 샘플 칸은 비워지며 안내 ✔ 정의서 생성 — 샘플은 비웠어요. 이후 편집은 정의서/표에서 한다(샘플은 일회성).
  5. 실패: 브라우저에서 먼저 JSON.parse 검사 → ⚠ JSON 오류: …. 서버 오류는 VO/DTO 탭 하단 로그(pre#out)에 응답 전체.

타입 추론 규칙:

샘플 값 추론 type 보정 포인트
int 범위 정수 INT 금액이면 LONG/DECIMAL 로
큰 정수 LONG
소수 DECIMAL length/scale 지정
true/false BOOLEAN
문자열 STRING 숫자처럼 보이는 문자열(카드번호)은 STRING 유지, length 필수 지정
객체 OBJECT + 하위 fields
배열 ARRAY(첫 원소 기준) 원소가 섞여 있으면 첫 원소 모양만 반영

추론값은 기본값일 뿐이다. 길이·필수·설명·필드명(한글)은 표에서 반드시 채운다.

4. 기존 VO · 엑셀 · 유형별 본문 파싱

4.1 기존 VO 불러오기
  • 라벨 기존 VO (어노테이션/POJO, className)(선택지가 하나라 셀렉트가 아닌 고정 라벨) + FQCN(com.example.card.CARD01In) → [불러오기] = POST /api/import/vo?className=….
  • 앱 클래스패스에서 로드한다. @ScField(order·length), @Encrypted/@Masked, POJO 필드·레코드 컴포넌트를 읽어 정의서로 만든다.
  • 없는 클래스: 404 devtools.import.class-not-found.
4.2 엑셀
  1. 파일 선택(.xlsx). 유형이 excel/sc-excel 가 아니면 자동으로 excel 로 처리한다.
  2. [시트·컬럼 분석(매핑 제안)] (POST /api/excel/inspect/{type}) → 시트별 헤더 행·헤더 목록·자동 매핑 제안이 드롭다운으로 표시된다.
  3. 드롭다운으로 필드 키(name·label·type·length·scale·required·description·encrypted·masking·io·level 등)↔엑셀 컬럼을 확정하면 매핑 JSON 칸이 자동 갱신된다. - IN 시트/OUT 시트 패턴(예 *_IN/*_OUT)과 기본 타입(예 STRING)도 지정 가능.
  4. [엑셀 펼치기] → 정의서·표. (업로드한 .xlsx 를 읽는다. JSON 정의서 칸 본문을 읽는 [본문 파싱]과 구분)

매핑 JSON 직접 입력 예(컬럼은 헤더명 또는 열 문자):

{"headerRow":1,"columns":{"name":"항목명","label":"한글명","length":"D","encrypted":"암호화여부","masking":"마스킹여부"},
 "ioSheets":{"in":"*_IN","out":"*_OUT"},"defaults":{"type":"STRING"}}

코어 기본 양식 헤더: 항목명·한글명·타입·길이·소수·필수·설명·암호화여부·마스킹여부·구분(IN/OUT)·레벨. 레벨 2 이상은 바로 위 OBJECT/ARRAY 의 하위 필드.

4.3 텍스트 계열 정의서(삼카 snippet·io, CSV 등)
  1. 공통 I/O 정의서 카드 상단 입력 유형을 고른다(예 samsung-card-snippet).
  2. JSON 정의서 칸에 원문을 그대로 붙여 넣는다(JSON 아니어도 된다 — 이때 표 동기화는 ⚠ JSON 오류 를 띄우지만 무시).
  3. [본문 파싱] (POST /api/import/{type}) → 공통 I/O JSON 으로 바뀌어 칸이 덮어써지고 표가 펼쳐진다. 이후 유형은 json 으로 다뤄진다.
#interface|CARD01|Card01In|DTO|com.example.card            ← samsung-card-io (placeholder 양식)
cardNo|String|16|Y|카드번호
#interface|CARD01|Card01Out|DTO|com.example.card|RESPONSE
resultCode|String|4|Y|결과코드
// #interface|CARD01|Card01Out|DTO|com.example.card|RESPONSE   ← samsung-card-snippet
@ChannelAnnotation(order = 1, dataType = IIMSDataType.NUMERIC, length = 8, scale = 0) private long msgLength;
// 또는 오프셋 패턴(블록당 한 패턴만): sysCode = WebtUtil.getString(byteArray, 8, 4);

snippet 규칙: (A) 어노테이션 패턴은 order 정렬, NUMERIC → LONG/DECIMAL. (B) 오프셋 패턴은 offset 정렬, 빈틈·겹침은 오류, parseInt/parseLong → LONG. ARRAY(그리드)는 아직 미지원(오류).

새 유형 추가: SpecParseService 빈 등록 → 드롭다운에 자동 노출(EXAMPLES §D1).

5. JSON 정의서 ↔ 펼친 항목 표 (양방향 동기화)

{"name":"Card01","kind":"DTO","package":"com.example.card","description":"카드 조회",
 "in":{"fields":[{"name":"cardNo","label":"카드번호","type":"STRING","length":16,"required":true,"encrypted":"card","masking":"card"}]},
 "out":{"fields":[{"name":"items","type":"ARRAY","fields":[{"name":"seq","type":"LONG"},{"name":"amount","type":"DECIMAL","length":15,"scale":2}]}]}}
속성 의미 / 생성 결과
name(정의서) 클래스 기본명. in/out 이 있으면 {name}In/{name}Out(접미사 규칙)
kind DTO/VO/… → codegen.suffixes 로 접미사
package 비면 codegen.default-package
필드 name Java 식별자 검사(위반 시 400 devtools.codegen.invalid)
type STRING·INT·LONG·DECIMAL·NUM·BOOLEAN·DATE·DATETIME·OBJECT·ARRAY 또는 FQCN
length/scale 문자열 length → @Size(max)(validation 데코레이터), 전문 레이아웃 길이
required @NotNull
label/description Javadoc·@Schema 설명. 표에서 "필드명"/"설명" 칸
encrypted/masking §6
fields OBJECT/ARRAY 하위(표에서 · 들여쓰기로 표시)
attributes 고객사 확장 자유 속성(interfaceId, offset 등) — 데코레이터가 읽음

동기화 규칙(코드 기준): - 정의서 → 표: 입력 후 300ms 디바운스로 JSON.parse → 표 재구성, ✔ 정의서 → 표 반영. JSON 이 깨지면 ⚠ JSON 오류(표는 마지막 정상값 유지) — 표는 바뀌지 않는다. - 표 → 정의서: 필드명·설명·암호·마스킹 칸을 고치면(change) 즉시 정의서 전체를 다시 직렬화하고 유형을 json 으로 전환, ✔ 표 → 정의서 반영. - 표에서 고칠 수 있는 것: 필드명(label)·설명·암호·마스킹. name·type·length·구조(추가/삭제)는 정의서에서 고친다. - 값을 비우면 그 속성은 삭제된다(빈 문자열로 남지 않음). - 정의서를 표에서 고친 뒤 원문(텍스트 유형)으로 되돌릴 수는 없다 — 원문이 필요하면 따로 보관.

6. 암호화·마스킹 체크와 정책 입력

표 조작 정의서 결과 생성 코드
체크만 "encrypted": true @Encrypted
옆 칸에 card 입력(체크 자동) "encrypted": "card" @Encrypted("card")
체크 해제 속성 삭제 없음
마스킹 동일 "masking": true / "규칙" @Masked / @Masked("규칙")
  • 정책·규칙명은 ColumnAttributeService.ColumnMeta.encrypted()/masking() 문자열로 그대로 전달된다 → 정책별 분기 구현은 EXAMPLES §E4.
  • 런타임 동작: DB VO 의 @Encrypted 컬럼은 MyBatis 저장 시 암호화·조회 시 복호화, 응답 DTO 의 @Masked 는 응답 직렬화 시 마스킹, 전문 코덱은 encode 전/decode 후 ColumnAttributeService 훅(CUSTOMIZATION §14.3).
  • 엑셀 암호화여부/마스킹여부 컬럼 값 Y → true, 그 외 문자열 → 정책명.

7. 생성 결과 편집·저장

미리보기 후 파일마다 경로 입력칸 + 내용 textarea 가 펼쳐진다. 목록은 그 생성을 시작한 탭에만 붙는다. 다른 탭으로 옮기면 그 탭의 파일만 보이고, 돌아왔을 때 고친 경로·내용이 남아 있다. [이 탭 로그 지우기]는 이 목록을 지우지 않는다. - 경로·내용을 직접 고칠 수 있다(패키지 이동 시 경로와 package 선언을 함께 고칠 것). - [수정본 소스에 쓰기] POST /api/files/save?overwrite= — output-dir 기준 상대경로만. 절대경로·C:·.. 는 400. - [zip 다운로드] POST /api/files/download → generated-sources.zip. 서버에 쓰지 않으므로 개발계(쓰기 금지)에서도 동작. - 결과(쓰기 결과 WRITTEN/OVERWRITTEN/SKIPPED_EXISTS 등)는 그 작업을 시작한 탭의 하단 로그에 JSON 으로 남는다. 다른 탭 로그와 섞이지 않는다.

8. 업무단위 Controller + Service + Repository 생성

필드 예 규칙
업무 루트 패키지 com.example.order 하위 패키지는 codegen.layer.<유형>.package(기본 controller/service/repository)
업무명 Order 클래스명 = prefix + 업무명 + suffix → OrderController·OrderService·OrderRepository
설명 주문 Javadoc·스웨거 태그, 기본 메소드 설명
경로 /api/order 컨트롤러 @RequestMapping
스웨거 체크 @Tag/@Operation 부여(정의서 카드 하단 스웨거와 별개)

현재 규칙은 화면 하단 작은 글씨(GET /api/generate-layer/rules)로 표시된다.

8.1 메소드 폼

행 하나 = 메소드 하나: 메소드명 · HTTP(GET/POST/PUT/PATCH/DELETE) · URI · in 타입 · out 타입 · 설명 · [✕]

  1. [+ 메소드 추가] 로 행 추가, [✕] 로 삭제.
  2. 메소드명이 빈 행은 무시. 유효한 행이 하나도 없으면 기본 list(GET "")·get(GET /{id})·create(POST "") 생성 — 자세한 규칙은 §8.2.
  3. URI 를 비우면 베이스 경로 그대로. {orderId} 는 @PathVariable String orderId 로.
  4. in 타입: GET/DELETE 는 쿼리 파라미터 객체(@ModelAttribute), POST/PUT/PATCH 는 Content-Type 선택에 따름 — JSON(기본) @RequestBody, Form @ModelAttribute(DTO 없으면 @RequestParam Map), Multipart @RequestPart("body"). 본문 메소드에는 consumes = MediaType.… 가 붙음. out 타입: 반환형(List<OrderDto> 가능). - 추가 파라미터(쉼표 구분, 여러 개): param page:Integer, header X-User-Id, path id:Long, part file → @RequestParam/@RequestHeader/@PathVariable(URI 변수 타입 지정)/@RequestPart MultipartFile. 타입 생략 시 String(part 는 MultipartFile). Service 시그니처에도 같은 순서로 전달. - 한 줄 입력에서는 ct:json|form|multipart 토큰으로 Content-Type 지정(JSON 기본이면 생략), 추가 파라미터는 공백 없이 param:page:Integer header:X-User-Id path:id:Long part:file 토큰으로.
  5. in/out 칸은 입력 즉시 자동완성 후보(datalist)가 뜬다. 행을 한 번 클릭하면 "선택 행"이 된다 → 아래 타입 찾기 검색 후 [선택 행 in에 넣기]/[선택 행 out에 넣기].
  6. 한 줄 텍스트 ↔ 폼 양방향: 폼 행을 입력·수정·삭제하면 "한 줄 텍스트" 칸이 실시간으로 같은 내용으로 변환된다(Content-Type·추가 파라미터 포함, 복사해 공유·재사용 가능). 반대로 텍스트를 고치거나 붙여넣고 [폼으로 가져오기(폼 교체)] 를 누르면 폼 행이 텍스트 내용으로 교체된다: cancel PUT /{orderId}/cancel in:OrderCancelDto out:OrderDto 주문 취소 search GET /search in:OrderSearchDto out:List<OrderDto> 주문 검색 ping GET - 상태 확인 형식: 이름 HTTP URI(없으면 -) [in:타입] [out:타입] 설명….
  7. [미리보기] → 파일 3개(+경고). [쓰기] 는 경고가 하나라도 있으면 400 으로 중단(타입 확인 필요(쓰기 중단)).

타입 규칙: in/out 은 프로젝트에 이미 있는 클래스만(단순명·FQCN·제네릭). 없거나 단순명이 둘 이상 겹치면 ⚠ 타입 경고(생성 안 함) — 그 메소드는 빠진다. 먼저 VO 를 생성·저장하고 §9 재색인.

8.2 메소드를 하나도 정의하지 않으면 — 기본 뼈대(list·get·create)

메소드 폼에 유효한 메소드가 하나도 없으면 아래 세 API 가 자동으로 생성된다. 버그가 아니라 의도된 기본 뼈대(와꾸) 다.

메소드 HTTP URI(베이스 경로 기준) 컨트롤러 반환형 Service 본문 설명(Javadoc·@Operation)
list GET ""(베이스 경로 그대로) List<Map<String, Object>> return repository.findAll(); {설명} 목록
get GET /{id} (@PathVariable String id) Map<String, Object> return repository.findById(id); {설명} 단건
create POST "" (@RequestBody Map<String, Object> body) Map<String, Object> @Transactional + return repository.insert(body); {설명} 등록
  • {설명} 은 상단 설명 칸 값, 비우면 업무명(예 Order).
  • 경로 칸을 비우면 베이스 경로는 /api/{업무명 kebab}(예 OrderItem → /api/order-item).
  • 예) 업무명 Order, 경로 /api/order → GET /api/order, GET /api/order/{id}, POST /api/order.

"유효한 메소드가 없다"의 정확한 기준 1. 화면: 메소드 행이 0개이거나, 있더라도 메소드명 칸이 전부 비어 있으면 화면이 그 행들을 버리고(methodRows() 가 name 빈 행 제외) 빈 목록을 보낸다 → 서버가 기본 3개를 붙인다. 2. 서버(LayerCodeGenerator.generate): methods 가 null 이거나 빈 배열이면 defaultMethods(설명) 사용. 3. REST 로 직접 호출할 때(§12)는 화면 필터가 없으므로 methods 에 이름이 빈 항목을 넣으면 400(메소드명 오류/중복). 기본 뼈대를 원하면 methods 를 아예 빼거나 [] 로 보낼 것.

하나라도 정의하면 기본 3개는 붙지 않는다 - 메소드명이 채워진 행이 1개라도 있으면 그 행들만 생성된다(기본 list/get/create 와 합쳐지지 않음). - 기본 3개에 하나를 더하고 싶다면 list·get·create 행도 직접 넣는다. 한 줄 붙여넣기 예: list GET - 주문 목록 get GET /{id} 주문 단건 create POST - 주문 등록 cancel PUT /{orderId}/cancel in:OrderCancelDto out:OrderDto 주문 취소 - 직접 넣은 행이라도 이름이 list/get/create 이고 in/out 타입을 비우면 Service 가 위 표처럼 Repository 를 호출한다. in/out 타입을 지정하면 Service 본문은 // TODO + return null;. - in/out 타입 경고가 난 메소드는 빠지고, 경고가 하나라도 있으면 [쓰기]는 400 으로 중단(§8.1 7번, 타입 규칙) — 기존 규칙 그대로.

왜 기본 뼈대를 주나 — 생산성 - 메소드를 정하지 않았어도 Controller / Service / Repository 3 클래스, 패키지, import, 생성자 주입(DI), @RestController·@Service·@Repository·@Transactional 구조가 한 번에 완성된다. - 당장 쓰지 않는 API 라도, 나중에 메소드명·URI·타입만 바꾸거나 지우면 되므로 처음부터 타이핑할 필요가 없다. - Repository 는 findAll/findById/insert 를 빈 값으로 반환하는 임시 구현(TODO: MyBatis Mapper·JPA 등으로 교체)이다. 실제 저장소로 교체할 것. - 필요 없는 기본 API 는 생성 후 지우면 된다. 운영 전 남겨 둔 list/get/create 가 의도치 않게 노출되지 않는지 확인할 것.

미리보기 · 쓰기 · 수정본 저장 (§7 과 동일) - [미리보기]: 서버에 쓰지 않고 3개 파일(+경고)만 보여 준다. 기본 뼈대 여부를 여기서 먼저 확인. - [쓰기]: 현재 폼 값으로 다시 생성해서 디스크에 쓴다. 미리보기 textarea 에서 고친 내용은 반영되지 않는다. - 미리보기에서 직접 고친 내용은 §7 [수정본 소스에 쓰기](또는 [zip 다운로드])으로만 저장된다. 개발계(쓰기 금지)는 zip 만 가능.

9. 타입 색인 · 재색인

  • 색인 대상: @SpringBootApplication 패키지 + codegen.type-packages 의 클래스패스 .class + output-dir 의 .java 소스. 내부 클래스($) 제외. java.lang·java.util·java.time·BigDecimal 등 내장 타입은 항상 인식.
  • 첫 조회 때 한 번 스캔·메모리 캐시 → 이후 검색은 메모리 필터. 입력 250ms 디바운스, 최대 30건(API 는 limit 최대 200).
  • 입력할 때마다 resolve 로 검증: ✔ import FQCN 또는 ⚠ 경고.
  • [재색인] = refresh=true — 캐시를 비우고 다시 스캔. 방금 저장한 클래스를 후보에 넣을 때.
  • 전문 테스트의 동적 VO(런타임 컴파일)는 색인 대상이 아니다(디스크/클래스패스 파일 필요). 존재 확인만 클래스로더 리소스도 본다.
  • 클래스가 많아 첫 색인이 느리면 type-packages 를 업무 루트로 좁힌다.
ongo.framework.devtools.codegen:
  type-packages: [com.example.order, com.example.common]
  layer:
    controller: { package: web,        suffix: Controller }
    service:    { package: service,    suffix: Service }
    repository: { package: repository, prefix: "", suffix: Mapper }

10. 배치 잡 스켈레톤 · 배치 프로젝트

필드 예 설명
이름 cardLimitSync 영문 시작 영숫자. → CardLimitSyncJobConfig, 잡 cardLimitSyncJob
패키지 com.example.batch
chunk 100 커밋 단위
Reader list / custom list = ListItemReader 예제, custom = 직접 구현 자리(TODO)
item 타입 String Reader→Writer 항목 타입
전문 채널 / 인터페이스ID mci / SCDEMO01 둘 다 채우면 Writer 에 ChannelService.send(채널, ID, 값) 호출 코드
  • 생성 스텝은 BatchFailurePolicyService.apply(...) 로 skip/retry 정책을 적용한다(정책 교체: EXAMPLES §B).
  • [스켈레톤 미리보기] POST /api/generate-batch — 잡 설정 클래스 1개.
  • [프로젝트 미리보기] POST /api/generate-batch-project — Gradle 단일 모듈(build.gradle·settings.gradle·application.yml·Application·잡). 프로젝트명·프레임워크 버전(0.1.0-SNAPSHOT)·Nexus URL(비우면 OnGoSoft 기본), 삼카 체크(삼카 데코레이터가 있을 때만 표시) 시 ongo-framework-samsung-card 의존 추가. zip 으로 받아 바로 빌드.
  • 참고 구현: ongo-framework-batch-demo/.../job/ScCardLimitSyncJobConfig.java(형식 오류 카드번호 skip, 삼카 레이아웃·loopback 전송).
  • 수동 실행: /framework-devtools/batch 화면 또는 POST /framework-devtools/api/batch/jobs/{name}/run(batch.launch.allowed-jobs 에 있는 잡만).

11. 전문 테스트 (동적 VO → encode → 송신 → decode)

정의서로 VO 를 즉석 컴파일·로드해 전문 바이트를 만들고 송신·응답을 파싱한다(코드생성 없이 레이아웃 검증).

송신 방식 - 시뮬레이션(기본): loopback — 서버 밖으로 나가지 않고 보낸 전문을 그대로 응답으로 받는다. MCI / EAI / FEP 버튼은 시뮬레이션에서도 그 채널 이름과 yml 문자셋(MCI·EAI UTF-8, FEP EUC-KR)으로 전문을 만든다. 누른 버튼은 밝은 배경·굵은 글씨·체크 표시로 남고, 다른 채널을 누르면 표시가 옮겨진다. 클릭과 키보드(Enter/Space) 동작은 같다. - 실송신: yml ongo.framework.channel.channels.<name> 의 transport·url·timeout 으로 실제 채널에 송신(데모 ChannelService 와 같은 엔드포인트). 채널 목록은 GET /api/dynamic/channels(문자셋 포함). 문자셋은 화면에서 고르지 않는다. channel.charset · charset-defaults · channels.<이름>.charset 을 바꾸고 서버를 재기동한다. - 안전장치: devtools 자체가 devtools.profiles(기본 local·dev)에서만 켜짐. 추가로 ongo.framework.devtools.dynamic.real-send-enabled=false 면 실송신 금지, real-send-channels: [mci-dev] 로 허용 채널 제한(비우면 전체).

  1. 공통 I/O 정의서 카드의 정의서 칸 본문 + 입력 유형(전문 테스트 카드에 "정의서·유형 = 위 공통 I/O 정의서 카드" 로 표시)(텍스트 유형이면 원문 그대로도 가능).
  2. 코덱 선택(GET /api/dynamic/codecs): 코어 고정길이(기본) / 삼카 표준전문(standard-telegram.enabled=true 일 때) 등 DynamicCodecOption 빈.
  3. 인터페이스ID(생략 시 첫 VO).
  4. 값(JSON): {"cardNo":"1234567812345678","amount":10000} — 없는 키는 공백/0 채움.
  5. 방향: 송신(요청 HEADER) / 수신 응답(타 시스템 요청에 대한 회신, @direction=response). 실송신 채널이 mci/eai 면 UTF-8, fep 면 EUC-KR(channel.charset-defaults, 채널별 channels.<이름>.charset 로 변경). 예: 채널 mci + HEADER {"rcvSysDvC":"MCI"} / 채널 eai + {"rcvSysDvC":"EAI"} / 채널 fep + {"rcvSysDvC":"FEP"}.
  6. 표준전문 코덱이면 HEADER·MESSAGE JSON 으로 헤더값 지정(설정 기본값보다 우선) → 내부적으로 header.이름/message.이름 키: {"sendSysDvC":"ONG","akRspDvC":"S"} / {"stdMsgPrntTpc":"1"} 값이 null/"" 면 설정·내장 기본값 유지, " "(length>0)는 공백 그대로. 우선순위 HEADER·MESSAGE 입력 > 업무단 ScTelegramContext > (수신 응답) 요청 HEADER 에코 > yml 기본값 > 내장 기본값 — 상세·수신 처리기 응답 맵 예시는 CUSTOMIZATION 「표준전문」절.
  7. [전문 송수신] → 결과: VO 목록(클래스·방향·총 길이·필드별 offset/length/type·existing=기존 VO 사용 여부), 교환 결과(요청/응답 문자열·바이트 수·디코드 값). 이 출력은 전문 테스트 탭 로그에만 남는다. VO/DTO·업무단위·배치 탭으로 이동하면 그 탭의 로그가 보이고, 다시 전문 테스트 탭으로 오면 이전 전문 로그가 있다. [이 탭 로그 지우기]는 현재 탭만 비운다.

sc-excel: 인터페이스ID는 원형(APC_M_ISS_00027)으로 attributes.interfaceId(@ScTelegram·전문 테스트 칸)에, VO 이름은 카멜(ApcMIss00027)+suffix. 소스/타겟 IO처럼 같은 클래스명의 정의가 여러 벌이면 [엑셀 펼치기] 후 「생성에 쓸 I/O」(attributes.ioRole=시트명)에서 하나를 골라 미리보기·소스에 쓰기.

VO 선택 규칙: prefer-existing-vo=true 면 정의서 FQCN 또는 vo-naming 규칙(기본 {package}.{prefix}{interfaceIdCamel}{suffix} — 예 APC_M_ISS_00027 → ApcMIss00027In/Out, prefix·in/out-suffix 설정, 생성 클래스명도 동일) 클래스가 있으면 그대로 사용, 없으면 동적 생성. 작업 디렉터리 devtools.dynamic.work-dir(기본 build/ongo-devtools/dynamic, 개발 서버는 쓰기 가능한 경로를 환경변수로), fat jar 실행 시 dynamic.extra-classpath 보강.

12. REST API (자동화·CI 용)

기본 경로 /framework-devtools. JSON 응답은 프레임워크 공통 응답 envelope 로 감싸진다, 단 xlsx·zip 은 @NoEnvelope.

메서드 경로 요청 응답 요약
GET /framework-devtools — 화면 HTML
GET /api — specTypes[{type,description}], decorators[], outputDir, writeEnabled
POST /api/parse/{type} 본문 = 정의서 원문(text) SpecModel[]
POST /api/import/{source} vo?className= · sample-json?name=(본문 샘플) · {유형}(본문 원문) · excel/sc-excel(본문 {"file":"data:…base64","mapping":{…}}) 편집용 공통 I/O JSON
POST /api/generate/{type}?write=&overwrite=&swagger= 정의서 원문 specs, files[{path,content}], written[]
POST /api/generate-layer?write=&overwrite= {"rootPackage","name","description","basePath","swagger","methods":[{"name","http","uri","description","paramType","returnType"}]} files, written, warnings
GET /api/generate-layer/types?q=&limit=30&refresh=false — FQCN 목록
GET /api/generate-layer/resolve?type=List<OrderDto> — {type, imports[], warnings[]}
GET /api/generate-layer/rules — {controller:{packageName,prefix,suffix},…}
POST /api/generate-batch?write=&overwrite= {"name","packageName","chunk","itemType","outputType","reader","channel","interfaceId"} files, written
POST /api/generate-batch-project?write=&overwrite= {"projectName","packageName","frameworkVersion","repositoryUrl","samsungCard","job":{…위와 동일}} files, written
POST /api/excel/inspect/{type} {"file":"data:…"} fieldKeys[], sheets[{name,headerRow,headers,suggested}]
GET /api/excel/template — ongo-spec-template.xlsx
POST /api/files/save?overwrite= {"files":[{path,content}]} 쓰기 결과
POST /api/files/download 동일 generated-sources.zip
GET /api/dynamic/codecs — [{name,label}]
POST /api/dynamic/{type} {"content","interfaceId","values":{},"codec","mode":"simulate|real","channel"} buildId, dir, vos[], exchange(mode·channel·transport 포함)
GET /api/dynamic/channels — 실송신 후보 [{name,type,transport,url}]
GET /api/batch/jobs — [{name,restartable,manualRunAllowed}]
POST /api/batch/jobs/{name}/run 잡 파라미터 맵 실행 정보
GET /api/batch/jobs/{name}/executions?limit= — 최근 실행

오류 코드: 미등록 유형 404 devtools.spec.unknown, 형식 오류 400 devtools.spec.invalid/devtools.codegen.invalid, 쓰기 금지 403 devtools.codegen.write-disabled, VO 없음 404 devtools.import.class-not-found, import 실패 400 devtools.import.invalid.

XSS: 개발툴 경로(/framework-devtools/**)는 XSS 파라미터·JSON 본문 정화를 건너뜁니다(ongo.framework.devtools.xss-skip=false 로 끔). 타입식 List<OrderDto> 가 &lt; 로 이스케이프돼 와도 검증 전에 원문으로 복원합니다.

curl 예(미리보기):

curl -s -X POST 'http://localhost:8080/framework-devtools/api/generate/json?swagger=true' \
  -H 'Content-Type: text/plain' \
  -d '{"name":"SampleOrder","kind":"DTO","package":"com.example.sample","fields":[{"name":"orderNo","type":"STRING","length":12,"required":true}]}'

13. 모바일 UX (폭 720px 이하)

  • 모든 입력·선택·버튼이 한 줄 전체 폭·최소 높이 44px, 글꼴 16px(iOS 확대 방지), safe-area 여백 적용, 가로 스크롤 없음.
  • I/O 표는 카드형: 한 필드 = 카드 한 장, 각 칸 앞에 라벨(I/O·필드·필드명·설명·타입·길이·암호·마스킹). 암호/마스킹 체크박스 26px 로 터치 가능, 정책명 칸이 옆에 붙는다.
  • JSON 정의서 칸은 펼친 표 바로 위에 있어 정의서↔표를 번갈아 보며 고칠 수 있다.
  • 메소드 폼 행은 세로 스택(메소드명→HTTP→URI→in→out→설명→✕).
  • <br> 은 숨겨지고 small 안내문은 블록으로 내려간다.

14. 트러블슈팅

증상 원인 / 조치
화면 404 활성 프로필이 devtools.profiles 에 없음 / devtools.enabled=false
[소스에 쓰기] 403 write-enabled=false(개발계) → [zip 다운로드]
체크했는데 @Encrypted 없음 생성은 정의서 기준. 정의서에 encrypted 가 들어갔는지, 유형이 json 인지 확인
표가 안 바뀜 정의서 JSON 오류(표는 마지막 정상값 유지). specinfo 메시지 확인
텍스트 유형 붙였더니 ⚠ JSON 오류 정상. 유형 선택 후 [본문 파싱]
in/out 타입 경고·쓰기 중단 클래스 없음/모호. VO 먼저 생성·저장 → [재색인] → FQCN 으로 지정
샘플 칸이 안 보임 유형이 샘플 JSON 입력 일 때만 보인다
동적 전문 컴파일 실패(개발 서버) dynamic.work-dir 쓰기 권한, fat jar 면 extra-classpath
표준전문 코덱이 목록에 없음 ongo.framework.samsung-card.standard-telegram.enabled=true 필요
모바일에서 표가 잘림 구버전 캐시 — 새로고침. 현재 버전은 카드형(§13)
화면 HTML 에 JS 코드가 본문으로 보임 인라인 <script> 안 문자열 리터럴에 </script> 가 들어가면 태그가 조기 종료된다. 예시 문자열은 <\/script> 또는 URL 인코딩(%3C%2Fscript%3E)으로 쓸 것(데모 쇼케이스 수정 사례: 97b0f4d)
한글 길이가 안 맞음 전문 길이는 문자셋 바이트 기준(MS949 한글 2바이트, UTF-8 3바이트) — channel.charset 확인

데모 확장·커스텀 카탈로그: 데모의 모든 SPI 교체 예시(DemoCustomizations·DevtoolsDemoCustomizations·DemoExtensionMocks)와 Redis·MQ(Rabbit/Kafka)·ES 목업은 코어 개발가이드 /framework-devtools/guide#demoext·#extmock 및 CUSTOMIZATION-EXAMPLES §M 참고. 코어에는 SPI 계약만 있고 클라이언트 의존성은 없다(일반 공통, 삼카 비의존).

원문 md(보조 — 위 「상세 문서 전문」에 수록): docs/AUTH-TOKEN.md, SESSION-IDLE.md, DOUBLE-SUBMIT.md, MESSAGE-CATALOG.md, CUSTOMIZATION.md, CUSTOMIZATION-EXAMPLES.md, DEPLOYMENT-GUIDE.md