PHP 개발자라면 누구나 한 번쯤 마주친 에러가 있다. 'Warning: Cannot modify header information - headers already sent by'라는 메시지. 특히 로그인 처리나 세션 설정을 하려고 session_start()를 호출할 때 이 오류가 터진다. 하지만 대부분의 개발자들은 이 에러가 왜 발생하는지, 정확히 어디서 output이 나갔는지 파악하지 못한 채 인터넷의 임시방편을 따라한다.
이번 글에서는 HTTP 헤더 전송 메커니즘이 뭔지, 왜 session_start()가 실패하는지, 그리고 현업에서 바로 쓸 수 있는 완벽한 해결책을 정리해서 소개하겠다.
웹 요청의 기본 흐름을 다시 생각해보자. 서버가 클라이언트에게 응답할 때 HTTP 프로토콜은 이 순서를 절대 어기지 않는다.
1. HTTP 상태 코드와 응답 헤더 전송 (예: 'HTTP/1.1 200 OK', 'Set-Cookie: ...')
2. 빈 줄 (헤더와 본문의 경계)
3. 응답 본문 (HTML, JSON 등 실제 데이터)
한 번 body(본문)의 첫 바이트가 클라이언트로 나갔다면, 그 이후의 헤더 설정 시도는 모두 실패한다. 이미 헤더 전송이 끝났기 때문이다. PHP의 session_start(), setcookie(), header() 같은 함수들은 모두 HTTP 헤더를 조작하려고 하기 때문에, output이 시작된 후에는 작동하지 않는다.
✗ 잘못된 코드.
<?php
// 이 줄 위에 공백이나 개행이 있나?
session_start();
$_SESSION['user_id'] = 123;
?>
대부분의 PHP 파일은 <?php로 시작한다. 하지만 파일 인코딩 문제, 복사-붙여넣기 실수, 또는 에디터의 자동 포맷팅으로 인해 <?php 앞에 눈에 띄지 않는 공백(BOM, Byte Order Mark)이나 개행이 숨어있을 수 있다. 이것도 output이다.
✓ 올바른 코드.
<?php
session_start();
$_SESSION['user_id'] = 123;
해결책: 파일 시작점을 깔끔하게 유지하고, UTF-8 인코딩 설정 시 BOM 옵션을 반드시 끈다. VSCode라면 파일 우측 하단의 인코딩 표시를 클릭해서 'UTF-8 with BOM' 대신 'UTF-8'을 선택하자.
✗ 잘못된 코드 (config.php).
<?php
$db_host = 'localhost';
?>
(이 아래 공백들이 output으로 전송됨)
config.php 같은 설정 파일을 include할 때, 닫는 태그 ?> 뒤에 공백이나 개행이 있으면 그것도 output이 된다. 그 후 메인 파일에서 session_start()를 호출하면 이미 늦다.
✓ 올바른 코드 (config.php).
<?php
$db_host = 'localhost';
해결책: PHP 설정 파일이나 라이브러리 파일의 마지막에는 닫는 태그 ?>를 쓰지 않는다. 현대 PHP 코딩 표준(PSR-12)도 이를 권장한다.
✗ 잘못된 코드.
<?php
echo 'Welcome to my site';
session_start(); // 이미 echo로 output이 나갔으므로 실패
$_SESSION['user'] = 'admin';
✓ 올바른 코드.
<?php
session_start(); // 먼저 헤더 작업을 완료
$_SESSION['user'] = 'admin';
echo 'Welcome to my site';
해결책: session_start(), setcookie(), header() 같은 헤더 조작 함수는 PHP 파일의 맨 처음에만 놓는다.
복잡한 프로젝트에서는 여러 파일이 include되면서 예상 밖의 output이 발생할 수 있다. 예를 들어 에러 로그를 출력하는 디버그 코드나, 데이터베이스 쿼리 결과를 직접 echo하는 코드가 숨어있을 수 있다.
해결책: 아래의 'output 확인' 섹션을 참고해 정확한 원인 지점을 찾자.
PHP 에러가 터질 때 메시지를 자세히 보면 원인 파일과 줄 번호가 나온다.
Warning: Cannot modify header information - headers already sent by (output started at /var/www/html/includes/db.php:5) in /var/www/html/login.php on line 3
여기서 '/var/www/html/includes/db.php:5'가 핵심이다. 이것이 output이 시작된 정확한 지점이다. 해당 파일의 5번 줄로 가서 뭐가 있는지 확인하면 된다. 많은 경우 공백이거나 부주의한 echo 문이다.
✓ index.php (프로젝트의 유일한 진입점).
<?php
// 1. 모든 헤더 작업을 먼저 수행
session_start();
header('Content-Type: application/json; charset=utf-8');
// 2. 그 다음 다른 파일 로드
require_once 'includes/config.php';
require_once 'includes/db.php';
// 3. 이제 output 시작 가능
echo json_encode(['status' => 'ok']);
이렇게 하면 헤더 작업과 output을 명확히 분리할 수 있다.
output을 하지만 실제 전송을 나중으로 미루고 싶다면 output buffering을 쓴다.
<?php
ob_start(); // output 버퍼 시작
// 이 사이의 모든 echo는 버퍼에만 저장되고 아직 클라이언트로 안 나감
echo 'Some output';
// 이제 헤더 설정 가능 (아직 output이 안 나갔으니까)
session_start();
$_SESSION['user'] = 'admin';
// 모든 버퍼 내용을 한 번에 전송
ob_end_flush();
다만 이건 임시방편일 뿐이다. 나중에 코드를 리팩토링할 때는 구조를 다시 정리하자.
프로젝트 전역에서 session_start()를 자동으로 호출하고 싶다면, php.ini 또는 .htaccess에서 설정하자.
// .htaccess
php_value auto_prepend_file /path/to/init.php
init.php에 session_start()를 넣으면, 모든 PHP 파일이 실행되기 전에 자동으로 세션이 시작된다. 하지만 공유 호스팅이 아닌 경우에만 가능하다.
✗ 틀린 접근.
// 에러 메시지를 무시하고 @를 붙이는 것
@session_start();
이렇게 하면 에러 메시지만 안 보일 뿐 실제 문제는 해결되지 않는다. 세션이 제대로 작동하지 않아서 나중에 더 큰 버그를 만든다.
✓ 올바른 접근.
// 1. 프로젝트 시작 시부터 구조를 잘 짜기
// - 모든 include를 index.php에 모으기
// - 헤더 작업(session_start, header, setcookie)은 맨 앞에만
// - 라이브러리 파일 마지막에 ?> 태그 생략하기
// 2. 개발 중에 error_reporting과 display_errors 켜기
error_reporting(E_ALL);
ini_set('display_errors', 1);
// 3. 정기적으로 파일 구조를 점검하기
'headers already sent' 에러는 HTTP 프로토콜의 기본 원리에서 비롯된 것이다. 헤더는 body가 나가기 전에 모두 전송되어야 한다는 규칙을 이해하면, 에러의 원인을 쉽게 찾을 수 있다. 작은 공백 하나, 닫는 태그 뒤의 개행 하나가 전체 인증 시스템을 무너뜨릴 수 있다는 점을 잊지 말자. 이 글의 '원인 찾기' 섹션을 참고해 에러 메시지를 정확히 읽고, 지시된 파일과 줄 번호를 확인하면, 대부분의 경우 몇 초 안에 해결할 수 있을 것이다.