λ ˆμ‹œν”Ό λͺ©λ‘

'πŸ„'.length === 2? μžλ°”μŠ€ν¬λ¦½νŠΈμ—μ„œ 이λͺ¨μ§€ λ¬Έμžμ—΄ 길이의 함정과 해결법

2025λ…„ 10μ›” 8일 5λΆ„ 읽기
JavaScript

κΈ€μž 수 μ œν•œμ„ κ±°λŠ” μ½”λ“œλŠ” μ„Έμƒμ—μ„œ 제일 λ‹¨μˆœν•œ μ½”λ“œλΌκ³  μƒκ°ν–ˆμŠ΅λ‹ˆλ‹€. str.length와 숫자 ν•˜λ‚˜λ©΄ λλ‚˜λŠ” μΌμ΄λ‹ˆκΉŒμš”. ν”„λ‘ νŠΈμ—μ„œ κ²€μ‚¬ν•˜κ³ , μ„œλ²„μ—μ„œ ν•œ 번 더 κ²€μ‚¬ν•˜κ³ , DB 컬럼 크기만 λ§žμΆ°λ‘λ©΄ λ˜λŠ” 쀄 μ•Œμ•˜μŠ΅λ‹ˆλ‹€.

κ·ΈλŸ¬λ‹€ 이λͺ¨μ§€ ν•˜λ‚˜κ°€ λ“€μ–΄μ˜΅λ‹ˆλ‹€.

"이λͺ¨μ§€ ν•˜λ‚˜λ₯Ό λ„£μ—ˆλŠ”λ° 길이가 2둜 계산돼?" 'πŸ„'.length // 2

λ²„κ·Έμ²˜λŸΌ λ³΄μ΄μ§€λ§Œ 버그가 μ•„λ‹™λ‹ˆλ‹€. μžλ°”μŠ€ν¬λ¦½νŠΈμ˜ λ¬Έμžμ—΄ λͺ¨λΈμ΄ μœ λ‹ˆμ½”λ“œμ™€ λ§žλ¬Όλ¦¬λŠ” μ§€μ μ—μ„œ μƒκΈ°λŠ”, μ•„μ£Ό ν”ν•˜μ§€λ§Œ ν—·κ°ˆλ¦¬λŠ” λ¬Έμ œμž…λ‹ˆλ‹€.

μžλ°”μŠ€ν¬λ¦½νŠΈλŠ” κΈ€μžκ°€ μ•„λ‹ˆλΌ 쑰각을 μ„Όλ‹€

μžλ°”μŠ€ν¬λ¦½νŠΈ λ¬Έμžμ—΄μ€ UTF-16 μ½”λ“œ μœ λ‹› λ‹¨μœ„λ‘œ μ €μž₯λ©λ‹ˆλ‹€. 즉, String.prototype.lengthλŠ” μ‹€μ œ "λ³΄μ΄λŠ” κΈ€μž 수"κ°€ μ•„λ‹ˆλΌ "16λΉ„νŠΈ 쑰각 개수"λ₯Ό λ°˜ν™˜ν•˜λŠ” κ±°μ£ .

"πŸ„" (U+1F344)은 BMP(κΈ°λ³Έ λ‹€κ΅­μ–΄ 평면) λ°–μ˜ 문자라 16λΉ„νŠΈ 두 쑰각(μ„œλ‘œκ²Œμ΄νŠΈ νŽ˜μ–΄)으둜 ν‘œν˜„λ©λ‹ˆλ‹€. BMPλŠ” U+0000λΆ€ν„° U+FFFFκΉŒμ§€μ˜ λ²”μœ„μΈλ°, 이 μ•ˆμ— λ“œλŠ” λ¬ΈμžλŠ” μ½”λ“œ μœ λ‹› ν•˜λ‚˜λ‘œ ν‘œν˜„λ˜μ§€λ§Œ κ·Έ λ°–μ˜ λ¬ΈμžλŠ” 두 개의 μ½”λ“œ μœ λ‹›, 즉 μ„œλ‘œκ²Œμ΄νŠΈ νŽ˜μ–΄λ‘œ ν‘œν˜„ν•΄μ•Ό ν•˜λ‹ˆκΉŒμš”. 이λͺ¨μ§€ λŒ€λΆ€λΆ„μ΄ 이 λ²”μœ„ 밖에 μžˆμ–΄μ„œ .lengthκ°€ 2κ°€ λ˜λŠ” κ²λ‹ˆλ‹€.

