이전 글 마지막에 의미 단위로 줄바꿈하는 라이브러리를 만들었다고 했는데, 이번에는 어떻게 사용하고 안에서는 어떤 일을 하는지, 그리고 왜 그렇게 만들었는지 적어보려고 한다. 일단 스샷이랑 링크부터 .. (스타 부탁드립니다)

이미지 1 / 2

처음엔 그냥 이런 줄바꿈이 되면 좋겠다는 생각이었다.

- 좋은 제품을 만들기 위해 작은
- 불편부터 하나씩 개선합니다
+ 좋은 제품을 만들기 위해
+ 작은 불편부터 하나씩 개선합니다

사실 한국어는 이미 띄어쓰기로 어절을 구분하고 있기 때문에 word-break: keep-all을 사용하면 단어 중간에서 줄바꿈이 일어나는것을 막을 수 있고, text-wrap: balance를 사용하면 줄바꿈이 말 그대로 밸런스있는 지점에 끊기게 된다.

이때 한 가지 아쉬운 점은, text-wrap: balance는 시각적인 밸런스만 맞출 뿐, 의미적으로 어떤것이 더 자연스러운가는 고려하지 않는다는 것이다. 예를 들어서 시각적인 밸런스만을 고려한다면 좋은 제품을 만들기 위해 작은 / 불편부터 하나씩 개선합니다가 더 나을 수 있지만, 문장을 읽을때 우리는 의미적으로 끊어서 읽기 때문에 좋은 제품을 만들기 위해 / 작은 불편부터 하나씩 개선합니다가 더 자연스럽게 느껴진다.

그렇다고 하나하나 손수 줄바꿈을 넣는 수고를 하고 싶지는 않았다. (아 물론 LLM이 알아서 잘 넣어줄 것 같긴 하지만 ..) 그리고 화면이 넓을 때는 괜찮다가 좁아지면 이상해질수도 있고, 폰트나 글자 크기를 바꾸면 또 줄바꿈이 어색해질수도 있으니까. 그래서 런타임에 문장에서 의미적으로 자연스러운 경계를 찾아서 실제 화면 너비에 맞게 줄바꿈해주면 좋겠다고 생각했고, 그래서 semantic-wrap을 만들었다.

들어가기 전에: 현재 글에서 보여지는 코드 스니펫들은 실제와 다르게 생략된 부분이 있거나 버전에 따라서 달라진 부분이 있을 수 있으니 정확한 코드는 레포에서 확인하시길 ..

리액트에서 사용하는 방법은 간단하다. 한국어 모델을 가져와서 줄바꿈을 적용할 요소를 감싸주면 된다. 지금 이 블로그 제목에도 이렇게 적용해두었다.

import { koTitleModel } from "@semantic-wrap/ko";
import { SemanticWrap } from "@semantic-wrap/react";

<SemanticWrap model={koTitleModel}>
  <h1>좋은 제품을 만들기 위해 작은 불편부터 하나씩 개선합니다</h1>
</SemanticWrap>

SemanticWrap은 별도의 wrapper를 만들지 않고 자식 요소를 그대로 사용한다. 실제로 표시되는 폰트와 너비를 측정해서 줄바꿈 위치를 고르고, 계산한 결과를 사용하기로 했다면 해당 위치에 <br>을 넣어주는 방식이다. 따라서 자식은 실제 HTML 요소로 ref를 전달할 수 있는 하나의 텍스트 요소여야 한다.

아 그리고 감쌌다고 무조건 줄바꿈이 달라지는 것은 아니고, 실제 브라우저가 줄바꿈한 부분보다 더 의미있는 줄바꿈이라고 판단되는 경우에만 줄바꿈한다. 이 부분은 뒤에서 더 이야기하기로 ..

