스칼라 타입만으로는 도메인 의미를 표현할 수 없으며, 진정한 타입 안정성을 위해서는 식별자, 금액, 단위, 정제 여부 같은 의미를 각각의 타입으로 모델링해야 한다는 내용의 글입니다.
.. 2026-04-13
최근 코드베이스에서 작업하던 중, 함수 인자를 아주 작고 사소하게 잘못 사용한 것에서 비롯된 심각한 버그를 겪었습니다. 이 문제는 제가 rust-clippy에 올린 이슈로도 이어졌습니다. 저는 X에서도 이 일에 대해 불만을 쏟아냈습니다:
그래서 개발자들이 왜 스칼라 타입에서 멈추면 안 되는지 글로 써보면 어떨까 싶었습니다. 어떤 함수가
string
를 받고
number
를 반환한다고 해서, 우리는 그것을 “타입이 있다”고 부릅니다. 하지만 이것은 피상적인 형태의 타입 안정성일 뿐이며, 수많은 종류의 버그를 눈치채지 못한 채 흘려보내면서도 우리에게 잘못된 안전감을 줍니다.
스칼라 타입이 왜 우리를 실망시키는지, 그리고 제가 생각하는 진정으로 타입이 잘 잡힌 코드베이스가 어떤 모습이어야 하는지 차근차근 설명해보겠습니다.
주문이 배송된 뒤 판매자 정산을 처리하는 함수가 있다고 해봅시다. 이 함수는 상점 ID, 고객 ID, 주문 ID, 총금액, 플랫폼 수수료, 거래 수수료, 그리고 실수령액을 받습니다.
JavaScript:
function processOrderPayout(shopId, customerId, orderId, amount, platformFee, txFee, netAmount) {
// ...
}
Go:
func ProcessOrderPayout(shopID string, customerID string, orderID string, amount int64, platformFee int64, txFee int64, netAmount int64) error {
// ...
}
Rust:
fn process_order_payout(shop_id: String, customer_id: String, order_id: String, amount: i64, platform_fee: i64, tx_fee: i64, net_amount: i64) {
// ...
}
매개변수가 일곱 개입니다. 문자열인 ID가 세 개, 정수인 금액 값이 네 개입니다. 이제 제 코드베이스 어딘가에서 호출자가 이렇게 작성했다고 상상해보세요:
process_order_payout(customer_id, shop_id, order_id, net_amount, tx_fee, platform_fee, amount);
고객 ID가 상점 ID 자리에 들어갔습니다. 실수령액이 총금액 자리에 들어갔습니다. 수수료도 서로 뒤바뀌었습니다. 컴파일러는 불평하지 않습니다. 테스트도 아마 통과할 것입니다. 애플리케이션은 실행되고, 잘못된 대상에게 잘못된 금액을 지급하며, 판매자가 왜 ₦54,000 대신 ₦350을 받았는지 묻기 전까지 아무도 눈치채지 못합니다.
바로 이런 일이 저를 물었습니다. 컴파일러는 데이터의 의미 가 아니라 형태 를 검사합니다.
String
은
String
이고 또
String
이며,
i64
는
i64
이고 또
i64
입니다. 타입 시스템은 기반 타입이 같을 때 상점 ID와 고객 ID를 구분할 방법도, 총금액과 실수령액을 구분할 방법도 없습니다.
자연스러운 다음 단계는 매개변수를 struct나 객체로 묶는 것입니다.
JavaScript:
function processOrderPayout({ shopId, customerId, orderId, amount, platformFee, txFee, netAmount }) {
// ...
}
Go:
type OrderPayoutParams struct {
ShopID string
CustomerID string
OrderID string
Amount int64
PlatformFee int64
TxFee int64
NetAmount int64
}
func ProcessOrderPayout(params OrderPayoutParams) error {
// ...
}
Rust:
struct OrderPayoutParams {
shop_id: String,
customer_id: String,
order_id: String,
amount: i64,
platform_fee: i64,
tx_fee: i64,
net_amount: i64,
}
fn process_order_payout(params: OrderPayoutParams) {
// ...
}
이쪽이 더 낫습니다. 이름 있는 필드는 위치 기반 혼동을 없애줍니다. 호출 지점에서 명시적으로 이름을 적기 때문에
shop_id
와
customer_id
를 실수로 바꿔 넣을 수는 없습니다.
하지만 우리는 겨우 한 가지 문제만 해결했습니다. 이 struct가 막지 못하는 것 을 보세요:
let params = OrderPayoutParams {
shop_id: customer_id, // oops, customer ID assigned to shop field
customer_id: shop_id, // oops, shop ID assigned to customer field
order_id: order_id,
amount: net_amount, // oops, net amount assigned to gross amount field
platform_fee: tx_fee, // oops, fees are swapped
tx_fee: platform_fee,
net_amount: amount, // oops, gross amount assigned to net field
};
컴파일러는 완전히 만족합니다. 모든 문자열 필드는
String
을 받았습니다. 모든 정수 필드는
i64
를 받았습니다. 그런데
customer_id
에 들어 있는 것이 상점 식별자가 아니라 고객 식별자라는 사실은? 타입 시스템에는 보이지 않습니다.
이것이 억지 예시처럼 보일 수도 있지만, 실제 코드베이스에서는 이런 일이 항상 벌어집니다. 변수 이름이 바뀝니다. 데이터는 여러 계층을 거쳐 흐릅니다. 어떤 함수는 데이터베이스 행에서 값을 받아 struct에 집어넣는데, 어느 컬럼이 어느 필드에 매핑되는지 아무도 정확히 기억하지 못합니다. 누군가 리팩터링하면서 두 필드를 바꾸고, 이제 잘못된 슬롯으로 데이터를 넣게 된 모든 호출 지점을 컴파일러는 하나도 잡아내지 못합니다.
struct는 이름 있는 할당은 제공했지만, 의미적 정확성은 제공하지 못했습니다. 타입은 여전히 거짓말을 하고 있습니다. “이 필드는 문자열을 받는다”라고 말하지만, 우리가 실제로 뜻하는 것은 “이 필드는 상점 식별자 를 받는다”입니다. “이 필드는 정수를 받는다”라고 말하지만, 우리가 실제로 뜻하는 것은 “이 필드는 코보 단위의 플랫폼 수수료 를 받는다”입니다.
이것은 제 정산 예시를 훨씬 넘어서는 문제입니다.
string
,
int
,
float
,
bool
같은 스칼라 타입은 구성 요소이지만, 도메인 의미를 담고 있지 않습니다. 코드베이스 전체에서 가공되지 않은 원시 타입을 여기저기 전달하면, 타입 수준에서 데이터가 실제로 무엇을 의미하는지 추론할 능력을 잃게 됩니다.
제가 직접 겪었거나 다른 사람들이 겪는 것을 본 버그 가운데, 스칼라 타입으로는 절대 잡을 수 없는 것들은 다음과 같습니다:
주문 ID가 필요한 곳에 사용자 ID를 넘기는 경우. 둘 다
int
이거나
string
입니다. 둘 다 식별자입니다. 하지만 둘을 섞으면 잘못된 테이블을 조회하거나, 잘못된 고객에게 과금하거나, 잘못된 레코드를 삭제하게 됩니다.
단위를 혼동하는 경우. 킬로미터를 기대하는 함수에 미터 단위 거리를 넘기는 경우. 달러를 기대하는 함수에 센트 단위 가격을 넘기는 경우. “minutes”라고 적힌 필드에 초 단위 지속 시간을 저장하는 경우. 전부 같은 타입인
f64
또는
int
일 뿐이고, 컴파일러는 아무 말도 하지 않습니다.
정제된 입력과 정제되지 않은 입력을 혼동하는 경우. 사용자가 제공한 원시 문자열을 SQL 쿼리나 HTML 템플릿에 직접 넘기는 경우입니다. 타입 시스템이 보는 것은
String
뿐입니다. “이 문자열은 아직 이스케이프되지 않았다”는 사실을 알지 못합니다. 이렇게 주입 취약점이 발생합니다.
위도와 경도를 뒤바꾸는 경우. 둘 다
f64
입니다. 둘 다 좌표입니다. 둘을 바꾸면 지도는 엉뚱한 대륙에 렌더링됩니다.
이 모든 것은 컴파일됩니다. 이 모든 것은 테스트를 통과할 수도 있습니다. 이 모든 것은 실제 운영 장애를 일으켜 왔습니다. 그리고 이 모든 것은 예방 가능합니다.
해결책은 생각보다 단순합니다. 도메인 개념에 스칼라 타입을 쓰지 마세요. 의미 있는 모든 값을 각자의 타입으로 감싸세요.
Rust:
struct ShopId(String);
struct CustomerId(String);
struct OrderId(String);
struct Amount(i64);
struct PlatformFee(i64);
struct TxFee(i64);
struct NetAmount(i64);
struct OrderPayoutParams {
shop_id: ShopId,
customer_id: CustomerId,
order_id: OrderId,
amount: Amount,
platform_fee: PlatformFee,
tx_fee: TxFee,
net_amount: NetAmount,
}
fn process_order_payout(params: OrderPayoutParams) {
// ...
}
이제 이것들을 바꿔 넣어보세요:
let params = OrderPayoutParams {
shop_id: customer_id, // ERROR: expected `ShopId`, found `CustomerId`
customer_id: shop_id, // ERROR: expected `CustomerId`, found `ShopId`
order_id: order_id,
amount: net_amount, // ERROR: expected `Amount`, found `NetAmount`
platform_fee: tx_fee, // ERROR: expected `PlatformFee`, found `TxFee`
tx_fee: platform_fee, // ERROR: expected `TxFee`, found `PlatformFee`
net_amount: amount, // ERROR: expected `NetAmount`, found `Amount`
};
컴파일러는 거부합니다. 데이터의 형태가 잘못돼서가 아니라, 의미 가 잘못됐기 때문입니다.
CustomerId
는
ShopId
가 아닙니다. 둘 다 내부적으로
String
을 감싸더라도 말입니다.
NetAmount
는
Amount
가 아닙니다. 둘 다 내부적으로
i64
를 감싸더라도 말입니다.
Go:
type ShopID string
type CustomerID string
type OrderID string
type Amount int64
type PlatformFee int64
type TxFee int64
type NetAmount int64
type OrderPayoutParams struct {
ShopID ShopID
CustomerID CustomerID
OrderID OrderID
Amount Amount
PlatformFee PlatformFee
TxFee TxFee
NetAmount NetAmount
}
func ProcessOrderPayout(params OrderPayoutParams) error {
// ...
}
Go의 type 정의는 별칭이 아닙니다.
ShopID
와
CustomerID
는 서로 다른 타입입니다. 하나가 필요한 자리에 다른 하나를 넘기면 컴파일 타임 에러가 납니다.
Amount
와
NetAmount
,
PlatformFee
사이도 마찬가지입니다.
TypeScript:
type ShopId = string & { readonly __brand: "ShopId" };
type CustomerId = string & { readonly __brand: "CustomerId" };
type OrderId = string & { readonly __brand: "OrderId" };
type Amount = number & { readonly __brand: "Amount" };
type PlatformFee = number & { readonly __brand: "PlatformFee" };
type TxFee = number & { readonly __brand: "TxFee" };
type NetAmount = number & { readonly __brand: "NetAmount" };
interface OrderPayoutParams {
shopId: ShopId;
customerId: CustomerId;
orderId: OrderId;
amount: Amount;
platformFee: PlatformFee;
txFee: TxFee;
netAmount: NetAmount;
}
function processOrderPayout(params: OrderPayoutParams) {
// ...
}
TypeScript에는 기본 제공되는 newtype 래퍼가 없기 때문에 branded type을 사용합니다. 실수로 서로 바꿔 쓸 수 없도록 유령 속성을 추가하는, 널리 알려진 패턴입니다. 약간의 형식적 장치가 필요하지만 바로 그 값을 해냅니다.
사람들이 이것을 보고 가장 먼저 묻는 것은 “좋아, 그런데 이제 내 데이터로 아무것도 못 하잖아”입니다. 그 말도 맞습니다.
ShopId(String)
에는
.len()
도 없고
.contains()
도 없으며, 평소
String
에 호출하던 메서드들도 없습니다. 그러면 어디서나
shop_id.0.len()
처럼 써야 하고, 그건 보기 좋지 않습니다.
여기서
Deref
가 등장합니다. Rust에서는
Deref
를 구현해서 newtype이 내부 타입의 메서드를 투명하게 노출하도록 만들 수 있습니다:
use std::ops::Deref;
struct ShopId(String);
impl Deref for ShopId {
type Target = String;
fn deref(&self) -> &String {
&self.0
}
}
let shop = ShopId("shop_abc123".to_string());
println!("{}", shop.len()); // works, delegates to String::len()
println!("{}", shop.to_uppercase()); // works too
래핑을 풀지 않고도 모든
String
메서드에 완전히 접근할 수 있습니다. 하지만 타입 시스템은 여전히
CustomerId
가 필요한 곳에
ShopId
를 넘기지 못하게 막아줍니다. 양쪽의 장점을 모두 얻는 셈입니다.
또한 타입들이 코드의 나머지 부분과 잘 어울리도록
Display
와
From
도 구현하고 싶을 것입니다:
use std::fmt;
impl fmt::Display for ShopId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for ShopId {
fn from(s: String) -> Self {
ShopId(s)
}
}
// Now you can do:
let shop: ShopId = "shop_abc123".to_string().into();
println!("Processing payout for {shop}");
그리고 여기서 정말 강력해집니다. 생성자에 검증을 직접 추가할 수 있으므로, 애초에 잘못된 데이터는
ShopId
가 될 수조차 없습니다:
impl ShopId {
pub fn new(id: String) -> Result<Self, String> {
if id.is_empty() {
return Err("Shop ID cannot be empty".into());
}
if !id.starts_with("shop_") {
return Err("Shop ID must start with 'shop_'".into());
}
Ok(ShopId(id))
}
}
시스템 안에
ShopId
가 존재한다면, 그것이 유효하다는 것을 알 수 있습니다.
ShopId
를 받는 모든 함수는 검증을 완전히 건너뛸 수 있습니다. 생성자가 이미 그 일을 해냈기 때문입니다.
Go에서는 정의된 타입이 비어 있는 메서드 집합으로 시작하지만,
len()
같은 내장 연산은 여전히 동작하며 직접 메서드를 추가할 수 있습니다:
type ShopID string
id := ShopID("shop_abc123")
fmt.Println(len(id)) // works, len() is a built-in function
// Add your own methods
func (id ShopID) Validate() error {
if id == "" {
return errors.New("shop ID cannot be empty")
}
return nil
}
TypeScript에서 branded type은 구조적 타입일 뿐이므로, 모든
string
또는
number
연산은 추가 코드 없이 그대로 동작합니다. 브랜드는 컴파일 타임에만 존재합니다.
이것은 단지 뒤바뀐 인자를 잡는 문제만이 아닙니다. 코드에 대해 생각하는 방식 자체를 바꿉니다.
자기 문서화되는 코드. 함수가
String
대신
ShopId
를 받는다면, 그 매개변수가 무엇인지 설명하는 doc comment가 필요 없습니다. 타입 자체가 문서입니다.
리팩터링에 대한 자신감. 필드 이름을 바꾸거나 데이터 흐름을 변경할 때, 컴파일러는 그 타입의 모든 사용 지점을 코드베이스 전체에서 추적합니다. 아무것도 빠져나가지 않습니다.
경계에서의 검증.
ShopId
를 만들 때 불변 조건을 강제할 수 있습니다. 유효한 ObjectID 형식이어야 한다, 비어 있으면 안 된다, 데이터베이스에 존재해야 한다 같은 조건들입니다. 시스템 안의 모든
ShopId
는 유효하다고 보장됩니다. 모든 함수가 매번 검사해서가 아니라, 생성자가 한 번 검사했고 타입 시스템이 그 보장을 계속 운반해주기 때문입니다.
grep 가능성. 코드베이스에서
ShopId
를 검색하면 상점 식별자가 생성되고, 전달되고, 저장되고, 변환되는 모든 위치를 볼 수 있습니다.
String
를 검색하면 모든 것이 나옵니다.
보안. 렌더링 전에 반드시 명시적으로
SanitizedHtml
로 변환되어야 하는
RawUserInput
타입이라고 해봅시다. 이것은 코드 리뷰의 규율이 아니라 컴파일러가 강제하는 주입 방지입니다.
가장 흔한 반론은 형식적 장치가 많다는 것입니다. “모든 string을 newtype으로 감싸고 싶지 않다”는 말입니다. 하지만 대안을 생각해보세요. 팀의 모든 개발자가, 모든 PR에서, 모든 심야 핫픽스에서, 이름 없는 문자열을 그 의도된 용도에 항상 정확히 맞춰 쓸 것이라고 믿는 셈입니다. 그것은 엔지니어링이 아닙니다. 희망입니다.
래퍼 타입은 보통 각각 두 줄에서 다섯 줄 정도면 됩니다. 한 번만 작성하면 됩니다. 그리고 컴파일러가 영원히 그것을 강제합니다.
스칼라 타입은 데이터가 어떻게 생겼는지 를 설명합니다. 문자들의 나열, 64비트 정수, 불리언 플래그. 도메인 타입은 데이터가 무엇을 의미하는지 를 설명합니다. 제목, USD 단위 가격, 정제된 HTML 조각, 사용자 ID 같은 것들입니다.
이 둘 사이의 간극에 버그가 삽니다. 저는 그것을 뼈아프게 배웠습니다. 원시 타입을 감싸세요. 타입이 무언가를 의미하게 하세요. 코드 리뷰와 테스트가 결코 해내지 못할 일을 컴파일러가 하게 하세요.
—Samuel
수정: