← 문제로

14. 로드-저장 사이 갱신을 되돌려 쓰는 전체 컬럼 UPDATE (blind overwrite)

난이도 하
내 리뷰 · C#
해설 · C#

해설 — 로드-저장 사이 갱신을 되돌려 쓰는 전체 컬럼 UPDATE (blind overwrite)

난이도: 하

요약

CharacterRepository.Load(A)는 SELECT *로 모든 컬럼(시즌 포인트 포함)을 읽어와 CharacterRecord.SeasonPoint에 담는다. 이 값은 어디까지나 "로드하는 그 순간" DB에 있던 값의 스냅샷일 뿐이다. 이후 Save(B)는 Name, Level, Gold, SeasonPoint를 포함한 자신이 아는 컬럼 전체를 SET 절에 넣어 UPDATE를 실행하는데, 이때 SeasonPoint에는 로드 시점에 읽어뒀던 옛 값이 그대로 다시 들어간다. 만약 구버전 서버가 Load한 뒤 Save를 실행하기까지의 사이에 신버전 서버(또는 다른 요청 처리 흐름)가 같은 캐릭터의 시즌 포인트를 갱신했다면, 그 갱신은 구버전의 Save가 실행되는 순간 통째로 덮어써져 사라진다. 구버전 코드가 시즌 포인트 컬럼을 SET 절에서 아예 빼는 것이 아니라 "안다"는 점이 오히려 문제다 — 컬럼을 안다는 것은 그 값을 읽어 되돌려 쓸 능력이 있다는 뜻이고, 로드 이후의 변경 여부를 확인하는 장치가 없으므로 그 능력이 곧 데이터 유실의 경로가 된다.

문제점

  • 분류: 전체 컬럼 덮어쓰기(blind overwrite)로 인한 동시 갱신 유실 — lost update
  • 증상: "분명 시즌 포인트를 지급했는데 잠시 후 확인하면 반영되지 않았다"거나 "간헐적으로 일부 유저의 시즌 포인트만 이전 값으로 되돌아간다"는 형태로 보고된다. 롤링 배포 중 구버전 인스턴스가 살아있는 동안 발생 빈도가 높고, 구버전이 모두 내려간 뒤에는 재현되지 않아 원인 추적이 늦어지기 쉽다. 배포와 무관하게도, LoadSave 사이에 시간 간격이 있는 한(긴 트랜잭션, 사용자 입력 대기, 다른 I/O) 언제든 발생할 수 있는 일반적인 lost update 패턴이다.
  • 재현 조건: (1) 서버 A가 캐릭터 레코드를 Load하고(이 시점 SeasonPoint = 100), (2) 그 사이 서버 B가 같은 캐릭터의 시즌 포인트를 120으로 갱신하며, (3) 이후 서버 A가 Save를 호출하면 SET season_point = 100이 실행되어 서버 B의 갱신(120)이 사라지고 100으로 되돌아간다.
  • 근본 원인:
    • (A) Load가 특정 시점의 스냅샷 값을 읽어 앱 객체에 담아두는 것 자체는 자연스럽지만, 이 값이 "박제된 과거 값"이라는 사실과 "이후 이 레코드가 변경될 수 있다"는 가능성을 앱 레이어 어디에서도 추적하지 않는다.
    • (B) Save가 로드 시점 값을 포함한 전체 컬럼을 무조건 UPDATE한다. "내가 로드한 시점 이후 이 레코드가 다른 프로세스에 의해 변경되었는가"를 전혀 확인하지 않고, 확인 없이 그대로 저장한다는 것이 근본 원인이다.

수정안

가장 직접적인 해결책은 두 가지를 함께 적용하는 것이다: (1) 로드 시점 이후 레코드가 변경되었는지 감지할 수 있도록 낙관적 동시성 제어(버전 컬럼)를 도입하고, (2) 변경이 감지되면 무조건 덮어쓰는 대신 저장을 실패시켜 호출자가 재조회 후 재시도하게 한다. 근본적으로는 애초에 자신이 값을 직접 갱신하지 않는 컬럼(SeasonPoint)은 SET 절에서 아예 제외하는 부분 UPDATE가 더 안전하지만, 최소 수정으로는 버전 컬럼 기반 검증이 효과적이다.

using System;
using System.Data;

public sealed class CharacterRecord
{
    public int CharacterId;
    public string Name;
    public int Level;
    public long Gold;
    public long SeasonPoint;
    public long Version; // 낙관적 동시성 제어용.
}

public sealed class OptimisticConcurrencyException : Exception
{
    public OptimisticConcurrencyException(string message) : base(message) { }
}

public sealed class CharacterRepository
{
    private readonly IDbConnection _connection;

    public CharacterRepository(IDbConnection connection)
    {
        _connection = connection;
    }

