본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Frontend

문자열 다루기

작성 2026. 10. 20.추가
목차14개 항목
출처와 참고

javascript.info(모던 JavaScript 튜토리얼)의 「문자열」, 「자료형」, 「원시값의 메서드」 글을 바탕으로, ES2022 이후 추가된 메서드는 MDN으로 확인해 재구성한 노트다. 템플릿 리터럴과 태그드 템플릿은 노션에 정리해 둔 내용을 합쳤다. 한글의 코드 영역은 「한글 인코딩과 자바스크립트」를 바탕으로 더했다. 헷갈리기 쉬운 출력 문제는 Lydia Hallie의 「JavaScript Questions」에서 개념별로 뽑아 보강했다.

문자열이란

문자열은 UTF-16 코드 단위(code unit)의 나열이다. 페이지 인코딩과 상관없이 엔진 안에서는 늘 UTF-16이다. 글자 하나를 담는 char 같은 별도 타입은 없고, 한 글자도 길이 1인 문자열이다.

문자열은 원시값이라 바꿀 수 없다(immutable). 메서드는 원본을 고치지 않고 새 문자열을 돌려준다. 원시값인데도 'hi'.toUpperCase()가 되는 이유(래퍼 객체)는 변수와 데이터 타입에 있다.

let str = 'Hi'
str[0] = 'h'           // 무시된다. strict mode에서는 TypeError
str = 'h' + str.slice(1) // 새 문자열을 만들어 다시 대입한다

리터럴과 이스케이프

따옴표특징
'…', "…"완전히 같다. 소스에서 줄을 바꿀 수 없다
`…`템플릿 리터럴. 여러 줄, ${식} 삽입, 태그드 템플릿(아래 「템플릿 리터럴」)

감싼 것과 같은 따옴표를 안에 쓰려면 백슬래시로 이스케이프한다('I\'m'). 다른 따옴표로 감싸면 이스케이프가 필요 없다("I'm", `I'm`). 백슬래시는 소스를 읽을 때만 쓰이고 메모리의 문자열에는 남지 않는다.

이스케이프뜻
\n, \t, \\줄바꿈, 탭, 백슬래시 자체
\r캐리지 리턴. Windows 줄바꿈은 \r\n
\uXXXXUTF-16 코드 단위(16진수 4자리). '©' → ©
\u{X…}코드 포인트(16진수 1~6자리). '\u{1F60D}' → 😍
\xXX16진수 2자리 코드. '\x7A' → z

length는 메서드가 아니라 속성이다(str.length). 값은 글자 수가 아니라 UTF-16 코드 단위 수라서 이모지는 2가 된다(아래 「유니코드」).

템플릿 리터럴

