thenullpage.com
문자열은 제일 만만해 보이는 자료형이다. 자르고 붙이고 바꾸면 끝일 것 같다. 그런데 검색창이나 가입 폼처럼 사람이 직접 친 값이 들어오는 자리에서는 얘기가 달라진다. 내가 겪은 문자열 버그는 사용자가 이상하게 입력해서 난 게 아니라, 메서드가 뭘 돌려주는지 내가 착각해서 난 쪽이 훨씬 많았다.
replace를 불러놓고 결과를 안 받았다. 게시글 제목에서 광고 표시를 떼어내는 코드였다.
const title = "긴급 [광고] 신규 오픈";
title.replace("[광고] ", "");
console.log(title); // 긴급 [광고] 신규 오픈, 하나도 안 지워졌다
문자열은 불변이라 한번 만들어진 값이 그 자리에서 바뀌는 일이 없다. replace도 trim도 toUpperCase도 전부 새 문자열을 만들어 돌려줄 뿐이고 원본은 손대지 않는다. 배열의 sort처럼 원본을 갈아엎는 메서드에 익숙해져 있으면 이 차이에서 한 번은 꼭 걸린다. 반환값을 받아야 일이 끝난다.
const clean = title.replace("[광고] ", "");
console.log(clean); // 긴급 신규 오픈
제대로 받아도 함정이 하나 더 남아 있다. 문자열을 넘긴 replace는 처음 걸린 하나만 바꾸고 멈춘다.
const tags = "공지,공지,잡담";
console.log(tags.replace("공지", "안내")); // 안내,공지,잡담
console.log(tags.replaceAll("공지", "안내")); // 안내,안내,잡담
공백 하나 때문에 로그인이 안 된다는 문의를 받았다. 자판이 아이디 끝에 공백을 붙여 보낸 경우였는데, 화면에서는 티가 안 나서 한참 헤맸다.
const input = " user01 ";
console.log(input === "user01"); // false
console.log(input.length); // 9, 눈에 안 보이는 글자가 세 개 섞여 있다
console.log(input.trim() === "user01"); // true
trim은 앞뒤 공백만 걷어내고 가운데 공백은 그대로 둔다. 한쪽만 지우고 싶으면 trimStart와 trimEnd가 따로 있다. 그 뒤로 폼에서 받은 값은 검사하기 전에 무조건 trim부터 걸고 본다.
따옴표를 이어 붙이다 줄바꿈에서 막혔다. 알림 문구를 두 줄로 만들어야 했는데 더하기로는 답이 안 나왔다.
const name = "박지훈";
const cnt = 3;
const msg = `${name}님, 안 읽은 알림 ${cnt}건
확인해 주세요.`;
console.log(msg);
// 박지훈님, 안 읽은 알림 3건
// 확인해 주세요.
작은따옴표 대신 백틱으로 감싼 걸 템플릿 리터럴이라 한다. ${} 안에 변수를 그대로 끼워 넣을 수 있고, 엔터를 친 모양 그대로 줄이 바뀐다. 안에 계산식이나 메서드 호출도 들어간다.
console.log(`재고 ${(1500).toLocaleString()}개`); // 재고 1,500개
타이머가 7:5로 찍혀서 자리를 맞춰야 했다. 앞에 0을 채우는 일은 padStart가 한다.
const m = 7, s = 5;
console.log(`${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`); // 07:05
console.log("abcdef".at(-1)); // f, 뒤에서 첫 글자
console.log("abcdef".slice(-3)); // def, 뒤에서 세 글자
padStart는 문자열 메서드라 숫자에 바로 못 쓴다. String으로 한 번 감싸야 한다. 음수 인덱스를 받는 at과 slice는 마지막 글자를 꺼낼 때 길이를 계산할 필요가 없어서 편하다.
빈 문자열을 쪼갰더니 태그가 하나 있는 걸로 셌다. 태그 입력칸을 비워둔 글에서만 빈 칩이 하나 그려졌다.
const csv = "";
console.log(csv.split(",")); // [""], 빈 배열이 아니다
console.log(csv.split(",").length); // 1
console.log(csv.split(",").filter(Boolean).length); // 0, 이렇게 걸러야 한다
split은 자를 기준이 한 번도 안 나오면 원래 값을 통째로 담은 원소 하나짜리 배열을 준다. 빈 문자열도 예외가 아니라서 개수를 그냥 믿으면 안 된다.
정규식으로 검사했더니 같은 값이 한 번은 통과하고 한 번은 걸렸다. 이건 원인을 찾는 데 반나절이 걸렸다.
const re = /\d{3}/g;
console.log(re.test("abc123")); // true
console.log(re.test("abc123")); // false, 값은 그대로인데 답이 다르다
g 플래그가 붙은 정규식은 성공한 위치를 자기 안에 기억해뒀다가 다음번엔 그 뒤부터 찾는다. 정규식을 변수에 담아 목록을 훑으면 한 줄 걸러 한 줄씩 통과하는 기묘한 결과가 나온다. 검사만 할 거면 g를 빼면 된다.
const reFixed = /^[\w.+-]+@[\w-]+\.[\w.-]+$/;
console.log(reFixed.test("[email protected]")); // true
console.log(reFixed.test("user@example")); // false
기호가 낯설어 보여도 조각은 몇 개 안 된다. ^는 시작, $는 끝이고 이 둘을 빼먹으면 문자열 어딘가에만 그 모양이 있어도 통과라 이상한 값이 다 들어온다. \d는 숫자 한 글자, \w는 영문자와 숫자와 밑줄, +는 한 번 이상 반복, {3}은 정확히 세 번이다.
날짜 표기를 바꾸는 데는 그룹이 제일 편했다. 괄호로 묶은 조각에 이름을 달아두면 바꿀 문자열에서 그대로 꺼내 쓸 수 있다.
const date = "2026-07-26";
console.log(date.replace(
/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/,
"$<y>년 $<m>월 $<d>일"
)); // 2026년 07월 26일
번호로 $1, $2를 써도 되지만 나중에 괄호를 하나 끼워 넣는 순간 조용히 어긋난다. 이름을 붙이는 쪽이 안전하다. 바꿀 값에 계산이 필요하면 문자열 자리에 함수를 넘긴다. 걸린 조각이 인자로 들어온다.
const text = "노트북 45000원, 마우스 12000원";
console.log(text.replace(/(\d+)원/g, (all, n) => Number(n).toLocaleString() + "원"));
// 노트북 45,000원, 마우스 12,000원
console.log([...text.matchAll(/(\d+)원/g)].map((mt) => Number(mt[1]))); // [45000, 12000]
여기서는 g가 반드시 있어야 한다. replace에 안 붙이면 첫 금액만 바뀌고, matchAll은 아예 에러를 낸다. 앞에서 test에 g를 붙였다가 데인 것과 정반대다.
돌아보면 문자열 쪽 사고는 두 갈래였다. 하나는 새 값이 나온 걸 안 받아서, 하나는 정규식이 상태를 들고 있다는 걸 몰라서 났다. 그래서 지금은 문자열 메서드를 쓸 때마다 이게 돌려주는 값을 어디에 담고 있는지부터 확인하고, 정규식에 g를 붙일 때는 여러 개를 다 찾으려는 건지 한 번 검사만 하려는 건지 스스로에게 묻는다. 이 두 개만 챙겨도 남는 버그는 거의 없다.