개발하다 보면 더 이상 필요 없는 변수나 배열 요소를 삭제해야 할 상황이 자주 생긴다. 대부분의 개발자는 단순히 `unset()`을 호출하면 된다고 생각하지만, 실제로는 메모리 해제 시점, 배열 구조 변화, 그리고 반복문 내에서의 예상치 못한 동작까지 신경 써야 할 게 많다. 이번에는 `unset()`의 정확한 동작 원리부터 실전에서 자주 하는 실수까지 완벽하게 정리해서 소개하겠다.

 

unset() 함수의 기본 동작 원리

`unset()`은 단순히 변수나 배열 요소를 "삭제"하는 함수가 아니라, 변수 자신을 Zend Engine의 심볼 테이블에서 제거하는 구문이다. 중요한 점은 메모리를 즉시 해제하는 게 아니라 변수에 대한 참조를 끊는 것이라는 뜻이다. PHP는 자동 가비지 컬렉션을 실행하므로, `unset()` 후 실제 메모리 해제는 참조 카운트가 0이 될 때 발생한다.

예를 들어, 변수 하나를 만들고 바로 `unset()`하면 메모리에서 제거된다. 하지만 그 변수를 다른 변수에 할당했다면, `unset()`은 한쪽 참조만 끊을 뿐 실제 데이터는 남아있다.

 

단순 변수 제거

✗ 잘못된 코드: unset() 후 변수 참조

<?php
$name = "John";
unset($name);
echo $name; // PHP Notice: Undefined variable: $name
?>

`unset()`으로 변수를 제거한 후 접근하면 Undefined 에러가 발생한다. 대신 `empty()` 체크로 변수 존재 여부를 먼저 확인해야 한다.

✓ 올바른 코드: 조건부 접근

<?php
$name = "John";
unset($name);
if (isset($name)) {
    echo $name;
} else {
    echo "Variable does not exist";
}
?>

출력: `Variable does not exist`

 

배열 요소 제거 시 키 구조 유지 문제

배열에서 특정 요소를 `unset()`으로 제거하면, 배열의 키(인덱스)가 자동으로 재정렬되지 않는다. 이는 많은 개발자들이 놓치는 함정이다.

✗ 잘못된 코드: unset() 후 배열 재구성 미흡

<?php
$fruits = array(0 => "Apple", 1 => "Banana", 2 => "Cherry");
unset($fruits[1]);
var_dump($fruits);
// 결과: array(2) { [0]=>string "Apple" [2]=>string "Cherry" }
// 인덱스 1이 빠졌다!
?>

Banana를 제거했지만 Cherry의 인덱스는 여전히 2로 남아있다. JSON 직렬화나 순차 처리 시 문제가 될 수 있다.

✓ 올바른 코드: array_values()로 재인덱싱

<?php
$fruits = array(0 => "Apple", 1 => "Banana", 2 => "Cherry");
unset($fruits[1]);
$fruits = array_values($fruits); // 키를 0부터 다시 정렬
var_dump($fruits);
// 결과: array(2) { [0]=>string "Apple" [1]=>string "Cherry" }
?>

이제 배열이 0부터 시작하는 연속된 인덱스를 가진다. API 응답이나 JSON 직렬화에서 예상한 형태가 나온다.

 

연관 배열(키-값)에서의 unset() 사용

연관 배열에서는 키 문자열이 유지되므로 `array_values()`가 필요 없는 경우가 많다.

<?php
$user = array(
    "name" => "John",
    "email" => "john@example.com",
    "phone" => "010-1234-5678"
);
unset($user["phone"]);
var_dump($user);
// 결과: array(2) { ["name"]=>"John" ["email"]=>"john@example.com" }
?>

연관 배열은 키 순서가 영향을 받지 않으므로 `unset()` 후에도 문제없다. 다만 데이터 구조 일관성을 위해 필요한 키만 빼도록 주의하자.

 

반복문에서의 unset() 주의사항

