22. 길드 임원 강등의 Write Skew와 재시도/캐시 결함 (C#)

난이도 상 해설 보기 →

결함을 모두 찾고 원인·수정안·더 나은 설계를 제시하라. 마커 (A)(B) 는 주목 위치 힌트다.

결함 코드 · C#
// ============================================================================
// 시나리오
// ----------------------------------------------------------------------------
// MMORPG 길드 서버의 "임원(officer) 관리" 서비스다. 테이블은
//   guild_members(guild_id BIGINT, member_id BIGINT, role VARCHAR(16), ...)
//   role 은 'master' | 'officer' | 'member'
//
// 운영 규칙(불변식)
//   I1. 임원은 항상 최소 1명 남아야 한다. 0명이 되면 길드 창고와 가입 승인 큐를
//       아무도 열 수 없어 길드가 사실상 잠긴다.
//   I2. 임원은 최대 5명까지만 임명할 수 있다.
//
// 강등(자진 사임)과 임명은 클라이언트 요청으로 들어오며, 여러 임원이 같은 순간에
// 사임 버튼을 누르는 일이 잦다(길드 해체 직전, 대규모 길드 이동 시). DB 는
// SQL Server 이고 스냅샷 격리를 쓴다. 길드 정보는 조회 부하를 줄이려고 프로세스
// 메모리에 캐시되어 있고, 길드 목록 UI 와 권한 체크가 이 캐시를 읽는다.
//
// 과제
// ----------------------------------------------------------------------------
//  이 코드를 리뷰하고, (A)/(B) 마커 지점을 중심으로 무엇이 잘못되었는지,
//  어떤 상황에서 I1/I2 가 깨지는지, 어떻게 고칠지 설명하라.
// ============================================================================

using System;
using System.Collections.Concurrent;
using System.Data;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;

namespace GuildService.Persistence
{
    public sealed class GuildSummary
    {
        public long GuildId; public int OfficerCount; public int MemberCount; public DateTime UpdatedUtc;
    }

    public sealed class GuildCache
    {
        private readonly ConcurrentDictionary<long, GuildSummary> _map = new();

        public GuildSummary Get(long g) => _map.GetOrAdd(g, id => new GuildSummary { GuildId = id });

        public void SetOfficerCount(long guildId, int count)
        {
            var s = Get(guildId);
            s.OfficerCount = count; s.UpdatedUtc = DateTime.UtcNow;
        }
    }

    public interface ILogger { void Warn(string m); void Error(string m, Exception e); }

    public sealed class GuildOfficerService
    {
        private const string ConnStr = "Server=guild-db;Database=game;Integrated Security=true;";
        private const int MaxOfficers = 5;

        private readonly GuildCache _cache;
        private readonly ILogger _log;

        public GuildOfficerService(GuildCache cache, ILogger log) { _cache = cache; _log = log; }

        public async Task<bool> DemoteSelfAsync(long guildId, long memberId)
        {
            var conn = new SqlConnection(ConnStr);                        // (B)
            await conn.OpenAsync();

            var tx = conn.BeginTransaction(IsolationLevel.Snapshot);
            try
            {
                int officerCount;
                using (var cmd = new SqlCommand(
                    "SELECT COUNT(*) FROM guild_members WHERE guild_id=@g AND role='officer'",
                    conn, tx))
                {
                    cmd.Parameters.AddWithValue("@g", guildId);
                    officerCount = (int)await cmd.ExecuteScalarAsync();
                }

                if (officerCount < 2)                                     // (A)
                {
                    tx.Rollback();
                    conn.Close();
                    return false;
                }

                _cache.SetOfficerCount(guildId, officerCount - 1);        // (B)

                using (var upd = new SqlCommand(
                    "UPDATE guild_members SET role='member' " +
                    "WHERE guild_id=@g AND member_id=@m AND role='officer'",
                    conn, tx))
                {
                    upd.Parameters.AddWithValue("@g", guildId);
                    upd.Parameters.AddWithValue("@m", memberId);
                    await upd.ExecuteNonQueryAsync();                     // (A)
                }

                tx.Commit();
                conn.Close();
                return true;
            }
            catch (SqlException ex)                                       // (B)
            {
                tx.Rollback();
                _log.Error($"demote failed guild={guildId} member={memberId}", ex);
                return false;
            }
        }

        public async Task<bool> PromoteWithRetryAsync(long guildId, long memberId)
        {
            int cachedOfficerCount = _cache.Get(guildId).OfficerCount;    // (B)

            for (int attempt = 0; attempt < 3; attempt++)
            {
                if (cachedOfficerCount >= MaxOfficers)                    // (A)
                    return false;

                var conn = new SqlConnection(ConnStr);
                await conn.OpenAsync();
                var tx = conn.BeginTransaction(IsolationLevel.Snapshot);
                try
                {
                    using (var upd = new SqlCommand(
                        "UPDATE guild_members SET role='officer' " +
                        "WHERE guild_id=@g AND member_id=@m AND role='member'",
                        conn, tx))
                    {
                        upd.Parameters.AddWithValue("@g", guildId);
                        upd.Parameters.AddWithValue("@m", memberId);
                        await upd.ExecuteNonQueryAsync();
                    }

                    tx.Commit();
                    _cache.SetOfficerCount(guildId, cachedOfficerCount + 1);
                    conn.Close();
                    return true;
                }
                catch (Exception ex)
                {
                    tx.Rollback();
                    _log.Warn($"promote retry {attempt}: {ex.Message}");  // (B)
                }
            }
            return false;
        }
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

작성 후 위 해설 보기에서 모범 해설과 대조하세요.