패키지는 core, react, ko/en로 나뉜다. core에는 문자와 너비만 있으면 사용할 수 있는 코어 로직이 들어있다. 따라서 리액트나 DOM 환경이 아니더라도 사용할 수 있으며, 모델이 제안한 텍스트 경계를 바탕으로 너비에 맞는 배치를 계산하고 최종 결과를 고른다. react는 리액트에서 core 로직을 쉽게 사용할 수 있도록 해주는 wrapper가 들어있고, ko/en에는 학습된 예시 모델이 들어있다.

패키지하는 일
@semantic-wrap/core모델이 제안한 경계를 모으고, 너비에 맞는 배치를 계산하고, 최종 결과를 고른다
@semantic-wrap/reactDOM을 측정하고, 계산을 언제 실행할지 정하고, 선택된 결과를 화면에 반영한다
@semantic-wrap/ko, @semantic-wrap/en한국어와 영어에서 선호하는 경계를 예측하는 모델을 제공한다

Core는 인풋을 가지고 최적의 결과를 계산한다. 텍스트와 모델, 사용할 수 있는 너비, 그리고 문자열의 너비를 재는 함수를 넘겨주면, 모델이 경계를 예측하고, 각 경계에서 끊었을 때 만들어지는 줄들이 주어진 너비에 들어가는지 계산하고, 그렇게 계산된 조합들중에 최적의 의미와 밸런스를 저울질하여 최종 줄바꿈을 선택한다.

이러한 계산은 환경을 한정하지 않기 때문에 꼭 웹브라우저가 아니더라도 인터페이스에 맞게 인풋만 줄 수 있다면 어디서든 코어 로직을 활용할 수 있다. 라이브러리가 판단한 최적의 결과를 돌려줄 뿐 DOM 자체를 수정하거나 하지는 않는다.

다음은 이 전체 계산 과정을 한 번에 실행하는 함수인 selectLineBreaks다. 

declare function selectLineBreaks(
  input: SelectLineBreaksInput,
  options?: SelectLineBreaksOptions,
): LineBreakSelection;

interface SelectLineBreaksInput {
  text: string; // 원문
  model: PhraseModel; // 경계 모델
  maxWidth: number; // 최대 너비
  measureText(text: string): number; // 문자열 너비를 maxWidth와 같은 단위로 반환
}

selectLineBreaks라는 이름만 보면 이 함수 안에 하나의 거대한 줄바꿈 알고리즘이 들어있는 것처럼 보일 수 있는데, 사실 이 함수는 core의 전체 과정을 한 번에 실행할 수 있도록 감싸놓은 편의 함수에 가깝다. 실제 계산은 createLineBreakPlan으로 만드는 plan이 있고, 이 plan이 aggregate → calculate → select 순서대로 실행하면서 결과를 만들어낸다.

이걸 코드로 따라가보자. 여기서는 계산 결과를 눈으로 비교하기 쉽도록 글자와 공백의 너비를 모두 1로 가정하겠다. 

import { createLineBreakPlan } from "@semantic-wrap/core";
import { koTitleModel } from "@semantic-wrap/ko";

const plan = createLineBreakPlan({
  text: "좋은 제품을 만들기 위해 작은 불편부터 하나씩 개선합니다",
  model: koTitleModel,
});

const measurement = {
  maxWidth: 20, // 한 줄에 사용할 수 있는 너비. 여기서는 20글자 분량
  measureText: (text: string) => text.length, // 설명용 가상 측정. 실제 픽셀 너비가 아님
};

먼저 모델이 끊을 만한 위치를 알려준다. 모델은 한 단계일 수도 있고, 여러 단계일수도 있다. 기본 모델을 사용하면 coarse, medium, fine으로 3단계의 모델을 사용하며, 이때 같은 위치를 여러 단계가 고를 수도 있다. 아래 코드 스니펫을 보면 해당 문장에서는 제품을 뒤 공백인 offset 6을 medium과 fine이 함께 고른다. medium은 penalty가 0.35이고 fine은 0.7인데, 실제로 그 위치에서 끊을 때는 어느 값을 써야 할까?