반복문 내에서 `unset()`을 사용하면 예상 밖의 동작이 발생할 수 있다. 특히 `foreach` 루프에서 참조 변수를 사용할 때 더 위험하다.

 

foreach 내 unset() 오류

✗ 잘못된 코드: foreach 중 참조 변수 unset()

<?php
$numbers = array(1, 2, 3, 4, 5);
foreach ($numbers as &$num) {
    if ($num == 3) {
        unset($num); // 참조만 제거, 배열 자체는 유지
    } else {
        $num *= 2;
    }
}
var_dump($numbers);
// 결과: array(5) { [0]=>int 2 [1]=>int 4 [2]=>int 3 [3]=>int 8 [4]=>int 10 }
// 3은 수정되지 않았다
?>

참조 변수를 `unset()`하면 그 순간의 참조만 끊길 뿐, 배열 요소 자체는 삭제되지 않는다.

✓ 올바른 코드: 조건부로 배열 요소 스킵 또는 array_filter() 사용

<?php
$numbers = array(1, 2, 3, 4, 5);
$numbers = array_filter($numbers, function($num) {
    return $num != 3;
});
$numbers = array_values($numbers); // 재인덱싱
var_dump($numbers);
// 결과: array(4) { [0]=>int 1 [1]=>int 2 [2]=>int 4 [3]=>int 5 }
?>

`array_filter()`는 조건에 맞는 요소만 남기므로 명확하고 안전하다. 반복문이 끝난 후 한 번에 처리되므로 참조 문제도 없다.

 

루프 중 unset() 시 반복 카운트 오류

✗ 잘못된 코드: for 루프 내 배열 요소 unset()

<?php
$items = array("a", "b", "c", "d", "e");
for ($i = 0; $i < count($items); $i++) {
    if ($items[$i] == "c") {
        unset($items[$i]);
        // 인덱스가 재구성되지 않아 다음 반복에서 오류 발생 가능
    } else {
        echo $items[$i] . "\n";
    }
}
var_dump($items);
?>

이 코드는 배열 구조가 변경되므로 예상 밖의 결과를 낼 수 있다.

✓ 올바른 코드: 루프 완료 후 재인덱싱

<?php
$items = array("a", "b", "c", "d", "e");
$items = array_filter($items, function($item) {
    return $item != "c";
});
$items = array_values($items);
foreach ($items as $item) {
    echo $item . "\n";
}
// 출력: a b d e
?>

루프 외부에서 배열을 정리하는 것이 더 안전하고 명확하다.

 

배열 전체 및 부분 삭제 패턴

 

배열 전체 삭제
<?php
$data = array("user" => "John", "age" => 30);
unset($data); // 배열 변수 전체 삭제

if (isset($data)) {
    echo "Data exists";
} else {
    echo "Data deleted"; // 이 문장이 출력됨
}
?>

 

다중 요소 한 번에 삭제
<?php
$config = array(
    "db_host" => "localhost",
    "db_user" => "root",
    "db_pass" => "password",
    "debug" => true
);

// 여러 키를 한 번에 제거
unset($config["db_pass"], $config["debug"]);
var_dump($config);
// 결과: array(2) { ["db_host"]=>"localhost" ["db_user"]=>"root" }
?>

`unset()`은 쉼표로 구분된 여러 인자를 받을 수 있다. 민감한 정보(비밀번호)를 빠르게 제거할 때 유용하다.

 

unset() vs null 할당 vs array_splice() 비교
방법 사용 사례 키 재정렬 메모리 해제
`unset($arr[0])` 배열 요소 제거 아니오 참조 제거 후 GC
`$arr[0] = null` 값만 초기화 아니오 아니오(메모리 유지)
`array_splice($arr, 0, 1)` 요소 제거 후 재정렬 필요 예 참조 제거 후 GC
`array_filter($arr, fn)` 조건부 필터링 아니오(수동 필요) 참조 제거 후 GC

`unset()`은 간단하지만 키가 그대로 남는다. 순차 인덱스가 중요하면 `array_splice()` 또는 `array_filter()` + `array_values()`를 쓰자. 단순히 값을 비우는 것이라면 `null` 할당으로 충분하다.

 