    public CharacterRecord Load(int characterId)
    {
        using var cmd = _connection.CreateCommand();
        cmd.CommandText = "SELECT name, level, gold, season_point, version FROM characters WHERE character_id = @id";
        AddParam(cmd, "@id", characterId);

        using var reader = cmd.ExecuteReader();
        if (!reader.Read())
            return null;

        return new CharacterRecord
        {
            CharacterId = characterId,
            Name = (string)reader["name"],
            Level = (int)reader["level"],
            Gold = (long)reader["gold"],
            SeasonPoint = (long)reader["season_point"],
            Version = (long)reader["version"],
        };
    }

    // 자신이 값을 직접 갱신하지 않는 SeasonPoint 는 SET 절에서 제외하고,
    // 로드했던 버전과 현재 DB 버전이 일치할 때만 UPDATE 한다. 그 사이 다른
    // 서버가 저장했다면(버전 불일치) 0 행 갱신으로 실패를 감지해 예외를
    // 던진다 — 자신이 모르는 변경을 되돌려 쓰는 대신 저장을 포기한다.
    public void Save(CharacterRecord record)
    {
        using var cmd = _connection.CreateCommand();
        cmd.CommandText = @"
            UPDATE characters
            SET name = @name, level = @level, gold = @gold, version = version + 1
            WHERE character_id = @id AND version = @expectedVersion";
        AddParam(cmd, "@id", record.CharacterId);
        AddParam(cmd, "@name", record.Name);
        AddParam(cmd, "@level", record.Level);
        AddParam(cmd, "@gold", record.Gold);
        AddParam(cmd, "@expectedVersion", record.Version);

        int affected = cmd.ExecuteNonQuery();
        if (affected == 0)
        {
            throw new OptimisticConcurrencyException(
                $"character {record.CharacterId} was modified by another writer since it was loaded (version {record.Version}).");
        }

        record.Version += 1;
    }

    private static void AddParam(IDbCommand cmd, string name, object value)
    {
        var p = cmd.CreateParameter();
        p.ParameterName = name;
        p.Value = value;
        cmd.Parameters.Add(p);
    }
}

이 수정은 두 가지를 동시에 해결한다. 첫째, SeasonPoint처럼 이 코드 경로가 직접 갱신하지 않는 컬럼을 SET 절에서 제외해 "모르는 값을 되돌려 쓰는" 경로 자체를 없앤다. 둘째, version 컬럼으로 낙관적 동시성 제어를 추가해 Load 이후 레코드가 변경됐다면(다른 서버가 name/level/gold 를 바꿨을 경우 포함) 저장을 실패시켜 무조건 덮어쓰지 않게 한다.

더 나은 설계

  • 부분 UPDATE(자신이 갱신하지 않는 컬럼은 SET에서 제외) + 낙관적 동시성(버전 컬럼) — 채택안: 두 방어선을 겹쳐, "모르는 컬럼을 되돌려 쓰는 사고"와 "아는 컬럼끼리의 경합"을 모두 막는다. 구버전 코드의 수정 범위가 작으면서도 효과가 확실하다.
  • dirty tracking 기반 부분 UPDATE: 애플리케이션이 로드 시점의 원본 값을 기억해두고, 실제로 값이 바뀐 컬럼만 SET 절에 넣는 방식. ORM(EF Core 의 change tracking 등)이 기본 제공하는 경우가 많아 이런 실수 자체를 원천 차단할 수 있다.
  • 비관적 락(SELECT ... FOR UPDATE): 로드 시점에 로우를 잠가 그 사이 다른 트랜잭션의 갱신을 아예 막는 방법. 쓰기 경합이 잦거나 트랜잭션이 길게 유지되는 경우 락 대기·데드락 위험이 커지므로, 이 시나리오(경합이 드물고 세션이 오래 유지될 수 있음)에는 낙관적 방식이 더 적합하다.
  • 트레이드오프: 부분 UPDATE + 버전 컬럼 조합은 코드 변경 범위가 작고 배포 중에도 점진적으로 적용 가능하다. dirty tracking/ORM 전환은 더 근본적이지만 도입 비용이 크다. 비관적 락은 구현이 단순해 보이지만 이 시나리오의 트래픽 패턴에는 성능/데드락 트레이드오프가 더 나쁘다.

면접 포인트

  • "읽은 시점의 값을 그대로 다시 쓰는 UPDATE"가 왜 위험한 기본값인지, 그리고 자신이 실제로 갱신하지 않는 컬럼은 왜 SET 절에서 제외해야 하는지 설명할 수 있어야 한다.
  • 낙관적 동시성 제어(버전 컬럼)가 lost update 를 어떻게 감지하는지, 그리고 감지 이후의 처리(예외 후 재시도 vs 병합)를 상황에 맞게 설계할 수 있어야 한다.
  • 무중단 롤링 배포에서는 신버전과 구버전 코드가 같은 스키마를 동시에 바라보는 구간이 반드시 존재한다는 전제를 이해하고, 이 구간에서 "내가 모르거나 갱신하지 않는 컬럼은 절대 건드리지 않는다"는 원칙을 코드 레벨에서 어떻게 지킬지 설명할 수 있어야 한다.