이름이나 항목을 여러 개 나열해 보여주는 화면은 어디에나 있다. 결재선의 결재자, 회의 참석 부서, 글에 붙은 태그, 파일을 공유한 사람들. 배열을 implode(', ', $items) 로 붙이면 "기획팀, 개발팀, 디자인팀" 이 나오는데, 문장 안에 넣으려면 마지막 항목 앞에 "및" 이나 "and" 를 넣어야 한다. 이걸 손으로 하는 코드가 대개 이런 모양이다.
$last = array_pop($items);
$text = $items ? implode(', ', $items) . ' 및 ' . $last : $last;
한국어만 쓰면 이걸로 끝이지만, 영어 화면이 하나 붙는 순간 문제가 커진다. 영어는 A, B, and C 처럼 마지막 쉼표(옥스퍼드 콤마)를 넣는 미국식과 A, B and C 로 빼는 영국식이 갈리고, 일본어는 쉼표 대신 、 를 쓰고 접속사를 아예 안 붙이며, 중국어는 和 를 띄어쓰기 없이 붙인다. 언어마다 규칙이 다르니 if ($lang === 'en') 분기가 하나씩 늘어난다.
PHP 8.5 에 이 규칙 표를 통째로 가진 클래스가 들어왔다. 이 글은 그 클래스가 실제로 무엇을 뱉는지 로케일별로 재 보고, 한국어에서는 어디까지 해 주고 어디부터 직접 해야 하는지를 정리한다. 실측은 PHP 8.5.4, ICU 78.2 에서 했다.
IntlListFormatter 한 줄
$f = new IntlListFormatter('ko');
echo $f->format(['기획팀', '개발팀', '디자인팀']);
// 기획팀, 개발팀 및 디자인팀
echo (new IntlListFormatter('en_US'))->format(['기획팀', '개발팀', '디자인팀']);
// 기획팀, 개발팀, and 디자인팀
클래스 이름은 IntlListFormatter 다. 네임스페이스가 없는 전역 클래스이고, IntlDateFormatter·NumberFormatter 와 같은 이름 규칙을 따른다. 소개 글 중에 Intl\ListFormatter 로 적힌 것이 있는데 그 이름으로는 class_exists 가 false 를 돌려준다.
생성자는 인자 세 개를 받고, 메서드는 사실상 format(array): string 하나다.
new IntlListFormatter(
string $locale,
int $type = IntlListFormatter::TYPE_AND, // TYPE_OR, TYPE_UNITS
int $width = IntlListFormatter::WIDTH_WIDE, // WIDTH_SHORT, WIDTH_NARROW
);
규칙 데이터는 PHP 가 아니라 ICU 의 CLDR 에 들어 있다. 같은 세 항목을 로케일만 바꿔 넣으면 이렇게 갈린다.
| 로케일 | TYPE_AND | TYPE_OR |
|---|---|---|
ko | 기획팀, 개발팀 및 디자인팀 | 기획팀, 개발팀 또는 디자인팀 |
en_US | 기획팀, 개발팀, and 디자인팀 | 기획팀, 개발팀, or 디자인팀 |
en_GB | 기획팀, 개발팀 and 디자인팀 | 기획팀, 개발팀 or 디자인팀 |
ja | 기획팀、개발팀、디자인팀 | 기획팀、개발팀、または디자인팀 |
zh_CN | 기획팀、개발팀和디자인팀 | 기획팀、개발팀或디자인팀 |
de | 기획팀, 개발팀 und 디자인팀 | 기획팀, 개발팀 oder 디자인팀 |
fr | 기획팀, 개발팀 et 디자인팀 | 기획팀, 개발팀 ou 디자인팀 |
미국식과 영국식의 쉼표 차이, 일본어의 、, 중국어의 붙여쓰기까지 데이터가 알아서 처리한다. 직접 짜면 언어당 분기 하나씩 늘던 부분이 로케일 문자열 하나로 바뀐다.
항목 수에 따라 달라지는 것
손으로 짠 코드가 가장 자주 틀리는 자리는 항목이 0개, 1개, 2개일 때다. 2개면 쉼표 없이 접속사만 들어가야 하고, 1개면 접속사가 없어야 한다. 한국어 TYPE_AND 로 재 본 결과다.
| 항목 수 | 결과 |
|---|---|
| 0 | "" (빈 문자열) |
| 1 | 기획팀 |
| 2 | 기획팀 및 개발팀 |
| 3 | 기획팀, 개발팀 및 디자인팀 |
| 4 | 기획팀, 개발팀, 디자인팀 및 총무부 |
CLDR 은 2개짜리 패턴({0} 및 {1}), 시작·중간·끝 패턴을 따로 갖고 있어서 이 경계가 전부 맞는다. 영어는 2개일 때 A and B 로 옥스퍼드 콤마가 빠지는 것까지 처리된다.
TYPE 과 WIDTH — 한국어는 폭이 의미 없다
TYPE_UNITS 는 "3시간 20분 5초" 처럼 단위를 이어 붙일 때 쓰는 타입이고, WIDTH_* 는 좁은 자리에서 접속사를 줄이는 옵션이다. 3가지 타입 × 3가지 폭 = 9가지 조합을 한국어와 미국 영어로 재 보면 차이가 확실하다.
| TYPE / WIDTH | ko |
en_US |
|---|---|---|
| AND / WIDE | A, B 및 C | A, B, and C |
| AND / SHORT | A, B 및 C | A, B, & C |
| AND / NARROW | A, B 및 C | A, B, C |
| OR / WIDE·SHORT·NARROW | A, B 또는 C | A, B, or C |
| UNITS / WIDE·SHORT | A B C | A, B, C |
| UNITS / NARROW | A B C | A B C |
영어는 폭에 따라 and → & → 쉼표만으로 줄어드는데, 한국어는 9가지 조합이 "및 / 또는 / 공백" 세 가지로 끝난다. CLDR 의 한국어 데이터에 짧은 형태가 따로 없어서 폭 옵션이 사실상 무시된다. 좁은 칸에 맞춰 줄이려면 한국어는 직접 해야 한다.
한국어에서 남는 일
여기부터가 이 글을 쓴 이유다. IntlListFormatter 가 한국어에 주는 것은 "및" 과 "또는" 두 단어뿐이고, 실제 화면에서 한국어 나열이 자연스러우려면 세 가지가 더 필요하다.
"및" 은 공문서 말투다
"기획팀, 개발팀 및 디자인팀이 참석했습니다" 는 틀린 문장이 아니지만 보고서 톤이다. 알림 문구나 채팅 화면에서는 "기획팀, 개발팀과 디자인팀" 이 자연스럽다. CLDR 이 "와/과" 를 쓰지 못하는 이유는 명확하다 — 앞 단어의 받침에 따라 조사가 갈린다. "사과와 귤" 이지만 "귤과 사과" 다. 정적인 패턴 데이터로는 표현할 수 없어서 한국어는 받침과 무관한 "및" 으로 고정된 것이다.
"와/과" 를 쓰고 싶으면 마지막 접속만 직접 붙이면 된다. 한글 음절은 유니코드에서 가(U+AC00) 부터 초성·중성·종성 순으로 규칙적으로 배열되어 있어, 코드값에서 0xAC00 을 뺀 값을 28 로 나눈 나머지가 0 이면 받침이 없다.
function hasBatchim(string $s): bool
{
$c = mb_ord(mb_substr($s, -1));
return $c >= 0xAC00 && $c <= 0xD7A3 && (($c - 0xAC00) % 28) !== 0;
}
function koAnd(array $items): string
{
$items = array_values(array_filter($items, fn ($v) => $v !== null && $v !== ''));
if (count($items) < 2) {
return implode('', $items);
}
$last = array_pop($items);
$prev = array_pop($items);
$items[] = $prev . (hasBatchim($prev) ? '과' : '와') . ' ' . $last;
return implode(', ', $items);
}
koAnd(['기획팀', '개발팀', '디자인팀']); // 기획팀, 개발팀과 디자인팀
koAnd(['사과', '귤']); // 사과와 귤
koAnd(['귤', '사과']); // 귤과 사과
마지막 글자가 한글이 아니면(영문 약어, 숫자) hasBatchim 이 false 를 돌려 "와" 가 붙는다. "3팀과" 같은 경우는 숫자 읽기 규칙까지 들어가야 해서 여기서는 다루지 않았다. 항목이 영문·숫자로 끝나는 일이 잦은 데이터라면 차라리 "및" 을 그대로 두는 편이 안전하다.
"외 N명" 은 포매터 밖에 있다
참석자가 열 명이면 다 나열하지 않고 "기획팀, 개발팀 외 2곳" 으로 줄이는 것이 한국어 화면의 관례다. 영어의 "and 2 others" 에 해당하는데, 이 패턴은 ICU 리스트 포매터의 범위가 아니다. 앞부분만 잘라 포매터에 넘기고 꼬리는 언어별로 붙여야 한다.
function listWithOthers(array $items, string $locale, int $max = 2): string
{
$f = new IntlListFormatter($locale);
if (count($items) <= $max) {
return $f->format($items);
}
$head = array_slice($items, 0, $max);
$rest = count($items) - $max;
return match ($locale) {
'ko' => implode(', ', $head) . " 외 {$rest}곳",
default => $f->format([...$head, "{$rest} others"]),
};
}
listWithOthers(['기획팀', '개발팀', '디자인팀', '총무부'], 'ko');
// 기획팀, 개발팀 외 2곳
listWithOthers(['기획팀', '개발팀', '디자인팀', '총무부'], 'en_US');
// 기획팀, 개발팀, and 2 others
한국어 쪽에서 포매터를 안 쓴 이유가 있다. "기획팀, 개발팀 및 외 2곳" 처럼 "및" 이 끼면 안 되기 때문이다. "외" 앞은 쉼표로 끝나야 한다.
나열 뒤에 오는 조사도 마찬가지
포매터가 돌려준 문자열을 문장에 끼우면 "…디자인팀이 참석" 처럼 마지막 항목 뒤에 또 조사가 붙는다. 이것도 받침 문제라 포매터가 해 주지 않는다. 위의 hasBatchim 을 마지막 항목에 한 번 더 적용하거나, 애초에 문장을 "참석: 기획팀, 개발팀 및 디자인팀" 처럼 나열이 문장 끝에 오도록 설계하면 피할 수 있다. 다국어 문구를 짤 때는 후자가 훨씬 편하다.
함정 네 가지
빈 항목을 걸러 주지 않는다. ['A', null, 'B'] 를 넣으면 "A, 및 B" 가 나온다. null 이 빈 문자열로 바뀌어 자리를 차지한다. DB 에서 온 배열은 array_filter 를 먼저 거칠 것. 문자열로 바꿀 수 없는 객체가 섞이면 Error 가 난다.
이스케이프하지 않는다. 항목에 <b> 가 있으면 그대로 나온다. 다른 문자열 조합과 똑같이 출력 직전에 htmlspecialchars 를 거쳐야 한다. 포매터가 넣는 것은 쉼표·공백·접속사뿐이라 결과 전체를 한 번에 이스케이프해도 된다.
Locale::getDefault() 는 서버 설정을 따른다. "기본 로케일로 한 줄" 이라는 소개가 많은데, intl.default_locale 이 비어 있으면 Locale::getDefault() 는 en_US 를 돌려준다. 실측한 로컬에서도 그랬다. 한국어 사이트라면 로케일을 명시하거나 php.ini 에 intl.default_locale = ko_KR 을 잡아야 한다. 잘못된 로케일 문자열은 생성자에서 ValueError 다.
intl 확장과 ICU 67 이상이 필요하다. 8.5 를 올렸는데 class_exists('IntlListFormatter') 가 false 면 십중팔구 intl 확장이 빠진 것이다. 공식 도커 이미지 php:8.5-cli 는 intl 을 기본으로 켜 두지 않아서 docker-php-ext-install intl 이 필요하다. 8.4 이하 서버와 코드를 공유한다면 폴백을 하나 두는 것이 무난하다.
function joinList(array $items, string $locale): string
{
if (class_exists('IntlListFormatter')) {
return (new IntlListFormatter($locale))->format($items);
}
// 8.4 이하: 한국어만 손으로, 나머지는 쉼표 나열
$last = array_pop($items);
if (!$items) {
return (string) $last;
}
return str_starts_with($locale, 'ko')
? implode(', ', $items) . ' 및 ' . $last
: implode(', ', [...$items, $last]);
}
정리
마지막 항목 앞에 "및" 을 붙이던 코드는 new IntlListFormatter($locale)->format($items) 한 줄로 바뀌고, 영어·일본어·중국어의 쉼표와 접속사 규칙이 로케일 문자열 하나로 따라온다. 항목이 0·1·2개일 때의 경계도 맞는다.
다만 한국어에 주는 것은 "및" 과 "또는" 까지다. 폭 옵션은 무시되고, "와/과" 는 받침 때문에 데이터로 표현할 수 없어 빠져 있으며, "외 N명" 과 뒤따르는 조사는 원래부터 포매터 범위 밖이다. 한국어 화면이라면 포매터는 다국어 분기를 없애는 용도로 쓰고, 말투가 중요한 자리에는 받침 판정 한 줄을 곁들이면 된다.