js
"πŸ„".length; // 2
"πŸ„".charCodeAt(0).toString(16); // "d83c"
"πŸ„".charCodeAt(1).toString(16); // "df44"
"πŸ„".codePointAt(0).toString(16); // "1f344"

κ²°κ΅­ 'πŸ„'은 μ½”λ“œ μœ λ‹› 2개둜 κ΅¬μ„±λ˜λ‹ˆκΉŒ .length === 2κ°€ λ˜λŠ” κ²λ‹ˆλ‹€.

"길이"λŠ” 무엇을 μ„ΈλŠλƒμ— 따라 달라진닀

μžλ°”μŠ€ν¬λ¦½νŠΈμ—μ„œ λ¬Έμžμ—΄μ˜ "길이"λŠ” 생각보닀 μ—¬λŸ¬ μΈ΅μœ„λ‘œ λ‚˜λ‰©λ‹ˆλ‹€. 같은 πŸ„ λ¬ΈμžλΌλ„ 무엇을 κΈ°μ€€μœΌλ‘œ μ„ΈλŠλƒμ— 따라 κ²°κ³Όκ°€ λ‹¬λΌμ§‘λ‹ˆλ‹€.

λ¨Όμ € λ°”μ΄νŠΈ 수 κΈ°μ€€μœΌλ‘œ 보면, πŸ„μ€ UTF-8μ—μ„œ 4λ°”μ΄νŠΈλ₯Ό μ°¨μ§€ν•©λ‹ˆλ‹€. 이건 μ €μž₯ κ³΅κ°„μ΄λ‚˜ λ„€νŠΈμ›Œν¬ 전솑 μ‹œ μš©λŸ‰μ„ 계산할 λ•Œμ˜ 관점이죠.

μ½”λ“œ μœ λ‹› 수 κΈ°μ€€μœΌλ‘œ 보면 2μž…λ‹ˆλ‹€. UTF-16의 16λΉ„νŠΈ λ‹¨μœ„ 쑰각을 μ„ΈλŠ” 것이고, μžλ°”μŠ€ν¬λ¦½νŠΈμ˜ String.prototype.lengthκ°€ λ°”λ‘œ 이 값을 λ°˜ν™˜ν•©λ‹ˆλ‹€.

μ½”λ“œ 포인트 수 κΈ°μ€€μ—μ„œλŠ” 1이 λ©λ‹ˆλ‹€. μœ λ‹ˆμ½”λ“œμ—μ„œ πŸ„μ€ ν•˜λ‚˜μ˜ κ³ μœ ν•œ μ½”λ“œ 포인트(U+1F344)둜 μ‹λ³„λ˜λ‹ˆκΉŒμš”.

λ§ˆμ§€λ§‰μœΌλ‘œ κ·Έλž˜ν•Œ 수, 즉 ν™”λ©΄μ—μ„œ μ‚¬μš©μžκ°€ μΈμ‹ν•˜λŠ” "λ³΄μ΄λŠ” κΈ€μž 수"둜 보면 μ—­μ‹œ 1μž…λ‹ˆλ‹€. 이게 μš°λ¦¬κ°€ 일반적으둜 "문자 ν•˜λ‚˜"라고 λŠλΌλŠ” UX κΈ°μ€€μ˜ λ‹¨μœ„μ£ .

μ •λ¦¬ν•˜μžλ©΄, πŸ„ ν•˜λ‚˜λŠ” μ €μž₯ μ‹œ 4λ°”μ΄νŠΈ, JS의 .lengthλ‘œλŠ” 2, μœ λ‹ˆμ½”λ“œ 문자 κΈ°μ€€μœΌλ‘œλŠ” 1, 그리고 μ‹œκ°μ μœΌλ‘œλ„ 1κΈ€μžμž…λ‹ˆλ‹€. 같은 문자 ν•˜λ‚˜λ₯Ό 두고 λ„€ 개의 닡이 λ‚˜μ˜€λŠ” μ…ˆμž…λ‹ˆλ‹€. "길이"λŠ” 기술적 관점에 따라 μ „ν˜€ λ‹€λ₯΄κ²Œ μ •μ˜λ  수 μžˆμŠ΅λ‹ˆλ‹€.

