Devin.KR

변수·타입·연산자 - 느슨한 비교를 조심하기

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서는 PHP 파일을 CLI로 실행하고 내장 웹 서버를 띄우는 방법을 익혔다. 이제 프로그램이 다루는 값이 무엇이고 어떻게 비교되는지 알아볼 차례다. 빵집 예약 주문 사이트에서는 품목 이름, 단가, 수량, 픽업 여부, 요청 사항 같은 값이 계속 오간다. 이 값들을 담는 변수와 값의 종류(타입), 값을 계산하고 비교하는 연산자를 이 장에서 다룬다. PHP는 다른 타입의 값을 비교할 때 스스로 타입을 바꿔서 비교하는 경우가 많다. 그래서 이 장의 제목처럼 느슨한 비교를 조심해야 한다.

  • int, float, string, bool, null이 어떤 값인지 구분하고 get_debug_type()으로 확인한다.
  • ==와 ===의 차이를 설명하고, 어느 쪽을 기본으로 써야 하는지 판단한다.
  • declare(strict_types=1)이 함수 호출에 미치는 영향을 이해한다.
  • 큰따옴표 문자열에 변수를 끼워 넣는 문자열 보간(interpolation)을 안전하게 쓴다.
  • null 병합 연산자 ??와 ??=로 값이 없는 경우의 기본값을 정한다.

문제 상황

빵집 사장님이 예약 주문 사이트에서 다음 두 가지 문의를 받았다고 하자.

  • 주문 번호가 1e3인 손님의 예약이 주문 번호 1000 손님의 예약으로 조회된다.
  • 수량 입력칸에 3을 적었는데 합계 계산 함수에서 오류가 나거나, 반대로 아무 오류 없이 엉뚱한 합계가 나온다.

둘 다 값의 타입을 신경 쓰지 않고 코드를 써서 생기는 문제다. 웹 폼에서 넘어오는 값은 거의 문자열이다. 수량 3도 숫자 3이 아니라 글자 '3'으로 들어온다. 이 글자를 숫자와 같은 것으로 볼지, 다른 것으로 볼지를 언어가 조용히 정해 버리면 문제를 찾기 어려워진다. 이 장에서는 그 기준이 어디에 있는지 확인하고, 코드가 기준을 스스로 정하도록 만든다.

값과 타입

변수는 이름표다

변수(variable)는 값에 붙이는 이름표다. PHP에서는 이름 앞에 달러 기호를 붙이고 등호로 값을 넣는다. 예를 들어 $unitPrice = 3200;은 3200이라는 값에 $unitPrice라는 이름을 붙인다. 변수를 만들 때 타입을 미리 적을 필요는 없다. 대신 값 자체가 타입을 가지고 있다. 같은 이름에 나중에 다른 종류의 값을 넣을 수도 있다. 그래서 코드를 읽을 때는 이 변수에 어떤 값이 들어 있는지 항상 따져 봐야 한다.

스칼라 타입 네 가지와 null

스칼라(scalar)는 더 쪼갤 수 없는 낱개 값을 뜻한다. PHP의 스칼라 타입은 네 가지다. 여기에 값이 없음을 나타내는 null을 더해 다섯 가지를 함께 살펴본다. null은 스칼라가 아니지만 웹 프로그램에서 값이 비어 있는 상태를 표현할 때마다 나오기 때문이다.

PHP의 기본 값 종류와 get_debug_type()이 돌려주는 이름
종류예get_debug_type()빵집에서의 쓰임
정수3200int단가, 수량
실수0.75float무게, 비율
문자열'소금빵'string품목 이름, 요청 사항
불리언truebool매장 픽업 여부
nullnullnull아직 입력되지 않은 메모

타입 이름을 확인할 때는 get_debug_type()을 쓴다. 비슷한 gettype()은 실수를 double로 알려 주는 등 이름이 달라서 이 책에서는 쓰지 않는다.

PHP의 값에는 타입이 붙어 있고, 변수는 그 값을 가리키는 이름표일 뿐이다.

실수의 오차와 나눗셈

실수는 컴퓨터 안에서 2진수로 근사해서 저장한다. 그래서 0.1 + 0.2는 정확히 0.3과 같지 않다. 금액은 원 단위 정수로 다루고, 실수를 비교해야 할 때는 차이가 충분히 작은지 확인하는 방식을 쓴다. 완성 코드에서 이 차이를 직접 볼 수 있다.

