고객사가 내부 로그를 달라고 했다 — 폐쇄망 Splunk ETL 파이프라인 설계기
원본 로그는 줄 수 없고, 아무것도 안 줄 수도 없었다. 개인정보 마스킹 ETL + 폐쇄망 Splunk 주입까지 제약 조건 속에서 설계한 과정.
고객사가 내부 로그를 달라고 했다 — 폐쇄망 Splunk ETL 파이프라인 설계기
상황
협업 SaaS에서 일하던 중, 대형 엔터프라이즈 고객사에서 특이한 요청이 왔습니다. 연간 보안 감사를 위해 자사 협업 플랫폼 내 모든 액션 로그가 필요하다는 것이었습니다. 메시지 생성·수정·삭제, 파일 접근 이력, 채널 입퇴장 등 사용자가 자사 협업 플랫폼에서 한 모든 행동의 기록이었습니다.
요구 자체는 이해할 수 있었습니다. 대기업은 내부 감사 요건이 있고, SaaS 서비스를 사용한다고 해서 감사 대상에서 제외되지 않습니다. "우리 플랫폼에서 어떤 일이 일어났는지 우리가 볼 수 있어야 한다"는 정당한 요구였습니다.
문제는 우리가 그 로그를 그대로 줄 수 없다는 것이었습니다.
제약 조건 두 가지
첫째, 원본 로그에는 개인정보가 있었습니다.
내부 로그에는 사용자 이름, 이메일, IP 주소 같은 정보가 포함되어 있었습니다. 개인정보보호법상 이걸 외부 고객사에 그대로 제공하는 건 불가능했습니다. 개인 식별 정보(PII)를 적절히 처리해야 했습니다.
둘째, 고객사 환경이 폐쇄망이었습니다.
일반적인 기업 보안 환경에서는 외부 인터넷 접근이 차단된 내부망(폐쇄망)에서 시스템을 운영합니다. 우리 서버에서 고객사 Splunk로 데이터를 직접 전송하는 게 불가능한 상황이었습니다.
이 두 제약 조건 안에서 고객의 감사 요건을 충족해야 했습니다.
설계한 구조
1단계: ETL — 원본 로그에서 감사용 데이터 추출
내부 로그 DB
│
│ Extract
▼
원본 로그 (이름, 이메일, IP 포함)
│
│ Transform (마스킹 + 포맷 변환)
▼
감사 로그 (고객 ID만, 개인정보 제거)
│
│ Load
▼
감사 로그 DB (고객사 전용)
마스킹 처리 기준
- 이름 → 제거
- 이메일 → 제거
- IP 주소 → 제거
- 사용자 ID → 유지 (고객사 내부에서 자신들의 ID와 매핑 가능)
- 액션 타입, 타임스탬프, 채널 ID → 유지
function maskAuditLog(rawLog) {
return {
eventId: rawLog.id,
timestamp: rawLog.createdAt,
userId: rawLog.userId, // 유지 (고객사 내부 매핑용)
action: rawLog.action, // 유지 (create, update, delete 등)
resourceType: rawLog.resourceType, // 유지 (message, file, channel 등)
resourceId: rawLog.resourceId, // 유지
// 제거: userName, userEmail, userIp
};
}중요한 설계 결정이 있었습니다. 사용자 이름과 이메일을 제거하면 고객사가 "이 액션이 누가 했는지"를 어떻게 알 수 있을까요? 고객사 자신의 디렉토리(AD, LDAP)에서 userId로 매핑할 수 있습니다. 내부 감사 목적이라면 고객사가 userId만으로 직원을 특정할 수 있습니다. 개인정보보호법상 민감한 PII를 우리가 외부에 제공하지 않아도 되고, 고객사는 감사 목적은 달성할 수 있는 구조입니다.
2단계: 폐쇄망 Splunk 전송 구조
폐쇄망 환경에서 외부 데이터를 받는 일반적인 방법은 프록시 서버입니다.
[우리 서버]
│
│ HTTPS 전송
▼
[고객사 DMZ의 프록시 서버]
│
│ 내부망 경유
▼
[폐쇄망 내부 Splunk]
Splunk는 HEC(HTTP Event Collector)라는 토큰 기반 데이터 수신 인터페이스를 제공합니다. 고객사는 DMZ에 Splunk Forwarder를 두고, 우리가 전송한 데이터를 내부 Splunk로 포워딩하는 구조로 구성했습니다.
// Splunk HEC로 데이터 전송
async function sendToSplunk(events, config) {
const payload = {
events: events.map(event => ({
time: new Date(event.timestamp).getTime() / 1000,
source: 'swit-audit',
sourcetype: 'swit:audit:log',
event: event,
})),
};
await axios.post(config.hecEndpoint, payload, {
headers: {
'Authorization': `Splunk ${config.hecToken}`,
'Content-Type': 'application/json',
},
httpsAgent: new https.Agent({
rejectUnauthorized: true, // 인증서 검증 필수
}),
});
}배치 처리와 재전송 처리
감사 로그는 실시간이 아니라 일 단위 배치로 전송했습니다.
실시간 전송의 문제점:
- 폐쇄망 프록시의 일시적인 다운으로 로그가 유실될 수 있음
- 네트워크 불안정 시 스파이크 발생
배치 처리의 장점:
- 실패 시 해당 배치를 재전송 가능
- 전송 시간을 새벽 저트래픽 시간대로 고정 가능
- 전송 이력을 DB에 남겨 어디까지 전송했는지 추적 가능
// 마지막으로 전송한 시점 이후의 로그만 가져오기
async function fetchUnsentLogs(lastSentAt) {
return auditLogRepo.findAll({
where: {
createdAt: { $gt: lastSentAt },
},
orderBy: { createdAt: 'asc' },
take: 1000, // 한 번에 최대 1000건
});
}
// 전송 완료 후 체크포인트 업데이트
async function updateSendCheckpoint(lastLogId, lastLogTime) {
await checkpointRepo.upsert({
key: 'splunk-audit-checkpoint',
lastSentId: lastLogId,
lastSentAt: lastLogTime,
});
}결과
| 항목 | 해결 방법 |
|---|---|
| 원본 PII 노출 | 마스킹 ETL로 이름·이메일·IP 제거, userId만 전달 |
| 폐쇄망 전송 | DMZ 프록시 + Splunk HEC 토큰 인증 |
| 전송 안정성 | 일 단위 배치 + 체크포인트 기반 재전송 |
| 법적 준수 | 개인정보보호법 준수 (PII 미전달), 고객사 감사 요건 충족 |
고객사는 자신들의 Splunk 대시보드에서 자사 협업 플랫폼 내 모든 액션 이력을 조회할 수 있게 됐고, 연간 보안 감사를 통과했습니다.
마치며
이 프로젝트에서 배운 것은 제약 조건이 곧 설계의 방향이라는 것입니다.
"원본 로그를 그대로 줄 수 없다"는 제약이 ETL + 마스킹이라는 해결책을 만들었습니다. "폐쇄망이라 직접 전송이 안 된다"는 제약이 프록시 + HEC라는 구조를 만들었습니다.
제약이 없었다면 더 간단한 방법을 썼을 것이고, 그랬다면 개인정보 문제나 보안 문제가 뒤에 터졌을 겁니다. 제약이 오히려 더 견고한 설계로 이끌었습니다.
"왜 이렇게 복잡하게 만들었냐"는 질문에 대한 답은 "제약 조건이 그 복잡함을 요구했기 때문"입니다.