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

난이도 하 해설 보기 →

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

결함 코드 · C#
// 시나리오 (서버-서버 / 배포): 무중단 롤링 배포 도중의 캐릭터 저장 서비스.
//
// 배포 절차상 DB 스키마는 이미 마이그레이션되어 캐릭터 테이블에 신규 컬럼
// (시즌 포인트)이 추가된 상태다. 하지만 롤링 배포는 한 번에 모든 서버
// 인스턴스를 교체하지 않으므로, 배포 큐에는 신규 컬럼을 이미 아는 신버전과
// 아직 구조체 필드만 추가됐을 뿐인 구버전 코드가 당분간 함께 떠 있다.
//
// 구버전 코드는 캐릭터 레코드를 저장할 때, 자신이 아는 컬럼 전체를 결합해
// 하나의 UPDATE 문으로 통째로 재작성한다. 이때 시즌 포인트 값은 구버전이
// "로드 당시" 들고 있던 값을 그대로 다시 써넣는다(구버전 코드에는 시즌
// 포인트를 별도로 계산·갱신하는 로직이 없다 — 그냥 읽은 값을 그대로 되돌려
// 쓸 뿐이다).
//
// 요구사항:
//   - 이미 신버전 서버가 기록한 시즌 포인트 값은, 구버전 서버가 같은
//     레코드를 이후에 저장하더라도 유실되면 안 된다.
//   - 구버전 서버는 자신이 로드한 이후 다른 서버가 갱신한 컬럼 값을
//     인지하지 못한 채로 저장을 수행해서는 안 된다(혹은 최소한 그런
//     상황을 감지할 수 있어야 한다).
//
// 과제: (A)(B) 지점을 검토해 위 요구사항이 깨지는 경로를 찾고 수정하라.

using System;
using System.Data;

// 구버전 서버가 아는 캐릭터 레코드 모양. SeasonPoint 필드 자체는 존재하지만,
// 구버전 코드는 이 값을 로드 시점에 읽어들일 뿐 그 이후의 변경은 알지 못한다.
public sealed class CharacterRecord
{
    public int CharacterId;
    public string Name;
    public int Level;
    public long Gold;
    public long SeasonPoint;
}

public sealed class CharacterRepository
{
    private readonly IDbConnection _connection;

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

    // 캐릭터 레코드를 조회한다.
    public CharacterRecord Load(int characterId)
    {
        // (A) 모든 컬럼을 조회해 구버전이 아는 필드에 그대로 담는다.
        //     이 시점의 SeasonPoint 는 "지금 이 순간" DB 에 있는 값이다.
        using var cmd = _connection.CreateCommand();
        cmd.CommandText = "SELECT * 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"],
        };
    }

    // 캐릭터 레코드를 저장한다.
    public void Save(CharacterRecord record)
    {
        // (B) 자신이 아는 컬럼 전체를 값으로 채워 UPDATE 한다. SeasonPoint 도
        //     함께 SET 절에 포함되지만, 그 값은 Load 시점에 읽었던 값이다.
        using var cmd = _connection.CreateCommand();
        cmd.CommandText = @"
            UPDATE characters
            SET name = @name, level = @level, gold = @gold, season_point = @seasonPoint
            WHERE character_id = @id";
        AddParam(cmd, "@id", record.CharacterId);
        AddParam(cmd, "@name", record.Name);
        AddParam(cmd, "@level", record.Level);
        AddParam(cmd, "@gold", record.Gold);
        AddParam(cmd, "@seasonPoint", record.SeasonPoint);
        cmd.ExecuteNonQuery();
    }

    private static void AddParam(IDbCommand cmd, string name, object value)
    {
        var p = cmd.CreateParameter();
        p.ParameterName = name;
        p.Value = value;
        cmd.Parameters.Add(p);
    }
}
내 리뷰 · C#
내 답안 · 자동 저장

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