나눗셈 연산자 /는 나누어떨어지지 않으면 실수를 돌려준다. 정수 몫이 필요하면 intdiv()를, 나머지가 필요하면 %를 쓴다. 할인 금액을 구할 때 intdiv($total * 10, 100)처럼 정수만으로 계산하면 실수 오차를 피할 수 있다.

느슨한 비교와 엄격한 비교

두 연산자가 하는 일

==는 두 값의 타입이 다르면 한쪽 또는 양쪽을 바꿔 본 뒤 값이 같은지 비교한다. 이것이 느슨한 비교(loose comparison)다. ===는 타입까지 같아야 참이 되는 엄격한 비교(strict comparison)다. 타입 변환이 없으므로 결과를 예측하기 쉽다.

느슨한 비교가 놀라운 결과를 내는 대표적인 경우는 숫자처럼 생긴 문자열이다. '10'과 '1e1'은 글자가 다르지만, 둘 다 숫자로 읽을 수 있는 문자열이어서 ==는 이를 숫자 10과 10.0으로 바꿔 비교하고 참을 돌려준다. 또 PHP 8부터는 숫자와 숫자로 읽을 수 없는 문자열을 비교할 때 숫자를 문자열로 바꿔 비교한다. 그래서 0 == 'abc'는 거짓이다. 예전 버전에서는 참이었으므로 오래된 자료를 볼 때 주의해야 한다. 자세한 변환 규칙은 PHP 매뉴얼의 비교 연산자 항목에서 확인할 수 있다.

같은 두 값을 == 와 === 로 비교했을 때의 결과
왼쪽과 오른쪽=====이유
'10', '1e1'truefalse둘 다 숫자 모양이라 숫자로 바꿔 비교한다
100, '1e2'truefalse문자열이 숫자 100.0으로 바뀐다
0, 'abc'falsefalsePHP 8은 숫자를 글자로 바꿔 비교한다
'0', falsetruefalse양쪽을 bool로 바꾸면 둘 다 false다
'', nulltruefalse빈 문자열과 null은 느슨하게 같다
null, falsetruefalsenull은 bool로 바뀌면 false다
같은 두 문자열도 ==는 숫자로 바꿔 같다고 보고, ===는 타입과 글자를 그대로 비교해 다르다고 본다.

기본은 ===

이 책의 예제는 특별한 이유가 없으면 ===를 쓴다. 느슨한 비교가 꼭 필요한 경우는 드물다. 문자열로 들어온 수량을 숫자와 비교하고 싶다면 먼저 (int)로 바꾸거나 검증한 뒤 ===로 비교하는 편이 의도가 분명하다.

strict_types와 함수의 타입 선언

함수는 이름을 붙인 코드 묶음이다. 자세한 내용은 뒤에서 다루고, 여기서는 매개변수(parameter)에 타입을 적을 수 있다는 점만 본다. function lineTotal(int $unitPrice, int $quantity): int는 두 인수가 정수여야 하고 정수를 돌려준다는 뜻이다.

이 선언은 파일 맨 위에 declare(strict_types=1);이 있는지에 따라 다르게 동작한다. 선언이 없으면 PHP는 문자열 '3'을 정수 3으로 조용히 바꿔서 함수에 넘긴다. 선언이 있으면 타입이 맞지 않는 값을 넘기는 순간 TypeError가 발생한다. 오류가 나는 대신 잘못된 호출을 바로 찾을 수 있다. 예외는 정수를 실수 매개변수에 넘기는 경우 하나뿐이다. 이때는 정수가 실수로 넓혀져 들어간다.

이 선언은 선언한 파일 안에서 이루어지는 호출에만 적용된다. 그리고 파일의 첫 문장이어야 한다. 규칙은 PHP 매뉴얼의 타입 선언 항목에 있다.

오류를 잡는 try와 catch는 예외를 다루는 부분에서 자세히 배운다. 지금은 try 안의 코드에서 오류가 나면 catch 안의 코드가 대신 실행된다고만 알면 된다.

문자열 보간과 null 병합

문자열 보간

큰따옴표로 감싼 문자열 안에 변수 이름을 적으면 그 자리에 변수의 값이 들어간다. 이것이 문자열 보간이다. 작은따옴표 문자열은 보간하지 않고 적은 그대로 출력한다. 변수 이름 뒤에 글자가 바로 이어질 때는 {$total}원처럼 중괄호로 변수의 끝을 표시한다. 한글이 바로 붙는 경우가 특히 위험하다. 이유는 뒤의 실수 항목에서 설명한다.

null 병합 연산자

