한 줄 요약 — 시각은 MySQL
TIMESTAMP로만 저장하고, 앱 연결마다 세션 타임존을+09:00으로 걸어 둡니다. 저장은 DB가 UTC로, 표시는 세션이 KST로 알아서 바꿔 주니 PHP 쪽 변환 코드가 사라집니다. 대신DATETIME은 CI에서 금지하고, 2038년 한계는 규칙으로 피해 갑니다.
프로젝트마다 시간이 달랐다
제품을 여러 개 만들다 예전 저장소를 다시 열어 보니, 시간을 다루는 방식이 프로젝트마다 제각각이었습니다.
- ci4-board의 첫 마이그레이션은
int unsigned유닉스 타임스탬프였습니다. CI3 시절 손버릇이 그대로 남은 것입니다. - 채팅 상담 솔루션 설계는
DATETIME(3)컬럼에 MySQL 서버 기본 타임존을+09:00으로 박아 둔 구성이었습니다. - 다른 곳에서는
DATETIME에 KST 문자열을 그대로 넣고 있었습니다.
셋 다 지금 당장은 잘 돌아갑니다. 문제는 조건이 하나만 바뀌어도 조용히 틀어진다는 점입니다. 서버를 UTC가 기본인 클라우드로 옮기거나, Node 채팅 엔진처럼 PHP가 아닌 클라이언트가 같은 테이블을 읽거나, 해외 고객이 생기는 순간입니다. 그때마다 date(), strtotime(), +9시간 같은 변환 코드가 여기저기 붙습니다. 변환 코드가 많을수록 한 군데 빠뜨릴 확률도 커집니다.
그래서 AIvance의 모든 제품에 한 가지 규칙을 정했습니다. 시각은 DB가 UTC로 저장하고, 변환은 연결 세션이 한다. 앱 코드는 변환하지 않는다.
DATETIME과 TIMESTAMP는 무엇이 다른가
둘 다 2026-10-20 11:58:00처럼 보이지만 저장 방식이 다릅니다.
|
구분 |
DATETIME |
TIMESTAMP |
|---|---|---|
|
저장하는 것 |
넣은 글자 그대로의 날짜·시각 |
UTC 기준 시점(유닉스 시간) |
|
타임존 변환 |
없음 |
넣을 때 세션 → UTC, 읽을 때 UTC → 세션 |
|
범위 |
1000년 ~ 9999년 |
1970-01-01 ~ 2038-01-19 03:14:07 UTC |
|
크기 |
5바이트 + 소수초 |
4바이트 + 소수초 |
DATETIME은 "벽에 걸린 시계 사진"입니다. 어느 나라 시계인지 정보가 없습니다. TIMESTAMP는 "지구 전체에서 하나뿐인 순간"입니다. 같은 순간을 세션마다 그 지역 시계로 보여 줍니다.
직접 확인해 보면 차이가 바로 보입니다.
CREATE TABLE t (dt DATETIME, ts TIMESTAMP);
SET time_zone = '+09:00';
INSERT INTO t VALUES ('2026-10-20 11:58:00', '2026-10-20 11:58:00');
SET time_zone = '+00:00';
SELECT dt, ts FROM t;
-- dt: 2026-10-20 11:58:00 (그대로)
-- ts: 2026-10-20 02:58:00 (UTC로 보임)
DATETIME은 세션이 바뀌어도 같은 글자를 돌려줍니다. 그 글자가 KST였는지 UTC였는지는 코드와 사람의 기억에만 남아 있습니다. TIMESTAMP는 그 정보를 DB가 들고 있습니다.
규칙: 저장은 UTC, 변환은 세션이
구성은 세 줄로 정리됩니다.
- 서버 전역 타임존은 UTC. Ubuntu 서버의 시스템 시간대와 MySQL의
default-time-zone을 모두 UTC로 둡니다. 관리 도구나 배치처럼 세션 설정을 하지 않은 연결은 UTC로 봅니다. - 앱 연결은 세션 타임존
+09:00. CI4가 DB에 붙을 때마다SET time_zone = '+09:00'을 한 번 실행합니다. - CI4
appTimezone은Asia/Seoul. PHP가 만드는 시각 문자열과 세션 타임존이 같은 기준을 씁니다.
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
default-time-zone = '+00:00'
이렇게 두면 앱은 처음부터 끝까지 KST로만 생각하면 됩니다. CI4 모델이 created_at에 2026-10-20 11:58:00을 넣으면 DB는 02:58:00 UTC로 저장하고, 다시 읽을 때 11:58:00으로 돌려줍니다. 그 사이에 PHP 변환 코드는 한 줄도 없습니다.
Asia/Seoul 같은 이름 대신 +09:00 오프셋을 쓰는 이유도 있습니다. 이름형 타임존은 MySQL에 mysql_tzinfo_to_sql로 타임존 테이블을 적재해야 동작합니다. 한국은 서머타임이 없으니 오프셋으로 충분하고, 서버를 새로 올릴 때 챙길 것이 하나 줄어듭니다.
CI4에서 세션 타임존 걸기
CI4의 Database 설정에는 세션 타임존 옵션이 없습니다. BaseController에서 SET time_zone을 실행하는 방법이 흔히 보이지만, 컨트롤러를 거치지 않는 경로를 놓칩니다. 필터, spark 명령, 큐 워커, 테스트가 그렇습니다.
그래서 모든 연결이 지나가는 Config\Database::connect()에 걸었습니다. db_connect() 헬퍼도, 모델도 결국 이 메서드로 연결을 받습니다.
// app/Config/Database.php
namespace Config;
use CodeIgniter\Database\BaseConnection;
use CodeIgniter\Database\Config;
use DateTimeImmutable;
use DateTimeZone;
class Database extends Config
{
/** @var array<int, true> 세션 타임존을 설정한 연결 */
private static array $tzApplied = [];
// ... 기존 $default, $tests 설정 ...
public static function connect($group = null, bool $getShared = true)
{
$db = parent::connect($group, $getShared);
if ($db instanceof BaseConnection
&& $db->getPlatform() === 'MySQLi'
&& ! isset(self::$tzApplied[spl_object_id($db)])
) {
$offset = (new DateTimeImmutable('now', new DateTimeZone(config('App')->appTimezone)))
->format('P'); // '+09:00'
$db->query('SET time_zone = ' . $db->escape($offset));
self::$tzApplied[spl_object_id($db)] = true;
}
return $db;
}
}
오프셋을 appTimezone에서 계산하므로 기준은 App.php 한 곳에만 있습니다. 공유 연결은 한 번만 설정하고, getShared = false로 새로 만든 연결도 각자 한 번씩 설정됩니다.
주의할 점이 하나 있습니다. 세션 변수는 연결이 끊기면 사라집니다. 오래 도는 큐 워커에서 wait_timeout으로 연결이 끊긴 뒤 다시 붙으면 세션 타임존이 UTC로 돌아갑니다. 워커는 작업 단위로 연결을 새로 받거나, 재연결 후 다시 설정하도록 해 둡니다.
설정이 실제로 먹었는지는 테스트 한 줄로 고정합니다.
public function testSessionTimezoneIsKst(): void
{
$tz = db_connect()->query('SELECT @@session.time_zone AS tz')->getRow()->tz;
$this->assertSame('+09:00', $tz);
}
마이그레이션과 모델
마이그레이션은 Forge로 TIMESTAMP를 선언합니다.
$this->forge->addField([
'id' => ['type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true],
// ...
'created_at' => ['type' => 'TIMESTAMP', 'null' => true],
'updated_at' => ['type' => 'TIMESTAMP', 'null' => true],
'deleted_at' => ['type' => 'TIMESTAMP', 'null' => true],
]);
모델은 손댈 것이 없습니다. useTimestamps = true, dateFormat = 'datetime'이면 CI4가 appTimezone 기준 Y-m-d H:i:s 문자열을 만들고, 세션 타임존이 같은 기준이니 그대로 맞물립니다.
MySQL 8.0부터 explicit_defaults_for_timestamp가 기본으로 켜져 있어서, 예전처럼 첫 번째 TIMESTAMP 컬럼에 ON UPDATE CURRENT_TIMESTAMP가 몰래 붙는 일도 없습니다. 기본값이 필요하면 명시적으로 적습니다.
DATETIME 금지를 CI로 강제하기
규칙을 문서에만 두면 AI도 사람도 가끔 잊습니다. 그래서 테스트로 고정했습니다. 마이그레이션을 모두 돌린 뒤 스키마에 DATETIME 컬럼이 하나라도 있으면 실패합니다.
final class SchemaConventionTest extends CIUnitTestCase
{
use DatabaseTestTrait;
protected $migrate = true;
/** 예외로 허용한 컬럼: '테이블.컬럼' */
private const ALLOW = [];
public function testNoDatetimeColumns(): void
{
$rows = $this->db->query(
"SELECT CONCAT(TABLE_NAME, '.', COLUMN_NAME) AS col
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND DATA_TYPE = 'datetime'"
)->getResultArray();
$found = array_diff(array_column($rows, 'col'), self::ALLOW);
$this->assertSame([], array_values($found), 'DATETIME 대신 TIMESTAMP 또는 DATE를 쓰세요: ' . implode(', ', $found));
}
}
실패 메시지에 대안까지 적어 두면, 클로드코드가 이 테스트에 걸렸을 때 스스로 컬럼 타입을 고쳐 옵니다. CLAUDE.md에도 "시각 컬럼은 TIMESTAMP, 달력 날짜는 DATE, DATETIME 금지"를 한 줄 적었습니다.
2038년 한계는 어떻게 다루나
TIMESTAMP의 약점은 범위입니다. 최댓값은 2038-01-19 03:14:07 UTC, 한국 시간으로 같은 날 낮 12시 14분 7초입니다. strict 모드에서는 이보다 뒤의 값을 넣으면 에러가 납니다.
MariaDB는 이미 이 한계를 넓혔습니다. <cite index="9-1">MariaDB 11.5부터 TIMESTAMP 최댓값이 2106-02-07 06:28:15 UTC로 늘어났고, 저장 형식은 바뀌지 않았으며 현재는 64비트 플랫폼에서만 지원됩니다.</cite> MySQL은 아직입니다. <cite index="10-1">MariaDB처럼 범위를 넓혀 달라는 기능 요청이 MySQL 버그 트래커에 올라와 있고, 검증(Verified) 상태로 남아 있습니다.</cite>
그래서 "무엇을 저장하는가"에 따라 타입을 나눴습니다.
|
저장하는 것 |
예 |
타입 |
|---|---|---|
|
이미 일어난 순간 |
가입, 결제, 메시지 전송, 로그 |
|
|
달력 날짜 |
계약 만료일, 정산일, 생년월일 |
|
|
기한 없음 |
영구 라이선스, 무기한 구독 |
|
|
2038년 이후의 정확한 순간 |
장기 예약 등 드문 경우 |
|
핵심은 둘입니다.
- "언제 일어났는가"는 대부분 과거나 현재입니다.
created_at,paid_at,sent_at은 2038년까지 12년 넘게 여유가 있습니다. 그 사이 MySQL이 범위를 넓히거나, 필요하면 그때 한 번 마이그레이션합니다. 이 일정은 운영 문서에 적어 두었습니다. - 먼 미래 값은 대부분 "날짜"이거나 "없음"입니다. 라이선스 만료일은 시각이 아니라 날짜이니
DATE면 충분하고, 9999년까지 들어갑니다. 영구 라이선스를9999-12-31이나2099-12-31같은 매직값으로 표현하던 습관도 버렸습니다.TIMESTAMP에는 애초에 들어가지도 않고,NULL이 "기한 없음"을 더 정직하게 나타냅니다.
BIGINT 에포크는 변환 코드가 필요한 유일한 예외입니다. 그래서 앞의 ALLOW 목록처럼 이름을 붙여 관리하고, 기본값으로는 쓰지 않습니다.
실제로 걸리는 함정들
기존 DATETIME을 TIMESTAMP로 바꿀 때. ALTER TABLE ... MODIFY created_at TIMESTAMP는 기존 값을 그 순간의 세션 타임존으로 해석해 UTC로 바꿉니다. 기존 값이 KST였다면 반드시 SET time_zone = '+09:00' 상태에서 ALTER해야 합니다. 서버 기본값인 UTC 세션에서 실행하면 모든 시각이 9시간 밀립니다. 변환 전후로 몇 행을 골라 UNIX_TIMESTAMP() 값을 비교해 두면 안전합니다.
PHP가 아닌 클라이언트. 같은 테이블을 Node 채팅 엔진이 읽는다면 그쪽도 세션 타임존을 정해야 합니다. JavaScript의 Date는 원래 절대 시점이라, Node 쪽은 세션을 +00:00으로 두고 드라이버도 UTC로 해석하게 맞추는 편이 깔끔합니다. 클라이언트마다 세션을 고를 수 있다는 것이 TIMESTAMP의 장점입니다. DATETIME이었다면 두 언어가 "이 글자는 KST"라는 약속을 코드로 똑같이 지켜야 했습니다.
관리 도구로 볼 때. 세션을 설정하지 않은 DB 클라이언트나 CLI로 보면 값이 UTC로 보입니다. 처음에는 "9시간 틀렸다"고 놀라기 쉬운데, 정상입니다. 자주 보는 도구라면 접속 시 실행할 쿼리에 SET time_zone = '+09:00'을 넣어 둡니다.
하루 경계. 세션이 KST이니 WHERE created_at >= '2026-10-20 00:00:00'은 "한국 시간 오늘 0시부터"로 해석됩니다. 일별 통계에서 9시간을 더하고 빼던 코드가 사라지는 지점이 바로 여기입니다. 인덱스도 그대로 탑니다.
백업. mysqldump는 기본값으로 덤프 맨 앞에 SET TIME_ZONE='+00:00'을 넣고 TIMESTAMP를 UTC로 내보냅니다. 서버 타임존이 다른 곳으로 복원해도 순간이 어긋나지 않습니다.
API 응답. 저장과 조회에는 변환이 없지만, 밖으로 내보내는 문자열에는 오프셋을 붙입니다. 2026-10-20 11:58:00보다 2026-10-20T11:58:00+09:00이 받는 쪽에서 오해할 여지가 없습니다. MSA에서 다른 서비스가 이 값을 받는다면 더욱 그렇습니다.
돌아보며
- 변환은 경계 한 곳에서만 한다. 시간 버그는 대부분 "여기서 이미 바꿨던가?"에서 생깁니다. DB 세션이라는 경계 하나에 변환을 몰아 두니 앱 코드에서 그 질문이 사라졌습니다.
- 타입이 의미를 들고 있어야 한다.
DATETIME은 "이 값이 어느 시간대인가"를 코드와 기억에 맡깁니다.TIMESTAMP,DATE,NULL로 나누니 컬럼 타입만 봐도 그 값이 순간인지, 날짜인지, 없음인지 알 수 있습니다. - 한계는 숨기지 말고 규칙으로 감싼다. 2038년은
TIMESTAMP의 진짜 약점입니다. 하지만 먼 미래 값의 대부분이 날짜이거나 "없음"이라는 것을 인정하자, 남는 예외는 거의 없었습니다. - 규칙은 테스트로 고정한다. "DATETIME 쓰지 말 것"을 문서에만 적었다면 몇 주 안에 다시 생겼을 겁니다. 스키마 테스트 하나가 사람과 AI 모두에게 같은 기준을 지키게 합니다.
처음 정리할 때는 큰 공사처럼 보였지만, 실제로 바뀐 것은 설정 파일 두 곳과 테스트 하나였습니다. 대신 지워진 변환 코드는 훨씬 많았습니다. 새 프로젝트라면 첫 마이그레이션을 쓰기 전에 이 규칙부터 정해 두시길 권합니다.
댓글 0개