실전 예제: 사용자 데이터 정제

API 응답에서 민감한 정보를 제거하고 클라이언트에 전송하는 경우를 생각해보자.

✗ 잘못된 코드: 반복문 내 직접 unset()

<?php
$users = array(
    array("id" => 1, "name" => "John", "password" => "secret123"),
    array("id" => 2, "name" => "Jane", "password" => "secret456"),
    array("id" => 3, "name" => "Bob", "password" => "secret789")
);

// 각 사용자 객체에서 password 제거
foreach ($users as &$user) {
    unset($user["password"]); // 작동하지만 참조 문제 발생 가능
}
unset($user); // 참조 끊기 필수

echo json_encode($users);
?>

이 방식은 작동하지만, 참조 변수 `&$user`를 쓴 후 반드시 `unset($user)`로 참조를 끊어야 한다. 그렇지 않으면 마지막 요소 참조가 유지되어 이후 수정 시 예상치 못한 동작이 발생할 수 있다.

✓ 올바른 코드: array_map() 또는 배열 재구성

<?php
$users = array(
    array("id" => 1, "name" => "John", "password" => "secret123"),
    array("id" => 2, "name" => "Jane", "password" => "secret456"),
    array("id" => 3, "name" => "Bob", "password" => "secret789")
);

// array_map으로 각 사용자에서 password 제거
$users = array_map(function($user) {
    unset($user["password"]);
    return $user;
}, $users);

echo json_encode($users);
// 출력: [{"id":1,"name":"John"},{"id":2,"name":"Jane"},{"id":3,"name":"Bob"}]
?>

`array_map()`을 쓰면 참조 문제가 없고, 각 요소가 독립적으로 처리된다. 클로저 내에서 배열을 수정하고 반환하는 방식이 더 함수형이고 안전하다.

 

메모리 누수 방지와 대용량 배열 처리

메모리 제약이 있는 환경에서 대용량 배열을 처리할 때는 `unset()`이 실제로 도움이 될 수 있다. 다만 인스턴스나 리소스가 포함된 배열은 `unset()` 후에도 소멸자(destructor)가 호출될 때까지 완전히 해제되지 않을 수 있다.

메모리 효율적 처리:

<?php
// 대용량 CSV 파일 행 처리
if (($handle = fopen("large_file.csv", "r")) !== FALSE) {
    $batch = array();
    $count = 0;
    
    while (($row = fgetcsv($handle)) !== FALSE) {
        $batch[] = $row;
        $count++;
        
        // 배치 처리: 1000행마다 저장
        if ($count % 1000 == 0) {
            processBatch($batch);
            unset($batch); // 메모리 즉시 해제
            $batch = array();
        }
    }
    
    // 남은 데이터 처리
    if (!empty($batch)) {
        processBatch($batch);
        unset($batch);
    }
    
    fclose($handle);
}

function processBatch($data) {
    // 데이터 처리 로직
    // INSERT INTO database ...
}
?>

이 패턴은 메모리 사용을 제한적으로 유지하면서 대용량 파일을 처리한다. 매 배치마다 `unset()`을 호출해 참조를 끊고, 새로운 배열을 시작한다.

 

정리와 실무 적용

`unset()`은 단순해 보이지만, 배열 키 구조, 참조 변수, 반복문 내 사용 시 예상 밖의 동작을 일으킬 수 있다. 단순히 변수를 지우려고 할 때는 문제없지만, 배열 요소를 제거한 후 순차 처리가 필요하면 `array_values()`로 재인덱싱하자. 루프 내에서 요소를 제거해야 한다면 `array_filter()`를 먼저 고려하고, 루프 후에 정리하는 방식이 훨씬 안전하다. 이런 작은 차이가 버그 없는 코드와 예상치 못한 오류를 구분한다. 이 글의 패턴을 참고해 배열 조작 방식을 다시 한 번 점검하면, 더 견고한 데이터 처리 로직을 만들 수 있을 것이다.