??는 왼쪽 값이 존재하고 null이 아니면 그 값을, 아니면 오른쪽 값을 돌려준다. 정의되지 않은 변수나 배열에 없는 키(key)가 왼쪽에 와도 경고를 내지 않는다. 배열은 뒤에서 자세히 배우고, 여기서는 ['item' => '소금빵']이 이름표(키)로 값을 찾는 묶음이라는 정도만 알면 된다. 폼에서 받은 값에 선택 항목이 없을 때 기본값을 채우는 데 쓴다. ??=는 값이 없을 때만 대입한다.

주의할 점이 있다. ??는 null인지만 본다. 빈 문자열이나 0은 값이 있는 것으로 취급해 그대로 돌려준다.

완성 코드

파일 하나로 위 내용을 순서대로 확인한다. 파일 이름은 main.php다.

<?php

declare(strict_types=1);

function show(string $label, bool $value): void
{
    echo $label, ' => ', $value ? 'true' : 'false', "\n";
}

function showType(string $name, mixed $value): void
{
    echo $name, ': ', get_debug_type($value), "\n";
}

function lineTotal(int $unitPrice, int $quantity): int
{
    return $unitPrice * $quantity;
}

$itemName = '소금빵';
$unitPrice = 3200;
$quantity = 3;
$discountPercent = 10;
$isPickup = true;
$memo = null;

echo "== 스칼라 타입 ==\n";
showType('itemName', $itemName);
showType('unitPrice', $unitPrice);
showType('ratio', 0.75);
showType('isPickup', $isPickup);
showType('memo', $memo);
show('0.1 + 0.2 == 0.3', 0.1 + 0.2 == 0.3);
show('오차 0.00001 이내', abs(0.1 + 0.2 - 0.3) < 0.00001);
echo '7 / 2 = ', 7 / 2, ', intdiv = ', intdiv(7, 2), ', 나머지 = ', 7 % 2, "\n";

echo "\n== 느슨한 비교와 엄격한 비교 ==\n";
show('"10" == "1e1"', "10" == "1e1");
show('"10" === "1e1"', "10" === "1e1");
show('100 == "1e2"', 100 == "1e2");
show('0 == "abc"', 0 == "abc");
show('"0" == false', "0" == false);
show('"" == null', "" == null);
show('null == false', null == false);
show('null === false', null === false);

echo "\n== strict_types ==\n";
echo lineTotal(3200, 3), "\n";
$fromForm = '3';
try {
    echo lineTotal(3200, $fromForm), "\n";
} catch (TypeError) {
    echo '타입 오류: ', get_debug_type($fromForm), " 값은 int 자리에 들어갈 수 없다\n";
}
echo lineTotal(3200, (int) $fromForm), "\n";

$total = lineTotal($unitPrice, $quantity);
$discount = intdiv($total * $discountPercent, 100);
$payable = $total - $discount;
$method = $isPickup ? '매장 픽업' : '배달';

echo "\n== 문자열 보간 ==\n";
echo "품목: {$itemName}, 수량: {$quantity}\n";
echo "합계: {$total}원, 할인: {$discount}원\n";
echo "결제 금액: ", number_format($payable), "원 ({$method})\n";
echo '작은따옴표: $itemName', "\n";

echo "\n== null 병합 ==\n";
$memoText = $memo ?? '요청 사항 없음';
echo "메모: {$memoText}\n";
$emptyMemo = '';
echo '빈 문자열 메모: [', $emptyMemo ?? '요청 사항 없음', "]\n";
$order = ['item' => '소금빵'];
echo '픽업 시각: ', $order['pickup'] ?? '미정', "\n";
$order['pickup'] ??= '10:00';
echo '픽업 시각: ', $order['pickup'], "\n";

줄별 해설

파일 맨 위. <?php 다음에 빈 줄을 두고 declare(strict_types=1);을 적었다. PHP 코드의 첫 문장이어야 하므로 이 위에는 다른 문장을 둘 수 없다.

show 함수. 라벨 문자열과 bool 값을 받아 라벨 => true 형태로 한 줄을 출력한다. echo는 쉼표로 여러 값을 이어 출력할 수 있다. 조건 ? 값1 : 값2는 조건이 참이면 값1, 아니면 값2를 고르는 식이다. 반환 타입 void는 돌려주는 값이 없다는 뜻이다.

showType 함수. mixed는 어떤 타입이든 받는다는 뜻이다. 다섯 종류의 값을 한 함수로 확인하려고 썼다.

lineTotal 함수. 단가와 수량을 곱해 정수로 돌려준다. strict_types가 켜져 있으므로 정수가 아닌 값이 오면 TypeError가 난다.