λˆˆμ— λ³΄μ΄λŠ” κΈ€μž μˆ˜λŠ” λ”°λ‘œ μ„Έμ•Ό ν•œλ‹€

μš”μ¦˜ 이λͺ¨μ§€λ“€μ€ μŠ€ν‚¨ν†€, κ°€μ‘±, 성별 μ‘°ν•© 같은 ZWJ μ‹œν€€μŠ€κ°€ ν”ν•©λ‹ˆλ‹€. λˆˆμ— λ³΄μ΄λŠ” κΈ€μž ν•˜λ‚˜κ°€ μ—¬λŸ¬ μ½”λ“œ μœ λ‹› λ˜λŠ” μ—¬λŸ¬ μ½”λ“œ 포인트둜 μ΄λ£¨μ–΄μ§ˆ 수 μžˆμŠ΅λ‹ˆλ‹€.

κ·Έλž˜μ„œ "λ³΄μ΄λŠ” κΈ€μž 수"λ₯Ό μ„Έλ €λ©΄ μ•„λž˜ 두 κ°€μ§€ 방식 쀑 ν•˜λ‚˜λ₯Ό 써야 ν•©λ‹ˆλ‹€.

μ½”λ“œ 포인트둜 μ„Έλ©΄ λŒ€λΆ€λΆ„μ€ λ§žλŠ”λ‹€

js
[..."πŸ„πŸ‘πŸ½"].length; // 2

const sliceByCodePoints = (str, n) => Array.from(str).slice(0, n).join("");

ES2015 이상이면 μ–΄λ””μ„œλ‚˜ μž‘λ™ν•œλ‹€λŠ” μž₯점이 μžˆμ§€λ§Œ, ZWJ μ‘°ν•©(πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦)은 μ—¬λŸ¬ κΈ€μžλ‘œ 잘릴 수 μžˆλ‹€λŠ” ν•œκ³„κ°€ μžˆμŠ΅λ‹ˆλ‹€.

κ·Έλž˜ν•ŒμœΌλ‘œ μ„Έμ•Ό μ‚¬μš©μžμ™€ λˆˆλ†’μ΄κ°€ λ§žλŠ”λ‹€

js
const countGraphemes = (str) => {
  const seg = new Intl.Segmenter("ko", { granularity: "grapheme" });
  return [...seg.segment(str)].length;
};

const sliceByGraphemes = (str, n) => {
  const seg = new Intl.Segmenter("ko", { granularity: "grapheme" });
  return [...seg.segment(str)]
    .slice(0, n)
    .map((s) => s.segment)
    .join("");
};

countGraphemes("πŸ‘πŸ½"); // 1

μ΅œμ‹  λΈŒλΌμš°μ €μ™€ Node(18+) λŒ€λΆ€λΆ„μ—μ„œ μ§€μ›ν•©λ‹ˆλ‹€. 미지원 ν™˜κ²½μ—μ„œλŠ” grapheme-splitter 폴리필을 μ“°λ©΄ λ©λ‹ˆλ‹€.

ν”„λ‘ νŠΈμ™€ μ„œλ²„κ°€ 같은 자λ₯Ό μ“°κ²Œ λ§Œλ“ λ‹€

ts
// utils/charLength.ts
export type LengthMode = "grapheme" | "codePoint" | "codeUnit";

export function lengthOf(str: string, mode: LengthMode = "grapheme"): number {
  switch (mode) {
    case "codeUnit":
      return str.length;
    case "codePoint":
      return Array.from(str).length;
    case "grapheme": {
      const AnyIntl: any = Intl as any;
      if (AnyIntl?.Segmenter) {
        const seg = new AnyIntl.Segmenter("ko", { granularity: "grapheme" });
        return [...seg.segment(str)].length;
      }
      const { default: GraphemeSplitter } = require("grapheme-splitter");
      return new GraphemeSplitter().countGraphemes(str);
    }
  }
}