이걸 정하는 것이 aggregate다. 기본 집계기인 lowestPenalty()는 같은 위치의 예측 중 가장 낮은 penalty를 남긴다. 공백에서는 끊을 수 있지만 어떤 모델도 고르지 않은 위치도 있는데, 그런 곳은 모델의 fallbackPenalty인 1을 붙여서 남겨둔다.

const candidates = plan.aggregate();

console.log(candidates);
// [
//   { offset: 2, level: null, penalty: 1 },
//   { offset: 6, level: 1, name: "medium", penalty: 0.35 },
//   { offset: 10, level: null, penalty: 1 },
//   { offset: 13, level: 0, name: "coarse", penalty: 0 },
//   { offset: 16, level: null, penalty: 1 },
//   { offset: 21, level: null, penalty: 1 },
//   { offset: 25, level: null, penalty: 1 },
// ]

offset은 원문에서 앞줄이 끝나는 위치이고, level은 그 위치의 비용을 결정한 모델 단계의 index다. null이면 모델이 고른 곳이 아니라 fallback이라는 뜻이다. 작은 앞인 offset 13은 coarse 모델이 골라서 비용이 0이고, 아무 모델도 고르지 않은 offset 16은 비용이 1이다.

aggregate에서 정해지는것은 문장을 어디에서 끊을 수 있고 어느 경계를 더 선호하는지까지다.

그럼 이제 이 경계들을 조합해서 실제로 들어가는 줄을 만들어야 한다. 작은 앞에서 끊으면 뒤쪽이 한 줄에 들어갈지, 작은까지 앞줄에 넣으면 양쪽 길이는 얼마나 비슷해질지 같은 것들이다. 여기서부터 실제 컨테이너 너비와 텍스트 너비가 필요해진다.

const layouts = plan.calculate(measurement);

console.log(layouts.map(({ breaks, widths, modelCost, balanceScore }) => ({
  breaks, // 줄을 끊은 원문 위치
  widths, // 각 줄의 측정 너비
  modelCost, // 선택한 경계들의 penalty 합
  balanceScore, // 줄 길이의 불균형. 낮을수록 균형 잡힘
})));
// [
//   { breaks: [16], widths: [16, 14], modelCost: 1, balanceScore: 0.05 },
//   { breaks: [13], widths: [13, 17], modelCost: 0, balanceScore: 0.1 },
// ]

첫 번째는 좋은 제품을 만들기 위해 작은 / 불편부터 하나씩 개선합니다이고, 두 번째는 좋은 제품을 만들기 위해 / 작은 불편부터 하나씩 개선합니다다. 둘 다 두 줄이고 각 줄이 너비 20 안에 들어간다. 근데 첫 번째는 길이가 더 비슷하고 두 번째는 모델이 더 선호하는 곳에서 끊었다. 그러면 어느 것을 남겨야 할까?

일단 둘 다 남긴다. 기본 계산기인 optimalLayouts()는 들어가는 최소 줄 수를 찾고, 그 안에서 의미 비용과 균형 중 어느 한쪽만 보고 버릴 수 없는 후보들을 남긴다.

마지막으로 select가 남은 후보 중 하나를 고른다. 기본 선택기인 balance()는 가장 균형 잡힌 결과에서 tolerance만큼 차이가 나는 후보까지 허용하고, 그 안에서는 모델 비용이 낮은 쪽을 우선한다. 기본 tolerance는 0.12인데 위의 두 후보는 균형 점수 차이가 0.05이니, 모델 비용이 0인 offset 13을 고를 수 있다.

const selection = plan.select({
  ...measurement, // 이번 선택에 사용할 너비와 측정 함수
  diagnostics: true, // 중간 결과도 받는 오버로드 사용
});

console.log(selection.lines);
// ["좋은 제품을 만들기 위해", "작은 불편부터 하나씩 개선합니다"]
console.log(selection.breaks); // [13]
console.log(selection.applied, selection.reason); // true "calculated-selected"
console.log(selection.diagnostics.selection);
// { selected: "calculated", index: 1, reason: "calculated-selected" }