변수 선언 여섯 줄. 품목, 단가, 수량, 할인율(퍼센트), 픽업 여부, 메모를 만든다. 할인율을 0.1이 아니라 정수 10으로 둔 것은 실수 오차를 피하려는 선택이다. $memo는 아직 입력되지 않은 상태라 null이다.

스칼라 타입 구역. 각 변수의 타입 이름을 출력한다. 0.75는 값을 바로 적어 실수를 확인한다. 0.1 + 0.2 == 0.3은 false가 나온다. abs()는 절댓값을 구하므로 차이의 크기가 0.00001보다 작은지 확인하면 true가 나온다. 마지막 줄은 /, intdiv(), %의 차이를 보여 준다.

비교 구역. 표에 있던 조합을 실제로 실행한다. 라벨은 작은따옴표로 감싸서 안의 큰따옴표가 그대로 출력되게 했다. 마지막 두 줄에서 null == false는 true, null === false는 false다.

strict_types 구역. 정수 두 개를 직접 넘기면 9600이 나온다. 문자열 '3'을 넘기면 TypeError가 발생해 catch 블록이 실행된다. catch (TypeError)는 오류 객체를 따로 쓰지 않을 때 변수를 생략한 형태다. 마지막 줄은 (int)로 문자열을 정수로 바꾼 뒤 넘겨서 정상 동작한다. 형 변환을 코드에 드러내는 것이 요점이다.

계산 네 줄. 합계 9600, 할인 960, 결제 금액 8640을 구한다. 할인은 intdiv(9600 * 10, 100)이다.

문자열 보간 구역. 큰따옴표 문자열 안에서 {$itemName}과 {$total}이 값으로 바뀐다. number_format()은 천 단위 쉼표를 넣는다. 마지막 줄은 작은따옴표 문자열이라 $itemName이 글자 그대로 출력된다.

null 병합 구역. $memo는 null이므로 기본값이 쓰인다. $emptyMemo는 빈 문자열이라 null이 아니므로 그대로 빈 값이 출력되어 대괄호 안이 비어 있다. $order['pickup'] ?? '미정'은 키가 없어도 경고 없이 '미정'을 고른다. ??= 다음 줄에서는 키가 없었으므로 '10:00'이 들어간다.

실행 결과

$ php main.php
== 스칼라 타입 ==
itemName: string
unitPrice: int
ratio: float
isPickup: bool
memo: null
0.1 + 0.2 == 0.3 => false
오차 0.00001 이내 => true
7 / 2 = 3.5, intdiv = 3, 나머지 = 1

== 느슨한 비교와 엄격한 비교 ==
"10" == "1e1" => true
"10" === "1e1" => false
100 == "1e2" => true
0 == "abc" => false
"0" == false => true
"" == null => true
null == false => true
null === false => false

== strict_types ==
9600
타입 오류: string 값은 int 자리에 들어갈 수 없다
9600

== 문자열 보간 ==
품목: 소금빵, 수량: 3
합계: 9600원, 할인: 960원
결제 금액: 8,640원 (매장 픽업)
작은따옴표: $itemName

== null 병합 ==
메모: 요청 사항 없음
빈 문자열 메모: []
픽업 시각: 미정
픽업 시각: 10:00

실무에서 자주 틀리는 것

변수 이름 뒤에 한글을 바로 붙인다

PHP는 변수 이름에 한글 같은 비ASCII 글자도 허용한다. 그래서 $total원을 $total 뒤에 '원'이 붙은 것으로 읽지 않고, $total원이라는 이름의 변수로 읽는다.

$total = 9600;
echo "합계: $total원\n";

실행하면 Warning: Undefined variable $total원 경고가 나오고 합계가 빈 채로 출력된다. 중괄호로 변수의 끝을 표시하면 해결된다.

$total = 9600;
echo "합계: {$total}원\n";

문자열 값을 == 로 비교한다

주문 번호처럼 식별자로 쓰는 문자열을 ==로 비교하면 숫자처럼 생긴 값끼리 같다고 판단할 수 있다.

$saved = '1000';
$input = '1e3';
if ($saved == $input) {
    echo "같은 주문으로 처리\n";
}

두 문자열이 모두 숫자 모양이라 1000과 1000.0으로 바뀌어 참이 된다. 엄격한 비교로 바꾸면 글자가 다르므로 다른 주문으로 처리된다.

if ($saved === $input) {
    echo "같은 주문으로 처리\n";
}

declare 를 첫 문장 뒤에 둔다

strict_types 선언 앞에 다른 문장이 있으면 파일이 실행되기 전에 오류가 난다.

<?php
echo "시작\n";
declare(strict_types=1);

