Ongo Framework 코어 개발가이드 · 삼카 확장
백엔드 개발자를 위한 프레임워크 레퍼런스예요. 각 컴포넌트를 ① 기본 동작 → ② yml 설정 → ③ SPI 커스텀 구현(케이스별) 순서로 설명해요. 개발툴 화면 사용법은 개발툴 사용가이드를 보세요.
개요·모듈 구성
| 모듈 | 역할 |
|---|---|
ongo-framework-common | SPI 인터페이스, 공통 타입(BusinessException, MessageCoded, AuthPrincipal 등). 업무 코드는 여기 타입에만 의존하면 돼요. |
ongo-framework-core | SPI 기본 구현(인메모리·yml 기반). 개발·테스트에 바로 쓸 수 있는 수준이에요. |
ongo-framework-starter | Spring Boot AutoConfiguration. 모든 기본 빈이 @ConditionalOnMissingBean이라 같은 타입 빈을 등록하면 자동으로 교체돼요(137개 지점). |
ongo-framework-devtools | local/dev 전용 코드생성·전문 테스트 화면(/framework-devtools). |
ongo-framework-batch | Spring Batch 공통 정책·실행 화면. |
ongo-framework-samsung-card | 표준전문(HEADER/MESSAGE) 코덱·레이아웃 규칙(삼카 고객사 확장). |
ongo-framework-sc-demo | 삼카 쇼케이스·레퍼런스 앱. 설정 예시는 이 앱의 application.yml 기준이에요. |
확장 원칙(SPI·yml)
- yml로 먼저 해결 — 대부분의 동작(경로, 타임아웃, 상태코드, 테이블·컬럼명)은
ongo.framework.*속성으로 바꿀 수 있어요. - 그래도 안 되면 SPI 빈 등록 — 인터페이스를 구현해
@Component/@Bean으로 올리면 기본 구현이 물러나요. - 기본 구현을 감싸기(데코레이터) — 기본 동작을 유지하면서 일부만 바꾸려면 기본 클래스를
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-enabled | true | false면 ApiResponseBodyAdvice 빈 자체가 안 올라가 컨트롤러 반환값이 그대로 나가요(레거시 전체 화면용). |
correlation-header | X-Correlation-Id | 상관ID 헤더명. 수신 시 읽고 없으면 생성, MDC traceId에 넣어요. 아웃바운드 전파(outbound.propagate-correlation-id)도 이 이름을 써요. |
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
| 키 | 기본값 | 설명·언제 쓰나 |
|---|---|---|
enabled | true | false면 기본 예외 핸들러만 빠져요(메시지 서비스는 유지). 사내 핸들러로 완전 교체할 때. |
business-status | 422 | BusinessException HTTP 상태. 레거시 화면이 200+code를 원하면 200. 예외에 HTTP 힌트(withStatus)가 있으면 그게 우선. |
validation-status | 400 | Bean Validation 실패 상태. |
system-status | 500 | SystemException 상태. |
unhandled-status | 500 | 그 외 미처리 예외 상태. |
validation-code | VALIDATION_FAILED | 검증 실패 envelope code. |
unhandled-code | UNHANDLED | 미처리 예외 code. |
malformed-code | MALFORMED_REQUEST | 본문 파싱 불가(JSON 오류) code. |
not-found-code | NOT_FOUND | 404 code. |
method-not-allowed-code | METHOD_NOT_ALLOWED | 405 code. |
unsupported-media-code | UNSUPPORTED_MEDIA_TYPE | 415 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 }
| 키 | 기본값 | 설명·언제 쓰나 |
|---|---|---|
basename | classpath:ongo/framework/messages | 쉼표 구분 번들 목록. 앞 basename이 같은 코드에서 이겨요. 기본 번들은 항상 마지막 폴백. |
default-locale | ko | 요청 로케일이 없을 때. HTTP 기본 로케일은 spring.web.locale. |
encoding | UTF-8 | 번들 파일 인코딩. |
messages | {} | 코드 → MessageFormat 템플릿. 번들보다 우선. 점이 있는 코드는 "[demo.order.rejected]" 대괄호 키. |
jdbc.enabled | false | 켜면 테이블 → (yml 맵 → 번들) 순으로 찾아요. |
jdbc.table | ongo_common_message | 메시지 테이블명(from이 있으면 무시). |
jdbc.code-column / locale-column / text-column | msg_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.ttl | true / 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 로그인 | AuthService | Optional<AuthPrincipal> authenticate(loginId, credential) |
| 메뉴·버튼 권한 DB 관리 | AuthorizationService | boolean hasPermission(principal, permission) |
| 토큰→사용자 재조회(SSO) | IdentityService | Optional<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.timeout | 30m | 유휴 만료(마지막 접근 기준). 요청마다 touch로 연장. |
session.max-per-user | 0(무제한) | 사용자당 동시 세션 수. 1이면 단일 기기. |
session.overflow | EXPIRE_OLDEST | 초과 시 정책: EXPIRE_OLDEST(오래된 세션 축출 → auth.duplicate-login) | REJECT(신규 로그인 거부). |
session.enabled | true | 세션 정책 빈 on/off. 저장소 자체는 SessionStoreService 빈 교체. |
세션 저장소
SessionStoreService: create / touch / find / updateAttributes / invalidate / invalidateUser / sessionsOf / wasDisplaced. 기본은 인메모리. 다중 서버면 Redis 구현을 등록하세요(중복 로그인 참고). 관리자 강제 로그아웃은 invalidateUser(userId).
중복 로그인 차단
① 기본 동작
- 같은 사용자가
max-per-user를 넘겨 로그인하면overflow=EXPIRE_OLDEST규칙대로 가장 오래된 세션이 밀려나요. - 밀려난 세션 ID는
SessionStoreService.wasDisplaced(id)가true를 돌려줘요. AuthenticationFilter→AuthorizationInterceptor가 이를 감지해 401auth.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.enabled | true | 기본 HashService(JDK 다이제스트) 등록. |
hash.algorithm | SHA-512 | 다이제스트 알고리즘(JDK MessageDigest 이름). |
hash.hmac-algorithm | HmacSHA512 | HMAC 알고리즘(JDK Mac 이름). |
hash.hmac-key | (없음) | HMAC 키. 비우면 hmac() 호출 시 예외. 저장소에 실제 키 커밋 금지. |
hash.salt-bytes | 16 | 비밀번호 해시 솔트 길이(byte). |
hash.iterations | 1 | 해시 반복 횟수. 비밀번호면 올리거나 bcrypt/argon2로 SPI 교체. |
hash.encoding | HEX | 출력 인코딩(HEX | BASE64). |
③ SPI
| 케이스 | SPI |
|---|---|
| 사내 HSM·KMS(ARIA/SEED) | FieldCryptoService |
| bcrypt/argon2 | HashService(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 | 메서드 | 커스텀 케이스 |
|---|---|---|
MaskingService | shouldMask / mask / maskText | 주민·카드번호 사내 규칙 |
XssFilterService | clean(input) | 에디터 HTML 허용 태그(OWASP Sanitizer) |
PasswordRuleService | List<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
| 키 | 기본값 | 설명 |
|---|---|---|
enabled | true | 헤더가 있는 변경 요청에만 동작. |
header | Idempotency-Key | 요청 헤더명. |
ttl | 24h | 기록 유지 시간 — 이 시간 안의 같은 키는 재생/거부. |
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 (처리 실패 시)
}
| 케이스 | 방법 |
|---|---|
| Redis | SET 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
| 키 | 기본값 | 설명·언제 쓰나 |
|---|---|---|
enabled | true | 프레임 MyBatis 기본값·페이징 헬퍼 등록. |
map-underscore-to-camel-case | true | snake_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-size | 20 / 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:" } }
| 키 | 기본값 | 설명·언제 쓰나 |
|---|---|---|
enabled | true | 프레임 캐시 빈(프로세스 내 맵) 등록. |
local.ttl | 10m | 로컬 캐시 기본 TTL. 0s = 만료 없음. |
local.max-size | 10000 | 최대 항목 수. 0 = 무제한. |
local.clear-on-meta-load | true | 메타 적재(Refreshable) 끝에 로컬 캐시 비우기. |
distributed.ttl | 30m | 분산 캐시 TTL. |
distributed.max-size | 10000 | 분산(기본 인메모리 대체) 최대 항목. |
distributed.key-prefix | ongo: | 키 접두어(앱·환경 구분). 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.enabled | true | 공통코드 기본 구현 on/off. |
code.groups | {} | yml 코드: 그룹 → [{code, name, sort-order, parent-code, attributes}]. jdbc를 켜면 무시. DB 없는 개발·테스트용. |
code.jdbc.enabled | false | 켜면 SQL로 코드 테이블 조회. |
code.jdbc.mode | SINGLE | SINGLE: 한 테이블에 그룹 컬럼 · SELF_REFERENCE: 그룹행·코드행이 한 테이블(group-column이 상위 행 code-column 참조) · QUERY: query 그대로. |
code.jdbc.table | ongo_common_code | 코드 테이블(from이 있으면 무시). 기본값은 placeholder라 현장 스키마로 바꾸세요. |
code.jdbc.group-column / code-column / name-column / sort-column | code_group / code / code_name / sort_order | 현장 컬럼명 매핑. |
code.jdbc.parent-column | parent_code | 계층(children)용. 비우면 계층 없음. |
code.jdbc.use-column / use-value | use_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 / ttl | true / 10m | 코드 캐시. ttl 0이면 만료 없음(메타 적재·refresh 때만 비움). |
meta-load.enabled | true | MetaLoadService 빈이 있을 때만 의미. |
meta-load.on-startup | false | 기동 완료(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/MDM | CodeLookupService 구현 + 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>.*가 우선해요.
| 키 | 기본값 | 설명·언제 쓰나 |
|---|---|---|
enabled | true | 프레임 공통 Feign 빈(타임아웃·상관ID·envelope 해제·오류 변환) 등록. |
connect-timeout | 2s | TCP 연결 타임아웃. |
read-timeout | 5s | 응답 대기 타임아웃. 느린 대외 API는 클라이언트별로 올리세요. |
follow-redirects | true | 3xx 따라가기. |
propagate-correlation-id | true | 상관ID 헤더 전파(헤더명은 web.correlation-header). 시스템 간 추적. |
unwrap-envelope | true | 상대가 공통 envelope으로 답하면 data만 꺼내 반환 타입으로. 반환 타입이 ApiResponse면 그대로. |
retry.max-attempts | 1(재시도 없음) | 총 시도 횟수. I/O 실패만 재시도, HTTP 오류 응답은 재시도 안 함(비멱등 보호). |
retry.period / max-period | 100ms / 1s | 재시도 간격·최대 간격(백오프). |
client-error-code | OUTBOUND_CLIENT_ERROR | envelope이 아닌 4xx 응답의 메시지 코드. |
server-error-code | OUTBOUND_SERVER_ERROR | envelope이 아닌 5xx 응답의 메시지 코드. |
unavailable-code | OUTBOUND_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 } ]
| 키 | 기본값 | 설명·언제 쓰나 |
|---|---|---|
enabled | true | 채널 기능 on/off. |
charset | UTF-8 | 고정길이 코덱 기본 문자셋(기간계가 EUC-KR/MS949면 변경). |
charset-defaults | { fep: EUC-KR } | 채널 type(또는 이름) → 기본 문자셋. 우선순위: channels.<이름>.charset > charset-defaults > charset. |
channels.<이름>.type | MCI | 분류 MCI/EAI/FEP 등(라우팅·로그용). |
channels.<이름>.transport | loopback | PhysicalTransportService#name() — 기본 http / loopback, 또는 앱 빈 이름(tcp 등). |
channels.<이름>.url | (없음) | 대상 주소(http transport 등). |
channels.<이름>.timeout | 10s | 송수신 타임아웃. |
channels.<이름>.charset | (상속) | 채널별 문자셋. |
layouts.<ID>.request / response | [] | 필드 [{name, length, type(CHAR 기본|NUM…)}]. response 비우면 request와 같음. |
async.pool-size | 4 | 비동기 송수신 전용 스레드 수. |
inbound.enabled / path | true / /framework/channel/inbound | 수신 endpoint: POST {path}/{channel}/{interfaceId}, GET {path}/receipts/{id}. |
inbound.max-receipts | 1000 | 비동기 접수 결과 보관 개수. |
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 }
| 키 | 기본값 | 설명·언제 쓰나 |
|---|---|---|
enabled | true | 삼카 확장 전체 on/off. |
standard-telegram.enabled | false | 표준전문 코덱을 채널 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-length | 10 / 100 | 반복 MESSAGE 1건의 코드·내용 길이. |
layout.enabled | true | 어노테이션 레이아웃 사용(끄면 yml 레이아웃만). |
layout.base-packages | [] | @ScTelegram VO 스캔 패키지. 비면 어노테이션 레이아웃 없음. |
layout.fallback-to-default | true | 어노테이션에 없는 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-suffix | In / Out | 요청/응답 VO 접미사. |
devtools.enabled | true | 개발툴에 삼카 파서(sc-excel 등)·데코레이터 등록. |
③ SPI
| 케이스 | SPI | 메서드 |
|---|---|---|
| 실제 TCP/MQ 연결 | PhysicalTransportService | name(), byte[] exchange(endpoint, payload) |
| 사내 전문 포맷(EBCDIC 등) | TelegramCodecService | encode/decode, charset(), bodyOffset |
| 레이아웃을 DB/메타에서 | TelegramLayoutService | find(interfaceId) |
| 타시스템 수신 처리 | InboundHandlerService | interfaceId(), supports(channel), handle(telegram) |
| 응답코드 → 업무오류 | ChannelResponseOutcomeService | verify(channel, ifId, values) |
| 대외 응답코드 → 사내 메시지코드 | ChannelMessageCodeMappingService | map(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 생성기 | CorrelationIdGenerator | demoCorrelationIdGenerator | web.correlation-header: X-Sample-Trace | 요청 필터 → MDC·응답 헤더 | GET /api/demo/chain/echo → 헤더 X-Sample-Trace: demo-000001 | UUID/채번 규칙 빈으로 교체 |
| 메시지 원천 | MessageSourceService | demoMessageSourceService | exception.business-status: 409, not-found-code | 예외 핸들러 → 메시지 해석 | GET /api/demo/errors/business → 문구 [sample-msg] … | 파일 기본 / message.jdbc(DB) / 운영 메시지 API |
| 롤백 규칙 | RollbackRuleService | demoRollbackRule | transaction.timeout: 15s | @Transactional 예외 판정 | POST /api/demo/tx/entries (DemoCheckedException 도 롤백) | 업무 예외 계층에 맞게 판정 |
| Feign 인터셉터 | feign.RequestInterceptor | demoClientTagInterceptor | outbound.read-timeout, client-error-code | 아웃바운드 HTTP 요청 헤더 | GET /api/demo/outbound/greet → X-Demo-Client: sample-app | 인증 토큰·채널 헤더 주입 |
| MyBatis 설정·플러그인 | ConfigurationCustomizer · Interceptor | demoMyBatisCustomizer, demoQueryCounter | mybatis.paging(5/10), plugins.slow-query-threshold | SqlSessionFactory 생성 시 | GET /api/demo/mybatis/items?size=999 → 10건 상한 | fetchSize·감사 플러그인 |
| 접근 로그 출력처 | AccessLogWriter | demoAccessLogWriter(DemoAccessLogBuffer) | interceptor.access-log.exclude-patterns | 인터셉터 완료 시 write | GET /api/demo/chain/echo 후 버퍼 확인(테스트) | 파일·ELK·DB 적재 |
| XSS 정화기 | XssFilterService | demoTagStrippingXssFilter | xss.excluded-fields: [body] | 요청 파라미터·JSON 필드 | GET /api/demo/secure/search?q=<b>hi</b> → hi | Lucy/AntiSamy 등 |
| 비밀번호 규칙 | PasswordRuleService | demoPasswordRule | validation.password.min-length 12, max-repeat 2 | @ValidPassword 검증 | POST /api/demo/validation/members (sample 포함 → demo.password.forbidden-word) | 사내 정책·금지어 DB |
| 해시 | HashService | demoHashService(SHA-256·1000회·BASE64) | — | 저장 해시·HMAC | POST /api/demo/hash {"text":…} | KMS 키·bcrypt/PBKDF2 |
| OpenAPI | GlobalOpenApiCustomizer | demoOpenApiCustomizer | openapi.profiles/title/version | /v3/api-docs 생성 | GET /v3/api-docs/default → x-sample-owner | 보안 스킴·서버 목록 |
| 메타 적재 | MetaLoadService | demoMetaLoad | meta-load.on-startup: true | 기동 시 1회 + 수동 reload | GET /api/demo/codes/SAMPLE_META, POST /api/demo/meta/reload | 운영 메타 원천(DB·API) |
| 분산 캐시 | DistributedCacheService | demoRemoteCache(DemoRemoteCache, TTL) | cache.distributed.* | CacheService.getOrLoad 등 | PUT/GET /api/demo/cache/distributed/{key} → impl DemoRemoteCache | Redis(아래 목업 참고) |
| 인가 | AuthorizationService (@Primary) | demoAuthorization | auth.role-permissions (sample.report.*) | @RequiresPermission 판정 | GET /api/demo/auth/reports | 권한 DB·SSO 클레임 |
| 필드 암호 | FieldCryptoService | demoFieldCrypto(SAMPLE-ENC: Base64 흉내) | crypto.* | @Encrypted VO 저장·조회 | POST /api/demo/crypto/field | KMS/HSM 엔진 |
| 물리 전송 | PhysicalTransportService | demoHostTransport(sample-host) | channel.channels.fep.transport: sample-host, charset MS949 | ChannelService.send → transport.exchange | POST /api/demo/channel/fep/SAMPLE0001 | AnyLink·WebT·TCP 어댑터 |
| 로그 브리지 | LoggingBridgeService | demoLogCollector(sample-collector) | logging.bridges | StructuredLogService.log → 각 브리지 emit | POST /api/demo/logging/slots/sample-trade | ELK·Kafka 수집기 |
| 세션 저장소 | SessionStoreService | demoRedisLikeSessionStore | session.timeout 15m, max-per-user 1, overflow REJECT | 로그인·인증 필터 | POST /api/demo/sessions, GET /api/demo/ext/session/keys | Redis |
| MQ(데모 전용) | — (SPI 없음) | demoInMemoryBroker | — | 데모 API 직접 | POST/GET /api/demo/ext/mq/{topic} | Rabbit/Kafka 선택 어댑터 |
| TCP 수신(EAI tcp.port) | InboundTelegramProcessor + InboundHandlerService | DemoEaiTcpInboundListener | demo.eai-tcp.enabled/port/bind/channel(앱 yml) | ServerSocket accept → processor.process(eai, id, bytes) → 응답 프레임 | POST /api/demo/ext/eai-tcp/SAMPLE0001 | 솔루션 규격 TCP 리스너(선택 어댑터) |
| 로그 색인 | LoggingBridgeService | demoElasticsearchBridge(sample-elasticsearch) | logging.bridges 에 추가 | emit → 링버퍼 | GET /api/demo/ext/logs | ES Bulk/Logstash |
| 정의서 파서(개발툴) | SpecParseService | demoCsvSpecParser(sample-csv) | devtools.profiles 에 custom | 개발툴 코드생성·정의서 업로드 | 개발툴 홈 → 정의서 유형 sample-csv | 엑셀·사내 정의서 포맷 |
| 코드생성 데코레이터(개발툴) | CodeGenDecorator | demoSampleLayoutDecorator(sample-layout) | devtools.codegen.decorators: [validation, sample-layout] | 생성 VO 어노테이션 | 개발툴 코드생성 미리보기 → @SampleLayout | 사내 어노테이션·주석 규칙 |
// 빈 교체 최소 형태 (예: 상관ID)
@Configuration @Profile("custom")
class MyCustomizations {
@Bean CorrelationIdGenerator myCorrelationId() { return () -> "APP-" + UUID.randomUUID(); }
}
@Primary 필요) · 데모 키/평문 값을 운영 프로필에 복사.외부 연계 확장 예시 — Redis 세션·캐시 · MQ · ES 로깅
원칙: 코어 JAR 에는 SPI 계약만 있고 Redis·RabbitMQ·Kafka·Elasticsearch 클라이언트 의존성은 없어요. 실제 연계는 같은 SPI 타입 빈을 선택 어댑터 모듈 또는 고객사 팩에 두고 등록해 교체해요. 데모는 custom 프로필에서 프로세스 내 목업으로 교체 지점만 보여 줘요.
목업 위치: 레퍼런스 앱 demo/custom/DemoExtensionMocks.java(+ 분산 캐시 DemoCustomizations.DemoRemoteCache), custom 프로필에서만 활성. 모두 일반 코어 SPI 만 사용하며 고객사 확장(표준전문 등)에 의존하지 않아요 — 어느 앱이든 같은 SPI 빈을 등록하면 동일하게 교체돼요.
| 케이스 | SPI(코어) | 데모 목업 | 활성 방법 | 시연 API | 운영 교체 |
|---|---|---|---|---|---|
| 세션 저장소 | SessionStoreService | DemoRedisLikeSessionStore — 키 sample:session:{id}·sample:user:{uid}, 키별 TTL, max-per-user/overflow 준수 | 빈 등록만(기본 InMemorySessionStore 는 @ConditionalOnMissingBean 로 양보) | GET /api/demo/ext/session/keys | Redis HASH/JSON + EXPIRE, 사용자 SET 인덱스, 동시 세션 판정은 Lua/트랜잭션 |
| 공용 캐시 | DistributedCacheService | DemoRemoteCache — 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/mq | spring-rabbit/spring-kafka 선택 어댑터. 기간계 전문은 PhysicalTransportService로 붙이기도 가능 |
| 로그 색인(ES/ELK) | LoggingBridgeService | DemoElasticsearchBridge(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 + 문구로 회신(연결 유지) |
| yml | demo.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);
@direction 규칙은 HTTP 수신과 동일하게 적용돼요.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
| 키 | 기본값 | 설명·언제 쓰나 |
|---|---|---|
enabled | true | false면 프레임 배치 빈(실행 서비스·실패 정책·모니터) 전체 OFF. |
launch.allowed-jobs | [] (전부) | 수동 실행 허용 잡 이름. 운영에선 명시 권장. |
launch.recent-limit | 20 | 최근 실행 조회 기본 개수. |
failure.skip-limit | 0 | 건너뛸 최대 건수(0 = 안 건너뜀). |
failure.skippable | [] | 건너뛸 예외 FQCN(하위 포함). |
failure.retry-limit | 0 | 재시도 횟수. |
failure.retryable | [] | 재시도할 예외 FQCN. |
monitor.logging | true | 잡 시작/종료·건수·소요 로그. |
실행 화면: /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.
배포 가이드(사이트·인프라 케이스별)
정본: 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. 케이스 선택 의사결정 트리
- 1. 모든 케이스 공통 전제
- A. IaaS — 실행형 jar 멀티 인스턴스
- B. IaaS — JEUS + WebToB
- C. 중앙 빌드/배포 관리 시스템 + 에이전트(원격 쉘)
- D. PaaS — Dockerfile + CI/CD
- E. 엔터프라이즈 PaaS — Tekton + Jira + ArgoCD
- F. 공통 운영 전환 체크리스트
- G. 장애 대응 런북(공통)
공통 — 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 반복. 실패 시 링크를 직전 릴리스로 되돌리고 재기동.
- sticky 세션은 임시 방편 — 인스턴스 재기동 시 로그인 유실. 공유 SessionStore 가 정답.
- logrotate(
copytruncate지양, logback rolling 사용)·보관 릴리스 5개·dumps 정리 cron. - 방화벽: 앱 포트는 LB 에서만, 관리 포트는 모니터링에서만.
상세 — 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=graceful30s <TimeoutStopSec45s - □ 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.1 | Boot2 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>
- DataSource: JEUS 에 등록 후
spring.datasource.jndi-name=jdbc/ongoDS(풀 설정은 JEUS 측). 트랜잭션은 앱 로컬 TX 기본. - 로깅: 앱 logback 사용 시 JEUS 로거와 경로 분리,
logging.file.path를 서버별 지정. - WebToB: 정적 리소스(
/static) 직접 서빙,/api/**만 JEUS(JSV/커넥터) 로 위임. 커넥터 수·타임아웃은 JEUS 스레드 풀과 맞춤. - 타임아웃 WebToB > JEUS > 앱 아웃바운드, multipart 한도 WebToB·JEUS·
spring.servlet.multipart3곳 일치. - 배포: 핫 리디플로이 지양(클래스로더 누수) — MS 단위 롤링(WebToB 에서 제외 → undeploy/deploy → 재기동 → 확인 → 투입). 롤백 = 직전 WAR 재배포.
- HttpSession 클러스터링은 프레임 세션을 공유하지 않음 → SessionStore 교체.
상세 — 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/DBSessionStoreService교체(§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 절차(클러스터 롤링)
- 아티팩트 반입:
ongo-app-<ver>.war+ sha256 검증 → DAS 배포 저장소에 업로드. - MS01 을 WebToB 분산에서 제외(WebToB 관리 명령으로 해당 서버 down/suspend, 또는 JEUS 서버 suspend — 신규 요청 차단·진행 요청 완료, 명령 문법 확인 필요).
- 진행 요청 소진 대기(20~30s, JEUS 스레드 모니터에서 active 0 확인).
- MS01 정지 → 애플리케이션 교체(undeploy → deploy, 또는 redeploy 대상 MS 지정) → MS01 기동. 핫 리디플로이(서버 무정지 교체)는 운영 지양 — 클래스로더 누수.
- 헬스:
curl -fs http://was01:<MS01 http 리스너>/actuator/health/readiness(관리 포트 분리 시 해당 포트). WebToB 경유 스모크curl -fs https://app/api/.... - WebToB 재투입 → 5분 관찰 → MS02… 반복.
- 실패 시: 해당 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.xmlcontext-root·클래스 우선 로딩 - □ 공용 lib 충돌 기동 검증(
-Xlog:class+load1회) - □ JNDI 이름·풀 크기(MS 수 × max ≤ DB 세션 80%)
- □ WebToB
http.mURI/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
- 스크립트:
set -euo pipefail·멱등(재실행 안전)·잠금 파일로 동시 실행 방지·로그는/app/<svc>/deploy-logs/. - 보관: 릴리스 N개(예 5), 배포 로그 90일. 비밀값은 스크립트에 넣지 않고 서버 외부 설정/에이전트 비밀 저장소.
- 서버 순서·동시 수는 중앙 시스템에서(1대 → 확인 → 나머지 배치).
삼카: 중앙 시스템 = 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.11jeusadmin시퀀스로 교체하고, 아티팩트를 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) 를 서버 담당자가 직접 관리. - 저장소 접근은 읽기 전용 배포 토큰(
~/.netrc600, 에이전트 계정 소유). 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/app/Dockerfile(ARG MODULE·BASE_REGISTRY·RUNTIME_JDK,src추가 컨텍스트), Boot2docker/app-boot2. devtools 동적 VO 컴파일이 필요한 dev 이미지는 JDK 런타임, 운영 이미지는 devtools 미포함 + JRE·비루트·-XX:MaxRAMPercentage=75. - 레이어드 jar(
java -Djarmode=tools -jar app.jar extract --layers)로 의존성 레이어 캐시.
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 | 핵심 |
|---|---|
| Jenkins | Declarative + K8s agent, stage('test')→('image')→('scan')→('push')→('deploy-dev'), prod 는 input 승인 단계 |
| GitHub Actions | setup-java+gradle 캐시, docker/build-push-action, OIDC 로 레지스트리 로그인, environment: prod 보호 규칙으로 승인 |
| GitLab CI | Kaniko 이미지 빌드(/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>/ composeadditional_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 전용(앞단 도커 nginxdocker/web2026-10-30 폐지 — 이미지 빌드 1개 감소, TLS·Host 는 Cloudflare+NPM 이 담당, NPM → WASAA01N직결, 스모크GET /api/runtime). 분리 FE:docker/demo-web(Nuxt,devfw-web:12003 →/api12012) ·docker/demo-web-custom(같은 빌드,devfw-web-custom:12004 →/api12014). 데모별 compose:docker/{generic-demo,sc-demo,sc-custom-demo,boot2-*,boot3-j17-*}/compose.yaml.sc-custom-demo(APPongo-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/NodePortTCP 포트 별도, 파드 다중화 시 수신 분산·세션 정책 확인(가이드#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 서명 검증 |
| Tekton | Task: 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 |
| Trigger | EventListener 는 내부망 + 토큰/서명 검증, CEL 로 대상 전이만 통과, promote 는 멱등(같은 digest 면 no-op) |
| Jira | 이슈 유형 「반영요청」 필드: app·image·digest·대상 env·변경요약·롤백 계획. 전이 조건: STG=팀장, PROD=CAB, 요청자≠승인자. Cloud /rest/api/3, DC /rest/api/2, 커스텀 필드 ID 는 사이트 확인 |
| ArgoCD | AppProject(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 운영 팁:
PipelineRunTTL/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) 규칙
- 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 쪽 설정(저장소 관리자)
- 저장소 → Repository settings → Webhooks → Create webhook
- URL:
https://tekton-hooks.<내부도메인>/bitbucket/<app>(Ingress 경로별 EventListener 또는 단일 리스너 + CEL 라우팅) - Secret: 32바이트 이상 랜덤값(
openssl rand -hex 32) → 같은 값을 클러스터 Secret 에 등록 - 이벤트: Repository → Push(
repo:refs_changed). PR 빌드(검증용)가 필요하면 Pull request → Opened/Source branch updated(pr:opened,pr:from_ref_updated) 별도 트리거로 분리 - 「Test connection」으로 200/202 확인, Bitbucket 웹훅 「View details」에서 요청/응답 이력 확인
- 서비스 계정 토큰(읽기 전용, HTTP access token) → Tekton
git-clone용basic-authSecret. 빌드 상태 회신을 쓰면 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 설정 +
bitbucketInterceptor 서명 검증 - □ 이벤트
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 |
운영 전환 체크리스트
- □ 암호화·HMAC 키를 환경변수/Vault로 주입
- □
SessionStoreService,IdempotencyStore,DistributedCacheService를 Redis/DB 구현으로 교체 - □ yml 데모 사용자(
auth.users) 제거,AuthService구현 등록 - □ 채널
transport: loopback→ 실제 transport - □ devtools/openapi 프로파일에 운영 프로파일이 없는지 확인
상세 문서 전문(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)로 밀려난 세션은 다음 요청에서 401auth.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 파이프라인)
- 401(
AUTH_ERROR)이고 코드가refresh목록이면refreshAuth()를 부른다. 로그인처럼authEvent: false이거나refresh: false인 요청은 갱신하지 않는다. - 동시 요청 단일 갱신: 갱신 중이면 같은 Promise 를 기다린다. 요청을 보낸 뒤 이미 다른 요청이 갱신을 끝냈다면(갱신 순번 비교) 다시 갱신하지 않고 바로 재시도한다.
- 성공하면 request 훅을 다시 돌려(새 Authorization) 한 번만 재시도한다. 재시도도 401 이거나 갱신이 실패하면 일반 401 처리(세션 만료).
- 실제 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-06terminateSession('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 | 오프라인·목업 시연 |
바꾸는 방법 (위에서 아래로 범위가 좁아진다)
- 환경 프로파일별:
.env.<profile>또는 실행 시NUXT_PUBLIC_MESSAGE_CATALOG_POLICY=catalog-first. 프리셋 이름 대신 순서를 직접 적어도 된다:override,catalog,server,default(모르는 단계는 무시). - 코드별: 카탈로그 JSON 항목에
"override": true— 정책에override단계가 있으면 그 코드만 FE 문구로 덮는다. - 실행 중(데모·테스트):
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을 매핑 순서대로 생성(또는 JacksonPropertyNamingStrategy/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. 요구사항 및 목표
-
파일 검증 (Validation) - 용량 제한 (
maxSizeBytes, 기본 10MB) - 허용 확장자/MIME 타입 검증 (allowedExtensions,allowedMimeTypes) - 다중 파일 개수 제한 (maxFiles) - 검증 실패 시 명확한 에러 코드/메시지 반환 (FILE_TOO_LARGE,INVALID_EXTENSION,TOO_MANY_FILES) -
멀티파트 업로드 (Multipart Upload) -
FormData기반 멀티파트 바디 구성 (File/Blob/ 추가 폼 필드) - 실시간 업로드 진행률(progress: 0~100%) 계산 및 상태(status: idle | validating | uploading | success | error) 관리 - XHR 또는 커스텀 업로더 어댑터를 통한 진행률 이벤트 추적 - 취소(abort) 지원 -
다운로드 (Blob Download) -
Content-Disposition헤더 파싱:filename*=UTF-8''...(RFC 5987/6266) 및filename="..."형식 지원, 한글 파일명 완벽 디코딩 - 브라우저 다운로드 트리거:URL.createObjectURL(blob)임시<a>태그 생성 후 자동 클릭 및 메모리 해제(revokeObjectURL) - 다운로드 진행률 및 오류 처리 (오류 발생 시 FE-02HttpError공통 예외 체계 연동) -
프레임워크 규격 준수 - 코어 로직(
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.
프레임의 모든 기능은 같은 세 단계로 바꾼다. 위에서부터 가벼운 방법이다.
- yml —
ongo.framework.<기능>.*값으로 on/off·순서·코드·상태·크기를 바꾼다. - 슬롯(SPI) 빈 추가 — 프레임이 주워 쓰는 타입의 빈을 앱에 등록한다(기본은 그대로 두고 덧붙임).
- 기본 빈 교체 — 같은 타입(또는 같은 이름) 빈을 앱에 등록하면 프레임 기본은
@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): 유효 세션 → 새 토큰 발급·기존 토큰 무효화. 만료면 401auth.token-expired.- 같은 키 재요청 → 저장 응답 재생(
Idempotent-Replayed: true), 처리 중 → 409idempotency.in-progress, 다른 본문 → 422idempotency.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 가 토큰). 동시 세션 거절은 409auth.session-limit.- 인증 필터: 헤더(기본
Authorization: Bearer <토큰>)·쿠키 →SessionStoreService.touch→IdentityService→AuthContext.current(). 차단은 하지 않는다. - 인가 가드:
protected-paths는 로그인 필요,@RequirePermission("코드")는 로그인 + 권한. 미로그인 401auth.unauthenticated, 권한 없음 403auth.forbidden, 로그인 실패 401auth.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바이트). 넘치면 자르지 않고 400channel.encode. - 기본 전송:
http(전문 바이트를application/octet-streamPOST — 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, 미등록 인터페이스 404channel.interface.unknown, 응답 길이 부족 502channel.decode, 전송 실패 502channel.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는 채널 전용 ExecutorongoChannelExecutor(고정 풀,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, 처리기 없음 404channel.inbound.unknown, 접수 번호 없음 404channel.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). 수신 응답만 다른 문자셋으로 나가는 일은 없다. - 필드 길이는 바이트 기준이다. 같은
holder20바이트 필드에 한글 이름은 UTF-8 6자 / EUC-KR 10자까지 들어간다. 넘치면 잘리지 않고 400channel.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 BootLoggingSystem). 잘못된 레벨 400logging.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(항상 남음), 생성기 = Javarecord(또는 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(MyBatisResultSetHandler.handleResultSets)가 결과 VO(가변 클래스·중첩·List/Map)의@EncryptedString 을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/, snapshotshttps://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 직접 전달
- 권장: 파일 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' }
- 최후:
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)
- VO:
@ScTelegram("SCDEMO01")(응답은direction = RESPONSE), 필드@ScField(order, length[, type = NUM][, name]). - 조회 순서(
AnnotatedTelegramLayouts,@Primary TelegramLayoutService): ①layout.base-packages스캔 ② 네이밍 규칙{package}.{interfaceId}In/Out클래스 로드·파싱(캐시) ③ 코어 yml 레이아웃(fallback-to-default). - 기동 검증: 중복 ID·order 중복·응답만 있는 ID·
@ScField없는 VO → 실패. - 개발툴
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 시트 패턴, 기본 타입 →mappingJSON 자동 작성 ③ [엑셀 펼치기]/[미리보기] → 생성 코드가 파일별 편집창 ④ 수정 후 수정본 소스에 쓰기 또는 [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 프로퍼티 전체 표 추가·관련분)
18.9 데모 = 삼카 확장 기준 예시
- 데모 앱은
ongo-framework-samsung-card를 의존하고samsung-card.layout.base-packages: com.ongosoft.framework.demo.samsungcard— 레이아웃·파서·데코레이터가 삼카 기준으로 뜬다. - 쇼케이스
http://localhost:8080/(코어/삼카/개발툴 카드별 ▶ 실행, 개발툴은local프로필): 채널 loopbackPOST /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. 교체가 안 될 때 점검표
- 빈이 스캔되는가 —
@SpringBootApplication하위 패키지인지. 아니면@Import. - 프로필 — 데모처럼
@Profile("custom")을 복사했다면 그 프로필을 켰는지. - 교체형/확장형 구분 — 교체형인데 기본 빈도 살아 있으면 이름 조건(
ConditionalOnMissingBean(name=…))이다 → 같은 이름 또는@Primary. - 위임(데코레이터 패턴) — 기본 빈을 주입받아 감쌀 때는 다른 이름 +
@Qualifier+@Primary(I3). - yml 키 —
ongo.framework.channel.channels.<이름>,devtools.codegen.decorators,batch.failure.skip-limit등은 CUSTOMIZATION.md 의 키 표와 대조. - 확인 방법 —
/actuator/beans(로컬) 또는 시작 로그의ConditionEvaluationReport(--debug), devtools 화면 정보줄의데코레이터:목록. - 시크릿 — 암호 키·토큰은 코드/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<InboundHandlerService> 빈<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.servlet26회,jakarta.validation25회. - 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+ 문법 사용(파일 수):
record51,List/Map/Set.of57,Stream.toList()21,var18,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. 리스크 / 기존 사용자 영향
- common을 Java 8로 낮추면 record 51개·
toList()·var·패턴매칭 다운그레이드 필요 — 가장 큰 작업. record는 equals/hashCode/accessor 이름(x()) 유지해 API 호환 맞출 것. (대안: Lombok@Value금지, 수동 클래스) - 패키지/아티팩트 좌표 변경 → 기존 소비자(demo, nanuer 등) 의존성 교체 필요. boot3 아티팩트는 기존 artifactId 유지하고 신규 common을 transitive로 끌어오면 소비자 영향 최소.
HttpStatusCode→ int 변경은 예외 public API 변경. 생성자 오버로드 유지(boot3 어댑터에 deprecated 브리지)로 완화.- Feign/Spring Cloud, Batch, springdoc(OpenAPI) 버전 매트릭스가 boot2 쪽에서 별도로 관리됨.
- 자동 치환은 문자열 리터럴 내
jakarta등 오탐 가능 → 생성 소스도 컴파일+테스트로 검증.
7. 다음 PoC 단계
ongo-framework-common모듈 생성, core A 중 record 없는 패키지(paging,code,crypto) 이동 +release=8컴파일 통과.- 예외 계층
HttpStatusCode→ int 전환 PoC, 기존 demo 테스트 green. - boot2
core-web모듈: jakarta→javax 치환 생성 태스크로 10개 파일 컴파일(Boot 2.7.18, JDK 8). - starter 중 1개 AutoConfiguration(
OngoXssAutoConfiguration)을 boot2로 생성·smoke 테스트. - 결과 보고 후 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→불변 클래스,InMemorySessionStoretoList()→Collectors),auth/*중 서블릿 무관한 것 전부 —AuthPrincipal(record→클래스),AuthContext,AuthService/IdentityService/AuthorizationService,RequirePermission,RoleBasedAuthorization,InMemoryUserAuth(Userrecord→클래스),AuthSessionService(LoginResultrecord→클래스),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/OngoAuthAutoConfiguration+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/OngoExceptionAutoConfiguration+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.MaskRulerecord→불변 클래스(접근자fields()등 유지). - override 1개 추가:
core/security/ColumnAttributeService—ColumnMetarecord·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 로 전파되므로 기존 사용처 컴파일·동작 유지. 대신 coreColumnMeta.of(RecordComponent, …)는c.getAccessor()의 애너테이션을 읽도록 변경. - core 에 남은 것:
HtmlEscapeXssFilter(SpringHtmlUtils)·XssRequestFilter(서블릿) — 기존대로 Boot2 생성.ColumnAttributeService(record·RecordComponent → override 유지)·ColumnValues(ColumnMeta 의존) — core 원본/override 로 Boot2 생성. - Boot2 생성 추가(starter):
OngoCryptoAutoConfiguration·OngoCryptoProperties(imports 노출), MyBatisEncryptedFieldInterceptor·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·애너테이션 해석), coreColumnMetaRecordTest(record 접근자 경유), boot2Boot2ValidationSmokeTest(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()추가 — 데모/slotsJSON 필드 유지).Slf4jLoggingBridge: slf4j 2.xatLevel→ 레벨별 분기(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 동일, JacksongetX()추가로 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 11java.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.9BOM 을 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.7org.springframework.boot.autoconfigure.http그대로.- 원본 8 문법 다운그레이드만:
isBlank→trim().isEmpty()(3곳),var→Collection<String>, instanceof 패턴→명시 캐스트(EnvelopeUnwrappingDecoder#candidate), private recordRemoteError→private static final class. - smoke
Boot2OutboundSmokeTest(3): 빈 등록·yml(read-timeout·retry)·끄기, 실제 Feign 호출(JDK HttpServer) — envelopedata언랩, 상관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개,generateBoot2ResourcesdoLast). - 제외:
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-retryRetryPolicy(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 -> ..캐스트 필요.
- Boot 2.7 은
- 원본 8 문법 다운그레이드:
BatchJobLaunchServicepublic record 3종(JobInfo·StepInfo·ExecutionInfo) → 불변 final class(접근자name()등·equals/hashCode/toString 유지, JacksongetX()/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/endLocalDateTime, 파라미터+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 gettergetX()/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지역F2개. - 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 6HttpStatusCode)@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/devtools4파일뿐.
남은 것 / 다음 (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-cardsamsungcard/devtools(ScIoSpecParser의String.lines()); batchBatchDevtoolsController. - record → 불변 final class: 필드·정식 생성자(
@ConstructorProperties— Jackson 역직렬화)·x()접근자(기존 호출 호환)+getX()(Jackson 직렬화 이름 동일)·equals/hashCode/toString(record 형식Name[a=..]). compact 생성자 로직(TelegramVoNamingRule기본값 등)은 정식 생성자 앞부분으로, 보조 생성자 그대로. 요청 DTODynamicTelegramController.Request의@XssSafe는 필드로. local recordF→ 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 buildgreen(JDK 21). devtools·samsung-card·batchsrc/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.1compileOnly(+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.7WebApplicationContextRunner, 작게):local프로필:OngoDevtoolsAutoConfiguration+SamsungCardAutoConfiguration→DevtoolsController·CodeGenService·파서(ongoJsonSpecParser·ongoExcelSpecParser·samsungCardIoSpecParser)·sc-standardDynamicCodecOption등록.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개):JsonSpecParserin/out 정의서 → 동적 VO 컴파일 →@TelegramField레이아웃(cardNoCHAR10 ·amountNUM5) →FixedLengthTelegramCodecencode("94001 00042", hex 고정) →LoopbackPhysicalTransport→ 응답 VO decode(cardNo=94001,echo=42). Boot3DynamicTelegramPipelineTest의exchange케이스를 Java 8 문법으로 옮긴 것(텍스트 블록·var제거, 테스트 전용 lines 파서 대신 실제 JSON 파서).javac없으면 assumption 으로 건너뜀(PoC18 과 동일).DynamicVoCompilerjavac 부재 메시지 단위 검증은 보류: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-cardScStandardTelegramCodecTest첫 케이스의 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·batchlibrary jar. - Boot2 세트 (javax):
boot2-core-web(생성본) +commonjar를 미리 빌드해 묶는 단계 추가 (현재는 이미지 빌드 시 소스 생성). - 원칙: 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)는 DockerfileARG BASE_REGISTRY(기본docker.io)로 받는다. 제니 배포는 OnGoDevOpsimage.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) withSPRING_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는 입사 후 팩으로 갈아 끼운다.
- 데모 로그인 —
POST /api/auth/login(id/pw) → AuthService 검증 → 세션 또는 JWT 발급 → C1 응답 - 보호 API — Authorization 헤더/세션 쿠키 → 필터가 IdentityService 채움 → @RequirePermission 또는 AuthorizationService
- 권한 거부 — AuthorizationService 실패 → C2 BusinessException → C1 success=false · 메시지 코드
- 역할·메뉴 예제 — 앱 DB에 ROLE/PERM 시드 ·
/api/me· 관리자 전용 API 1개 · 일반 유저 거부 시나리오 - 로그아웃·만료 — 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. 구축 순서
- C1 요청/응답 — JSON envelope · Advice · OpenAPI
- C2~C2f 기반 — 예외 · TX · Feign · MyBatis · 인터셉터 · 마스킹·XSS
- C3 코드·메시지 SPI — Lookup/Message/MetaLoad 서비스 · 기본 DB 구현 · 외부 오버라이드 경로
- C4 캐시·세션 SPI — Local + Distributed + SessionStore · Redis 어댑터는 선택 모듈
- C5 인증 SPI + POC 데모 앱 — 서비스·필터 골격 · 샘플 로그인/권한 예제 앱
- 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).
동료·에이전트는 작업 전에 아래를 반드시 읽는다.
- docs/AGENT-HANDBOOK.md — 방향·전략·Do/Don’t (숙지용 정본) 2-0. docs/FRONTEND-AGENT-CONTEXT.md — 프론트(Nuxt) 에이전트 공통 컨텍스트: 백엔드 모듈·DevOps 배포(OnGoDevOps 참조)·도메인·Nuxt↔백엔드 연계
- docs/FRONTEND-COMMON-WORKLIST.md — 프론트 공통 작업 리스트(레이어 A–E, FE-01~FE-36, SEO·AEO 포함, P0/P1/P2)
- docs/architecture.md — 꼭지(C1~C7) 설계 요약
브랜치·배포 (2026-10-27~)
- 개발 전용 저장소: 기본 브랜치
develop,master는 삭제됨. 작업 브랜치는develop에서 따고 PR 은develop으로 머지한다. developpush(머지) → GitHub 웹훅 → JenkinsWEBHOOK-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/ WASAA01N, 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수용(없거나 형식 불량 시 생성) → 응답 헤더·MDCtraceId·envelopetraceId. 실패 응답도 같은traceId를 쓴다. - 성공 래핑 교체:
CorrelationIdGenerator·ApiResponseBodyAdvice빈을 앱이 등록하면 기본값 후퇴(@ConditionalOnMissingBean, 복수면@Primary). ymlongo.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 로 바꾼다.
교체 경로 (메시지·예외)
- SPI:
MessageSourceService빈을 앱이 등록하면 기본 구현(DefaultMessageSource)은 올라오지 않는다(@ConditionalOnMissingBean). 구현이 둘 이상이면@Primary. - 핸들러:
ApiExceptionHandler빈을 등록하거나ongo.framework.exception.enabled=false. 끈 뒤에도 메시지 서비스는 남는다. - 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'
교체 경로 (트랜잭션)
- 매니저 빈:
PlatformTransactionManager를 등록하면 기본 매니저는 올라오지 않는다. JPA·JTA·고객사 매니저는 이 경로다. 후보가 둘 이상이면@Primary. - 단일 DataSource 서비스:
TransactionManagerService빈이 DataSource 로 매니저를 만든다. 매니저 빈이 있으면 서비스는 호출되지 않는다. - 분산 서비스:
DistributedTransactionManagerService빈이 있으면 단일 DataSource 매니저 대신 그 매니저를 쓴다. 매니저 빈보다는 뒤다. 기본 구현은 없다. - DataSource: 앱의
DataSource빈, 또는spring.datasource·임베디드 DB 가 우선이다. 둘 다 없을 때만DataSourceService가 DataSource 를 만든다. DataSource 가 둘 이상이고 분산 서비스가 없으면 프레임은 매니저를 만들지 않는다. - 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 기본 매니저는 남을 수 있다. - 롤백 판단:
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)을 호출한다.
교체 경로 (아웃바운드)
- 빈:
ErrorDecoder·Decoder·Encoder·Retryer·Request.Options를 앱이 등록하면 프레임 기본은 물러난다(@ConditionalOnMissingBean). 후보가 둘 이상이면@Primary. - 클라이언트 하나만:
@FeignClient(configuration = ...)또는spring.cloud.openfeign.client.config.<name>.*가 공통 기본값보다 우선한다. - 인터셉터:
RequestInterceptor빈을 더하면 함께 적용된다. 상관ID 인터셉터는CorrelationIdRequestInterceptor빈 등록으로 교체한다. - 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
SAMPLE0001defined inapplication.yml,mcichannel on loopback transport. - Devtools (
/framework-devtools, profileslocal/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. 형식 오류는 ymlskippable로 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 asvalue,type) - OnGo DevOps (Jennie):
docker/sc-democompose profilebatch— seedocker/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. 형식 오류는 ymlskippable로 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-democompose profilebatch. - 테스트:
./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.tsruntimeConfig기본값 → 실행 시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.mdFE-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등, Tailwindbg-default·text-muted등)만 쓰고#fff같은 고정 색을 넣지 않는다. - 정적 페이지와 다크모드 공유: 개발툴 홈·배치실행·코어 개발가이드·개발툴 사용가이드·쇼케이스(boot2·generic-demo·sc-demo)는 같은 토글 스크립트로
html[data-theme]를 바꾸고, 선택을 cookieongo-theme(light|dark, path=/, 1년, SameSite=Lax) + localStorageongo-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, URLNUXT_PUBLIC_TRACKER_ENDPOINT— 실 자체 로그서버 예POST /api/collect, GA4 는 같은 인터페이스로gtag래퍼 어댑터 추가) /bridge(앱 WebView 감지 시 자동, FE-13analytics.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, GitHubOnGoSoft/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
- 환경 접두어 (
{환경접두어}): - 개발(development):dev(예:devfw-web.ongosoft.com,devfw21-sc.ongosoft.com) - 스테이징(staging, 도입 시):stg(예:stgfw-web.ongosoft.com) - 운영(production): 생략 (예:fw-web.ongosoft.com,nanuer.ongosoft.com) - 백엔드 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. 작업 규칙 (요약)
- 시작 전 AGENT-HANDBOOK.md → 이 문서 → FRONTEND-COMMON-WORKLIST.md 순으로 읽는다.
- 작업 단위 = 워크리스트
FE-xx(현재 FE-01~FE-36). 우선순위 P0부터, ID 재사용 금지. - develop 에서 브랜치 → PR → develop 머지(머지 = 개발계 백엔드 및 분리된 프론트 웹서버
ongo-demo-webDocker 재배포 트리거임을 기억). - 백엔드 API가 부족하면 목업으로 진행하고 "백엔드 호출 위치"를 명기한다(FE 워크리스트 원칙). 부족/요청 사항은 BACKEND-COLLABORATION.md에 즉시 기록하여 관리한다.
- 배포·도메인·포트 변경은 OnGoDevOps 쪽 일이다. 이 저장소에서 추측해 쓰지 않는다.
- 프레임 코어에 화면·업무 도메인을 넣지 않는다(핸드북).
- UI 는
Ui*계약 컴포넌트·useUi*만 쓴다(현재 구현 Nuxt UI, 교체 가능). Nuxt UI 직접 사용은 어댑터 파일에서만 —npm test의 경계 검사가 막는다. 워크리스트 §1-5. - 프레임워크(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. - XSS 안전 표시(FE-29): 서버가 이스케이프한 값은
<CommonSafeText :value>, 서식 HTML 은<CommonSafeHtml :html>로만 그린다.v-html은components/common/SafeHtml.vue한 곳에서만,innerHTML·insertAdjacentHTML·document.write는 쓰지 않는다(tests/ui-boundary.test.ts). 데모/xss. - 민감정보 마스킹(FE-27): 카드·계좌·주민·휴대폰·이메일·이름은
<CommonMasked :value kind>또는mask()(utils/mask.ts)로만 표시하고, 민감 영역은v-sensitive(복사·드래그·우클릭 차단) /v-sensitive="'mask'"(클립보드엔 마스킹본). 콘솔·수집기 로그는console.*대신safeLog.*(객체 키·자유 텍스트 자동 마스킹). 데모/mask. - 포맷·검증(FE-28): 금액·일자·카드·계좌·사업자·휴대폰은
utils/format.ts(format*/parse*/is*/validate*)와<CommonFormattedInput kind v-model>(v-model = 서버 전송값, 화면 = 포맷 문자열)을 쓴다. 데모/format. - 약관 동의(FE-21):
<CommonTermsAgreement group @proceed>— 메타(필수/선택·약관/설명서·버전·contentUrl·mustRead·children) 조회 후 본문 비동기 로딩, HTML 본문은 FE-29 정화. 목업/mock-cms/terms/<group>.json(실 BE 메타 API·컨텐츠 서버로 교체). 데모/terms. - 태깅/클릭코드(FE-36): 화면진입은 자동(화면ID 는 FE-20 화면 메타 레지스트리 값 —
definePageMeta({ screenId })는 예외적 덮어쓰기만), 클릭은v-track="'CLK_…'"또는data-click-code, 전환은useTracker().conversion('CNV_…', payload). GA·로그서버를 화면에서 직접 부르지 않는다. 분석·마케팅 동의(useConsentStore) 전에는 전송되지 않고, payload 의 PII 키는 제거된다(이름·카드·계좌·주민·연락처 등을 넣지 말 것). 감사 로그(FE-31)와 분리. 데모/tracking. - 삼카 확장 · 커스텀 데모: 기본 삼카 데모와 별도 참조 화면
/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. - 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·스토리지 APIFE-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. 설계 결정 (원우 승인 방향)
- 환경 프로파일 고정명 —
local-local/local-dev/dev/stg/prod. 에이전트가 헷갈리지 않게 이름을 바꾸지 않는다. - 요청 파이프라인 단일 설계 — HTTP 훅·오류 분류·에러 카탈로그·다이얼로그·옵션 자동 팝업·로딩·인증/세션/로그아웃 이벤트·라우트 가드는 따로 만들지 않고 레이어 B 하나의 흐름으로 설계한다. 먼저 FE-02에서 파이프라인 골격(단계·이벤트 이름·옵션)을 확정하고 나머지 B 항목은 그 훅에 붙인다.
- 브릿지 목업 우선 — 브라우저용 목업 네이티브 앱으로 규약·데모를 먼저 끝내고, 네이티브는 인터페이스만 맞춘다.
- 원우 필수 항목이라도 더 나은 방향이 있으면 PR 설명에 제안으로 남긴다(임의 삭제 금지).
- 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 프레임워크」. - 프레임워크(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(codeauth.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-02plugins/http.tspopup훅 연동, 데모pages/dialog.vue, 테스트tests/dialog.test.ts. - 목표: 브라우저 alert/confirm 대신 공통 디자인 다이얼로그.
- 완료 기준:
alert/confirmPromise API, 버튼별 클릭 이벤트, show/hide 이벤트 훅, 큐(중복 팝업 처리), FE-02 자동 팝업과 연결, 포커스 트랩(FE-24과 연계). - 연동: 해당없음(UI)
FE-05. 글로벌 로딩·스켈레톤·토스트 (P2) — 구 #25
- [x] 완료 —
feat/fe-p2-batch·useUiToast큐·app.beginLoading전역 로딩(httploading: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-06terminateSession('expired')·BroadcastChannel),composables/useSessionIdle.ts, 데모pages/session.vue, 테스트tests/idle-session.test.ts. 연장 BE 호출 = 기존GET /api/me(BE 슬라이딩 만료, 신규 API 없음). 설정runtimeConfig.public.session(BEongo.framework.session.timeout30m 과 같게). - 목표: 유휴 감지 후 만료 안내.
- 완료 기준: 만료 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— refreshauth.token-expired, forcedauth.duplicate-login·auth.forced-logout·auth.session-revoked, envNUXT_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-02lock, 멱등 키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), 데모/uiFE-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 두 모드가 같은 상태 기계, BEpage(0-base)·size·totalElements·totalPages↔ 화면 1-base, 마지막·빈 목록·오류 retry·범위 밖 보정·늦은 응답 무시·메타 없는 응답 추정),composables/usePaging.ts(usePaging·httpPage(url, query)), 무한 스크롤components/common/InfiniteScroll.vue(IntersectionObserver·짧은 목록 연속 로딩·로딩/마지막/빈/오류 표시), 데모pages/paging.vue(실제 BEGET /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 임베드 + 새 창·다운로드 폴백, 앱은 bridgedocument.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-LDfaqJsonLd/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-33meta스토어) · 플러그인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.ts11건(전 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.orgOrganization·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 → 스토리지 scopewcmsTTL 캐시 →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(htmlhc·reduce-motion클래스), 데모/a11y. - 목표: 포커스 트랩·키보드·고대비.
- 완료 기준: 다이얼로그 포커스 트랩/복귀, 키보드 탐색, 고대비 모드 토글 샘플.
- 연동: 해당없음
FE-25. 반응형 샘플 (P2) — 구 #11
- [x] 완료 — 1차(#162) + 2차: 브레이크포인트 공통 변수
assets/css/main.css(--breakpoint-sm/md/lg= Tailwind 접두어 정본,--bp-*px) ↔utils/device.tsBREAKPOINTS(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.pyCSS/토글에 반영(재실행 멱등). - 2차 반영: 브레이크포인트 변수화·3폭 샘플
/responsive·카드형 표(FE-26). - 목표: 모바일/태블릿/PC 하나의 마크업 + 브레이크포인트.
- 완료 기준: 3개 폭에서 깨짐 없는 샘플 페이지, 브레이크포인트 공통 변수.
- 연동: 목업
FE-26. 적응형 샘플 (P2) — 구 #12
- [x] 완료 — 순수 함수
utils/device.ts(deviceFromWidth< md mobile / < lg tablet / desktop,deviceFromUserAgentiPadOS 터치 보정,resolveDevice폭 우선·UA 폴백) · composableuseDevice()(width·device·isMobile/isTablet/isDesktop, resize 반영) ·UiTablelayout='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 전환(스토어 반응형). 앱 웹뷰(useBridgenative-*)는 웹 헤더 숨김. 본문 바로가기(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 스토리지 scopemenupublic TTLNUXT_PUBLIC_MENU_TTL_SEC(300), 실패 시 내장 폴백utils/menu-fallback.ts),components/common/MenuTree.vue(재귀, aria-expanded/aria-current, 외부 링크 rel=noopener). 목업 JSONpublic/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-13analytics.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 인코딩된 값(
<등)을 디코딩해 읽기용으로만 표시. - 완료 기준: 디코딩 유틸 + 표시 시 스크립트 실행 불가 보장(텍스트 바인딩 또는 화이트리스트 정화, 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 안전(NuxtuseState/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. 동료 에이전트 작업 방법
- 새 항목 추가 시 ID는 마지막 번호 다음(현재 다음 = FE-36)을 쓰고 §3 표와 §4 상세를 함께 갱신한다.
master최신화 후 이 파일에서 미체크 항목 하나(FE-ID)를 고른다(P0 → P1 → P2 순, 의존은 §3 표의존열).- 브랜치
feat/fe-common-FE-<NN>-<요약>생성. 항목 1개 = 작은 PR 1개 원칙. - 구현은 위 §0 제약대로 데모 앱 내부 공통 폴더에. 쇼케이스/데모 페이지로 시연 가능하게 만든다.
- 같은 PR에서 이 파일의 해당 항목
[ ]→[x](요약 표 + 상세 둘 다) 체크, 상세에 PR 번호(그리고 ID 유지)·주요 파일 경로를 한 줄 기록. - 범위가 커지면 항목을 하위 단계로 쪼개 이 파일에 추가하고 부분 체크.
- 완료 기준이 모호하면 임의 확정하지 말고 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경로로 401auth.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 요청
- 동작:
- 클라이언트가
Idempotency-Key: <UUID>헤더 전달 - 서버는
(사용자ID, 요청경로, Idempotency-Key)를 키로 24시간 저장 - 동일 키로 동일 본문 재요청 시: 새로운 비즈니스 로직을 실행하지 않고 기존 처리 성공 응답을 그대로 반환 (헤더에
Idempotent-Replayed: true권장) - 처리 중(In-Progress)인 상태에서 동일 키 재요청 시:
409 Conflict반환 - 동일 키인데 요청 본문(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필드 또는 HTTPETag헤더로 반환 - 필요 사유: 현재 백엔드 캐시 리로드(
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, APIDevtoolsController·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 정의서
- 유형에서
샘플 JSON 입력선택 → "① 샘플 요청/응답 JSON" 칸이 나타난다(다른 유형일 때는 숨김). - 실제 데이터(또는 그 모양의 임의 값)를 붙여 넣는다. 요청/응답은
in/out키로 구분:json {"in":{"cardNo":"1234567812345678","amount":10000}, "out":{"resultCode":"0000","items":[{"seq":1,"name":"a"}]}}구분 없이 주면 전체가 하나의 VO:{"cardNo":"1234567812345678"} - 이름(
CARD01— 클래스명·인터페이스ID 기준) 입력 → [샘플 파싱 → 정의서 생성] (POST /api/import/sample-json?name=CARD01). - 성공: JSON 정의서·표가 채워지고 샘플 칸은 비워지며 안내
✔ 정의서 생성 — 샘플은 비웠어요. 이후 편집은 정의서/표에서 한다(샘플은 일회성). - 실패: 브라우저에서 먼저
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 엑셀
- 파일 선택(.xlsx). 유형이
excel/sc-excel가 아니면 자동으로excel로 처리한다. - [시트·컬럼 분석(매핑 제안)] (
POST /api/excel/inspect/{type}) → 시트별 헤더 행·헤더 목록·자동 매핑 제안이 드롭다운으로 표시된다. - 드롭다운으로 필드 키(
name·label·type·length·scale·required·description·encrypted·masking·io·level등)↔엑셀 컬럼을 확정하면 매핑 JSON 칸이 자동 갱신된다. -IN 시트/OUT 시트패턴(예*_IN/*_OUT)과기본 타입(예STRING)도 지정 가능. - [엑셀 펼치기] → 정의서·표. (업로드한 .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 등)
- 공통 I/O 정의서 카드 상단 입력 유형을 고른다(예
samsung-card-snippet). - JSON 정의서 칸에 원문을 그대로 붙여 넣는다(JSON 아니어도 된다 — 이때 표 동기화는
⚠ JSON 오류를 띄우지만 무시). - [본문 파싱] (
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 타입 · 설명 · [✕]
- [+ 메소드 추가] 로 행 추가, [✕] 로 삭제.
- 메소드명이 빈 행은 무시. 유효한 행이 하나도 없으면 기본
list(GET "")·get(GET /{id})·create(POST "")생성 — 자세한 규칙은 §8.2. - URI 를 비우면 베이스 경로 그대로.
{orderId}는@PathVariable String orderId로. - 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토큰으로. - in/out 칸은 입력 즉시 자동완성 후보(datalist)가 뜬다. 행을 한 번 클릭하면 "선택 행"이 된다 → 아래 타입 찾기 검색 후 [선택 행 in에 넣기]/[선택 행 out에 넣기].
- 한 줄 텍스트 ↔ 폼 양방향: 폼 행을 입력·수정·삭제하면 "한 줄 텍스트" 칸이 실시간으로 같은 내용으로 변환된다(Content-Type·추가 파라미터 포함, 복사해 공유·재사용 가능). 반대로 텍스트를 고치거나 붙여넣고 [폼으로 가져오기(폼 교체)] 를 누르면 폼 행이 텍스트 내용으로 교체된다:
cancel PUT /{orderId}/cancel in:OrderCancelDto out:OrderDto 주문 취소 search GET /search in:OrderSearchDto out:List<OrderDto> 주문 검색 ping GET - 상태 확인형식:이름 HTTP URI(없으면 -) [in:타입] [out:타입] 설명…. - [미리보기] → 파일 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] 로 허용 채널 제한(비우면 전체).
- 공통 I/O 정의서 카드의 정의서 칸 본문 + 입력 유형(전문 테스트 카드에 "정의서·유형 = 위 공통 I/O 정의서 카드" 로 표시)(텍스트 유형이면 원문 그대로도 가능).
- 코덱 선택(
GET /api/dynamic/codecs): 코어 고정길이(기본) / 삼카 표준전문(standard-telegram.enabled=true일 때) 등DynamicCodecOption빈. - 인터페이스ID(생략 시 첫 VO).
- 값(JSON):
{"cardNo":"1234567812345678","amount":10000}— 없는 키는 공백/0 채움. - 방향: 송신(요청 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"}. - 표준전문 코덱이면 HEADER·MESSAGE JSON 으로 헤더값 지정(설정 기본값보다 우선) → 내부적으로
header.이름/message.이름키:{"sendSysDvC":"ONG","akRspDvC":"S"}/{"stdMsgPrntTpc":"1"}값이null/""면 설정·내장 기본값 유지," "(length>0)는 공백 그대로. 우선순위 HEADER·MESSAGE 입력 > 업무단ScTelegramContext> (수신 응답) 요청 HEADER 에코 > yml 기본값 > 내장 기본값 — 상세·수신 처리기 응답 맵 예시는 CUSTOMIZATION 「표준전문」절. - [전문 송수신] → 결과: 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> 가 < 로 이스케이프돼 와도 검증 전에 원문으로 복원합니다.
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