여기서 index: 1은 계산된 후보 목록의 두 번째 배치를 골랐다는 뜻이다. select 함수는 이렇게 어떤 후보를 골랐는지만 알려주고, plan이 그 배치의 줄별 문자열과 경계 위치를 최종 결과로 돌려준다. applied도 화면을 이미 수정했다는 뜻이 아니라 계산한 배치를 반영할 조건을 만족했다는 값이다. 실제로 그리는 일은 여전히 사용하는 쪽의 몫이고.

근데 브라우저가 이미 잘 끊어놓았을 수도 있지 않나? 그 결과까지 비교하고 싶다면 nativeLayout을 함께 넘기면 된다.

const withNative = plan.select({
  ...measurement,
  // 여기서는 비교 과정을 보여주려고 [16]을 주입했다.
  // 실제 사용에서는 화면에서 측정한 기본 줄바꿈 위치를 넘긴다.
  nativeLayout: { breaks: [16] },
});

console.log(withNative.breaks); // [13]
console.log(withNative.applied, withNative.reason); // true "calculated-selected"

기본 배치는 [16]에서 끊었으니 모델 비용이 1이다. 계산한 [13]은 같은 두 줄이면서 비용이 0이고 균형도 허용 범위에 들어와서 바꾸기로 한다. 반대로 기본 배치보다 나아지는 후보가 없으면 원래 줄바꿈을 유지할 수도 있다.

결국 selectLineBreaks는 그냥 이거다. 사용하기 용이하도록 텍스트와 모델, 너비를 한 번에 넣어 결과를 받을 수 있게 해둔 것이다.

function selectLineBreaks(
  input: SelectLineBreaksInput,
  options: SelectLineBreaksOptions = {},
) {
  const plan = createLineBreakPlan({
    text: input.text,
    model: input.model,
    strategy: options.strategy,
  });

  const measurement = {
    maxWidth: input.maxWidth,
    measureText: input.measureText,
    measureTexts: input.measureTexts,
    nativeLayout: options.nativeLayout,
  };

  return options.diagnostics
    ? plan.select({ ...measurement, diagnostics: true })
    : plan.select(measurement);
}

그리고 아까도 말했지만 꼭 DOM이 아니어도 계산이 가능하다. 예를 들어서 canvas로 문자열 너비를 측정한다면 이런식으로 작성할 수 있다.

const context = document.createElement("canvas").getContext("2d")!;
context.font = "700 28px system-ui";

const input = {
  text: "좋은 제품을 만들기 위해 작은 불편부터 하나씩 개선합니다",
  model: koTitleModel,
  maxWidth: 360,
  measureText: (text: string) => context.measureText(text).width,
};

const result = selectLineBreaks(input, {
  diagnostics: true, // 최종 결과와 함께 중간 계산 결과도 받기
});

console.log(result.lines);
// ["좋은 제품을 만들기 위해", "작은 불편부터 하나씩 개선합니다"]

console.log(result.breaks);
// [13]

console.log(result.applied, result.reason);
// true "calculated-selected"

console.log(result.diagnostics.calculatedLayouts);
// [
//   {
//     breaks: [16],
//     lines: ["좋은 제품을 만들기 위해 작은", "불편부터 하나씩 개선합니다"],
//     widths: [314.32, 302.48],
//     selectedCandidates: [{ offset: 16, level: null, penalty: 1 }],
//     lineCount: 2,
//     balanceScore: 0.0164,
//     modelCost: 1,
//     overflow: false,
//   },
//   {
//     breaks: [13],
//     lines: ["좋은 제품을 만들기 위해", "작은 불편부터 하나씩 개선합니다"],
//     widths: [259.96, 356.84],
//     selectedCandidates: [{ offset: 13, level: 0, name: "coarse", penalty: 0 }],
//     lineCount: 2,
//     balanceScore: 0.1346,
//     modelCost: 0,
//     overflow: false,
//   },
// ]