ν”„λ‘ νŠΈμ™€ μ„œλ²„κ°€ 같은 μœ ν‹Έμ„ μ“°λ©΄ 길이 계산 뢈일치 문제λ₯Ό μ›μ²œ 차단할 수 μžˆμŠ΅λ‹ˆλ‹€.

DBλŠ” 또 λ‹€λ₯Έ κΈ°μ€€μœΌλ‘œ μ„Όλ‹€

μžλ°”μŠ€ν¬λ¦½νŠΈμ˜ λ¬Έμžμ—΄ κΈΈμ΄λŠ” UTF-16 μ½”λ“œ μœ λ‹› μˆ˜μ§€λ§Œ, MySQL은 λ¬Έμžμ—΄μ„ λ°”μ΄νŠΈ(byte) λ˜λŠ” 문자(character) κΈ°μ€€μœΌλ‘œ κ³„μ‚°ν•©λ‹ˆλ‹€.

이 차이λ₯Ό ν˜Όλ™ν•˜λ©΄ 특히 이λͺ¨μ§€Β·ν•œκΈ€Β·νŠΉμˆ˜λ¬Έμž μž…λ ₯ μ‹œ μ œμ•½ 쑰건 였λ₯˜λ‚˜ 데이터 잘림 같은 λ¬Έμ œκ°€ μ‰½κ²Œ λ°œμƒν•©λ‹ˆλ‹€.

LENGTH()λŠ” μ €μž₯ μš©λŸ‰μ„ μ„Όλ‹€

LENGTH()λŠ” λ¬Έμžμ—΄μ΄ μ‹€μ œλ‘œ μ €μž₯μ†Œμ—μ„œ μ°¨μ§€ν•˜λŠ” λ°”μ΄νŠΈ 크기λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€. 즉, 같은 λ¬Έμžμ—΄μ΄λΌλ„ 인코딩에 따라 κ²°κ³Όκ°€ λ‹¬λΌμ§‘λ‹ˆλ‹€.

MySQL이 utf8mb4λ₯Ό μ“Έ λ•Œμ˜ μ˜ˆμ‹œλŠ” μ΄λ ‡μŠ΅λ‹ˆλ‹€.

sql
SELECT LENGTH('πŸ„');  -- 4
SELECT LENGTH('ν•œ');  -- 3
SELECT LENGTH('A');   -- 1

이λͺ¨μ§€λŠ” 4λ°”μ΄νŠΈ, ν•œκΈ€μ€ 3λ°”μ΄νŠΈ, ASCII λ¬ΈμžλŠ” 1λ°”μ΄νŠΈλ₯Ό μ°¨μ§€ν•©λ‹ˆλ‹€. LENGTH()λŠ” 컬럼 크기(VARCHAR(255) λ“±)λ₯Ό κ³„μ‚°ν•˜κ±°λ‚˜ μ €μž₯ μš©λŸ‰μ„ 확인할 λ•ŒλŠ” μœ μš©ν•œλ°, μ‚¬μš©μž μž…λ ₯ 길이 μ œν•œμ²˜λŸΌ "κΈ€μž 수"κ°€ μ€‘μš”ν•œ λ‘œμ§μ—λŠ” μ ν•©ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

CHAR_LENGTH()λŠ” μœ λ‹ˆμ½”λ“œ 문자λ₯Ό μ„Όλ‹€

CHAR_LENGTH() (λ˜λŠ” CHARACTER_LENGTH())λŠ” 문자 개수, κ·ΈλŸ¬λ‹ˆκΉŒ μ½”λ“œ 포인트 수 κΈ°μ€€μœΌλ‘œ 길이λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€.

sql
SELECT CHAR_LENGTH('πŸ„');  -- 1
SELECT CHAR_LENGTH('ν•œ');  -- 1
SELECT CHAR_LENGTH('A');   -- 1

