CSS ch 단위는 숫자 0 한 글자 폭이다 — 한글에서 어긋나는 이유

  • 23rd August 2026
  • 7 min read

선택 입력 위젯에 안내 문구를 한글로 넣었더니 중간에서 잘렸다. "메일주소 입력 후 쉼표 또는 엔터"라고 썼는데 화면에는 "메일주소 입력 후 쉼표 또"까지만 보인다.

영문으로 바꿔보면 멀쩡하다. 글자를 줄이면 또 멀쩡하다. CSS를 아무리 뒤져도 폭을 제한하는 규칙이 없다.

범인은 위젯이 인라인으로 박아넣는 한 줄이었다.

// Choices.js — Input.setWidth()
input.style.minWidth = (placeholder.length + 1) + 'ch';

글자 수를 세서 그만큼 ch를 주는, 얼핏 합리적으로 보이는 계산이다. 문제는 ch가 글자 수 단위가 아니라는 것이다.

먼저, CSS 길이 단위를 한 번 훑고

CSS의 길이 단위는 크게 세 갈래다.

갈래 단위 무엇을 기준으로 재나
절대px pt cm in고정값. px은 물리 픽셀이 아니라 기준 픽셀이다
폰트 상대em rem해당 요소 / 루트의 font-size
ex cap소문자 x의 높이 / 대문자 높이
ch ic숫자 0의 폭 / 한자 의 폭
lh rlh해당 요소 / 루트의 line-height
뷰포트 상대vw vh vmin vmax뷰포트 크기
svh lvh dvh모바일 주소창을 뺀 / 포함한 / 실시간 높이

여기서 눈여겨볼 것은 폰트 상대 단위 중 상당수가 "폰트 크기"가 아니라 "특정 글리프의 실제 치수"를 잰다는 점이다. emremfont-size 값을 그대로 쓰고, 나머지는 폰트 파일 안의 글자를 실제로 재서 나온다.

baseline 0 x H 1ex x-height 1cap cap-height 1ch '0' 의 advance 1ic '水' 의 advance ≈1ic 한글도 전각 폰트 상대 단위는 '글자 수'가 아니라 특정 글리프의 치수를 잰다 em 은 폰트 크기, rem 은 루트 폰트 크기. 나머지는 아래처럼 실제 글리프에서 나온다. 같은 폰트·같은 크기에서도 1ic 는 대체로 1ch 의 두 배 안팎이다. 정확한 비율은 폰트마다 다르다.
폰트 상대 단위는 폰트 파일 안의 글자를 실제로 재서 나온다.

그래서 같은 font-size: 16px이라도 폰트를 바꾸면 1ex1ch도 값이 달라진다. 글꼴에 딸린 단위인 셈이다.

ch는 "글자 하나"가 아니라 "숫자 0 하나"다

명세가 정의하는 ch는 이렇다.

글꼴에서 문자 0(U+0030 ZERO)의 advance measure — 즉 그 글자를 그린 뒤 다음 글자가 시작하는 지점까지의 거리.

이름이 ch(character)라서 "글자 수"로 오해하기 쉽지만, 실제로는 숫자 0 한 글자의 폭이다. 이 차이가 언제 드러날까.

  • 등폭 글꼴에서는 모든 글자 폭이 같으므로 ch가 정말 글자 수와 일치한다. 코드 편집기나 터미널 폭을 80ch로 잡는 건 정확한 사용법이다.
  • 비례 글꼴에서는 근사치다. i는 훨씬 좁고 W는 훨씬 넓다. 다만 라틴 문자의 평균 폭이 숫자 폭과 대체로 비슷해서, 문장 전체로는 그럭저럭 맞는다.
  • 한글·한자·가나에서는 완전히 어긋난다.

한글에서 무슨 일이 벌어지나

한글은 대부분의 글꼴에서 전각(full-width)에 가깝다. 정사각형 한 칸을 꽉 채운다는 뜻이고, 대략 1em에 해당한다.

반면 숫자 0은 같은 글꼴에서 대개 0.5em 남짓이다. 정리하면 이렇다.

