PHP 8.0 이상을 쓰다 보면 갑자기 마주하는 에러가 있다. 함수나 메서드에 정수를 넘겨야 하는데 문자열을 넘겼다거나, 배열을 기대했는데 객체를 전달했을 때 발생하는 TypeError다. 문제는 이 에러가 개발 중엔 잘 안 보이다가 프로덕션 환경에서 특정 사용자의 요청이 들어올 때만 터진다는 것이다.

대부분의 개발자는 이 에러를 만나면 타입 힌트를 제거하거나, 그냥 입력값을 느슨하게 처리하는 방식으로 넘어간다. 하지만 이건 근본 해결이 아니다. 이번에는 TypeError가 정확히 뭔지, 왜 발생하는지, 어떻게 올바르게 대처하는지 완벽하게 정리해서 소개하겠다.

 

1단계. 타입 힌트와 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가 발생한다.

 

strict_types란 무엇인가

파일 맨 처음에 declare(strict_types=1);을 선언하면, PHP는 타입 변환을 자동으로 하지 않는다. 선언이 없으면 일부 타입은 자동 변환된다. 이것 때문에 같은 코드가 파일마다 동작이 달라진다.

 

2단계. 실무에서 자주 보이는 TypeError 패턴

데이터베이스 조회 결과, 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' 형태로 온다. 따라서 함수에 넘기기 전에 명시적으로 캐스팅해야 한다.

 

3단계. API 응답 데이터 처리할 때

외부 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이면 명시적으로 캐스팅해야 한다.

 

4단계. 실전 패턴: 입력값 검증과 타입 변환 함수

매번 모든 함수에서 타입 캐스팅을 하는 것은 비효율적이다. 입력값을 받는 시작점(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()]);
}
?>

 

5단계. Nullable 타입과 Union 타입 다루기

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
?>

 

6단계. 객체 타입 힌트와 instanceof 확인

클래스를 타입 힌트로 쓸 때도 부모 클래스나 인터페이스를 넘기면 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는 자연스럽게 줄어들 것이다.