'ZZ' 다음은 'AAA' — $s++ 대신 PHP 8.3 str_increment(), 그리고 0 으로 시작하면 못 내리는 str_decrement()

  • 21st September 2026
  • 5 min read

주문번호, 시리얼, 자산 태그처럼 "다음 번호"를 만들어야 하는 값 중에는 숫자만으로 안 되는 것이 많다. SKU001 의 다음은 SKU002 고, 엑셀 열 이름 Z 의 다음은 AA 다. PHP 에서는 오래전부터 $s++ 가 이걸 해 줬다. 'ABC'++ 를 하면 'ABD' 가 되고 'ZZ''AAA' 가 된다. Perl 에서 넘어온 동작이다.

문제는 이 연산자가 문자열마다 다르게 구는 데 있다. '9'++ 는 문자열 '10' 이 아니라 정수 10 이 된다. '5.0'++ 는 실수 6.0, ''++'1', 그리고 'ABD'--아무 일도 안 일어난다. 증가는 되는데 감소는 안 되는 짝짝이였다. PHP 8.3 이 이 동작을 함수로 떼어내 str_increment()·str_decrement() 로 넣고, 비숫자 문자열에 대한 ++ 는 폐기 예고(deprecated)했다.

이 글은 두 함수가 실제로 무엇을 돌려주는지, 어디서 예외를 던지는지를 PHP 8.5.4 와 8.3.30 에서 재 본 기록이다. 매뉴얼에 없는 규칙이 하나 나왔다.

str_increment — ++ 에서 문자열 부분만 떼어낸 것

str_increment('ABC');    // 'ABD'
str_increment('ABZ');    // 'ACA'
str_increment('ZZ');     // 'AAA'
str_increment('zz');     // 'aaa'
str_increment('Az');     // 'Ba'
str_increment('a9');     // 'b0'
str_increment('Z9');     // 'AA0'
str_increment('SKU001'); // 'SKU002'
str_increment('a1z9');   // 'a2a0'

규칙은 단순하다. 맨 뒤 글자를 하나 올리고, 넘치면(za, ZA, 90) 앞자리로 올림한다. 올림이 맨 앞까지 가면 같은 종류의 첫 글자를 하나 앞에 붙인다ZZAAA, z9aa0, 99100. 대소문자와 숫자가 섞여 있어도 각 자리는 자기 종류 안에서만 돈다.

++ 와 다른 점은 입력이 어떻든 항상 문자열을 돌려준다는 것이다.

