변경된 컬럼만 동적으로 Update하는 SQL 생성 기법

데이터베이스 레코드를 수정할 때 흔히 사용하는 UPDATE 문은 UPDATE user SET username='001', nickname='Tom', age=18 WHERE id = 1와 같은 형태입니다. 하지만 실제로는 username만 변경했는데도 불필요하게 모든 필드를 업데이트하는 경우가 많습니다. 이는 SQL Server의 트리거와 같은 감시 메커니즘에서도 문제를 일으킵니다.

예를 들어 사용자 테이블에 다음과 같은 AFTER UPDATE 트리거가 있다고 가정해봅시다.

CREATE TRIGGER [dbo].[trg_user_audit]
ON [dbo].[user]
AFTER UPDATE
AS
BEGIN
    DECLARE @uid INT;
    SELECT @uid = id FROM inserted;
    
    IF UPDATE(nickname)
    BEGIN
        -- 닉네임 변경 시 실행되는 로직
        INSERT INTO UserChangeLog(userId, changedField, changeTime)
        VALUES (@uid, 'nickname', GETDATE());
    END;
END;

이 경우 UPDATE user SET username='001', nickname='Tom', age=18 WHERE id = 1을 실행하면 nickname 값이 실제로 변경되지 않았더라도 트리거가 발동하여 잘못된 로그가 기록됩니다.

핵심 해결 방식

현재 데이터와 수정된 데이터를 비교하여 실제로 값이 달라진 컬럼만 UPDATE 문에 포함시키는 방식입니다. C#의 리플렉션과 DataRow를 활용하여 이를 자동화할 수 있습니다.

엔티티 클래스 정의

먼저 데이터베이스 테이블과 매핑되는 클래스를 작성합니다. 속성명은 반드시 테이블 컬럼명과 일치해야 합니다.

public class MemberInfo
{
    public string LoginId { get; set; }
    public string DisplayName { get; set; }
    public string SecretCode { get; set; }
    public string MailAddress { get; set; }
    public string MobileNum { get; set; }
    public int YearsOld { get; set; }
}

DataRow에서 엔티티로 변환

데이터베이스 조회 결과를 객체로 변환하는 제네릭 메서드입니다.

public static TEntity RowToEntity<TEntity>(DataRow sourceRow, TEntity target) 
    where TEntity : new()
{
    if (target == null)
        target = new TEntity();
    
    Type entityType = typeof(TEntity);
    PropertyInfo[] attributes = entityType.GetProperties();
    
    foreach (PropertyInfo attr in attributes)
    {
        string fullTypeName = attr.PropertyType.FullName;
        object rawValue = sourceRow[attr.Name];
        
        if (fullTypeName == "System.Int32")
        {
            int parsed = rawValue != DBNull.Value ? Convert.ToInt32(rawValue) : 0;
            attr.SetValue(target, parsed);
        }
        else if (fullTypeName == "System.Decimal")
        {
            decimal parsed = rawValue != DBNull.Value ? Convert.ToDecimal(rawValue) : 0m;
            attr.SetValue(target, parsed);
        }
        else if (fullTypeName == "System.Double")
        {
            double parsed = rawValue != DBNull.Value ? Convert.ToDouble(rawValue) : 0.0;
            attr.SetValue(target, parsed);
        }
        else if (fullTypeName == "System.Boolean")
        {
            bool parsed = rawValue != DBNull.Value ? Convert.ToBoolean(rawValue) : false;
            attr.SetValue(target, parsed);
        }
        else if (fullTypeName == "System.DateTime")
        {
            if (rawValue != DBNull.Value)
            {
                attr.SetValue(target, Convert.ToDateTime(rawValue));
            }
        }
        else
        {
            string parsed = rawValue != DBNull.Value ? rawValue.ToString() : string.Empty;
            attr.SetValue(target, parsed);
        }
    }
    return target;
}

