멱등성을 보장하는 길드 레이드 정산 시스템 구현
1. 배경
길드 레이드 시즌이 종료되면, 참여한 모든 유저에게 등급 보상, 길드 랭킹 보상, 개인 랭킹 보상을 메일로 지급해야 한다. 문제는 이 작업이 단 한 번만 실행되어야 하며, 실패하더라도 재시도 시 중복 지급이 발생하면 안 된다는 점이다.
초기 설계에서는 단순히 모든 유저에게 한 번에 메일을 발송하려 했지만, 다음 문제들이 있었다.
- 대량 데이터 처리: 한 레이드에 수천 개의 길드, 수만 명의 유저가 참여
- 메모리 부족: 모든 데이터를 메모리에 올리면 OutOfMemory 발생 가능
- 장애 복구: 중간에 실패 시 어디까지 처리했는지 추적 불가능
- 중복 지급 방지: 같은 유저에게 두 번 보상을 주면 안 됨
따라서 청크 단위 처리와 멱등성 보장이 필수였다.
2. 문제 정의
2.1 멱등성이란
멱등성(Idempotency)은 동일한 요청을 여러 번 수행해도 결과가 동일한 성질을 말한다. 정산 시스템에서는 "같은 레이드에 대해 몇 번을 실행하더라도, 각 유저는 정확히 한 번만 보상을 받아야 한다"는 의미다.
2.2 구체적인 Pain Point
- 중복 실행 시나리오: Job이 실패 후 재시도되거나, 운영자가 수동으로 다시 실행할 수 있다
- 부분 성공 처리: 1000개 길드 중 500개 처리 후 오류 발생 시, 재시작 시 처음 500개를 다시 처리하면 안 됨
- 트랜잭션 범위: 전체를 하나의 트랜잭션으로 묶으면 Lock이 오래 유지되어 성능 문제 발생
- 데이터베이스 격리 수준: 기본 격리 수준에서는 동시성 이슈 가능
3. 해결 방법
3.1 청크 단위 처리 + 상태 플래그
전체 데이터를 작은 단위(Chunk)로 나누어 처리하고, 각 청크마다 완료 상태를 DB에 기록한다.
private const int GuildSessionChunkSize = 10;
while (true)
{
var hasMore = await gameDbContext.ExecuteWithTransactionScope(async () =>
{
var guildSessions = await gameDbContext.GuildRaidGuildSessions
.Where(_ => _.GuildRaidId == guildRaidId && !_.SendMails)
.OrderBy(_ => _.Id)
.Take(GuildSessionChunkSize)
.AsNoTracking()
.ToListAsync();
if (guildSessions.Count == 0) return false;
// ... 보상 처리 로직 ...
// 처리 완료 마킹
await gameDbContext.GuildRaidGuildSessions
.Where(_ => guildSessionIds.Contains(_.Id))
.ExecuteUpdateAsync(_ => _
.SetProperty(p => p.SendMails, true)
.SetProperty(p => p.SendMailTime, DateTimeOffset.UtcNow));
return true;
});
if (!hasMore) break;
}핵심은 SendMails 플래그다.
이미 처리된 길드 세션은 WHERE !_.SendMails 조건으로 제외되므로, 재실행 시 이미 처리된 데이터를 건너뛴다.
3.2 트랜잭션 스코프 분리
전체 정산을 하나의 트랜잭션으로 묶지 않고, 청크마다 독립적인 트랜잭션을 사용한다.
// 청크 단위 트랜잭션
await gameDbContext.ExecuteWithTransactionScope(async () =>
{
// 1. 데이터 조회
// 2. 보상 계산 및 메일 발송
// 3. 처리 완료 플래그 업데이트
});
// 모든 청크 처리 후 최종 완료 마킹 (별도 트랜잭션)
await gameDbContext.ExecuteWithTransactionScope(async () =>
{
await gameDbContext.GuildRaids
.Where(_ => _.Id == guildRaidId)
.ExecuteUpdateAsync(_ => _.SetProperty(p => p.SendAllMails, true));
});이렇게 하면:
- Lock 시간이 줄어들어 성능 향상
- 일부 청크 실패 시 처리된 부분은 유지됨
- 재시도 시 실패한 청크부터 이어서 처리
3.3 왜 ExecuteUpdateAsync인가
처음에는 다음처럼 구현했었다.
// 잘못된 방법
var sessions = await dbContext.GuildRaidGuildSessions
.Where(...)
.ToListAsync();
foreach (var session in sessions)
{
session.SendMails = true;
session.SendMailTime = DateTimeOffset.UtcNow;
}
await dbContext.SaveChangesAsync();이 방식의 문제점:
- EF Core가 모든 엔티티를 **추적(Tracking)**하여 메모리 사용량 증가
UPDATE쿼리가 개별적으로 N번 실행됨- 불필요한 데이터 로드로 성능 저하
대신 ExecuteUpdateAsync를 사용하면:
- 단 하나의 UPDATE 쿼리로 변환됨
- 엔티티 추적 없이 직접 DB 업데이트
- 메모리 효율적
-- ExecuteUpdateAsync가 생성하는 쿼리
UPDATE GuildRaidGuildSessions
SET SendMails = 1, SendMailTime = @p0
WHERE Id IN (@p1, @p2, @p3, ...)4. 구현
4.1 아키텍처
graph TD
A[Quartz Job 시작] --> B{GuildRaid.SendAllMails?}
B -->|true| Z[종료]
B -->|false| C["청크 조회<br/>SendMails=false인<br/>GuildSession 10개"]
C --> D{청크 존재?}
D -->|No| E[GuildRaid.SendAllMails=true]
E --> Z
D -->|Yes| F[트랜잭션 시작]
F --> G[유저 세션 조회]
G --> H[보상 계산]
H --> I[메일 발송]
I --> J[SendMails=true 업데이트]
J --> K[트랜잭션 커밋]
K --> C4.2 멱등성 보장 메커니즘
레이드 레벨 중복 실행 방지
var guildRaid = gameDbContext.GuildRaids.FirstOrDefault(_ => _.Id == guildRaidId);
if (guildRaid is null || guildRaid.SendAllMails) return;최상위에서 SendAllMails 플래그를 확인한다.
이미 정산이 완료된 레이드라면, Job이 다시 실행되어도 즉시 종료된다.
청크 레벨 중복 실행 방지
var guildSessions = await gameDbContext.GuildRaidGuildSessions
.Where(_ => _.GuildRaidId == guildRaidId && !_.SendMails)
.OrderBy(_ => _.Id)
.Take(GuildSessionChunkSize)
.ToListAsync();WHERE !_.SendMails 조건으로, 이미 처리된 길드 세션은 조회 대상에서 제외된다.
재시작 시 자동으로 처리되지 않은 청크부터 이어서 실행된다.
트랜잭션 내 원자적 업데이트
await gameDbContext.ExecuteWithTransactionScope(async () =>
{
// 보상 계산 및 메일 발송
// ...
// 처리 완료 마킹 (같은 트랜잭션 내)
await gameDbContext.GuildRaidGuildSessions
.Where(_ => guildSessionIds.Contains(_.Id))
.ExecuteUpdateAsync(_ => _
.SetProperty(p => p.SendMails, true)
.SetProperty(p => p.SendMailTime, DateTimeOffset.UtcNow));
await gameDbContext.SaveChangesAsync();
});보상 발송과 플래그 업데이트를 하나의 트랜잭션으로 묶었다. 메일 발송 중 오류가 나면 플래그도 업데이트되지 않아, 다음 실행 시 해당 청크를 다시 처리한다.
4.3 고민했던 부분
문제 1: 메일 발송 실패 시 어떻게 처리할까?
메일 발송은 외부 시스템(메일 서버)과의 통신이다. 트랜잭션 내에서 메일을 발송하면, 메일은 이미 보내졌는데 트랜잭션이 롤백될 수 있다.
처음에는 Outbox Pattern을 고려했다. 메일 데이터를 DB에 저장하고, 별도 프로세스가 비동기로 발송하는 방식이다.
하지만 게임 서버 특성상 메일 발송 실패율이 매우 낮고(거의 DB Insert), 실패 시 수동 대응이 가능하다고 판단했다. 따라서 현재 트랜잭션 내에서 메일을 직접 발송하는 방식을 채택했다.
대신 Sentry로 모든 예외를 캐치하여, 실패 시 알림을 받을 수 있도록 했다.
await sentryUtil.ExecuteWithSentryCatchAsync(
commandName: "GuildRaidScheduler 정산",
func: async () =>
{
// 정산 로직
}
);문제 2: AsNoTracking을 써야 하나?
청크를 조회할 때 AsNoTracking()을 사용했다.
.AsNoTracking()
.ToListAsync();이유는:
- 조회한
GuildSession을 수정하지 않음 - 업데이트는
ExecuteUpdateAsync로 직접 수행 - 추적 비용 절약으로 메모리 사용량 감소
만약 AsNoTracking 없이 엔티티를 수정한다면:
var sessions = await dbContext.GuildRaidGuildSessions
.Where(...)
.ToListAsync(); // Tracking 활성화
foreach (var session in sessions)
{
session.SendMails = true; // Change Tracker에 기록됨
}
await dbContext.SaveChangesAsync(); // N개의 UPDATE 쿼리 실행EF Core의 Change Tracker가 모든 변경사항을 추적하고, SaveChangesAsync 호출 시 개별 UPDATE를 생성한다.
대규모 배치에서는 비효율적이다.
문제 3: OrderBy는 왜 필요한가?
.OrderBy(_ => _.Id)
.Take(GuildSessionChunkSize)데이터베이스가 매번 같은 순서로 청크를 반환한다는 보장이 없다.
OrderBy가 없으면, 같은 쿼리를 실행해도 결과 순서가 달라질 수 있다.
예를 들어 청크 크기가 10이고, 총 25개 레코드가 있다고 하자.
OrderBy 없이 실행:
- 1차 실행: A, B, C, ... J (10개)
- 2차 실행: K, M, L, ... (순서 보장 안 됨)
- 3차 실행: 누락되거나 중복 발생 가능
OrderBy 사용:
- 1차: ID 1~10
- 2차: ID 11~20
- 3차: ID 21~25
OrderBy로 결정론적 순서를 보장하여, 청크가 겹치지 않도록 한다.
4.4 보상 중복 지급 방지
유저별로 세 가지 보상을 지급한다.
- 레이드 등급 보상 (미수령 등급 보상)
- 길드 랭킹 보상
- 개인 랭킹 보상
여기서 추가로 고민한 점이 있다. 등급 보상을 실시간으로 받지 않은 유저가 있다면?
var raidNotReceivedGradeRewards = guildRaidRaidLevelDataTable.Array
.Where(_ => _.Level > userSession.LastGradeRewardIndex &&
_.Level < guildSession.RaidGrade)
.ToList();LastGradeRewardIndex는 유저가 마지막으로 수령한 등급을 저장한다.
정산 시 미수령 등급 보상만 메일로 발송하여, 중복 지급을 방지한다.
세션이 없는 유저(길드 가입만 하고 플레이 안 함)는 어떻게 할까?
if (userSession != null)
{
// 세션 있는 유저: LastGradeRewardIndex부터
raidNotReceivedGradeRewards = guildRaidRaidLevelDataTable.Array
.Where(_ => _.Level > userSession.LastGradeRewardIndex &&
_.Level < guildSession.RaidGrade)
.ToList();
}
else
{
// 세션 없는 유저: 0부터 길드 등급-1까지 전부
raidNotReceivedGradeRewards = guildRaidRaidLevelDataTable.Array
.Where(_ => _.Level > 0 &&
_.Level <= guildSession.RaidGrade - 1)
.ToList();
}길드에 소속되어 있으면, 참여하지 않았어도 길드 등급에 따른 보상을 받을 수 있도록 했다. 이는 기획 의도에 따른 것이다.
5. 결과
5.1 정산 시스템 안정화
- 멱등성 보장: Job이 여러 번 실행되어도 중복 지급 없음
- 부분 실패 복구: 중간에 실패해도 재시작 시 이어서 처리
- 메모리 효율: 청크 단위 처리로 대규모 데이터도 안정적으로 처리
- 성능: ExecuteUpdateAsync로 불필요한 쿼리 제거
5.2 실제 운영 결과
첫 정산 실행 시:
- 약 3,000개 길드 세션
- 50,000명 이상의 유저
- 청크 크기 10으로 300번 반복 처리
- 중복 지급 0건
중간에 DB 연결 끊김으로 인한 실패가 1회 있었지만, 재시작 시 처리된 청크는 건너뛰고 이어서 정상 완료했다.
5.3 한계점
현재 방식의 한계:
-
메일 발송 실패 시 부분 롤백 불가
한 청크 내에서 일부 유저에게만 메일이 발송되고 실패하면, 해당 청크 전체가 다시 처리되어 일부 유저는 중복 수령 가능. 현재는 메일 발송 실패가 거의 없어 문제되지 않지만, 향후 Outbox Pattern 도입 고려 중. -
청크 크기 튜닝
현재 10은 임의로 설정한 값이다. 길드당 유저 수, 보상 종류에 따라 최적값이 달라질 수 있다. 모니터링을 통해 적정 크기를 찾아야 한다. -
동시 실행 방지 없음
현재는 Job 스케줄러가 중복 실행하지 않도록 설정되어 있지만, 코드 레벨에서는 분산 락이 없다. 수동 실행 시 동시에 두 번 실행하면 문제가 생길 수 있다.
참고 자료
Loading comments...