깨끗하고 현대적이며 원칙 중심적인 .NET 개발을 위해 Record가 더 나은 선택인 이유
요약
C#에서 DTO(Data Transfer Object) 설계 시 가변 클래스 대신 Record를 사용해야 하는 이유를 설명합니다. 특히 금융 시스템과 같은 정밀한 데이터 처리가 필요한 환경에서 불변성(Immutability)이 왜 중요한지 강조합니다.
핵심 포인트
- 가변 클래스는 데이터 손상 및 경합 조건 등 운영 사고의 원인이 될 수 있음
- Record를 통한 불변성 확보는 데이터 무결성을 보장하는 핵심 방법임
- 금융/핀테크 시스템처럼 데이터 정확성이 중요한 도메인에서 필수적임
- DTO의 본질은 단순하고 구조화된 데이터 전달자임을 이해해야 함
C#을 몇 년 이상 작성해 오셨다면, 수천 개의 DTO (Data Transfer Object)를 작성해 보셨을 것입니다. 요청 모델 (Request models), 응답 모델 (Response models), 이벤트 페이로드 (Event payloads), 통합 계약 (Integration contracts) — 이는 모든 분산 시스템의 결합 조직입니다. 그리고 여러분이 우리 대부분과 같다면, .NET Framework 2.0 시절부터 해왔던 방식대로 거의 모든 DTO를 { get; set; } 속성을 가진 클래스 (Class)로 작성했을 것입니다.
여기 불편한 진실이 있습니다: 가변 클래스 (Mutable class)는 DTO를 위한 잘못된 데이터 구조이며, C# 9이 더 나은 대안을 제공한 이후로 계속 그래왔습니다. 이는 단순히 "스타일적으로 틀린" 것이 아닙니다. 중복 결제, 손상된 감사 추적 (Audit trails), 비동기 파이프라인 (Async pipelines)에서의 경합 조건 (Race conditions)과 같이 실제 운영 사고로 이어지는 방식으로 틀린 것입니다.
이 글은 은행 및 핀테크 시스템 — 결제 처리 (Payment processing), 대조 (Reconciliation), 메이커-체커 워크플로 (Maker-checker workflows) — 에서 가져온 예시를 통해 제1원칙 (First principles) 관점에서 논거를 제시합니다. 왜냐도 이 분야에서는 잘못된 선택의 비용이 단순한 버그가 아닌 돈으로 측정되기 때문입니다. 여러분이 첫 API 계약을 작성하는 주니어 엔지니어이든, 결제 플랫폼 설계를 검토하는 솔루션 아키텍트 (Solutions architect)이든, 목표는 동일합니다. Record를 어떻게 타이핑하는지가 아니라, Record가 왜 존재하는지를 이해하는 것입니다.
🚚 실무에서 DTO란 무엇인가?
Uber나 Bolt와 같은 차량 공유 앱을 구축하고 있다고 가정해 봅시다. 프론트엔드에서 근처 드라이버 목록을 요청하면, 여러분은 DriverDto를 반환합니다:
{
"id": 142,
"name": "Linda",
...
C#에서는 다음과 같이 작성할 수 있습니다:
public class DriverDto
{
public int Id { get; set; }
...
이 방식은 작동하지만, 비대하고 가변적이며 DTO의 진정한 의도인 **단순하고 구조화된 데이터 전달자 (Data carrier)**를 반영하지 못합니다.
이제 차량 공유 서비스에서 벗어나 DTO가 진정으로 제 역할을 하는 곳, 즉 엔터프라이즈 소프트웨어의 시스템 경계 (System boundaries)로 시야를 넓혀 봅시다. 결제 플랫폼에서 DTO는 어디에나 존재합니다:
- 카드 결제망이나 파트너 은행으로부터 정산 사실을 통보받는 인바운드 웹훅 페이로드 (inbound webhook payload).
- 고객이 잔액을 조회할 때 모바일 앱이 받는 API 응답 (API response).
- 이체 상태가
PENDING에서SETTLED로 변경될 때 발행되는 Kafka 이벤트 (Kafka event). - 처리사가 제공하는 일일 마감 파일과 비교하는 대사 기록 (reconciliation record).
이 모든 것은 _계약(contract)_입니다. 이는 경계를 넘어 전달되는 사실, 즉
이제 여러분의 원장(ledger)에 도달하는 금액은 파트너 은행으로부터 받은 금액과 다를 수 있습니다. 일과 종료 시 조정(reconciliation)을 실행했을 때 200만 건의 트랜잭션 전체에서 ₦0.005의 불일치가 발견된다면, 여러분은 이 한 줄을 찾아내느라 매우 불쾌한 일주일을 보내게 될 것입니다. 컴파일러가 이를 방지할 수 있었을 것입니다. 하지만 가변 클래스(mutable class)로는 불가능합니다.
금융 분야에서 이는 가설이 아닙니다. 전송 중인 금융 데이터의 가장 가치 있는 단일 속성은 경계(boundary)와 원장(ledger) 사이에서 데이터가 변조될 수 없다는 점입니다. 가변성(Mutability)은 변조에 대한 공개적인 초대장과 같습니다. 대개는 실수로 발생하지만, 때로는 더 심각한 상황을 초래합니다.
2. 참조 동등성(Reference equality)은 잘못된 동등성입니다
동일한 데이터를 담고 있는 두 개의 DTO는 실질적인 모든 목적에서 _동일한 사실(the same fact)_입니다. 하지만 클래스는 참조(reference)를 기준으로 비교합니다:
var a = new PaymentNotificationDto { TransactionRef = "TXN-8842", Amount = 50_000m, Currency = "NGN" };
var b = new PaymentNotificationDto { TransactionRef = "TXN-8842", Amount = 50_000m, Currency = "NGN" };
...
이것이 왜 중요할까요? 모든 결제 시스템의 중추인 멱등성 (Idempotency) 때문입니다. 파트너 은행은 웹훅(webhook)을 재시도합니다. Kafka는 최소 한 번(at-least-once) 전달을 보장합니다. 모바일 앱은 타임아웃 시 재전송합니다. 여러분의 시스템은 "이 정확한 사실을 이전에 본 적이 있다"는 것을 저렴하고 정확하게 인식해야 합니다. 클래스를 사용하면 Equals/GetHashCode를 직접 작성하거나(그리고 속성이 추가될 때 이를 업데이트하는 것을 잊어버려 미묘한 중복 제거 버그의 고전적인 원인이 되기도 합니다), 라이브러리를 가져오거나, 아니면 야만인처럼 직렬화된 JSON을 비교해야 합니다.
3. 상용구(Boilerplate)가 계약(contract)을 가립니다
적절한 동등성, 생성자 주입을 통한 불변성(immutability), 그리고 구조화된 로깅을 위한 유용한 ToString을 갖춘 클래스 기반 DTO는 40~60줄에 달합니다. 엔지니어가 실제로 읽어야 하는 _계약(contract)_은 단 5줄뿐입니다. 나머지 90%는 유지 관리하고, 검토하고, 동기화 상태를 유지해야 하는 의식(ceremony)에 불과합니다. 400개의 DTO가 있는 엔터프라이즈 코드베이스에서 이는 수만 줄의 순수한 노이즈이며, 모든 줄은 버그가 숨을 수 있는 장소가 됩니다.
4. 의도(intent)를 전달하는 것이 아무것도 없습니다
public class PaymentNotificationDto는 이 타입이 어떻게 사용될 의도인지 독자에게 아무것도 알려주지 않습니다. 캐싱해도 안전할까요? 스레드 간에 공유해도 안전할까요? 변이 (Mutation)가 예상되나요? 타입 시스템은 침묵하고 있으며, 따라서 그 답은 암묵적인 지식 (Tribal knowledge)과 코드 리뷰의 경계심 속에 존재합니다. 이 두 가지는 소프트웨어에서 가장 비용이 많이 들고 신뢰도가 가장 낮은 강제 메커니즘입니다.
✨ Record의 등장: 의미를 말하는 타입
동일한 DTO를 record로 작성하면 다음과 같습니다:
public record PaymentNotificationDto(
string TransactionRef,
decimal Amount,
...
단 다섯 줄입니다. 그리고 컴파일러는 다음과 같은 것들을 무료로 생성해 줍니다:
- 초기화 전용 속성 (Init-only properties) — 객체는 생성 후 동결됩니다.
- 값 기반의
Equals및GetHashCode— 동일한 데이터를 가진 두 record는 서로 같습니다. - 읽기 쉬운
ToString—PaymentNotificationDto { TransactionRef = TXN-8842, Amount = 50000, ... }와 같이 출력되며, 이는 Serilog를 사용한 구조화된 로깅 (Structured logging)에서 조용한 초능력이 됩니다. - 분해 (Deconstruction) —
var (txnRef, amount, currency, _, _) = dto; with식 (with expressions) — 새로운 복사본을 생성하는 비파괴적 "변이 (Mutation)"
이제 우리의 멱등성 (Idempotency) 체크가 그냥 작동합니다:
var a = new PaymentNotificationDto("TXN-8842", 50_000m, "NGN", "0123456789", timestamp);
var b = new PaymentNotificationDto("TXN-8842", 50_000m, "NGN", "0123456789", timestamp);
...
그리고 앞서 언급한 데이터 변조 버그는 어떻게 되었을까요? 이제는 **컴파일 타임 에러 (Compile-time error)**가 됩니다:
notification.Amount = Math.Round(notification.Amount, 2);
// CS8852: 초기화 전용 속성은 객체 이니셜라이저에서만 할당할 수 있습니다.
펜이 압수된 것입니다. 만약 파이프라인의 특정 단계에서 진정으로 정규화된 금액이 필요하다면, 새로운 사실을 생성한다는 점을 명시적으로 드러내야 합니다:
var normalized = notification with { Amount = Math.Round(notification.Amount, 2) };
_의미론 (semantics)_에 어떤 변화가 일어났는지 주목하십시오. 제자리 변이 (mutation-in-place, 보이지 않고 추적 불가능함)가 변환 (transformation, 보이고 검토 및 로그 기록 가능함)으로 바뀌었습니다. 감사인이 "이 값이 변경될 수 있는 모든 지점을 보여달라"고 요구하는 규제 환경에서, 이 차이는 단순히 외관상의 차이가 아닙니다.
🏦 실제 사례: 엔터프라이즈 핀테크 시나리오
추상적인 논쟁은 비용이 적게 듭니다. 거의 모든 은행 또는 핀테크 플랫폼에서 반복되는 네 가지 시나리오를 살펴보며, Record가 엔지니어링을 어떻게 변화시키는지 확인해 보겠습니다.
시나리오 1: 멱등성 (Idempotent) 웹훅 수집
귀하의 플랫폼은 여러 파트너 은행으로부터 결제 알림을 받습니다. 네트워크는 신뢰할 수 없으며, 파트너들은 공격적으로 재시도를 수행합니다. 중복 게시가 발생하면 고객에게 금액이 두 번 입금되는 것을 의미하며, 이는 직접적인 금융 손실로 이어집니다.
public record SettlementNotification(
string ProviderRef,
string SessionId,
...
Record는 GetHashCode를 무료로 올바르게 제공하기 때문에, 처리 윈도우(processing window) 내에서의 중복 제거가 매우 간단하고 정확하게 이루어집니다.
// 처리 윈도우를 위한 인메모리 중복 제거 — HashSet이 바로 작동합니다
private readonly HashSet<SettlementNotification> _seenInWindow = new();
...
Class를 사용한다면, 각 객체가 서로 다른 참조(reference)이기 때문에 HashSet.Add는 동일한 알림의 복사본 10개를 아무 문제 없이 받아들일 것입니다. 직접 구현한 동등성 비교(equality)가 필요할 것이며, 누군가 DTO에 NarrationText를 추가하고 해시를 업데이트하는 것을 잊어버린다면, 직접 구현한 모든 GetHashCode는 미래의 버그가 됩니다.
(실제 운영 환경에서는 ProviderRef + SessionId를 키로 사용하는 내구성이 있는 멱등성 저장소(durable idempotency store)를 별도로 갖추게 될 것입니다. 값 기반의 동등성(value equality)은 이를 보완하는 것이지 대체하는 것이 아닙니다. 이에 대한 자세한 내용은 제한 사항 섹션에서 다룹니다.)
시나리오 2: Kafka 기반 이벤트 주도 아키텍처 (Event-driven architecture)
이벤트 주도 코어 뱅킹 시스템에서 이벤트는 **과거에 대한 사실 (facts about the past)**입니다: TransferInitiated (이체 시작됨), FraudCheckPassed (사기 검사 통과됨), TransferSettled (이체 결제됨). 이벤트 소싱 (event sourcing)의 첫 번째 규칙은 사실은 불변(immutable)이라는 것입니다. 즉, 역사를 다시 쓰는 것이 아니라 새로운 이벤트를 추가하는 것입니다.
public record TransferInitiated(
Guid TransferId,
string SourceAccount,
...
소비자가 복구(recovery) 중에 이 이벤트를 재생(replay)하거나 이벤트 로그로부터 새로운 프로젝션(projection)을 구축할 때, _어느 핸들러도 이벤트를 변경(mutate)할 수 없었다_는 보장이 재생을 결정론적(deterministic)으로 만듭니다. 가변(mutable) 클래스 이벤트의 경우, 필드를 "수정(fix up)"하는 하나의 핸들러가 동일한 프로세스 내의 모든 다운스트림(downstream) 소비자에게 독을 퍼뜨리게 되며, 이는 재구축된 프로젝션이 원장(ledger)과 일치하지 않을 때에야 비로소 발견하게 됩니다.
Record를 사용하면 아키텍처 규칙("이벤트는 불변의 사실이다")이 아무도 읽지 않는 위키 페이지가 아니라, **컴파일러에 의해 강제되는 불변성(invariant)**이 됩니다.
시나리오 3: Maker-checker 워크플로
규제 대상인 백오피스 운영(대량 계좌 온보딩, 한도 변경, 수동 취소 등)은 maker-checker 방식을 따릅니다. 즉, 한 명의 담당자가 요청(initiate)하면 다른 담당자가 승인(approve)하는 방식입니다. 상태 전이(state transition)는 명시적이고 감사 가능(auditable)해야 합니다.
public record ReversalRequest(
Guid RequestId,
string TransactionRef,
...
with 식은 이 전이를 아름답게 모델링합니다. 원본 요청은 손대지 않은 채 보존되며, 승인은 새로운 상태를 생성합니다:
public ReversalRequest Approve(ReversalRequest request, string checkerId) =>
request with
{
...
감사 목적으로 전후(before and after) 두 객체를 모두 로그에 남길 수 있으며, 생성된 ToString은 감사자에게 완전하고 읽기 쉬운 차이점(diff)을 제공합니다. 가변 클래스로 이를 구현해 보십시오. "이전(before)" 상태는 사라지고, 당신이 그것을 덮어써 버렸을 것입니다.
이것이 바로 축소판으로 구현된 이벤트 소싱(event-sourcing) 사고방식입니다. 상태 변경은 편집(edit)이 아니라 새로운 값(new value)입니다. Record는 이를 가장 저항이 적은 경로로 만들어 줍니다.
시나리오 4: 일일 대조(End-of-day reconciliation)
대조(Reconciliation)는 근본적으로 집합 비교(set-comparison) 문제입니다. 즉, 우리가 보는 당일 거래 내역과 그들의 파일 간의 비교입니다. 값 기반의 동등성(Value equality)은 이를 맞춤형 매칭 엔진에서 LINQ로 변환해 줍니다:
public record ReconRow(string Ref, decimal Amount, string Currency, DateOnly ValueDate);
var internalRows = await _ledger.GetRowsAsync(date);
...
Except, Intersect, Distinct, GroupBy — LINQ의 집합 대수(set-algebra) 툴킷 전체가 레코드(record)에서 기본적으로 올바르게 작동합니다. 이는 레코드가 적절한 값 의미론(value semantics)을 갖기 때문입니다. 클래스(class)를 사용했다면 비교할 때마다 IEqualityComparer<T> 구현체를 각각 작성해야 했을 것이며, 이는 매번 유지보수 부담이 되었을 것입니다.
🧭 레코드가 소프트웨어 공학 원칙을 충족하는 방식
이 지점부터 이 글은 문법에 대한 이야기를 넘어 설계에 관한 이야기로 넘어갑니다. 레코드는 단순히 더 간결할 뿐만 아니라, 우리가 이미 따르고 있다고 주장하는 원칙들과 데이터 계약(data contracts)을 일치시킵니다.
단일 책임 원칙 (Single Responsibility Principle, SRP)
DTO의 역할은 단 하나입니다: 경계를 넘어 데이터를 전달하는 것입니다. 가변 클래스(mutable class)는 파이프라인 단계에서 낙서하듯 데이터를 수정하는 _작업 공간(workspace)_이라는 두 번째 역할을 조용히 떠맡게 됩니다. 이 두 번째 역할이 바로 위에서 언급한 데이터 변조 버그를 일으키는 정확한 원인입니다. 레코드는 구조적으로 이 두 번째 역할을 거부합니다. 타입은 한 가지 일만 수행하며, 컴파일러가 이를 강제합니다.
주니어 개발자를 위한 비유: DTO는 화이트보드가 아니라 봉투여야 합니다. 레코드는 그 봉투를 밀봉합니다.
개방/폐쇄 원칙 (Open/Closed Principle, OCP)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기