실행하면 Fatal error: strict_types declaration must be the very first statement in the script가 나온다. 선언을 맨 위로 옮긴다.

<?php

declare(strict_types=1);

echo "시작\n";

이 선언은 파일마다 따로 필요하다. 다른 파일에서 불러오는 함수의 정의 쪽이 아니라, 호출하는 파일의 선언이 적용된다는 점도 기억해 두자.

?? 가 빈 문자열까지 바꿔 줄 것이라고 기대한다

폼에서 요청 사항 칸을 비워 제출하면 null이 아니라 빈 문자열이 온다.

$memo = '';
echo $memo ?? '요청 사항 없음';

출력은 빈 줄이다. 빈 문자열까지 기본값으로 바꾸려면 조건을 직접 적는다.

$memo = '';
echo ($memo ?? '') === '' ? '요청 사항 없음' : $memo;

참고로 ?:로 바꾸는 방법도 있지만 이 연산자는 문자열 '0'과 숫자 0도 비어 있는 값으로 본다. 그래서 수량 0이나 메모 '0' 같은 값이 섞이는 곳에서는 위처럼 조건을 직접 적는 편이 안전하다.

한눈에 보기

이 장에서 다룬 문법과 주의할 점
주제문법동작주의
타입 확인get_debug_type($x)int, float, string, bool, null 이름을 돌려준다실수는 double이 아니라 float로 나온다
느슨한 비교$a == $b타입을 바꿔 값을 비교한다숫자 모양 문자열이 서로 같아질 수 있다
엄격한 비교$a === $b타입과 값이 모두 같아야 참이다기본으로 쓴다
엄격한 타입declare(strict_types=1);타입이 다른 인수에 TypeError를 낸다파일 첫 문장, 파일마다 선언
문자열 보간"{$name}님"큰따옴표 안에서 변수 값을 끼운다한글이 붙으면 중괄호를 쓴다
null 병합$a ?? $b, $a ??= $bnull이거나 없을 때만 기본값을 쓴다빈 문자열과 0은 그대로 남는다
나눗셈/, intdiv(), %실수 결과, 정수 몫, 나머지금액은 정수로 계산한다

연습 문제

  1. 다음 비교의 결과가 true인지 false인지 실행하기 전에 예상해 보자. (가) "1" == "01" (나) "1" === "01" (다) null == 0 (라) "" == 0
  2. $price = 4500;과 $qty = 2;가 있다. 문자열 보간으로 합계: 9000원을 출력하는 코드를 쓰되, 합계는 변수에 먼저 계산해 둔다.
  3. 변수 $nickname은 null일 수도 있다. null이면 '손님'으로 인사하는 손님, 어서 오세요 형태의 문장을 출력하는 코드를 쓰자. 또 정의되지 않은 $size에 기본값 '보통'을 넣는 코드도 쓰자.
  4. declare(strict_types=1);이 켜진 파일에서 function half(int $n): int { return intdiv($n, 2); }를 정의했다. half("8")은 왜 오류가 나는가. 고치는 방법 두 가지를 적어 보자.

정답과 해설

  1. (가) true (나) false (다) true (라) false. (가)는 둘 다 숫자 모양 문자열이라 1과 1로 바뀐다. (나)는 글자가 '1'과 '01'로 다르다. (다)는 null을 bool로 바꾸면 false이고 0도 false이므로 같다. (라)는 PHP 8에서 숫자 0을 글자 '0'으로 바꿔 빈 문자열과 비교하므로 다르다.
  2. 예시는 다음과 같다.
    $price = 4500;
    $qty = 2;
    $sum = $price * $qty;
    echo "합계: {$sum}원\n";
    
    한글이 바로 붙으므로 중괄호가 필요하다.
  3. 예시는 다음과 같다.
    $name = $nickname ?? '손님';
    echo "{$name}, 어서 오세요\n";
    $size ??= '보통';
    
    $size가 정의되지 않았어도 ??=는 경고 없이 값을 넣는다.
  4. strict_types가 켜져 있으면 문자열 "8"이 int 매개변수에 자동으로 바뀌지 않고 TypeError가 나기 때문이다. 방법 하나는 호출하는 쪽에서 half(8)처럼 정수를 넘기는 것이다. 다른 하나는 문자열 입력을 half((int) $text)처럼 명시적으로 바꿔 넘기는 것이다. 입력이 숫자인지 확인해야 한다면 filter_var($text, FILTER_VALIDATE_INT)로 검사한 뒤 넘기는 방법도 있다. 이 함수는 정수로 읽을 수 없으면 false를 돌려준다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.