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++
// ============================================================================
// 시나리오 (서버-서버 / 배포)
// ----------------------------------------------------------------------------
// 무중단 롤링 배포 도중의 캐릭터 저장 서비스.
//
// 배포 절차상 DB 스키마는 이미 마이그레이션되어 캐릭터 테이블에 신규 컬럼
// (시즌 포인트)이 추가된 상태다. 하지만 롤링 배포는 한 번에 모든 서버
// 인스턴스를 교체하지 않으므로, 배포 큐에는 신규 컬럼을 이미 아는 신버전과
// 아직 구조체 필드만 추가됐을 뿐인 구버전 코드가 당분간 함께 떠 있다.
//
// 구버전 코드는 캐릭터 레코드를 저장할 때, 자신이 아는 컬럼 전체를 결합해
// 하나의 UPDATE 문으로 통째로 재작성한다. 이때 시즌 포인트 값은 구버전이
// "로드 당시" 들고 있던 값을 그대로 다시 써넣는다(구버전 코드에는 시즌
// 포인트를 별도로 계산·갱신하는 로직이 없다 — 그냥 읽은 값을 그대로 되돌려
// 쓸 뿐이다).
//
// 요구사항:
// - 이미 신버전 서버가 기록한 시즌 포인트 값은, 구버전 서버가 같은
// 레코드를 이후에 저장하더라도 유실되면 안 된다.
// - 구버전 서버는 자신이 로드한 이후 다른 서버가 갱신한 컬럼 값을
// 인지하지 못한 채로 저장을 수행해서는 안 된다(혹은 최소한 그런
// 상황을 감지할 수 있어야 한다).
//
// 과제: (A)(B) 지점을 검토해 위 요구사항이 깨지는 경로를 찾고 수정하라.
// ============================================================================
#include <cstdint>
#include <sstream>
#include <string>
// 이 예제에서는 실제 DB 드라이버 대신 최소한의 인터페이스만 흉내낸다.
class IDbRow
{
public:
virtual std::string GetString(const std::string& column) const = 0;
virtual int GetInt(const std::string& column) const = 0;
virtual int64_t GetInt64(const std::string& column) const = 0;
virtual ~IDbRow() = default;
};
class IDbConnection
{
public:
// SELECT * 실행 후 첫 행을 반환한다(간략화를 위해 단일 행만 지원).
virtual IDbRow* QueryRow(const std::string& sql) = 0;
virtual void Execute(const std::string& sql) = 0;
virtual ~IDbConnection() = default;
};
// 구버전 서버가 아는 캐릭터 레코드 모양. season_point 필드 자체는 존재하지만,
// 구버전 코드는 이 값을 로드 시점에 읽어들일 뿐 그 이후의 변경은 알지 못한다.
struct CharacterRecord
{
int characterId = 0;
std::string name;
int level = 0;
int64_t gold = 0;
int64_t seasonPoint = 0;
};
class CharacterRepository
{
public:
explicit CharacterRepository(IDbConnection* connection)
: connection_(connection)
{
}
// 캐릭터 레코드를 조회한다.
CharacterRecord Load(int characterId)
{
std::ostringstream sql;
sql << "SELECT * FROM characters WHERE character_id = " << characterId;
// (A) 모든 컬럼을 조회해 구버전이 아는 필드에 그대로 담는다.
// 이 시점의 season_point 는 "지금 이 순간" DB 에 있는 값이다.
IDbRow* row = connection_->QueryRow(sql.str());
CharacterRecord record;
record.characterId = characterId;
record.name = row->GetString("name");
record.level = row->GetInt("level");
record.gold = row->GetInt64("gold");
record.seasonPoint = row->GetInt64("season_point");
return record;
}
// 캐릭터 레코드를 저장한다.
void Save(const CharacterRecord& record)
{
// (B) 자신이 아는 컬럼 전체를 값으로 채워 UPDATE 한다. season_point 도
// 함께 SET 절에 포함되지만, 그 값은 Load 시점에 읽었던 값이다.
std::ostringstream sql;
sql << "UPDATE characters SET "
<< "name = '" << record.name << "', "
<< "level = " << record.level << ", "
<< "gold = " << record.gold << ", "
<< "season_point = " << record.seasonPoint
<< " WHERE character_id = " << record.characterId;
connection_->Execute(sql.str());
}
private:
IDbConnection* connection_;
}; 내 리뷰 · C#
내 답안 · 자동 저장
작성 후 위 해설 보기에서 모범 해설과 대조하세요.
내 리뷰 · C++
내 답안 · 자동 저장
작성 후 위 해설 보기에서 모범 해설과 대조하세요.