한글 한 글자  ≈  1em      ≈  2ch
숫자 한 글자  ≈  0.5em    =  1ch

따라서 한글 문자열에 글자 수만큼 ch를 주면 실제 필요한 폭의 절반쯤만 확보된다. 14글자짜리 안내 문구에 15ch를 줬으니, 대략 7글자 반 만큼의 자리만 생긴 것이다.

Choices.js 가 하는 계산 : minWidth = (안내 문구 글자수 + 1) + 'ch' 영문 안내 문구 — 14 글자 Enter address 15ch 글자가 칸 안에 들어간다 라틴 문자의 평균 폭이 '0' 폭과 비슷해서다 한글 안내 문구 — 14 글자 메일주소 입력 후 쉼표 또 는 엔터 15ch 같은 15ch 인데 글자가 넘친다 한글 한 글자가 '0' 폭의 두 배 가까이라 실제로 필요한 폭의 절반만 확보된다
같은 15ch 인데 한글은 넘친다. 글자 수를 세어 ch 를 주면 절반쯤 모자란다.

정확한 비율은 글꼴마다 다르다. 본문용 한글 글꼴들도 숫자 폭이 제각각이라 "정확히 두 배"라고 못 박을 수는 없다. 다만 글자 수와 ch 값이 1:1이 아니라는 것만은 어느 글꼴에서나 같다.

본문 폭을 65ch로 잡는 관행도 다시 봐야 한다

읽기 좋은 줄 길이로 max-width: 65ch를 주는 관행이 널리 퍼져 있다. 영문에서 한 줄에 65자 남짓을 노린 값이다.

같은 값을 한글 본문에 주면 한 줄에 30자 남짓이 된다. 한글에서 편한 줄 길이는 대략 25~35자로 알려져 있으니 우연히 나쁘지 않은 결과가 나오는데, 그건 어디까지나 우연이다. 의도한 값이 아니라는 것만 알고 쓰면 된다.

그래서 어떻게 해야 하나

1. 대개는 글자 수로 폭을 잡지 않는 것이 답이다

가장 먼저 물어볼 것은 "이 폭을 꼭 글자 수로 정해야 하나"다. 대부분은 아니다. 레이아웃이 정하게 두고, 넘칠 때의 처리만 정해주면 된다.

.field {
    flex: 1 1 auto;
    min-width: 0;          /* flex 아이템의 기본 min-width:auto 를 푼다 */
}

min-width: 0은 자주 빠뜨리는 한 줄이다. flex 아이템은 기본값이 auto여서 내용보다 작아지지 않는데, 이것 때문에 긴 한글 문자열이 컨테이너를 밀어내는 일이 흔하다.

2. 문자 기준이 정말 필요하면 ic를 쓴다

CJK를 위한 ch의 대응물이 ic다. 한자 (U+6C34)의 advance를 재는데, 이 글자는 전각 한 칸을 채우므로 ic는 사실상 "CJK 글자 한 칸"이 된다.

/* 한글 14글자가 들어갈 최소 폭 */
min-width: 14ic;

/* 한글·영문이 섞이면 둘 중 큰 쪽 */
min-width: max(14ic, 28ch);

다만 icch보다 지원이 늦게 시작됐다. 오래된 브라우저를 받아야 한다면 @supports로 감싸거나 em 기반 값을 폴백으로 두는 편이 안전하다.

3. 실측이 필요하면 캔버스로 잰다

남이 만든 위젯이 이미 폭을 계산해 박아넣는 경우처럼, 어떤 글꼴이 적용될지 미리 알 수 없을 때는 실제로 재는 수밖에 없다. canvasmeasureText()가 그 일을 한다.