템플릿 리터럴(template literal) 은 백틱(`)으로 감싼 문자열이다. ES2015 초안에서는 "template strings"라고 불렀다. 따옴표 문자열과 달리 세 가지가 된다.

// 여러 줄: 줄바꿈이 그대로 들어간다 ('line 1\n' + 'line 2' 대신)
`line 1
line 2`
 
// 표현식 삽입: ${} 안에는 변수·연산·함수 호출 등 어떤 식이든 온다
`Fifteen is ${a + b} and not ${2 * a + b}.`
 
// 중첩: 템플릿 안에 템플릿. 공통 접두사 'icon-'을 한 번만 쓴다
const classes = `header ${
  isLargeScreen() ? '' : `icon-${item.isCollapsed ? 'expander' : 'collapser'}`
}`

문자열을 이을 때는 +보다 템플릿 리터럴이 안전하다. +는 피연산자 하나만 숫자여도 계산 순서에 따라 결과가 바뀐다(연산자와 제어문).

태그드 템플릿

템플릿 앞에 함수 이름을 붙이면 그 함수(태그)가 템플릿을 받아 처리한다. 첫 인자는 ${} 사이사이의 문자열 조각 배열(TemplateStringsArray), 나머지 인자는 각 표현식의 값이다. 조각 수는 항상 표현식 수 + 1이다.

function describe(strings: TemplateStringsArray, person: string, age: number) {
  // strings = ['that ', ' is a ', '']: 마지막 표현식 뒤에도 빈 조각이 있다
  const ageWord = age > 99 ? 'centenarian' : 'youngster'
  return `${strings[0]}${person}${strings[1]}${ageWord}`
}
describe`that ${'Mike'} is a ${28}` // 'that Mike is a youngster'

String.raw(이스케이프를 해석하지 않은 원문), GraphQL의 gql, styled-components가 태그드 템플릿을 쓴다.

styled-components의 원리: 표현식 자리에 함수를 넣고, 태그 함수가 props를 넣어 그 함수를 실행하면 props에 따라 바뀌는 CSS가 나온다.

const props = { primary: true, theme: { colors: { primary: 'coral', secondary: 'peachpuff' } } }
type Props = typeof props
 
function styled(css: TemplateStringsArray, ...fns: ((p: Props) => string)[]) {
  return css.map((chunk, i) => chunk + (fns[i] ? fns[i](props) : '')).join('')
}
 
styled`
  background: ${({ primary, theme }) => (primary ? theme.colors.primary : theme.colors.secondary)};
` // 'background: coral;'

실제 라이브러리는 이 CSS 문자열에 고유 클래스 이름을 붙여 <style> 태그로 넣는다. 런타임에 스타일을 만드는 이 방식은 서버 컴포넌트·스트리밍 SSR과 잘 맞지 않아, styled-components는 2025년 3월 유지보수 모드에 들어갔다. 새 프로젝트는 CSS Modules·Tailwind CSS나 빌드 타임 CSS-in-JS(vanilla-extract, Panda CSS)를 많이 쓴다(React 스타일링).

글자 꺼내기

방법범위 밖일 때비고
str[i]undefined기본
str.at(i)undefined음수 인덱스로 뒤에서 센다. str.at(-1)은 마지막 글자(ES2022)
str.charAt(i)''(빈 문자열)옛 방식

for...of로 돌면 코드 포인트 단위로 나뉘어 이모지가 반쪽으로 깨지지 않는다(이터레이터와 제너레이터).

검색

메서드반환쓰는 때
includes(sub, from)불리언포함 여부만
startsWith(sub) / endsWith(sub)불리언앞·뒤 일치
indexOf(sub, from) / lastIndexOf(sub, from)위치, 없으면 -1위치가 필요할 때
search(regex) / match / matchAll위치 / 결과정규식으로 찾을 때(정규 표현식)

모두 대소문자를 구분한다. indexOf를 그대로 조건에 넣지 않는다. 맨 앞에서 찾으면 0이라 falsy가 된다.

if ('Widget'.indexOf('Widget')) {}         // 실행되지 않는다: 0은 falsy
if ('Widget'.indexOf('Widget') !== -1) {}  // 위치 비교는 -1과
if ('Widget'.includes('Widget')) {}        // 포함 여부는 includes

옛 코드의 if (~str.indexOf(x))는 비트 NOT이 -1만 0으로 만드는 성질을 쓴 포함 검사다. 읽기 어려우니 includes로 바꾼다.

부분 문자열

메서드인자음수start > end일 때
slice(start, end)시작, 끝(미포함)뒤에서 센다빈 문자열
substring(start, end)시작, 끝(미포함)0으로 본다두 값을 바꿔서 처리
substr(start, length)시작, 길이시작만 뒤에서 센다-
'stringify'.slice(2, 6)     // 'ring'
'stringify'.slice(-4, -1)   // 'gif'
'stringify'.substring(6, 2) // 'ring': slice(6, 2)는 ''

slice만 쓰면 된다. 배열의 slice와 규칙이 같고 음수도 받는다. substr은 명세 부록 B(웹 호환용 레거시)에 있어 쓰지 않는다.

바꾸기·다듬기

메서드하는 일
toUpperCase() / toLowerCase()대소문자. 로케일 규칙이 필요하면 toLocaleUpperCase('tr')
trim() / trimStart() / trimEnd()앞뒤 공백·줄바꿈 제거
padStart(n, ch) / padEnd(n, ch)길이 n까지 채운다. '7'.padStart(2, '0') → '07'. ch를 빼면 공백으로 채우고, n이 원래 길이 이하면 그대로 돌려준다('abc'.padStart(2) → 'abc')
repeat(n)n번 반복
replace(a, b) / replaceAll(a, b)처음 하나 / 전부 교체(ES2021). 정규식도 받는다(정규 표현식)
split(sep, limit) / 배열의 join(sep)문자열 ↔ 배열
concat(...)이어 붙이기. 보통 +나 템플릿 리터럴을 쓴다

비교하기

<, >는 문자열을 코드 단위 값 순서로 한 글자씩 비교한다. 사전 순과 비슷하지만 다르다.

'a' > 'Z'                 // true: 소문자(97~)가 대문자(65~)보다 뒤
'Österreich' > 'Zealand'  // true: Ö(214)가 z(122)보다 뒤
'Bee' > 'Be'              // true: 앞이 같으면 긴 쪽이 크다

사람이 기대하는 순서로 정렬하려면 언어별 정렬 규칙(ECMA-402) 을 쓴다.

'Österreich'.localeCompare('Zealand', 'de')  // 음수: 앞에 온다
names.sort((a, b) => a.localeCompare(b, 'ko'))
 
const collator = new Intl.Collator('ko', { numeric: true, sensitivity: 'base' })
const files = ['파일10', '파일2'].sort(collator.compare) // ['파일2', '파일10']: 숫자를 숫자로 비교

localeCompare는 음수·0·양수를 돌려준다. 많은 값을 정렬할 때는 Intl.Collator를 한 번 만들어 compare를 재사용하는 편이 빠르다. sensitivity: 'base'면 대소문자·악센트를 무시하고 비교한다.

유니코드: 코드 단위, 코드 포인트, 글자

단위뜻세는 법
코드 단위UTF-16의 16비트 한 칸str.length, str[i], charCodeAt
코드 포인트유니코드 문자 번호 하나. 큰 번호는 코드 단위 2개(서로게이트 쌍)[...str].length, codePointAt, for...of
자소 클러스터사람이 한 글자로 보는 단위Intl.Segmenter

서로게이트 쌍

16비트로 표현할 수 없는 이모지·수학 기호·희귀 한자는 서로게이트 쌍(앞 0xD800~0xDBFF, 뒤 0xDC00~0xDFFF) 두 칸으로 저장된다.

'😂'.length                  // 2
'😂'[0]                      // '\uD83D': 반쪽짜리
'😂'.codePointAt(0)          // 128514: 쌍을 합친 코드 포인트
String.fromCodePoint(128514) // '😂'
[...'a😂'].length            // 2

charCodeAt·String.fromCharCode는 코드 단위만 다루는 옛 메서드다. 코드 포인트는 codePointAt·String.fromCodePoint를 쓴다. split('')·slice로 자르면 쌍이 갈라질 수 있다.

짝 잃은 서로게이트가 섞인 문자열은 encodeURI나 TextEncoder에서 오류·대체 문자를 만든다. str.isWellFormed()로 검사하고 str.toWellFormed()로 대체 문자(U+FFFD)로 고친다(ES2024).

보이는 글자 단위: Intl.Segmenter

👨‍👩‍👧(ZWJ로 이은 이모지)나 국기 이모지는 코드 포인트 여러 개가 한 글자로 보인다. 글자 수 제한, 커서 이동처럼 사람이 세는 글자가 필요하면 Intl.Segmenter를 쓴다.

const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' })
const count = [...seg.segment('👨‍👩‍👧가')].length // 2

정규화: 같은 글자, 다른 바이트

é는 미리 합쳐진 코드 하나(é)로도, e + 결합 악센트(é)로도 쓸 수 있다. 한글도 완성형 가와 자모를 이은 조합형이 있다. 눈에 같아도 ===는 false다.

'Ṩ' === 'Ṩ'                          // false
'Ṩ'.normalize() === 'Ṩ'.normalize()  // true: 'Ṩ' 하나로 합쳐진다

normalize()의 기본 형식은 'NFC'(합친 형태)다. 사용자 입력을 저장·비교·검색하기 전에 NFC로 맞춘다. macOS에서 온 파일 이름처럼 조합형(NFD)이 섞여 들어오는 경우가 흔하다.

한글의 세 가지 코드

한글은 같은 모양이 세 군데 코드 영역에 있다. replace·includes·정규식은 모양이 아니라 코드로 비교하므로, 어느 영역의 글자인지 모르면 검색과 치환이 조용히 실패한다.

영역범위예: ㅅ / 스생기는 곳
완성형 음절U+AC00–U+D7A3 (11,172자)스 = U+C2A4보통의 키보드 입력, NFC
첫가끝 자모(조합용)U+1100–U+11FFᄉ = U+1109, 뒤에 모음 ᅳ U+1173이 붙는다NFD로 분해된 문자열. macOS 파일 이름
호환 자모U+3131–U+318Eㅅ = U+3145자음·모음 하나만 따로 쓸 때

완성형 음절은 초성 19 × 중성 21 × 종성 28(받침 없음 포함) 조합을 순서대로 늘어놓은 것이라 계산으로 분해·조립한다. 초성 검색이나 받침 유무에 따른 조사(은/는) 선택이 이 계산을 쓴다.

const CHO = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'
function decompose(ch) {
  const n = ch.normalize('NFC').charCodeAt(0) - 0xac00
  if (n < 0 || n > 11171) return null              // 완성형 음절이 아니다
  return { cho: CHO[Math.floor(n / 588)],          // 588 = 21 × 28
           jung: Math.floor((n % 588) / 28),       // 중성 번호
           jong: n % 28 }                          // 0이면 받침 없음
}
decompose('강')  // { cho: 'ㄱ', jung: 0, jong: 21 }: 21번 종성이 ㅇ
const josa = (w) => (decompose(w.at(-1))?.jong ? '은' : '는')

입력은 받는 즉시 normalize('NFC')로 맞추고, 그다음에 비교·저장·분해한다. 업로드 파일 이름, 다른 OS에서 붙여 넣은 텍스트, 외부 API 응답이 NFD로 들어오는 대표적인 경로다. 과거에는 escape·unescape로 코드를 들여다봤지만 둘 다 표준에서 권장하지 않는 레거시 함수다. 코드 확인은 codePointAt(0).toString(16)으로 한다.

정리

  • 문자열은 바꿀 수 없는 UTF-16 코드 단위의 나열이다. length는 코드 단위 수다.
  • 백틱 템플릿 리터럴은 여러 줄·${식} 삽입을 지원하고, 태그드 템플릿은 (조각 배열, ...값)을 받는 함수로 문자열을 해석한다.
  • 포함 여부는 includes, 위치는 indexOf와 -1 비교. 부분 문자열은 slice 하나로 충분하다.
  • <·>는 코드 순서다. 사람용 정렬은 localeCompare나 Intl.Collator.
  • 이모지는 서로게이트 쌍이다. 코드 포인트는 for...of·codePointAt, 보이는 글자는 Intl.Segmenter.
  • 비교·저장 전에 normalize()로 정규화한다.

관련 노트: React 스타일링, 배열·객체 다루기, 변수와 데이터 타입, 이터레이터와 제너레이터, 연산자와 제어문, 정규 표현식: 문법·플래그·메서드

원본 기록9

댓글

GitHub 계정으로 댓글을 남길 수 있어요.