
프로그래밍을 처음 배울 때 연산자는 보통 값을 계산하거나 비교하는 기호라고 배운다.
const sum = 10 + 20;
const isAdult = age >= 18;
const canEnter = hasTicket && isOpen;
+는 두 값을 더하고, >=는 왼쪽 값이 오른쪽 값보다 크거나 같은지 비교하며, &&는 두 조건이 모두 참인지 확인한다.
연산자의 기본 동작을 이해하기에는 충분한 설명이다.
하지만 실제 서비스를 개발할 때 연산자는 단순한 계산 기호가 아니다.
상품의 최종 가격을 계산하고, 쿠폰을 사용할 수 있는지 판단하고, 사용자가 특정 페이지에 접근할 권한이 있는지 결정하는 데 사용된다.
즉, 실제 서비스에서 연산자를 사용한다는 것은 데이터 사이의 관계와 서비스의 규칙을 코드로 표현한다는 것에 가깝다.
다음 코드는 두 숫자를 더한다.
const result = 100 + 10;
컴퓨터는 100과 10을 더해 110을 만든다.
하지만 이 숫자가 상품 가격인지, 세금인지, 배송비인지 알 수 없다.
실제 서비스에서는 같은 연산도 데이터의 의미에 따라 전혀 다른 규칙이 된다.
const subtotalInCents = productPriceInCents * quantity;
const taxInCents = Math.round(subtotalInCents * taxRate);
const totalInCents = subtotalInCents + taxInCents + shippingFeeInCents;
여기에서 *와 +는 단순한 산술 연산자지만, 코드 전체는 다음과 같은 가격 정책을 표현한다.
상품 단가에 수량을 곱해 소계를 구하고, 소계를 기준으로 세금을 계산한 뒤 배송비를 더해 최종 결제 금액을 만든다.
연산자 자체는 서비스의 의미를 알지 못한다.
변수명, 데이터의 단위, 계산 순서가 함께 있을 때 비로소 비즈니스 규칙이 된다.
쇼핑몰의 할인 가격을 다음과 같이 계산할 수 있다.
const discountAmount = originalPrice * discountRate;
const finalPrice = originalPrice - discountAmount;
수학적으로는 간단하지만 실제 서비스에서는 더 많은 질문이 필요하다.
예를 들어 할인 금액이 상품 가격보다 커질 수 있다면 단순한 뺄셈만으로는 부족하다.
const discountedPriceInCents =
originalPriceInCents - discountAmountInCents;
이 계산은 음수를 만들 수 있다.
const finalPriceInCents = Math.max(
0,
originalPriceInCents - discountAmountInCents,
);
Math.max를 사용해 최종 금액이 0보다 작아지지 않도록 서비스 규칙을 추가했다.
따라서 실제 개발에서 중요한 것은 뺄셈을 할 줄 아는지가 아니다.
어떤 값에서 무엇을 빼야 하며, 계산 결과가 어느 범위까지 허용되는지를 정의하는 것이다.
JavaScript에서 소수를 계산하면 예상과 다른 결과가 나올 수 있다.
console.log(0.1 + 0.2);
// 0.30000000000000004
결제나 정산 서비스에서 금액을 소수로 계속 계산하면 작은 오차가 누적될 수 있다.
그래서 금액을 달러가 아닌 센트처럼 가장 작은 화폐 단위의 정수로 관리하는 경우가 많다.
const productPriceInCents = 1999;
const quantity = 2;
const totalInCents = productPriceInCents * quantity;
화면에 표시할 때만 달러 단위로 변환한다.
const formattedTotal = new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
}).format(totalInCents / 100);
같은 +, -, *, /를 사용하더라도 데이터의 단위와 정밀도를 먼저 정하지 않으면 올바른 결과를 보장하기 어렵다.
연산자가 계산을 수행한다면, 단위는 그 계산의 의미를 결정한다.
비교 연산자는 두 값이 같거나, 한 값이 다른 값보다 큰지를 확인한다.
const isAdult = age >= 18;
실제 서비스에서는 비교 연산자 하나가 사용 가능 여부와 정책의 경계를 결정할 수 있다.
const isMinimumOrderMet = orderTotalInCents >= 5000;
const hasRemainingUses = coupon.remainingUses > 0;
const isWithinUploadLimit = file.size <= MAX_FILE_SIZE_BYTES;
이때 >와 >=의 차이는 작아 보이지만 결과는 다르다.
const canUseCoupon = orderTotalInCents > 5000;
이 코드는 주문 금액이 정확히 50달러일 때 쿠폰을 사용할 수 없다.
const canUseCoupon = orderTotalInCents >= 5000;
이 코드는 주문 금액이 50달러인 경우도 허용한다.
기획 요구사항에 “50달러 이상”이라고 적혀 있다면 >=가 맞고, “50달러 초과”라면 >가 맞다.
연산자 하나의 차이가 실제 고객의 결제 가능 여부를 바꾼다.
따라서 비교 연산자를 작성할 때는 기획 문장의 경계를 정확하게 코드로 번역해야 한다.
쿠폰이 만료되었는지 확인한다고 생각해보자.
const isExpired = coupon.expiresAt < new Date();
현재 시간이 만료 시각보다 늦으면 만료된 것으로 판단한다.
하지만 만료 시각과 현재 시간이 정확히 같다면 어떻게 해야 할까?
const isExpired = coupon.expiresAt <= new Date();
이 코드에서는 만료 시각이 되는 순간부터 사용할 수 없다.
실제 서비스에서는 다음과 같은 정책을 먼저 결정해야 한다.
날짜 비교는 단순한 대소 비교처럼 보이지만, 실제로는 기간의 포함 범위와 시간대 정책을 구현한다.
JavaScript의 ==는 비교하기 전에 값의 타입을 자동으로 변환할 수 있다.
console.log("1" == 1);
// true
console.log(false == 0);
// true
입문 예제에서는 편리해 보일 수 있지만, 실제 서비스에서는 예상하지 못한 값이 같은 것으로 판단될 수 있다.
const inputUserId = "100";
const currentUserId = 100;
console.log(inputUserId == currentUserId);
// true
===는 값뿐 아니라 타입도 함께 비교한다.
console.log(inputUserId === currentUserId);
// false
실제 서비스에서는 입력값의 타입을 명시적으로 변환한 뒤 엄격하게 비교하는 편이 안전하다.
const inputUserId = Number(request.params.userId);
if (!Number.isInteger(inputUserId)) {
throw new Error("올바르지 않은 사용자 ID입니다.");
}
const isCurrentUser = inputUserId === currentUser.id;
자동 타입 변환에 기대기보다 데이터를 먼저 검증하고 정규화한 다음 ===로 비교하면 코드의 의도가 훨씬 명확해진다.
쿠폰을 사용할 수 있는 조건을 생각해보자.
const canRedeemCoupon =
coupon.status === "active" &&
coupon.remainingUses > 0 &&
coupon.expiresAt > new Date() &&
coupon.recipientId === currentUser.id;
&&는 모든 조건이 참이어야 한다는 의미다.
이 코드는 다음 정책을 표현한다.
쿠폰이 활성 상태이고, 남은 사용 횟수가 있으며, 만료되지 않았고, 현재 사용자가 수신자일 때만 사용할 수 있다.
관리자이거나 쿠폰 소유자인 사람만 상세 페이지를 볼 수 있다면 ||를 사용할 수 있다.
const canViewCoupon =
currentUser.role === "admin" ||
coupon.recipientId === currentUser.id;
!는 상태를 반대로 표현한다.
const isExpired = coupon.expiresAt <= new Date();
const isNotExpired = !isExpired;
논리 연산자는 여러 참과 거짓을 계산하는 도구지만, 실제 서비스에서는 여러 정책을 하나의 최종 결정으로 결합한다.
조건을 한 줄에 모두 작성하면 코드는 짧지만 의미를 파악하기 어렵다.
if (
coupon.status === "active" &&
coupon.remainingUses > 0 &&
coupon.expiresAt > new Date() &&
coupon.recipientId === currentUser.id
) {
// 쿠폰 사용
}
각 조건에 이름을 붙이면 서비스 규칙이 드러난다.
const isActive = coupon.status === "active";
const hasRemainingUses = coupon.remainingUses > 0;
const isNotExpired = coupon.expiresAt > new Date();
const isRecipient = coupon.recipientId === currentUser.id;
const canRedeemCoupon =
isActive &&
hasRemainingUses &&
isNotExpired &&
isRecipient;
이제 어떤 조건 때문에 쿠폰을 사용할 수 없는지 구분하기도 쉽다.
if (!isActive) {
throw new Error("활성화된 쿠폰이 아닙니다.");
}
if (!hasRemainingUses) {
throw new Error("사용 가능한 횟수가 남아 있지 않습니다.");
}
if (!isNotExpired) {
throw new Error("만료된 쿠폰입니다.");
}
if (!isRecipient) {
throw new Error("이 쿠폰을 사용할 권한이 없습니다.");
}
복잡한 조건을 나누는 이유는 단순히 가독성을 높이기 위해서만이 아니다.
실패 원인을 구분하고, 테스트하고, 정책을 변경하기 쉽게 만들기 위해서다.
JavaScript에서는 ||를 이용해 기본값을 지정하는 코드를 자주 볼 수 있다.
const quantity = inputQuantity || 1;
하지만 ||는 왼쪽 값이 false, 0, 빈 문자열, null, undefined처럼 falsy한 값이면 오른쪽 값을 선택한다.
const discountRate = inputDiscountRate || 0.1;
사용자가 할인율을 0으로 지정하더라도 0.1이 적용된다.
const inputDiscountRate = 0;
const discountRate = inputDiscountRate || 0.1;
console.log(discountRate);
// 0.1
0이 유효한 값이라면 null 또는 undefined인 경우에만 기본값을 사용해야 한다.
const discountRate = inputDiscountRate ?? 0.1;
??는 왼쪽 값이 null이나 undefined일 때만 오른쪽 값을 선택한다.
0 ?? 0.1; // 0
"" ?? "default"; // ""
false ?? true; // false
null ?? 0.1; // 0.1
기본값을 정할 때는 “거짓으로 평가되는 값인가?”와 “값이 존재하지 않는가?”를 구분해야 한다.
중첩된 객체의 속성에 접근할 때 ?.를 사용할 수 있다.
const profileImageUrl = user.profile?.imageUrl;
profile이 없으면 오류를 발생시키지 않고 undefined를 반환한다. 선택적인 데이터에 접근할 때 유용하다.
하지만 필요한 데이터까지 무조건 ?.로 접근하면 문제를 조용히 숨길 수 있다.
const issuerId = coupon?.issuer?.id;
쿠폰에는 반드시 발행자가 존재해야 하는데 issuerId가 undefined가 되도록 허용한다면 데이터 오류가 더 늦게 발견될 수 있다.
필수 관계라면 명확하게 검증하는 편이 낫다.
if (!coupon.issuer) {
throw new Error("쿠폰 발행자 정보가 없습니다.");
}
const issuerId = coupon.issuer.id;
?.는 데이터가 없어도 괜찮은 경우에 사용하는 연산자다.
데이터가 없어서는 안 되는 경우까지 안전한 것처럼 보이게 만드는 장치는 아니다.
다음 코드는 값을 다시 할당한다.
let remainingUses = 3;
remainingUses = remainingUses - 1;
축약 대입 연산자를 사용할 수도 있다.
remainingUses -= 1;
하지만 실제 서비스에서 중요한 것은 문법의 길이가 아니라 상태 변경의 안전성이다.
데이터베이스에서 남은 쿠폰 횟수를 조회한 뒤 애플리케이션에서 감소시키면, 여러 요청이 동시에 들어왔을 때 문제가 생길 수 있다.
const coupon = await couponRepository.findById(couponId);
coupon.remainingUses -= 1;
await couponRepository.save(coupon);
두 요청이 동시에 remainingUses를 1로 읽으면 두 요청 모두 0을 저장하고 쿠폰이 두 번 사용될 수 있다.
이런 경우에는 데이터베이스의 원자적 업데이트나 트랜잭션이 필요하다.
await database.coupon.update({
where: {
id: couponId,
remainingUses: {
gt: 0,
},
},
data: {
remainingUses: {
decrement: 1,
},
},
});
코드에서 -=를 사용했다고 해서 서비스의 상태 변경이 안전하게 처리되는 것은 아니다.
상태가 여러 요청과 서버에서 공유된다면 동시성까지 고려해야 한다.
다음 두 계산은 비슷해 보이지만 결과가 다를 수 있다.
const totalA = subtotal - discount + shippingFee;
const totalB = subtotal - (discount + shippingFee);
첫 번째 코드는 상품 금액에서 할인액을 뺀 뒤 배송비를 더한다.
두 번째 코드는 할인액과 배송비를 모두 상품 금액에서 뺀다.
세금과 할인 계산도 순서에 따라 결과가 달라진다.
const discountedSubtotal = subtotal - discountAmount;
const taxAmount = discountedSubtotal * taxRate;
const total = discountedSubtotal + taxAmount;
다음 코드는 할인 전 금액에 세금을 계산한다.
const taxAmount = subtotal * taxRate;
const total = subtotal + taxAmount - discountAmount;
어느 계산이 맞는지는 연산자의 우선순위가 아니라 서비스의 가격 정책과 세금 규칙에 따라 결정된다.
괄호는 계산 순서를 바꾸는 문법이면서 동시에 개발자의 의도를 보여주는 설명이다.
계산이 복잡하다면 하나의 긴 표현식보다 의미 있는 중간 변수로 나누는 편이 안전하다.
한 번 사용되는 간단한 계산은 변수로 표현할 수 있다.
const hasRemainingUses = coupon.remainingUses > 0;
하지만 여러 화면과 API에서 동일한 규칙을 사용한다면 조건을 복사해서는 안 된다.
function canRedeemCoupon(
coupon: Coupon,
currentUserId: string,
now: Date,
): boolean {
const isActive = coupon.status === "active";
const hasRemainingUses = coupon.remainingUses > 0;
const isNotExpired = coupon.expiresAt > now;
const isRecipient = coupon.recipientId === currentUserId;
return (
isActive &&
hasRemainingUses &&
isNotExpired &&
isRecipient
);
}
중요한 규칙을 함수로 분리하면 다음과 같은 장점이 있다.
연산자는 규칙을 구성하는 재료이고, 함수는 그 규칙에 이름과 경계를 부여한다.
실제 서비스에서 계산과 조건을 작성할 때는 다음 질문을 확인해야 한다.
이 질문에 답하지 않고 연산자만 조합하면 문법적으로는 올바르지만 서비스 정책과 다른 코드가 만들어질 수 있다.
입문 과정에서는 연산자를 덧셈, 뺄셈, 비교와 논리 계산을 위한 기호로 배운다.
틀린 설명은 아니지만 실제 서비스에서 연산자가 맡는 역할을 모두 보여주지는 못한다.
예를 들어, 실제 서비스에서
+와 -는 가격 정책을 구현한다. >와 >=는 사용 가능 여부의 경계를 정한다. &&와 ||는 여러 정책을 하나의 결정으로 결합한다. ??는 값이 없는 경우와 유효한 falsy 값을 구분한다. 따라서 연산자를 제대로 사용하려면 기호의 동작뿐 아니라 데이터의 의미, 단위, 경계, 순서와 변경 시점까지 이해해야 한다.
연산자는 컴퓨터에 계산 방법을 알려주는 기호다.
그러나 개발자가 실제로 해야 하는 일은 현실의 정책을 정확한 계산과 조건으로 번역하는 것이다.
서비스 개발에서 중요한 질문은 “어떤 연산자를 사용할까?”가 아니다.
우리 서비스의 규칙을 어떤 데이터와 조건으로 표현해야 하는가?
이 질문에 먼저 답할 수 있다면 연산자는 단순한 문법이 아니라 서비스를 움직이는 언어가 된다.