thenullpage.com
회원 가입과 초대 메일을 처리하는 코드를 예로 든다. 이메일은 문자열로 받는다.
function signup(email: string) {
if (!email.includes("@")) throw new Error("이메일 형식이 아니다");
console.log("가입 처리 " + email);
}
function invite(email: string, message: string) {
console.log("[메일] " + email + " : " + message);
}
signup("[email protected]");
invite("환영한다", "[email protected]");
// 가입 처리 [email protected]
// [메일] 환영한다 : [email protected]
첫 줄은 제대로 찍힌다. 둘째 줄은 인자를 거꾸로 넘겼는데 오류 하나 없이 통과했고, 받는 사람 자리에 환영한다는 문장이 들어간 채로 메일이 나갔다. 편집기도 조용했다. 두 인자가 모두 문자열이니 순서가 뒤바뀐 것을 알아챌 방법이 없다.
문자열은 무엇이든 담는다
이메일이라는 개념에는 규칙이 있다. 골뱅이가 하나 있어야 하고, 앞뒤 공백은 의미가 없고, 대소문자가 달라도 같은 주소다. 문자열에는 그런 규칙이 없다. 빈 값도 담기고 사람 이름도 담기고 주문 번호도 담긴다.
도메인 개념을 문자열이나 숫자 같은 원시 타입으로만 표현하는 습관을 원시값 집착(Primitive Obsession)이라 한다. 증상은 둘로 나타난다.
하나는 검증이 흩어지는 것이다. 위 코드에서 형식 검사는 signup 안에만 있다. invite에는 없다. 비밀번호 재설정 기능을 추가하면 거기에도 같은 조건문을 다시 적게 되고, 규칙이 바뀌는 날 몇 곳을 고쳐야 하는지는 아무도 모른다. 넣어야 할 자리를 빠뜨렸는지 확인하려면 코드를 전부 읽는 수밖에 없다.
다른 하나는 타입이 의미를 구분하지 못하는 것이다. 이메일도 문자열이고 메시지도 문자열이니 서로 바꿔 넣어도 통과한다. 함수 인자가 셋 넷으로 늘고 그중 문자열이 여럿이면 순서를 외워서 맞추는 수밖에 없다. 컴파일러가 도와줄 수 있는 실수를 사람이 검토로 막고 있는 상태다.
규칙을 타입 안으로 옮긴다
고치는 방법은 그 개념에 이름을 붙여 클래스로 만드는 것이다. 이런 클래스를 값 객체(Value Object)라 한다.
class Email {
private constructor(readonly value: string) {}
static of(text: string) {
const t = text.trim().toLowerCase();
const at = t.indexOf("@");
if (at <= 0 || at === t.length - 1) throw new Error("이메일 형식이 아니다: " + text);
return new Email(t);
}
}
function signup(email: Email) {
console.log("가입 처리 " + email.value);
}
function invite(email: Email, message: string) {
console.log("[메일] " + email.value + " : " + message);
}
const mail = Email.of(" [email protected] ");
signup(mail);
invite(mail, "환영한다");
// 가입 처리 [email protected]
// [메일] [email protected] : 환영한다
Email.of("환영한다"); // Error: 이메일 형식이 아니다: 환영한다
// invite("환영한다", mail); 타입이 달라서 실행 전에 컴파일 에러
생성자를 private으로 막고 of를 거치게 했으므로, 검사를 통과하지 않은 Email은 만들어질 방법이 없다. 앞뒤 공백을 떼고 소문자로 맞추는 정규화도 여기 한 곳에서 끝난다. 넘겨 준 값은 공백과 대문자가 섞여 있었지만 출력은 정돈된 주소다.
얻은 것은 검증 위치가 하나로 줄었다는 것만이 아니다. Email을 받는 함수는 값이 올바른지 다시 묻지 않는다. 인자로 들어온 순간 이미 유효하다는 사실이 타입에 적혀 있다. 규칙이 바뀌면 고칠 곳은 of 하나다.
인자를 뒤바꾼 호출은 실행조차 되지 않는다. Email 자리에 문자열을 넣으면 편집기가 그 자리에 빨간 줄을 긋는다. 검토로 잡던 실수가 타입 검사로 내려온 것이다.
값 객체가 검증만 맡는 것도 아니다. 그 값에 관한 계산은 자연스럽게 이 클래스로 모인다. 회사 계정인지 가려내야 한다면 골뱅이 뒤를 잘라 보는 코드가 호출부마다 흩어지는 대신 domain이라는 메서드 하나가 되고, 금액을 다루는 값 객체라면 더하기와 통화 비교가 그 안으로 들어간다. 이름 없는 문자열 자르기와 숫자 계산이 이름 있는 메서드로 바뀌면서 부르는 쪽 코드가 짧아진다.
같은 값이면 같은 것으로 친다
값 객체를 처음 쓰면 비교에서 걸린다.
const a = Email.of("[email protected]");
const b = Email.of("[email protected]");
console.log(a === b); // false
console.log(a.value === b.value); // true
같은 주소인데 첫 줄이 false다. 등호 세 개는 같은 값이냐가 아니라 같은 객체냐를 묻기 때문이다. of를 두 번 불렀으니 객체는 둘이다.
여기서 구분해야 할 것이 식별성과 동등성이다. 회원이나 주문에는 식별자가 있고, 이름이나 배송지가 바뀌어도 번호가 같으면 같은 대상이다. 값 객체에는 식별자가 없다. 이메일은 글자 하나만 달라도 다른 이메일이고, 글자가 똑같으면 언제 어디서 만들었든 같은 이메일이다. 그래서 비교하는 메서드를 Email 안에 직접 둔다.
equals(other: Email) {
return this.value === other.value;
}
console.log(a.equals(b)); // true
같은 이유로 값 객체는 내용을 바꾸지 않게 만든다. 필드를 readonly로 두고, 값을 바꾸는 메서드 대신 바뀐 값을 담은 새 객체를 돌려주는 메서드를 둔다. 어딘가에 건네준 이메일이 나중에 다른 주소로 변해 있다면 값이라는 말과 맞지 않는다.
감쌀 것과 그냥 둘 것
값 객체에도 값은 붙는다. 클래스가 늘고, 바깥에서 문자열이 들어오는 자리마다 만들어 주는 코드가 필요하고, 저장하거나 화면에 보낼 때 다시 풀어야 한다. 반복문 인덱스나 배열 길이, 로그로 찍을 문장까지 감싸면 손해만 남는다.
기준은 세 가지다. 그 값에 지켜야 할 규칙이 있는가. 그 규칙을 검사하는 코드가 두 군데 이상 보이는가. 같은 원시 타입인 다른 개념과 나란히 놓일 자리가 있는가. 이메일, 금액, 전화번호, 좌표는 대개 셋을 다 만족한다. 둘 이상에 해당하면 감쌀 값이 있다고 봐도 된다.
만드는 자리도 정해 두는 편이 낫다. 사용자 입력이나 외부 응답이 들어오는 경계에서 한 번 만들고, 그 안쪽에서는 값 객체 그대로 주고받는다. 함수마다 문자열로 풀었다가 다시 감싸면 검증을 여러 번 하면서 이득은 사라진다.
판단이 서지 않으면 이렇게 물으면 된다. 이 자리에 엉뚱한 값이 들어왔을 때 어디서 알아채는가. 들어온 즉시 막힌다면 지금 구조로 충분하다. 한참 뒤 다른 기능에서 이상한 결과로 드러난다면 그 값은 문자열이 아니라 타입이어야 한다.