
Catalog는 DB 연결 관리, 메타데이터 동기화, 마이그레이션, 데이터 추출을 담당하는 Control Plane입니다.
빠른 검증이 우선이었던 PoC 단계에서는 모든 API가 permitAll()로 설정되어 인증 없이 호출이 가능했고, application.yml에는 DB 비밀번호와 AES 마스터키가 평문으로 저장되어 있었습니다.
또한 클라이언트가 보낸 generatedSql을 원천 DB에 그대로 실행했으며, userId 요청 헤더를 감사 주체로 신뢰하는 상태였습니다.
PoC 단계에서는 용인될 수 있으나 상용 환경에서는 어느 하나도 그대로 둘 수 없는 항목들입니다.
이번 작업은 기능 추가가 아니라 최소한의 변경으로 보안 기준선을 세우는 것을 목표로 삼았습니다.
P0 보안 → P1 Worker 계약 → P2 아키텍처 경계 → P3 의존성·성능
이 글은 그중 P0 단계의 작업만을 다룹니다.
| 위험 | PoC 상태 | 영향 |
|---|---|---|
| 인증·인가 없음 | anyRequest().permitAll() | 누구나 연결 등록, 추출, 마이그레이션 가능 |
| 시크릿 저장소 포함 | password: postgres, AES 키 하드코딩 | 저장소 유출 시 즉시 시스템 침해 발생 |
| Raw SQL 실행 | generatedSql → JDBC executeQuery | 임의 SELECT 및 우회 공격 가능 |
| 신원 위조 | userId 헤더를 audit ID로 사용 | userId: admin 설정만으로 감사 로그 조작 가능 |
permitAll() 제거.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll())
누구나 Swagger UI 등을 통해 제약 없이 API를 호출할 수 있는 상태였습니다.
spring-boot-starter-oauth2-resource-server를 추가하고 API 경로별로 역할을 매핑했습니다.
.authorizeHttpRequests(authorize -> {
authorize.requestMatchers("/actuator/health").permitAll();
authorize.requestMatchers("/internal/worker/**").hasRole("WORKER");
authorize.requestMatchers("/api/v1/conn/**").hasRole("ADMIN");
authorize.requestMatchers("/api/v1/migration/**").hasRole("MIGRATION_OPERATOR");
authorize.requestMatchers("/api/v1/extract/**").hasRole("EXTRACT_OPERATOR");
authorize.requestMatchers("/api/v1/meta/**").hasRole("METADATA_READER");
authorize.requestMatchers("/api/v1/category/**").hasRole("ADMIN");
// ...
authorize.anyRequest().authenticated();
})
Control Plane은 연결 관리자, 마이그레이션 운영자, 추출 운영자, 메타데이터 조회자 등 역할이 명확히 나뉩니다.
permitAll()을 한 번에 제거할 때 발생할 수 있는 기존 클라이언트의 충격을 최소화하기 위해, 경로 단위 RBAC를 적용하여 변경 범위를 통제했습니다.
로드밸런서의 헬스체크용 엔드포인트인 /actuator/health만 permitAll을 유지했으며, Swagger는 swagger-enabled 플래그를 통해 운영 환경에서 비활성화하거나 관리자만 접근할 수 있도록 제어합니다.
JWT의 roles 클레임을 Spring Security 권한으로 변환하는 과정을 정의했습니다.
public class JwtRoleConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
return new JwtAuthenticationToken(jwt, authorities, jwt.getSubject());
}
}
ADMIN 형태의 클레임을 ROLE_ADMIN으로 정규화하여 hasRole("ADMIN") 표현식과 일치시켰습니다.
일반 사용자 JWT와 Worker 토큰은 서로 다른 인증 경로를 가집니다.
/internal/worker/ 경로는 전용 필터가 처리하도록 구성했습니다.
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String presented = resolveToken(request);
if (!StringUtils.hasText(presented) || !workerToken.equals(presented)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
SecurityContextHolder.getContext().setAuthentication(new WorkerTokenAuthentication(WORKER_PRINCIPAL));
filterChain.doFilter(request, response);
}
Extract Worker callback은 사용자 세션이 아닌 시스템 간 통신에 해당하므로, JWT 발급 체계와 분리하는 것이 운영과 보안 관점 모두에서 명확합니다.
password: postgres
secret-master-key: 9YEf7AK6dGWza7+0ffcpVWr/O3B3VqH5rHqfImHEvWo=
DB 비밀번호와 AES-GCM 마스터키가 소스 코드 저장소에 평문으로 포함되어 있었습니다.
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
security:
jwt-secret: ${JWT_SECRET}
worker-token: ${EXTRACT_WORKER_TOKEN}
swagger-enabled: ${SWAGGER_ENABLED:false}
connection:
secret-master-key: ${SECRET_MASTER_KEY}
저장소에 실제 시크릿이 노출되면 유출 즉시 시스템 침해로 이어집니다.
${ENV_VAR} 플레이스홀더를 적용하여 값이 없을 경우 Spring Boot가 fail-fast 형태로 기동을 차단하도록 유도했습니다.
과거 커밋에 노출되었던 키들은 단순히 소스 코드에서 삭제하는 것에 그치지 않고 전체 폐기 및 재발급 절차를 밟아야 합니다.
로컬 환경 실행 시에는 다음과 같은 환경변수 정의가 필요합니다.
export DB_USERNAME=postgres
export DB_PASSWORD=...
export SECRET_MASTER_KEY=... # Base64 인코딩된 32바이트 AES 키
export JWT_SECRET=... # 32자 이상의 문자열
export EXTRACT_WORKER_TOKEN=...
generatedSql 제한 및 클라이언트 SQL 직접 실행 차단PoC 단계에서는 클라이언트가 전달한 SQL 문자열을 별도 검증 없이 원천 DB에서 직접 실행하는 구조였습니다.
POST /api/v1/extract/prepare
→ generatedSql 수신
→ JdbcExtractStagingAdapter.loadFromSql()
→ connection.prepareStatement(generatedSql).executeQuery()
인증체계가 부재한 상태에서 SQL 검증마저 이루어지지 않아 임의의 SELECT 문을 실행할 수 있는 통로가 열려 있었습니다.
ExtractQueryService를 도입하여 오직 두 가지 경로만 허용하도록 제한했습니다.
첫 번째이자 권장 방식은 tableName과 columns 정보를 바탕으로 서버가 직접 SELECT 문을 생성하는 방식입니다.
두 번째는 client-generated-sql-enabled: true 설정이 명시적으로 활성화되어 있을 때만 클라이언트 SQL을 허용하되 철저한 검증을 거치도록 하는 방식입니다.
public ValidatedExtractQuery build(ExtractQueryCommand command, int maxRows, Duration timeout) {
// ...
if (hasSql) {
if (!command.clientGeneratedSqlEnabled()) {
throw new IllegalArgumentException("클라이언트 generatedSql 경로는 비활성화되어 있습니다. tableName을 사용하세요.");
}
sqlValidator.validateReadOnlySelect(command.generatedSql());
return new ValidatedExtractQuery(/* ... */);
}
// tableName 기반 서버 SQL 생성
String sql = "SELECT " + columnList + " FROM " + qualifiedTable;
return new ValidatedExtractQuery(sql, List.of(), maxRows, timeout);
}
기본적인 설정 상태에서는 클라이언트 SQL 진입 경로가 비활성화됩니다.
client-generated-sql-enabled: false
query-max-rows: 100000
query-timeout: 30s
정규식 기반 검증만으로는 완벽할 수 없으나, 1차적인 방어선으로서 특정 키워드와 구문을 명확히 차단합니다.
private static final Set<String> FORBIDDEN_KEYWORDS = Set.of(
"INSERT", "UPDATE", "DELETE", "MERGE", "DROP", "ALTER", "TRUNCATE",
"CREATE", "GRANT", "REVOKE", "CALL", "EXEC", "EXECUTE", "COPY", "INTO", "FOR"
);
// 세미콜론(;)을 이용한 다중 문장 실행 거부
// 주석(--, /* */) 활용 우회 시도 거부
// 오직 SELECT 구문만 허용하며 UNION 구문 차단
ExtractDatasetCommandService는 이제 검증되지 않은 raw SQL 문자열을 staging adapter로 직접 전달하지 않습니다.
ValidatedExtractQuery query = extractQueryService.build(
new ExtractQueryCommand(
command.source().vendor(),
command.sourceSchema(),
command.tableName(),
command.generatedSql(),
sourceColumnKeys,
extractPipelinePolicy.clientGeneratedSqlEnabled()
),
extractPipelinePolicy.queryMaxRows(),
extractPipelinePolicy.queryTimeout()
);
long loaded = extractStagingPort.loadFromSql(
command.source(),
query,
// ...
);
클라이언트로부터의 입력값은 신뢰 경계를 넘어 DB 레이어까지 직접 도달해서는 안 됩니다.
기존 API 계약과의 호환성을 고려해 generatedSql 필드는 유지하되, 기능 플래그를 통해 안전한 기본값을 제공합니다.
아울러 아키텍처의 원칙에 따라 검증 로직은 domain service에 위치시키고 JDBC 실행 역할은 adapter 영역에 고립시켰습니다.
userId 헤더 제거 및 인증 주체 기반 사용자 식별@RequestHeader(value = "userId", required = false) String userIdHeader
private String actor(String userIdHeader) {
return StringUtils.hasText(userIdHeader) ? userIdHeader.trim() : "system";
}
HTTP 요청 헤더에 userId: admin과 같은 임의의 값을 지정하는 것만으로 creat_id나 mdfr_id 같은 감사 컬럼 값을 변조할 수 있는 상태였습니다.
AuthenticatedUserProvider 도입@Component
public class SecurityAuthenticatedUserProvider implements AuthenticatedUserProvider {
@Override
public String currentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// ...
if (principal instanceof Jwt jwt) {
String subject = jwt.getSubject();
if (subject != null && !subject.isBlank()) {
return subject.trim();
}
}
// ...
}
}
컨트롤러 영역에서는 주입받은 컴포넌트를 통해 안전하게 사용자 식별 정보를 참조합니다.
private final ConnectionApplicationService connectionApplicationService;
private final AuthenticatedUserProvider authenticatedUserProvider;
public ConnectionController(
ConnectionApplicationService connectionApplicationService,
AuthenticatedUserProvider authenticatedUserProvider
) {
authenticatedUserProvider.currentUserId() // 외부 변조가 불가능한 JWT subject에서 직접 추출
HTTP 헤더 정보는 클라이언트가 임의로 조작할 수 있으므로 신뢰할 수 있는 인증 정보로 취급해서는 안 됩니다. 반면 서명으로 보호되는 JWT의 sub 클레임은 감사 주체로 활용하기에 적합합니다. AuthenticatedUserProvider 인터페이스를 통해 감사 및 권한 조회를 한곳으로 집중시켰습니다.
보안 관련 변경 사항은 검증을 위한 테스트 코드가 동반되어야 완료된 것으로 판단했습니다.
이번 작업에서 추가된 주요 테스트 명세는 다음과 같습니다.
@Test
@DisplayName("인증 없이 연결 API 호출 시 401")
void rejectUnauthenticatedConnectionApi() throws Exception {
mockMvc.perform(get("/api/v1/conn/list"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("UNAUTHORIZED"));
}
@Test
@DisplayName("권한이 부족하면 403")
void rejectInsufficientRole() throws Exception {
String token = TestJwtSupport.bearerToken("reader", "METADATA_READER");
mockMvc.perform(get("/api/v1/conn/list")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token))
.andExpect(status().isForbidden());
}
| 테스트 항목 | 주요 검증 내용 |
|---|---|
| 401 Unauthorized | 유효한 토큰 없이 API 호출 시 접근 차단 여부 |
| 403 Forbidden | 인가된 역할이 일치하지 않을 때 접근 차단 여부 |
| 200 OK | 올바른 역할(ADMIN)을 보유한 경우 정상 허용 여부 |
| Worker 401 | /internal/worker/** 경로 접근 시 전용 토큰 누락 차단 여부 |
| Health 200 | 헬스체크 엔드포인트의 무인증 접근 허용 여부 |
| SQL 정책 검증 | DDL/DML 수행 시도, UNION 구문 및 주석을 활용한 우회 시도 차단 여부 |
기존에 작성되어 있던 기능 테스트들 역시 userId 헤더 기반 방식에서 JWT Bearer 토큰을 활용하도록 전면 수정했으며, 전체 테스트 수트를 안정적으로 통과하는 상태를 확보했습니다.
이번 P0 작업에서는 핵심적인 보안 기준선을 구축하는 것에 집중했으며, 아래 항목들은 아키텍처 및 단계별 로드맵에 따라 의도적으로 다음 일정으로 분류했습니다.
| 작업 항목 | 현재 대응 상태 |
|---|---|
Worker 인메모리 상태 제어 (local-* Job ID) | P1 단계에서 DB를 통한 영속화 구조로 전환 예정 |
| Worker callback 엔드포인트 설계 및 멱등성 보장 | P1 단계 반영 예정 |
| Meta/Category 영역의 MyBatis 직접 의존성 제거 | P2 단계에서 포트 분리 리팩터링 진행 |
CORS 설정 내부의 allowed-headers: "*" 허용 정돈 | P3 단계 반영 예정 |
| JaCoCo 커버리지 기준 CI 게이트 통과 설정 | P1 단계 반영 예정 |
| Hadoop transitive 의존성 구조 정돈 | P3 단계 반영 예정 |
permitAll() 무조건적 허용 구조 제거 및 JWT 기반 RBAC 적용generatedSql 진입 경로 기본 비활성화 및 서버 중심 SQL 생성 방식 전환userId 헤더 의존 구조를 JWT subject 참조 방식으로 변경generatedSql 필드 자체의 스펙 유지PoC 단계에서 상용화 단계로 전환할 때 빈번히 발생하는 오류는 새로운 기능을 구현하는 과정 뒤로 보안 조치를 미루는 현상입니다.
이번 작업은 시스템 구조 전체를 무리하게 변경하기보다 안전한 흐름을 지속적으로 검증할 수 있는 최소 변경 루프를 기반으로 진행되었습니다.
현 상태 고정 → 최소 변경 → 테스트 → 회귀 확인 → KEEP / HOLD / REVERT
Catalog와 같이 인프라 및 데이터베이스 레이어에 직접 접근하는 Control Plane 구조물은 핵심적인 인증 유효성 검증과 진입 경로 통제만 적절히 수행해도 공격 표면을 유의미하게 축소할 수 있습니다.
이어지는 P1 단계에서는 Extract Worker의 인메모리 상태를 데이터베이스 영역으로 이관하고 callback 처리 계약을 확정하는 작업을 이어갈 계획입니다.