변경 감지 및 동적 SQL 생성

폼 로드 시점에 원본 데이터를 DataTable에 보관합니다.

private DataTable _cachedData;

private void LoadMemberData()
{
    using (SqlConnection connection = new SqlConnection(_connectionString))
    {
        connection.Open();
        using (SqlDataAdapter fetcher = new SqlDataAdapter(
            "SELECT * FROM member WHERE member_id = @id", connection))
        {
            fetcher.SelectCommand.Parameters.AddWithValue("@id", 1);
            _cachedData = new DataTable();
            fetcher.Fill(_cachedData);
        }
    }
}

저장 버튼 클릭 시 변경된 필드만 감지하여 UPDATE 문을 구성합니다.

private void ExecutePartialUpdate()
{
    if (_cachedData.Rows.Count == 0) return;
    
    // 화면에서 수정된 값을 DataRow에 반영
    DataRow modifiedRow = _cachedData.Rows[0];
    modifiedRow["LoginId"] = txtLoginId.Text;
    modifiedRow["DisplayName"] = txtDisplayName.Text;
    modifiedRow["SecretCode"] = txtPassword.Text;
    
    using (SqlConnection connection = new SqlConnection(_connectionString))
    {
        connection.Open();
        
        // DB의 최신 상태 다시 조회
        DataTable freshData = new DataTable();
        using (SqlDataAdapter fetcher = new SqlDataAdapter(
            "SELECT * FROM member WHERE member_id = @id", connection))
        {
            fetcher.SelectCommand.Parameters.AddWithValue("@id", 1);
            fetcher.Fill(freshData);
        }
        
        // 이전/현재 상태를 객체로 변환
        MemberInfo before = RowToEntity(freshData.Rows[0], new MemberInfo());
        MemberInfo after = RowToEntity(modifiedRow, new MemberInfo());
        
        // 변경된 필드 수집
        List<string> changedColumns = new List<string>();
        List<SqlParameter> updateParams = new List<SqlParameter>();
        
        PropertyInfo[] props = typeof(MemberInfo).GetProperties();
        foreach (PropertyInfo prop in props)
        {
            if (prop.Name == "id") continue; // 키 필드 제외
            
            object oldVal = prop.GetValue(before);
            object newVal = prop.GetValue(after);
            
            if (!Equals(oldVal, newVal))
            {
                changedColumns.Add(prop.Name);
                updateParams.Add(new SqlParameter(
                    $"@{prop.Name}", 
                    newVal ?? DBNull.Value));
            }
        }
        
        if (changedColumns.Count > 0)
        {
            // 동적 UPDATE 문 조립
            StringBuilder commandBuilder = new StringBuilder();
            commandBuilder.Append("UPDATE member SET updated_at = GETDATE()");
            
            foreach (string col in changedColumns)
            {
                commandBuilder.Append($", [{col}] = @{col}");
            }
            commandBuilder.Append(" WHERE member_id = @keyId");
            
            using (SqlCommand updater = new SqlCommand(commandBuilder.ToString(), connection))
            {
                updater.Parameters.AddRange(updateParams.ToArray());
                updater.Parameters.AddWithValue("@keyId", 1);
                
                int affected = updater.ExecuteNonQuery();
                // 필요시 감사 로그 기록
            }
        }
    }
}

활용 시 고려사항

  • 대용량 테이블에서는 변경 감지를 위한 재조회 대신 낙관적 동시성 제어(버전 컬럼 활용)를 고려할 수 있습니다.
  • NULL 값 비교 시 DBNull.Value와 null의 구분이 필요합니다.
  • Binary 데이터나 복잡한 타입의 경우 Equals 비교 방식을 조정해야 합니다.
  • 생성된 SQL은 로깅하여 추적 가능하게 관리하는 것이 바람직합니다.

태그: SQL Server C# reflection Dynamic SQL DataRow

10월 10일 06:14에 게시됨