마지막 항목 앞에 "및" 을 붙이던 코드 — PHP 8.5 IntlListFormatter 로 한 줄, 그리고 한국어에서 남는 일

  • 18th September 2026
  • 7 min read

이름이나 항목을 여러 개 나열해 보여주는 화면은 어디에나 있다. 결재선의 결재자, 회의 참석 부서, 글에 붙은 태그, 파일을 공유한 사람들. 배열을 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_existsfalse 를 돌려준다.

생성자는 인자 세 개를 받고, 메서드는 사실상 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 / WIDEA, B 및 CA, B, and C
AND / SHORTA, B 및 CA, B, & C
AND / NARROWA, B 및 CA, B, C
OR / WIDE·SHORT·NARROWA, B 또는 CA, B, or C
UNITS / WIDE·SHORTA B CA, B, C
UNITS / NARROWA B CA 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(['귤', '사과']);                   // 귤과 사과

마지막 글자가 한글이 아니면(영문 약어, 숫자) hasBatchimfalse 를 돌려 "와" 가 붙는다. "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명" 과 뒤따르는 조사는 원래부터 포매터 범위 밖이다. 한국어 화면이라면 포매터는 다국어 분기를 없애는 용도로 쓰고, 말투가 중요한 자리에는 받침 판정 한 줄을 곁들이면 된다.