← 문제로

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
  • 증상: "분명 시즌 포인트를 지급했는데 잠시 후 확인하면 반영되지 않았다"거나 "간헐적으로 일부 유저의 시즌 포인트만 이전 값으로 되돌아간다"는 형태로 보고된다. 롤링 배포 중 구버전 인스턴스가 살아있는 동안 발생 빈도가 높고, 구버전이 모두 내려간 뒤에는 재현되지 않아 원인 추적이 늦어지기 쉽다. 배포와 무관하게도, Load와 Save 사이에 시간 간격이 있는 한(긴 트랜잭션, 사용자 입력 대기, 다른 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 병합)를 상황에 맞게 설계할 수 있어야 한다.
  • 무중단 롤링 배포에서는 신버전과 구버전 코드가 같은 스키마를 동시에 바라보는 구간이 반드시 존재한다는 전제를 이해하고, 이 구간에서 "내가 모르거나 갱신하지 않는 컬럼은 절대 건드리지 않는다"는 원칙을 코드 레벨에서 어떻게 지킬지 설명할 수 있어야 한다.