function textWidth(text, el) {
    const cs = window.getComputedStyle(el);

    // 캔버스의 font 는 CSS font 단축 속성과 문법이 같다.
    // size 와 family 는 필수이고, line-height 는 넣을 거라면 반드시
    // 'size/line-height' 형태로 붙여 써야 한다. 문법이 틀리면 지정 자체가
    // 무시되고 기본값(10px sans-serif)으로 재게 된다.
    const font = [
        cs.fontStyle, cs.fontVariant, cs.fontWeight,
        cs.fontSize + '/' + (cs.lineHeight === 'normal' ? '1.2' : cs.lineHeight),
        cs.fontFamily,
    ].join(' ');

    const ctx = (textWidth._c ||= document.createElement('canvas')).getContext('2d');
    ctx.font = font;

    const m = ctx.measureText(text);
    // actualBoundingBox* 가 있으면 그쪽이 정확하다 — 글리프의 실제 좌우 끝이다
    if (typeof m.actualBoundingBoxLeft === 'number') {
        return Math.max(m.width,
            Math.abs(m.actualBoundingBoxLeft) + Math.abs(m.actualBoundingBoxRight));
    }
    return m.width;
}

여기서 getComputedStyle로 폰트 속성을 그대로 옮기는 것이 핵심이다. 캔버스에 폰트를 지정하지 않으면 기본값(10px sans-serif)으로 재기 때문에 전혀 다른 값이 나온다.

4. 잰 값은 CSS 변수로 넘기고, 폭은 스타일시트가 먹인다

측정한 값을 자바스크립트가 인라인 스타일로 직접 박으면, 위젯이 다시 덮어쓴다. 앞서 본 setWidth()는 입력 이벤트마다 실행되기 때문이다.

// JS 는 값만 넘긴다
container.style.setProperty('--placeholder-w', width + 'px');
/* 폭을 먹이는 것은 스타일시트 */
.choices__input--cloned {
    min-width: min(var(--placeholder-w, 12rem), 100%) !important;
}

두 가지가 들어 있다.

!important가 필요한 이유. 인라인 스타일은 보통 스타일시트를 이기지만, 작성자 스타일시트의 !important 선언은 !important가 아닌 인라인 선언을 이긴다. 캐스케이드 우선순위가 그렇게 정의돼 있다. 남의 라이브러리가 인라인으로 박는 값을 되돌릴 때 쓸 수 있는 정당한 수단이다.

min(…, 100%)로 감싸는 이유. 최소 폭이 컨테이너보다 커지면 그 자체가 레이아웃을 밀어낸다. 선택된 항목이 늘어 줄이 좁아졌을 때 옆 요소가 화면 밖으로 밀려나는 것을 막는다.

5. 웹폰트는 늦게 온다

마지막 함정이다. 측정 시점에 웹폰트가 아직 로드되지 않았다면 대체 글꼴 기준으로 재게 된다. 한글 웹폰트는 용량이 커서 늦게 도착하는 일이 흔하다.

measure();                                  // 일단 한 번
document.fonts?.ready.then(() => measure()); // 폰트가 다 온 뒤 다시

정리

  • ch숫자 0 한 글자의 폭이다. 글자 수 단위가 아니다.
  • 등폭 글꼴에서는 글자 수와 일치하고, 라틴 비례 글꼴에서는 근사치이며, 한글에서는 절반쯤 어긋난다.
  • CJK 글자 한 칸을 재고 싶으면 ic가 있다.
  • 그보다 먼저, 폭을 글자 수로 정해야 하는지를 의심해본다. 대개는 레이아웃에 맡기고 min-width: 0을 풀어주는 편이 낫다.
  • 실측이 필요하면 canvas.measureText(). getComputedStyle의 폰트를 그대로 옮기고, 웹폰트 로드 후 다시 잰다.
  • 남의 라이브러리가 인라인으로 박는다면, 값은 CSS 변수로 넘기고 폭은 스타일시트가 !important 먹인다.

라틴 문자를 기준으로 만들어진 도구를 한글 환경에서 쓰면 이런 자리를 반복해서 만난다. 대개는 버그라기보다 기본 가정이 다른 것이라, 고칠 곳을 찾으려면 그 도구가 무엇을 당연하게 여겼는지부터 짚어야 한다. 이번 경우엔 "글자 하나의 폭은 대체로 비슷하다"가 그 가정이었다.