이 ν•¨μˆ˜λŠ” 인코딩 방식(UTF-8, UTF-16, UTF-32)에 상관없이 "μœ λ‹ˆμ½”λ“œ 문자 ν•˜λ‚˜"λ₯Ό 1둜 κ³„μ‚°ν•©λ‹ˆλ‹€. λ”°λΌμ„œ λ‹‰λ„€μž„, λŒ“κΈ€, μ‚¬μš©μž μž…λ ₯ ν•„λ“œμ²˜λŸΌ UX κΈ°μ€€μ˜ κΈ€μž 수 κ²€μ¦μ—λŠ” CHAR_LENGTH()λ₯Ό μ“°λŠ” 게 μ˜¬λ°”λ₯Έ μ„ νƒμž…λ‹ˆλ‹€.

κ·Έλž˜ν•Œ λ‹¨μœ„λŠ” μ—¬μ „νžˆ λ³„κ°œλ‹€

μ£Όμ˜ν•  점은, λ³΄μ΄λŠ” κΈ€μž(κ·Έλž˜ν•Œ)와 μœ λ‹ˆμ½”λ“œ 문자(μ½”λ“œ 포인트)κ°€ 항상 μΌμΉ˜ν•˜μ§„ μ•ŠλŠ”λ‹€λŠ” κ²λ‹ˆλ‹€. 예λ₯Ό λ“€μ–΄ κ°€μ‘± 이λͺ¨μ§€ πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ λŠ” μ—¬λŸ¬ μ½”λ“œ 포인트λ₯Ό ZWJ(Zero Width Joiner)둜 μ—°κ²°ν•œ μ‘°ν•©μ΄λ‹ˆκΉŒμš”.

sql
SELECT CHAR_LENGTH('πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'); -- 7

λˆˆμœΌλ‘œλŠ” "ν•œ κΈ€μž"μ§€λ§Œ, MySQL μž…μž₯μ—μ„œλŠ” 7개의 문자둜 κ³„μ‚°λ©λ‹ˆλ‹€. MySQL은 κ·Έλž˜ν•Œ λ‹¨μœ„λ₯Ό μ΄ν•΄ν•˜μ§€ λͺ»ν•˜λ‹ˆκΉŒμš”.

κ²°κ΅­ LENGTH()λŠ” μ €μž₯ μš©λŸ‰(λ°”μ΄νŠΈ) 기쀀이라 인코딩에 따라 값이 달라지고, CHAR_LENGTH()λŠ” μ½”λ“œ 포인트 κΈ°μ€€μœΌλ‘œ 일반적인 "문자 수" 계산에 μ”λ‹ˆλ‹€. ν•˜μ§€λ§Œ κ·Έλž˜ν•Œ κΈ°μ€€μ˜ λ³΄μ΄λŠ” κΈ€μž μˆ˜λŠ” DBμ—μ„œ μ§€μ›ν•˜μ§€ μ•ŠμœΌλ‹ˆκΉŒ JSλ‚˜ μ„œλ²„μ—μ„œ 계산해야 ν•©λ‹ˆλ‹€.

κ·Έλž˜μ„œ μ €λŠ” DB의 컬럼 μ œμ•½(VARCHAR, TEXT)은 μ €μž₯ μš©λŸ‰ κΈ°μ€€μœΌλ‘œ, μž…λ ₯ 검증은 "λ³΄μ΄λŠ” κΈ€μž 수" κΈ°μ€€μœΌλ‘œ λ‚˜λˆ„μ–΄ μ„€κ³„ν•˜λŠ” μͺ½μ΄ λ§žλ‹€κ³  μƒκ°ν•˜κ²Œ λμŠ΅λ‹ˆλ‹€. μ§€κΈˆ λ§Œλ“€κ³  μžˆλŠ” μž…λ ₯ 폼이 μžˆλ‹€λ©΄, 거기에 이λͺ¨μ§€ ν•˜λ‚˜λ₯Ό 넣어보면 μ’‹κ² μŠ΅λ‹ˆλ‹€. μ–΄λ–€ 자둜 재고 μžˆμ—ˆλŠ”μ§€κ°€ λ°”λ‘œ λ“œλŸ¬λ‚ μ§€λ„ λͺ¨λ¦…λ‹ˆλ‹€.