UNION ALL로 분산된 이벤트 로그 통합하기
백오피스 개발 중 이런 요구사항을 받았습니다. "특정 엔티티의 전체 이벤트 이력을 시간순으로 보여달라"는 것이었습니다. 단순해 보였지만, 이벤트 데이터가 여러 테이블에 분산되어 있다는 문제가 있었습니다.
문제 상황: 분산된 이벤트 로그
시스템에서 하나의 엔티티에 대해 여러 종류의 이벤트가 발생합니다. 문제는 이 이벤트들이 성격에 따라 다른 테이블에 저장된다는 것이었습니다.
┌─────────────────────┐ ┌─────────────────────┐
│ pause_log │ │ transaction_log │
├─────────────────────┤ ├─────────────────────┤
│ - 일시정지 시간 │ │ - 처리 시간 │
│ - 사유 코드 │ │ - 트랜잭션 ID │
│ - 사용자/시스템 여부 │ │ - 에러 코드 │
│ - 사용자 ID │ │ - 상태 코드 │
└─────────────────────┘ └─────────────────────┘프론트엔드에서 시간순으로 정렬된 통합 타임라인을 보여주려면, 백엔드에서 이 두 테이블을 합쳐야 했습니다. 단순히 각각 조회해서 애플리케이션 레벨에서 합치는 방법도 있었지만, 페이지네이션을 고려하면 SQL 레벨에서 통합하는 것이 효율적이었습니다.
왜 UNION ALL인가?
SQL에서 여러 쿼리 결과를 합치는 방법은 두 가지가 있습니다.
UNION : 중복 제거 (내부적으로 정렬 필요 → 느림)
UNION ALL : 중복 허용 (정렬 불필요 → 빠름)일시정지 이벤트와 트랜잭션 이벤트는 서로 다른 종류의 이벤트입니다. 동일한 row가 양쪽 테이블에 존재할 수 없으므로, 중복 제거가 필요 없습니다. UNION ALL을 사용해 불필요한 정렬 연산을 생략할 수 있었습니다.
구현 패턴
Knex.js를 사용한 구현 예시입니다. 핵심은 두 쿼리의 컬럼 수와 순서를 맞추는 것입니다.
async getEntityTimeline(
entityId: string,
offset: number = 0,
limit: number = 10,
): Promise {
// 1. 일시정지 이벤트 쿼리
const pauseQuery = this.db.read
.select([
this.db.read.raw("'pause' as event_type"),
'entity_id',
'paused_at as event_time',
'reason_code',
'is_user_action',
'user_id',
// 트랜잭션 전용 컬럼은 NULL로 채움
this.db.read.raw('NULL as transaction_id'),
this.db.read.raw('NULL as error_code'),
])
.from('pause_log')
.where('entity_id', entityId);
// 2. 트랜잭션 이벤트 쿼리
const transactionQuery = this.db.read
.select([
this.db.read.raw("'transaction' as event_type"),
'entity_id',
'processed_at as event_time',
// 일시정지 전용 컬럼은 NULL로 채움
this.db.read.raw('NULL as reason_code'),
this.db.read.raw('NULL as is_user_action'),
this.db.read.raw('NULL as user_id'),
'transaction_id',
'error_code',
])
.from('transaction_log')
.where('entity_id', entityId)
.whereNotNull('processed_at');
// 3. UNION ALL로 합침
const unionQuery = pauseQuery.unionAll(transactionQuery);
// 4. 통합 결과를 시간순 정렬 + 페이지네이션
return this.db.read
.select('*')
.from(unionQuery.as('timeline'))
.orderBy('event_time', 'desc')
.offset(offset)
.limit(limit);
}컬럼 매핑의 핵심
UNION ALL은 컬럼 수가 같아야 동작합니다. 한 테이블에만 있는 컬럼은 NULL로 채워서 구조를 맞춥니다.
pause_log 쿼리 transaction_log 쿼리
───────────────────── ─────────────────────
event_type: 'pause' event_type: 'transaction'
entity_id entity_id
event_time (paused_at) event_time (processed_at)
reason_code NULL
is_user_action NULL
user_id NULL
NULL transaction_id
NULL error_code이렇게 하면 결과 row에서 event_type을 보고 어떤 종류의 이벤트인지 구분할 수 있고, 해당 이벤트에 맞는 컬럼만 사용하면 됩니다.
타입 정의
TypeScript에서 통합 타임라인의 타입을 정의하는 방법입니다.
// 공통 필드
interface BaseTimelineEvent {
entityId: string;
eventTime: Date;
}
// 일시정지 이벤트
interface PauseEvent extends BaseTimelineEvent {
eventType: 'pause';
reasonCode: string;
isUserAction: boolean;
userId: string;
}
// 트랜잭션 이벤트
interface TransactionEvent extends BaseTimelineEvent {
eventType: 'transaction';
transactionId: string;
errorCode: string | null;
}
// 통합 타입 (Discriminated Union)
type TimelineEvent = PauseEvent | TransactionEvent;eventType 필드로 구분되는 Discriminated Union 패턴을 사용하면, 타입 가드가 자연스럽게 동작합니다.
function processEvent(event: TimelineEvent) {
if (event.eventType === 'pause') {
// TypeScript가 PauseEvent로 추론
console.log(event.reasonCode);
} else {
// TypeScript가 TransactionEvent로 추론
console.log(event.transactionId);
}
}프론트엔드 연동
프론트엔드에서는 eventType에 따라 UI를 분기합니다.
const EVENT_CONFIG = {
pause: {
label: '일시정지',
icon: '⏸',
className: 'event-pause',
},
transaction: {
label: '처리',
icon: '✓',
className: 'event-transaction',
},
} as const;
function renderTimelineItem(event: TimelineEvent) {
const config = EVENT_CONFIG[event.eventType];
return (
<div className={config.className}>
<span>{config.icon}</span>
<span>{config.label}</span>
<time>{formatDate(event.eventTime)}</time>
{event.eventType === 'pause' && (
<span>사유: {event.reasonCode}</span>
)}
</div>
);
}주의사항
1. 컬럼 순서 일치
UNION ALL은 컬럼 이름이 아닌 순서로 매핑됩니다. 두 쿼리의 컬럼 순서가 다르면 데이터가 잘못 매핑될 수 있습니다.
// ❌ 잘못된 예: 컬럼 순서가 다름
const query1 = db.select(['entity_id', 'event_time']);
const query2 = db.select(['event_time', 'entity_id']); // 순서 다름!
// ✅ 올바른 예: 컬럼 순서 일치
const query1 = db.select(['entity_id', 'event_time']);
const query2 = db.select(['entity_id', 'event_time']);2. NULL 처리
NULL로 채운 컬럼은 애플리케이션에서 적절히 처리해야 합니다. TypeScript의 경우 해당 필드를 optional로 정의하거나, null 타입을 명시합니다.
3. 인덱스 고려
UNION ALL 결과에 대한 정렬은 서브쿼리 이후에 수행됩니다. 각 테이블의 entity_id와 시간 컬럼에 인덱스가 있어야 성능이 보장됩니다.
-- 권장 인덱스
CREATE INDEX idx_pause_log_entity_time
ON pause_log(entity_id, paused_at);
CREATE INDEX idx_transaction_log_entity_time
ON transaction_log(entity_id, processed_at);4. 페이지네이션과 전체 카운트
페이지네이션 UI를 위해 전체 개수가 필요하다면, 별도 카운트 쿼리를 실행해야 합니다.
async getTimelineCount(entityId: string): Promise {
const pauseCount = this.db.read
.count('* as cnt')
.from('pause_log')
.where('entity_id', entityId);
const transactionCount = this.db.read
.count('* as cnt')
.from('transaction_log')
.where('entity_id', entityId)
.whereNotNull('processed_at');
const [pause, transaction] = await Promise.all([
pauseCount.first(),
transactionCount.first(),
]);
return Number(pause?.cnt || 0) + Number(transaction?.cnt || 0);
}UNION vs UNION ALL 선택 기준
정리하면, 선택 기준은 다음과 같습니다.
UNION ALL 사용 (권장)
├─ 서로 다른 종류의 데이터를 합칠 때
├─ 중복이 발생할 수 없는 구조일 때
└─ 성능이 중요할 때
UNION 사용
├─ 실제로 중복 제거가 필요할 때
├─ 같은 테이블을 다른 조건으로 조회할 때
└─ 데이터 정합성이 불확실할 때정리
여러 테이블에 분산된 이벤트 로그를 통합 타임라인으로 제공해야 할 때, SQL의 UNION ALL은 효과적인 해결책입니다.
핵심 포인트를 정리하면 다음과 같습니다.
컬럼 수와 순서를 맞추고, 없는 컬럼은 NULL로 채움
event_type 컬럼을 추가해 어떤 테이블에서 온 데이터인지 구분
중복이 없다면 UNION ALL을 사용해 불필요한 정렬 연산 제거
TypeScript의 Discriminated Union 패턴으로 타입 안전성 확보
처음에는 애플리케이션 레벨에서 합치는 것을 고려했지만, 페이지네이션과 정렬을 고려하면 SQL 레벨에서 통합하는 것이 훨씬 깔끔했습니다. 비슷한 상황을 마주한다면 UNION ALL을 고려해보시기 바랍니다.
관련 글
NestJS 슬로우 쿼리 자동 감지: 스택 트레이스 캡처부터 Slack 알림까지
슬로우 쿼리가 발생했을 때 SQL은 쉽게 알 수 있지만, 어디서 호출했는지는 파악하기 어렵습니다. Knex Proxy 패턴으로 호출 스택을 자동 캡처하고 Slack으로 알림받는 시스템을 구현했습니다.
NestJS에서 Drizzle ORM을 선택한 이유: TypeORM, Prisma와의 비교
새로운 SaaS 모듈에 MySQL을 도입하면서 TypeORM, Prisma, Drizzle ORM을 비교했습니다. 각 ORM의 장단점과 Drizzle을 선택한 이유를 실제 코드 예시와 함께 정리했습니다.
k6와 실시간 Pool 모니터링으로 시스템 한계점 찾기
k6로 시스템 한계점을 찾는 Breakpoint 테스트와 NestJS Connection Pool 실시간 모니터링 시스템을 구현한 경험. 최적 RPS를 찾기까지의 과정을 정리했습니다.