PHP 8.0 이상을 쓰다 보면 갑자기 마주하는 에러가 있다. 함수나 메서드에 정수를 넘겨야 하는데 문자열을 넘겼다거나, 배열을 기대했는데 객체를 전달했을 때 발생하는 TypeError다. 문제는 이 에러가 개발 중엔 잘 안 보이다가 프로덕션 환경에서 특정 사용자의 요청이 들어올 때만 터진다는 것이다.
대부분의 개발자는 이 에러를 만나면 타입 힌트를 제거하거나, 그냥 입력값을 느슨하게 처리하는 방식으로 넘어간다. 하지만 이건 근본 해결이 아니다. 이번에는 TypeError가 정확히 뭔지, 왜 발생하는지, 어떻게 올바르게 대처하는지 완벽하게 정리해서 소개하겠다.
PHP 7.0부터 도입된 타입 힌트(Type Hint)는 함수나 메서드의 파라미터, 반환값에 특정 타입을 강제한다. PHP 8.0부터는 이 기능이 더 엄격해져서 타입 불일치 시 TypeError를 던진다.
예를 들어, 다음 함수를 보자.
<?php
function calculateTotal(int $price, int $quantity): int {
return $price * $quantity;
}
// 사용
$result = calculateTotal('100', 5); // TypeError 발생
?>
위 코드에서 첫 번째 인자로 문자열 '100'을 넘겼는데, 함수는 int 타입을 기대한다. PHP 7.4까지는 자동 형변환이 일어나지만, PHP 8.0 이상에서 strict_types 설정이 활성화되면 TypeError가 발생한다.
파일 맨 처음에 declare(strict_types=1);을 선언하면, PHP는 타입 변환을 자동으로 하지 않는다. 선언이 없으면 일부 타입은 자동 변환된다. 이것 때문에 같은 코드가 파일마다 동작이 달라진다.
데이터베이스 조회 결과, API 응답, 폼 입력값 처리할 때 타입 불일치가 가장 자주 터진다.
<?php
function processUser(int $userId, string $email): array {
// ...
}
// ✗ 잘못된 코드: 데이터베이스에서 문자열로 가져온 ID
$row = $pdo->query('SELECT id, email FROM users LIMIT 1')->fetch();
processUser($row['id'], $row['email']); // TypeError: Argument 1 must be of type int, string given
// ✓ 올바른 코드: 타입 캐스팅
processUser((int)$row['id'], (string)$row['email']);
?>
데이터베이스 드라이버가 반환하는 값은 항상 문자열이다. 숫자라도 '123' 형태로 온다. 따라서 함수에 넘기기 전에 명시적으로 캐스팅해야 한다.
외부 API에서 JSON 응답을 받으면 모든 데이터가 배열이나 객체다. 여기서도 타입 불일치가 자주 일어난다.
<?php
function saveProduct(int $productId, float $price, bool $isActive): void {
// ...
}
// API 응답
$apiData = json_decode('{"product_id": "12345", "price": "29.99", "is_active": "1"}', true);
// ✗ 잘못된 코드
saveProduct($apiData['product_id'], $apiData['price'], $apiData['is_active']); // TypeError
// ✓ 올바른 코드: 타입 변환
saveProduct(
(int)$apiData['product_id'],
(float)$apiData['price'],
(bool)$apiData['is_active']
);
?>
JSON 문자열을 배열로 변환해도 모든 값이 string 타입이다. 함수의 타입 힌트가 int, float, bool이면 명시적으로 캐스팅해야 한다.
매번 모든 함수에서 타입 캐스팅을 하는 것은 비효율적이다. 입력값을 받는 시작점(Controller나 API Handler)에서 한 번에 처리하자.
<?php
function sanitizeInput(array $data, array $schema): array {
$result = [];
foreach ($schema as $key => $type) {
if (!isset($data[$key])) {
throw new InvalidArgumentException("Missing required field: $key");
}
$value = $data[$key];
switch ($type) {
case 'int':
$result[$key] = (int)$value;
break;
case 'float':
$result[$key] = (float)$value;
break;
case 'string':
$result[$key] = (string)$value;
break;
case 'bool':
$result[$key] = filter_var($value, FILTER_VALIDATE_BOOLEAN);
break;
case 'array':
if (!is_array($value)) {
throw new TypeError("Field $key must be an array");
}
$result[$key] = $value;
break;
default:
$result[$key] = $value;
}
}
return $result;
}
// 사용
$requestData = $_POST; // 또는 json_decode(..., true)
$schema = [
'user_id' => 'int',
'name' => 'string',
'balance' => 'float',
'is_premium' => 'bool'
];
try {
$validated = sanitizeInput($requestData, $schema);
// 이제 $validated의 값들은 정확한 타입이 보장됨
saveUserData($validated['user_id'], $validated['name'], $validated['balance'], $validated['is_premium']);
} catch (InvalidArgumentException|TypeError $e) {
http_response_code(400);
echo json_encode(['error' => $e->getMessage()]);
}
?>
PHP 7.1 이상에서 ?int처럼 null을 허용하는 타입이 있고, PHP 8.0부터는 int|string처럼 여러 타입을 허용한다. 이 경우에도 주의가 필요하다.
<?php
function updateUserName(?string $name): void {
if ($name === null) {
// null 처리
return;
}
// ...
}
// ✗ 잘못된 코드: 정수 0을 넘김
updateUserName(0); // TypeError: Argument must be of type ?string, int given
// ✓ 올바른 코드: null 또는 문자열만
updateUserName(null);
updateUserName('John');
// Union 타입
function processValue(int|string $value): void {
if (is_int($value)) {
echo "Integer: $value";
} else {
echo "String: $value";
}
}
processValue(123); // OK
processValue('hello'); // OK
processValue(12.5); // TypeError: Argument must be of type int|string, float given
?>
클래스를 타입 힌트로 쓸 때도 부모 클래스나 인터페이스를 넘기면 TypeError가 날 수 있다.
<?php
class User {}
class Admin extends User {}
function updateProfile(User $user): void {
// ...
}
$admin = new Admin();
// ✓ OK: Admin은 User를 상속받음
updateProfile($admin);
// ✗ 잘못된 코드: 배열을 넘김
updateProfile(['id' => 1]); // TypeError: Argument must be of type User, array given
// 동적으로 객체 생성할 때
function createObjectFromArray(string $className, array $data): object {
if (!class_exists($className)) {
throw new InvalidArgumentException("Class $className does not exist");
}
$object = new $className();
foreach ($data as $key => $value) {
$object->$key = $value;
}
return $object;
}
$user = createObjectFromArray('User', ['id' => 1, 'name' => 'John']);
?>
| 실수 | 올바른 방법 |
|---|---|
| ✗ 타입 힌트를 완전히 제거 (strconv 느슨하게) | ✓ 함수 시작점에서 입력값 검증 및 변환 |
| ✗ 데이터베이스 값을 그대로 함수에 전달 | ✓ (int), (float), (bool) 캐스팅 후 전달 |
| ✗ null과 0, ''을 혼동 | ✓ ?type 명시, isset() 또는 null coalescing 사용 |
| ✗ JSON 응답 데이터의 타입 확인 안 함 | ✓ json_decode 후 모든 값 타입 변환 |
| ✗ 상속 관계 모르고 임의의 클래스 전달 | ✓ instanceof로 먼저 확인하거나 인터페이스 사용 |
TypeError는 PHP가 강력한 타입 안정성을 제공한다는 증거다. 이 에러를 피하려고 타입 힌트를 없애면, 오히려 버그를 더 늘린다. 대신 데이터가 함수에 들어오는 진입점(API 핸들러, 컨트롤러)에서 한 번에 타입을 검증하고 변환하는 습관을 들이면, 함수 로직은 안전하고 깔끔해진다. 위 글의 sanitizeInput 패턴을 참고해 입력값 검증 계층을 먼저 만들고, 그 다음 비즈니스 로직 함수들에 명확한 타입 힌트를 붙이면, TypeError는 자연스럽게 줄어들 것이다.