입력 $s++ str_increment($s)
'9'10 (int)'10'
'0099'100 (int, 0 사라짐)'0100'
'5.0'6.0 (float)ValueError
'1e2'101.0 (float)ValueError
'''1'ValueError
'ORD-001''ORD-002'ValueError
' ', '-'그대로 (변화 없음)ValueError
'ABC''ABD' + Deprecated 경고'ABD'

세 줄이 눈에 띈다. 첫째, 0 으로 채운 번호가 살아남는다. '0099'++ 는 정수 100 이 되어 앞의 0 이 날아가지만 str_increment'0100' 을 준다. 자릿수를 맞춘 번호 체계에서 ++ 를 쓰면 str_pad 를 다시 해야 했던 이유가 사라진다.

둘째, 영숫자가 아닌 글자가 하나라도 있으면 ValueError 다. 하이픈·공백·점·한글 전부 거부한다. ++'ORD-001''ORD-002' 로 만들어 줬는데 함수는 안 한다. 이게 "주문번호를 한 줄로" 라는 소개와 어긋나는 지점이라 아래에서 따로 다룬다.

셋째, 'ABC'++ 는 8.3 부터 Deprecated: Increment on non-numeric string is deprecated, use str_increment() instead 를 낸다. 지금은 경고지만 PHP 9 에서 TypeError 로 바뀔 예정이다. 로그에 이 문구가 보이면 그 자리를 함수로 바꾸면 된다.

str_decrement — 없던 반대 방향

-- 는 문자열에서 원래 아무 것도 안 했다. 'ABD'--'ABD' 그대로다(8.3 부터는 "no effect and is deprecated" 경고가 붙는다). 그래서 이전 번호를 구하려면 손으로 짜야 했다. 8.3 의 str_decrement 가 이 빈자리를 채운다.

str_decrement('ABD');  // 'ABC'
str_decrement('AAA');  // 'ZZ'
str_decrement('Ba');   // 'Az'
str_decrement('b0');   // 'a9'
str_decrement('B000'); // 'A999'
str_decrement('10');   // '9'

str_decrement('A');    // ValueError: "A" is out of decrement range
str_decrement('a');    // ValueError
str_decrement('0');    // ValueError

증가의 정확한 역이다. ZZAAA 였으니 AAAZZ 로 줄어든다. 더 내려갈 곳이 없는 A·a·0 은 예외를 던진다. 여기까지는 매뉴얼에 있는 그대로다.

매뉴얼에 없는 규칙: 0 으로 시작하면 못 내린다

실측하다 걸린 것이다.

str_increment('000999');  // '001000'  — 잘 된다
str_decrement('001000');  // ValueError: "001000" is out of decrement range
str_decrement('008');     // ValueError
str_decrement('0A');      // ValueError
str_decrement('A00');     // '99'  — 'Z99' 가 아니다

매뉴얼은 "감소할 수 없는 문자열, 예를 들어 "A""0"" 이라고만 적어 놓았는데, 소스(ext/standard/string.c)를 보면 조건이 훨씬 넓다. 첫 글자가 0 이면 길이와 무관하게 무조건 거부한다. 그리고 계산 결과의 첫 글자가 0 이 되거나 맨 앞자리에서 빌림이 일어나면 그 글자를 잘라내 문자열을 한 칸 줄인다. A00 에서 A 는 빌려올 데가 없으니 사라지고 99 만 남는 것이다. 8.3.30 과 8.5.4 에서 동일했다.

결과적으로 0 채움 번호는 증가만 되고 감소는 안 된다. '000999''001000' 은 되지만 그 역은 예외다. 앞 번호로 되돌리는 코드(취소·롤백)에 str_decrement 를 쓸 생각이라면, 0 채움이 있는 번호 체계에서는 못 쓴다. 정수로 바꿔 빼고 str_pad 로 다시 채우는 옛날 방식이 여전히 맞다.

실전 모양: 접두어를 떼고 붙인다

번호 체계에는 거의 항상 구분자가 들어간다. ORD-000999, 2026-A-0031. 함수는 구분자를 거부하니 증가할 부분만 떼어 넘기고 다시 붙인다.

function nextCode(string $code): string
{
    $pos = strrpos($code, '-');
    return substr($code, 0, $pos + 1) . str_increment(substr($code, $pos + 1));
}

nextCode('ORD-000998'); // ORD-000999
nextCode('ORD-000999'); // ORD-001000
nextCode('ORD-999999'); // ORD-1000000  ← 자릿수가 늘어난다
nextCode('SKU-AZ9');    // SKU-BA0

마지막 줄을 보자. 999999 다음은 1000000 이고 함수는 자릿수를 늘려서라도 다음 값을 만든다. 자릿수가 고정이어야 하는 체계라면 이 시점을 잡아 주는 것은 호출하는 쪽 몫이다. strlen 이 달라지면 예외를 던지든 접두어를 바꾸든 정책을 정해 둬야 한다.

엑셀 열 이름처럼 순수 알파벳만 도는 자리에는 그대로 쓰면 된다.

$col = 'A';
for ($i = 0; $i < 30; $i++) {
    $cols[] = $col;
    $col = str_increment($col);
}
// A B C … Y Z AA AB AC AD

속도는 걱정할 수준이 아니다. 'AAA000' 에서 100만 번 증가시키는 데 23ms 가 걸렸다('BMM000' 이 나온다).

두 가지 함정

정렬과 비교는 별개다. str_increment('Z')'AA' 지만 PHP 에서 'Z' < 'AA'false 다. 문자열 비교는 사전순이고, strcmp('ORD-999', 'ORD-1000') 도 양수다. "다음 번호" 를 만드는 함수일 뿐 "큰 번호" 를 판정해 주지 않는다. DB 에서 ORDER BY code DESC LIMIT 1 로 마지막 번호를 가져오는 코드가 있다면 자릿수가 바뀌는 순간 틀린 행을 집는다. 자릿수를 고정하거나 정렬용 정수 열을 따로 둬야 한다.

동시성도 별개다. "마지막 번호 읽기 → str_increment → 저장" 은 두 요청이 겹치면 같은 번호가 나온다. 이건 함수와 무관하게 SELECT … FOR UPDATE 나 유일 제약으로 막아야 한다. 함수가 한 줄이 되었다고 채번 로직이 한 줄이 되는 것은 아니다.

정리

str_increment'ABC''ABD', 'ZZ''AAA', '0099''0100' 을 항상 문자열로 돌려준다. ++ 가 숫자 문자열을 정수로 바꿔 버리던 문제와 0 채움이 사라지던 문제가 같이 해결됐고, 비숫자 문자열에 대한 ++ 는 이제 폐기 예고 상태라 어차피 바꿔야 한다.

str_decrement 는 처음 생긴 반대 방향인데, 0 으로 시작하는 문자열은 매뉴얼에 없는 규칙으로 통째 거부하고, 맨 앞자리 빌림은 문자열을 줄여 버린다. 0 채움 번호의 "이전 값" 에는 못 쓴다. 두 함수 다 하이픈 하나만 있어도 예외이니 접두어는 떼어서 넘기고, 자릿수 증가·정렬·동시성은 예전처럼 호출하는 쪽이 챙긴다.