간단하게 결과를 설명해보자면 .. 줄 길이만 보면 작은까지 첫 줄에 넣는 쪽이 훨씬 균형 잡혀 있지만, 작은 앞은 coarse 모델이 고른 경계라서 penalty가 0이다. 두 결과의 균형 점수 차이도 기본 tolerance인 0.12 안에 들어오므로 최종적으로 offset 13이 선택된다. 그러니까 시각적으로 가장 균형 잡힌 첫 번째 후보 대신, 균형을 이 정도 양보해도 된다면 의미적으로 더 선호하는 두 번째 후보를 고른 것이다.

아 그리고 이 예시에는 nativeLayout을 넘기지 않았으니 계산한 후보들끼리만 비교한다. 다른 줄바꿈(아마 대부분의 경우에 웹 브라우저의 기본 줄바꿈이 될 것 같다)도 비교하고 싶으면 실제로 측정한 경계 위치를 두 번째 인자의 nativeLayout: { breaks: [...] }로 넘겨주어야 한다. 뒤에서 설명할 리액트 패키지에서는 해당 측정까지 대신 해준다.

그럼 브라우저에서는 Core에 넘길 너비를 어떻게 구할까? 같은 문장이라도 폰트나 글자 크기, 자간이 달라지면 너비가 달라지고, 기본 줄바꿈과 비교하려면 브라우저가 실제로 어디서 끊었는지도 알아야 한다.

이때 화면에 보이는 제목을 계속 바꿔가면서 측정하는 것은 아니다. 후보 문자열의 너비는 숨겨진 span으로 재고, 기본 줄바꿈은 화면 밖에 같은 태그와 computed style, 컨테이너 너비를 가진 요소를 만들어 확인한다. 여기에 Range (새 창에서 열림)를 사용해서 글자의 세로 위치가 달라지는 지점을 찾으면 어느 줄에 속하는지 알 수 있다. 실제 폰트로 그려진 결과를 읽을 수는 있지만 원래 요소가 놓인 모든 맥락을 그대로 복제하는 것은 아니라서, 조상의 스타일 변화 같은 부분은 아직 더 고려가 필요하다.

맨 처음에 보여준 SemanticWrap이 이 부분을 맡는다. 요소를 측정해서 Core에 넘기고, 계산한 배치가 선택되면 그 위치에 <br>을 넣고, 기본 줄바꿈이 선택되면 원문을 보여주는 식이다. 그래서 사용하는 쪽에서는 매번 측정 함수를 만들거나 계산 결과를 직접 JSX로 옮기지 않고 제목을 감싸서 쓸 수 있다.

import { koTitleModel } from "@semantic-wrap/ko";
import { SemanticWrap } from "@semantic-wrap/react";

<SemanticWrap model={koTitleModel}>
  <h1>좋은 제품을 만들기 위해 작은 불편부터 하나씩 개선합니다</h1>
</SemanticWrap> 

만약 계산 결과를 가지고 직접 렌더링하고 싶다면 useSemanticWrap 훅도 사용할 수 있다. 측정할 원문을 text로 넣고, 반환된 ref를 실제 제목 요소에 연결하면 된다. 예를 들어서 아래처럼 사용할 수 있다.

import { Fragment } from "react";
import { koTitleModel } from "@semantic-wrap/ko";
import { useSemanticWrap } from "@semantic-wrap/react";

export function Title({ text }: { text: string }) {
  const { ref, selection } = useSemanticWrap({
    text,
    model: koTitleModel,
  });

  return (
    <h1 ref={ref}>
      {selection?.applied
        ? selection.lines.map((line, index) => (
            <Fragment key={index}>
              {index > 0 && <br />}
              {line}
            </Fragment>
          ))
        : text}
    </h1>
  );
}

