개발

마지막 항목 앞에 "및" 을 붙이던 코드 — 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_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 / 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(['귤', '사과']);                   // 귤과 사과

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

함께 읽으면 좋은 글