서론
MyISAM에서는 트랜잭션을 사용할 수 있을까요?
"아뇨, 사용할 수 없습니다."

엥??? 그럼 제목은 어그로임???
아쉽게도 MyISAM은 트랜잭션을 지원하지 않습니다. MySQL 공식 문서에서도 분명 명시되어있습니다.
더 아쉽게도, 저는 현업에서 아직도 MyISAM 엔진을 사용중이며, InnoDB로의 마이그레이션이 불가능한 상황입니다.
협업 과정에서 데이터베이스 엔진의 마이그레이션을 하지 않으면 안되겠냐는 얘기를 들었고, 마이그레이션이 불가능한 원인을 소통 과정에서 짐작해보자면 기술적인 문제보다는 현재 잘 동작하기에, 그리고 변경에 대한 불안감 등의 심리적인 요인일 듯 합니다.
이런 상황을 베이스로 실제 발생했던 아래 문제들과
- 데이터 정합성이 맞지 않음(CS로 이어짐): 주문은 했는데 캐시 사용이 제대로 안되거나 발주서가 기업체에게 도달하지 않음.
- DX문제: 보상 로직을 예외 처리 구간에서 직접 하나하나 넣어줘야함.
트랜잭션과 유사한 동시성과 데이터 정합성을 지키기 위해 애플리케이션 레벨에서 시도했던 경험,
어쩌면 InnoDB를 사용하지 않는 환경이었기에 얻었던 경험에 대해 기록하고 공유하고자 합니다.
아래부터 나올 모든 코드는 Typescript로 작성되어있습니다.
MyISAM의 데이터 정합성 문제
다들 아시다시피 MyISAM은 트랜잭션을 지원하지 않습니다.
간단하게, 결제 시 캐시를 차감하고 히스토리를 남기는 로직을 예시로 들어보겠습니다.
async function createOrder(userId: number, amount: number) {
const rollbackActions: Array<() => Promise<void>> = [];
try {
// 1. 포인트 차감
const user = await prisma.user.update({
where: { id: userId },
data: { icash: { decrement: amount } }
});
rollbackActions.push(async () => {
await prisma.user.update({
where: { id: userId },
data: { icash: { increment: amount } }
});
});
if (user.icash < 0) {
throw new Error('잔액 부족');
}
// 2. 주문 생성
const order = await prisma.order.create({
data: { userId, amount, status: 'PAID' }
});
rollbackActions.push(async () => {
await prisma.order.delete({ where: { id: order.id } });
});
// 3. 결제 이력 생성
await prisma.paymentHistory.create({
data: { userId, amount, action: 'DEDUCT' }
});
} catch (error) {
// 역순으로 롤백 실행
while (rollbackActions.length > 0) {
try {
const rollback = rollbackActions.pop();
if (!rollback) break;
await rollback();
} catch(rollbackError) {
// 보상 로직 실패
console.error(rollbackError);
}
}
throw error;
}
}
MyISAM은 트랜잭션이 없기 때문에 원자성을 보장하지 않습니다.
따라서 성공 작업들을 모두 개발자가 직접 롤백해줘야겠군요.
이정도면, 개발자가 작업하면서 실수로 코드를 뺴먹는 일만 없다면, 문제 없을 것 처럼 보입니다.
실수 방지를 위해 타입스크립트를 활용해서 보완할 수 있습니다.
아래처럼 compensate를 강제한다면 컴파일 단계에서 휴먼 에러도 방지할 수 있을 것 같아요. 훌륭합니다!
interface TransactionStep<TResult = any> {
name: string;
execute: () => Promise<TResult>;
compensate: (result: TResult) => Promise<void> | void;
}
async function createOrder(userId: number, amount: number) {
// Step 정의: execute와 compensate를 함께 정의하도록 강제
const steps: TransactionStep[] = [
{
name: '포인트_차감',
execute: async () => {
const user = await prisma.user.update({
where: { id: userId },
data: { icash: { decrement: amount } }
});
if (user.icash < 0) {
throw new Error('잔액 부족');
}
return { userId, amount };
},
compensate: async (result) => {
await prisma.user.update({
where: { id: result.userId },
data: { icash: { increment: result.amount } }
});
}
},
// ... 나머지 Step들
];
}
하지만, 롤백 로직만 커스터마이징한다고 해결되는 문제가 아닙니다.
- 보상에 실패한다면?
- 동시 요청이 발생한다면?
보상에 실패한다면 어떻게 해야 할까요? 무한 반복? 코드를 아래처럼 바꾸면 될까요?
while (rollbackActions.length > 0) {
try {
const rollback = rollbackActions[rollbackActions.length - 1]; // peek
await rollback();
rollbackActions.pop(); // 성공 시에만 제거
} catch(rollbackError) {
// 계속 재시도... 무한 반복
await sleep(1000);
}
}
예상되지 않는 상황에 무한정 반복을 하는 것은 올바른 패턴이 아닙니다.
어떤 원인으로 인해 실패했는지 모르는데 무한정 반복하게 되면 어떤 다른 문제들이 더 발생할지 짐작할 수 없기 때문입니다.
동시 요청을 막기 위해 MyISAM의 Lock을 사용하면 될까요?
MyISAM의 Lock은 InnoDB처럼 레코드 단위의 락이 아닙니다. 테이블 단위의 락은 락을 해제할 때까지 그 누구도 해당 테이블에 접근하지 못하는 것을 의미합니다. 1번 유저의 캐시 차감을 위해 모든 사용자의 정보 변경, 조회 등 모든 요청이 락이 해제될 때 까지 대기하게 되는 문제가 발생합니다.
트랜잭션이 없다면 직접 만들자
지금까지 확인한 문제들을 정리하면
- 보상 로직 누락 방지(O): TransactionStep 인터페이스로 컴파일 타입에 누락 방지 가능
- 보상 실패 처리(X): 무한 재시도는 답이 아님 - 데이터 복구를 위한 시스템(DLQ 등)이 필요
- 동시성 제어(X): MyISAM의 테이블 락은 안됨 - 세밀한 동시성 제어를 위한 수단이 필요함
MyISAM에서 트랜잭션을 제공하지 않기 때문에, 애플리케이션 레벨에서 하나씩 구현하기로 했습니다.
- 트랜잭션 컨텍스트: 트랜잭션 관리와 트랜잭션과 유사한 다양한 기능을 지원하는 무언가가 필요함
- 원자성: 메시지 큐를 이용한 Step 단위의 실행과 자동 보상
- 격리성: Redis 분산락을 이용한 리소스별 동시성 제어
- 장애 복구: DLQ로 보상 실패에 대한 격리 및 재시도 인터페이스
트랜잭션 컨텍스트
트랜잭션을 위해서는 우선 나는 트랜잭션이다!!! 라는 명확한 컨텍스트가 필요했습니다.
또한 이 트랜잭션 컨텍스트 내부에는 다음과 같은 정보들이 포함되어야 한다고 생각했습니다.
{
transactionId: "payment-123", // 트랜잭션 식별자
currentStep: 2, // 현재 진행 중인 Step 인덱스
executedSteps: [ // 완료된 Step들의 결과
{ name: "포인트_차감", result: {...} },
{ name: "주문_생성", result: {...} }
],
status: "in_progress", // 트랜잭션 상태
lockKey: "user:123", // 획득한 락 정보
businessContext: { userId: 123, ... } // 비즈니스 메타데이터
}
저는 이 트랜잭션 컨텍스트를 BullMQ의 Job으로 구현했는데요, 이유는 다음과 같습니다.
1. 중단 지점 재개
실행된 Step은 건너뛰고 중단 지점부터 재실행이 가능합니다.
@Process('execute-transaction')
async handleTransaction(job: Job) {
const executedSteps = job.data.executedSteps || [];
const startIndex = executedSteps.length; // 이미 실행된 Step 건너뛰기
for (let i = startIndex; i < steps.length; i++) {
const result = await steps[i].execute();
executedSteps.push({ step: steps[i].name, result });
await job.updateData({ ...job.data, executedSteps });
}
}
2. 트랜잭션 추적 및 상태 조회
Job ID가 트랜잭션 식별자(txid) 역할을 하며, 언제든 상태 조회가 가능합니다.
const status = await transactionManager.getStatus(jobId);
// { status: 'active', progress: 66, currentStep: 2 }
3. 동기식 API 응답 지원
try {
// 대부분의 트랜잭션은 3초 내 완료가 됨
const result = await job.waitUntilFinished(queueEvents, 5000);
return { status: 'completed', result };
} catch (timeout) {
// 긴 트랜잭션의 경우 jobId 반환으로 추가 조회(폴링 등)
return { status: 'processing', jobId };
}
waitUntilFinished는 언제 완료될 지 모르기 때문에, timeout과 반드시 결합해서 사용해야 합니다.
저는 정량적으로 측정이 가능한 작업 시간이기 때문에, 타임아웃을 사용했지만 보통 권장하는 패턴은 아니라고합니다.
(문서 참조)
4. 재시도 정책
BullMQ는 일시적 장애일 경우를 대비해 자동 재시도를 구성할 수 있습니다.
await queue.add('transaction', data, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
}
});
원자성
트랜잭션의 원자성을 만족하려면, 모두 커밋되거나 모두 롤백되어야합니다.
@Process('execute-transaction')
async handleTransaction(job: Job) {
const { steps } = job.data;
const executionResults: ExecutionResult[] = [];
try {
// 1. Step 순차 실행
for (const stepData of steps) {
const step = this.stepRegistry.get(stepData.executeFnName);
this.logger.log(`[${stepData.name}] 실행 중...`);
const result = await step.execute(stepData.params);
executionResults.push({
stepName: stepData.name,
compensateFnName: stepData.compensateFnName,
result
});
this.logger.log(`[${stepData.name}] 완료`);
}
// 2. 모든 Step 성공
return { status: 'success', results: executionResults };
} catch (stepError) {
// 3. Step 실패 → 자동 보상 (역순)
this.logger.error(`Step 실패, 보상 시작: ${stepError.message}`);
for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
try {
this.logger.log(`[${stepName}] 보상 실행 중...`);
const compensateFn = this.stepRegistry.get(compensateFnName);
await compensateFn(result);
this.logger.log(`[${stepName}] 보상 완료`);
} catch (compensationError) {
// 보상 실패 → 에러 기록
this.logger.error(`[${stepName}] 보상 실패: ${compensationError.message}`);
// 보상 실패 건은 "장애 복구" 섹션에서 다룰 DLQ로 격리됩니다.
break;
}
}
throw stepError;
}
}
기본적인 흐름은 다음과 같습니다.
- 트랜잭션 컨텍스트 내 각 작업(Step)을 순차 실행
- 완료된 스텝들은 혹시 모를 롤백을 위해 기록
위처럼 원자성을 보장하기 위한 시도도 보상 로직의 실패에 대한 잠재적인 위험은 그대로 남아있기 때문에, 이리저리 고민을 해봤습니다.
애플리케이션 레벨에서 보상을 계속 진행하기 위해서는 데이터베이스와의 통신이 불가피한데, 예기치 못한 상황으로 계속 실패할 수 있습니다. 결국 직접 데이터베이스 엔진에서 수행하는 일련의 동작이 아니기 때문에, 어느정도 수동 개입은 필요하다고 판단했습니다.
이는 아래 장애 복구 세션에서 다루도록 하겠습니다.
격리성
앞서 언급했듯이, MyISAM은 레코드 단위 락을 지원하지 않습니다.
// 사용자 1의 결제 처리 중
LOCK TABLES User WRITE, `Order` WRITE;
await processPayment(userId: 1, amount: 1000);
UNLOCK TABLES;
// 사용자 2의 결제 시도
await processPayment(userId: 2, amount: 500);
// 사용자 1의 락이 해제될 때까지 대기
위의 코드에서, 사용자 1이 결제를 위해 Lock을 획득했습니다.
이 때 MyISAM에서는 사용자 1의 결제 중 모든 사용자가 대기하는 상황이 발생하게 됩니다.
같은 결제 로직 뿐 아니라 단순 조회까지 차단됩니다.
InnoDB라면 당연히 얻을 수 있는 레코드 단위 락과, MVCC같은 잠금 없는 일관된 읽기가 지원되지 않습니다.
이를 해결하기 위해 Redis로 분산락을 사용해 InnoDB의 레코드 락 처럼 구현해보려고 했습니다.
export class DistributedLockService {
async acquire(key: string, ttl: number): Promise<RedisLock> {
const lockKey = `lock:transaction:${key}`;
const lockValue = this.generateLockId();
const ttlSeconds = Math.ceil(ttl / 1000);
const result = await this.redis.set(
lockKey,
lockValue,
'EX', ttlSeconds,
'NX'
);
if (result !== 'OK') {
throw new Error(`락 획득 실패: ${key} (다른 프로세스가 사용 중)`);
}
return { key: lockKey, value: lockValue, acquiredAt: Date.now() };
}
}
@Process('execute-transaction')
async handleTransaction(job: Job) {
const { steps, lockKey } = job.data;
const executionResults: ExecutionResult[] = [];
let lock: RedisLock | null = null;
try {
// 1. 분산락 획득
if (lockKey) {
lock = await this.lockService.acquire(lockKey, 30000);
this.logger.log(`분산락 획득: ${lockKey}`);
}
// 2. Step 순차 실행
for (const stepData of steps) {
const step = this.stepRegistry.get(stepData.executeFnName);
const result = await step.execute(stepData.params);
executionResults.push({
stepName: stepData.name,
compensateFnName: stepData.compensateFnName,
result
});
}
return { status: 'success' };
} catch (stepError) {
// 3. 보상 트랜잭션
for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
try {
const compensateFn = this.stepRegistry.get(compensateFnName);
await compensateFn(result);
} catch (compensationError) {
this.logger.error(`[${stepName}] 보상 실패`);
// 보상 실패는 장애 복구 섹션에서 처리
break;
}
}
throw stepError;
} finally {
// 4. 락 해제
if (lock) {
await this.lockService.release(lock);
this.logger.log(`분산락 해제: ${lockKey}`);
}
}
}
1. TTL
TTL을 추가한 이유는, 뭔가 재배포 상황이나 기타 프로세스의 크래시로 인해 락이 영원히 남아있는 것을 고려하여 자동 만료 처리를 위해 구성해두었습니다.
const result = await this.redis.set(
lockKey,
lockValue,
'EX', ttlSeconds, // TTL
'NX'
);
2. Lua로 Lock 소유권 검증
다른 프로세스가 혹여 락을 삭제할 수도 있기 때문에, Lua 스크립트를 통해 내 락인지 확인 후 삭제하도록 구성해뒀습니다.
Lua 스크립트는 원자적인 실행을 보장하기 때문에 Race Condition을 방지하고, 내가 획득한 락만 해제가 가능합니다.
async release(lock: RedisLock): Promise<void> {
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
const result = await this.redis.eval(
luaScript,
1,
lock.key, // KEYS[1]: "lock:transaction:user:123"
lock.value // ARGV[1]: "processA-12345"
);
if (result === 0) {
this.logger.warn(`락 소유권 불일치: ${lock.key}`);
}
}
3. 멱등키로 중복 요청 방지
분산락은 동시 요청을 제어하지만, 처리가 완료된 후의 중복 요청 처리를 방지하지는 못합니다.
// 최초 결제 요청
POST /api/payment { userId: 123, amount: 10000 }
// 가정: 결제는 성공했지만 네트워크 문제로 클라이언트 응답 못 받음
// 클라이언트는 실패한 줄 알고 재시도 -> 락 해제되어 재결제
POST /api/payment { userId: 123, amount: 10000 }
멱등키를 구현함으로써, 위와 같은 엣지 케이스에 대해 중복 처리를 방지할 수 있다고 생각했습니다.
(멱등키는 클라이언트에서 생성을 하도록 구성해뒀기 때문에 서버 코드에는 생략되어있습니다.)
@Post('payment')
async createPayment(
@Body() dto: PaymentDto,
@Headers('idempotency-key') idempotencyKey: string
) {
// 1. 멱등키 확인 (중복 트랜잭션 방지)
const existing = await redis.get(`idempotency:${idempotencyKey}`);
if (existing) return JSON.parse(existing);
// 2. 분산락 획득 (동시 실행 방지)
const lock = await lockService.acquire(`user:${dto.userId}`, 30000);
try {
// 3. 트랜잭션 실행
const result = await transactionManager.executeTransaction({
transactionId: idempotencyKey,
lockKey: `user:${dto.userId}`,
steps: [
{ name: '포인트_차감', executeFn: 'deductIcash', ... },
{ name: '주문_생성', executeFn: 'createOrder', ... },
{ name: 'SMS_발송', executeFn: 'sendSMS', ... }
]
});
// 4. 멱등키에 결과 저장
await redis.setex(`idempotency:${idempotencyKey}`, 86400, JSON.stringify(result));
return result;
} finally {
// 5. 락 해제
await lockService.release(lock);
}
}
이렇게, Redis 분산락과 멱등키를 활용해서 MyISAM에서도 활용가능한 격리성을 구현할 수 있었습니다.
InnoDB의 레코드 단위의 락 처럼 사용자별 독립적인 처리가 가능하고, 중복 요청을 완전 차단하며 크래시 안정성을 TTL + Lua로 보완할 수 있었습니다.
장애 복구
원자성 섹션에서 언급했듯이, 보상 트랜잭션도 실패할 수 있습니다.
일부 수동 처리는 불가피하다고 판단했기 때문에 현재 Job의 구성과 동일한 DLQ를 구성해 사용했습니다.
@Process('execute-transaction')
async handleTransaction(job: Job) {
const { steps, lockKey } = job.data;
const executionResults: ExecutionResult[] = [];
let lock: RedisLock | null = null;
try {
// 1. 분산락 획득
if (lockKey) {
lock = await this.lockService.acquire(lockKey, 30000);
}
// 2. Step 순차 실행
for (const stepData of steps) {
const step = this.stepRegistry.get(stepData.executeFnName);
const result = await step.execute(stepData.params);
executionResults.push({
stepName: stepData.name,
compensateFnName: stepData.compensateFnName,
result
});
}
return { status: 'success' };
} catch (stepError) {
// 3. 보상 트랜잭션
const compensationResults = [];
for (const { stepName, compensateFnName, result } of executionResults.reverse()) {
try {
const compensateFn = this.stepRegistry.get(compensateFnName);
await compensateFn(result);
compensationResults.push({ stepName, status: 'compensated' });
} catch (compensationError) {
// 보상 실패 기록
compensationResults.push({
stepName,
status: 'compensation_failed',
error: compensationError
});
this.logger.error(`[${stepName}] 보상 실패: ${compensationError.message}`);
break;
}
}
// 4. DLQ에 격리
const hasFailure = compensationResults.some(
r => r.status === 'compensation_failed'
);
if (hasFailure) {
await this.dlqService.add({
jobId: job.id!,
transactionId: job.data.transactionId,
executedSteps: executionResults,
compensationResults,
failedAt: compensationResults.find(r => r.status === 'compensation_failed')?.stepName,
error: stepError,
businessContext: job.data.businessContext
});
this.logger.error(`DLQ 추가: ${job.id}`);
}
throw stepError;
} finally {
// 5. 락 해제
if (lock) {
await this.lockService.release(lock);
}
}
}
전체 보상 트랜잭션의 상태를 저장해서, 수동 처리 시 전체 컨텍스트를 파악할 수 있게 DLQ 관리 인터페이스들을 만들 수 있었습니다.
// DLQ 조회
GET /api/dlq
// 재시도 (실패한 보상부터 다시 실행)
POST /api/dlq/retry/:jobId
// 수동 처리 후 제거
POST /api/dlq/resolve/:jobId
// 재시도 로직
async retry(jobId: string): Promise<void> {
const dlqEntry = await this.get(jobId);
// 실패한 보상부터 다시 시도
const failedIndex = dlqEntry.compensationResults.findIndex(
r => r.status === 'compensation_failed'
);
const remainingSteps = dlqEntry.executedSteps.slice(failedIndex);
for (const step of remainingSteps) {
const compensateFn = this.stepRegistry.get(step.compensateFnName);
await compensateFn(step.result);
}
// 성공 시 DLQ에서 제거
await this.remove(jobId);
}
마무리
긍정적인 효과를 정리하자면, MyISAM의 한계를 BullMQ + Redis로 극복하여 유사 트랜잭션을 얻을 수 있게 되었다는 점입니다.
- DX 개선: 보상 로직 코드를 더이상 일일이 만들지 않고 트랜잭션 컨텍스트화 하면 됨
- UX 개선: 데이터 부정합 CS가 월 3+a건에서 0~1건으로 줄어듬
- 운영 효율: DLQ 기반 자동 복구로 수동 작업 불필요
하지만, 이 접근법은 결국 트랜잭션은 아니기 때문에 한계가 존재하는데요.
- 보상 트랜잭션의 한계: 보상 실패 시 수동 개입 필요
- 성능 오버헤드와 운영 복잡도: 분산락(네트워크 I/O)과 메시지 큐 관리 오버헤드 및 DB I/O에 필요한 시스템이 많아짐
무에서 유를 창조해야하는데 이정도의 트레이드오프는 어느정도 감수해야하지 않을까 생각은 했지만 정말 이 방법 밖에 없었을까, 조금 더 간소화 할 수 있었을까 하는 생각이 계속 드는 프로젝트였습니다.