1. 배경
길드 레이드 시즌이 종료되면, 참여한 모든 유저에게 등급 보상, 길드 랭킹 보상, 개인 랭킹 보상을 메일로 지급해야 한다.
문제는 이 작업이 단 한 번만 실행되어야 하며, 실패하더라도 재시도 시 중복 지급이 발생하면 안 된다는 점이다.
초기 설계에서는 단순히 모든 유저에게 한 번에 메일을 발송하려 했다.
그런데 한 레이드에 수천 개의 길드, 수만 명의 유저가 참여하기 때문에, 모든 데이터를 메모리에 올리면 OutOfMemory가 발생할 수 있다.
중간에 실패하면 어디까지 처리했는지 추적할 방법이 없었고, 같은 유저에게 보상이 두 번 나가는 것도 막아야 했다.
따라서 청크 단위 처리와 멱등성 보장이 필수였다.
2. 문제 정의
2.1 멱등성이란
멱등성(Idempotency)은 동일한 요청을 여러 번 수행해도 결과가 동일한 성질을 말한다.
정산 시스템에서는 "같은 레이드에 대해 몇 번을 실행하더라도, 각 유저는 정확히 한 번만 보상을 받아야 한다"는 의미다.
2.2 구체적인 문제
- 중복 실행: 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 쿼리가 엔티티 수만큼 개별 실행된다.
수정하지도 않을 데이터를 전부 로드하는 것도 낭비다.
ExecuteUpdateAsync를 사용하면 엔티티 추적 없이 단 하나의 UPDATE 쿼리로 처리되어 메모리 부담이 없다.
-- ExecuteUpdateAsync가 생성하는 쿼리
UPDATE GuildRaidGuildSessions
SET SendMails = 1, SendMailTime = @p0
WHERE Id IN (@p1, @p2, @p3, ...)4. 구현
4.1 아키텍처
4.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 고민했던 부분
메일 발송을 트랜잭션 안에 둘 것인가
메일 발송은 외부 시스템(메일 서버)과의 통신이다.
트랜잭션 내에서 메일을 발송하면, 메일은 이미 보내졌는데 트랜잭션이 롤백될 수 있다.
처음에는 Outbox Pattern을 고려했다.
메일 데이터를 DB에 저장하고, 별도 프로세스가 비동기로 발송하는 방식이다.
하지만 게임 서버 특성상 메일 발송 실패율이 매우 낮고(거의 DB Insert), 실패 시 수동 대응이 가능하다고 판단했다.
따라서 현재 트랜잭션 내에서 메일을 직접 발송하는 방식을 채택했다.
대신 Sentry로 모든 예외를 캐치하여, 실패 시 알림을 받을 수 있도록 했다.
await sentryUtil.ExecuteWithSentryCatchAsync(
commandName: "GuildRaidScheduler 정산",
func: async () =>
{
// 정산 로직
}
);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를 생성한다.
대규모 배치에서는 비효율적이다.
OrderBy가 필요한 이유
.OrderBy(_ => _.Id)
.Take(GuildSessionChunkSize)데이터베이스가 매번 같은 순서로 청크를 반환한다는 보장이 없다.
OrderBy가 없으면, 같은 쿼리를 실행해도 결과 순서가 달라질 수 있다.
예를 들어 청크 크기가 10이고, 총 25개 레코드가 있다고 하자.
OrderBy 없이는 2차, 3차 실행에서 어떤 10개가 반환될지 알 수 없어, 레코드가 누락되거나 중복될 수 있다.
OrderBy를 걸면 1차는 ID 110, 2차는 ID 1120, 3차는 ID 21~25로 진행되어 청크가 겹치지 않는다.
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. 결과
첫 정산에서는 약 3,000개 길드 세션, 50,000명 이상의 유저를 청크 크기 10으로 300번에 걸쳐 나눠 처리했다.
중간에 DB 연결 끊김으로 인한 실패가 1회 있었지만, 재시작 시 처리된 청크는 건너뛰고 이어서 정상 완료했다.
중복 지급 건수를 별도 지표로 계측해 둔 것은 아니다.
확인한 것은 재시작 시 SendMails가 켜진 세션이 조회에서 제외되어, 이미 커밋된 청크에 메일이 다시 나가지 않았다는 점이다.
한계점
가장 큰 한계는 메일 발송 실패 시 부분 롤백이 되지 않는다는 점이다.
한 청크 안에서 일부 유저에게만 메일이 발송된 채 실패하면, 해당 청크 전체가 다시 처리되어 일부 유저가 중복 수령할 수 있다.
현재는 메일 발송 실패가 거의 없어 문제가 되지 않지만, 향후 Outbox Pattern 도입을 고려하고 있다.
청크 크기 10도 임의로 정한 값이다.
길드당 유저 수, 보상 종류에 따라 최적값이 달라질 수 있어서, 모니터링을 통해 적정 크기를 찾아야 한다.
코드 레벨의 동시 실행 방지는 아직 없다.
Job 스케줄러가 중복 실행하지 않도록 설정되어 있을 뿐 분산 락이 없어서, 수동 실행으로 동시에 두 번 돌리면 문제가 생길 수 있다.