측정 전이나 텍스트·너비가 바뀌어서 다시 계산하는 동안에는 selection이 null일 수 있어서 그때는 원문을 보여준다. 기본 줄바꿈을 유지하기로 했다면 applied가 false이니 그때도 원문을 쓰고. 훅은 자식이나 CSS를 직접 바꾸지 않아서, 계산이 끝날 때까지 제목을 숨길지도 사용하는 쪽에서 정해야 한다.

마지막으로 의미론적 경계를 알려주는 모델은 그래서 어떻게 구현했나? 사실 내가 딱히 한건 없고 BudouX (새 창에서 열림)를 간단하게 학습시켜서 사용했다. 그리고 그 모델을 보다 잘 사용할 수 있도록 계층적인 설계만 약간 더해봤다.

@semantic-wrap/ko와 @semantic-wrap/en에서 제공하는 예시 모델인 koTitleModel, enTitleModel은 둘 다 BudouX 형식의 가중치를 사용하고, 경계 예측을 coarse, medium, fine 세 단계로 나눠 제공한다. coarse는 우선해서 끊을 곳, medium은 그다음으로 허용할 곳, fine은 공간이 부족하면 여기까지는 나눌 수 있는 곳이라고 보면 된다. 그러니까 corase가 의미적으로 더 강한 경계이고, medium, fine으로 갈수록 약한 경계라고 보면 된다.

왜 세 단계냐면, 앞에서 봤던 좋은 제품을 만들기 위해 / 작은 불편부터 하나씩 개선합니다도 화면이 좁아지면 뒤쪽을 한 번 더 나눠야 할 수 있기 때문이다. 그렇다고 작은 불편부터 하나씩 개선합니다 안에서는 절대 끊지 말라고 할 수도 없고, 모든 공백을 똑같이 취급하면 다시 처음의 아쉬움으로 돌아가게 된다. 그래서 끊을 수 있는지 없는지만 정하기보다는 어디를 더 선호하는지 알려주고 실제 선택은 너비를 보고 하도록 했다.

관련해서 찾아봤던 Typesetting for Improved Readability using Lexical and Syntactic Information (새 창에서 열림)에서 힌트를 얻었다. 해당 연구는 어휘와 구문 정보를 이용해서 줄바꿈 위치를 정하는 연구인데, 끊을 수 있다/없다라는 이진 구분 대신 끊었을 때의 부자연스러움을 3~4단계로 나누는 방향을 제안한다. 그래야 부자연스러운 줄바꿈을 피하는 것과 줄 길이의 균형 사이에서 조절할 수 있다는 것이다. (논문에서 그 단계별 방식을 구현해서 검증했다는 뜻은 아니고, 후속 연구 방향으로 언급한 부분이다)

현재 프리셋에서는 세 단계에 각각 0, 0.35, 0.7의 penalty를 주고, 어느 단계에서도 고르지 않은 공백은 1로 남겨둔다. 앞에서 Core가 비교하던 비용은 이렇게 정해진다. 모델이 선호하지 않은 공백도 필요하면 사용할 수 있게 남겨두되, 가능한 배치 중에서는 더 선호하는 경계를 고르게 한 것이다. 물론 이 숫자가 사람이 느끼는 자연스러움을 정확하게 측정한 값은 아니다.

이렇게 텍스트와 너비로 줄바꿈을 계산하는 Core, 브라우저에서 측정하고 보여주는 React, 그리고 어디를 더 자연스럽게 끊을지 알려주는 ko/en 모델까지 살펴봤다. 근데 아직 이 라이브러리를 만들면서 어떤 시행착오를 겪었는지는 아직 시작도 하지 않았다 ㅋㅋㅋ큐ㅠㅋㅋ. 여기까지 한 번에 이야기하면 글이 너무 길어질 것 같아서, (그리고 글이 길어지니까 나도 좀 힘들다) 실제로 겪은 시행착오들은 다음 글에서 이어가려고 한다. 투 비 컨티뉴드 ..