fe.resolver.ts
fe.resolver.ts

Powered by Notion & Next.js

Navigate

  • 개인정보처리방침

Connect

  • GitHub

© 2026 Hanul Lee. All rights reserved.

Powered by Notion & Next.js

목록으로
Development2025년 6월 30일

canvas 무한루프 디버깅 과정과 jest 검증

cavas API는 어떻게 테스트 코드를 짜야할까

#TroubleShooting

개요

병원에서 환자 정보를 영수증 프린터(네모닉 이라고 한다)로 출력하는 기능이 있다. 이 출력은 HTML이 아닌 <canvas>로 그려진다.

Canvas에는 CSS의 word-wrap이나 overflow 같은 것이 없다. 텍스트 줄바꿈을 직접 구현해야 한다. 글자를 한 자씩 찍으면서 "이 줄에 더 들어갈 공간이 있는가?"를 계산하고, 넘치면 다음 줄로 내리는 로직을 손수 짜야 한다.

이 네모닉 출력 부분을 개발한 사람은 나름대로 합리적이고 잘 구현했다. 인입이 들어오기 전까지는.

문제는 특정 상황에서 무한 루프에 빠졌다는 것이다.

문제가 된 텍스트: "쥬베룩2cc+물광22ccM하이쿡스+LDM6분+팩"

실제 병원에서 출력하려던 시술 이름이다.

기존 wrapText 함수의 줄바꿈 로직은 대략 이런 흐름이다.

  1. 텍스트를 줄바꿈(\n) 기준으로 분리
  2. 각 줄을 공백(' ') 기준으로 단어 분리
  3. 단어를 하나씩 추가하면서 measureText()로 너비 계산
  4. 넘치면? → 글자 단위로 쪼개서 줄바꿈
CanvasRenderingContext2D: measureText() method - Web APIs | MDN

CanvasRenderingContext2D: measureText() method - Web APIs | MDN

The CanvasRenderingContext2D.measureText() method returns a TextMetrics object that contains information about the measured text (such as its width, for example).

faviconhttps://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/measureText

그리고 이 measureText 의 리턴값은 OS 별로 다르다는 사실도 알게 되었다. 이 메서드는 브라우저가 아니라 OS의 폰트 렌더링 엔진에 위임되기 때문에 원인을 찾기 더 힘들었다. ”제 Mac에선 잘 뽑히는데요?”

핵심 기능만 추리면 이렇다.

typescript
wordsInLine.forEach((wordText) => {
  let unprocessedText = wordText;

  while (unprocessedText.length > 0) {
    // 🔵 (A) 현재 줄 + 남은 텍스트 + 공백 으로 너비 측정
    const potentialLine = currentLineText + unprocessedText + ' ';
    const isLineWidthAcceptable = canvasContext.measureText(potentialLine).width <= maxLineWidth;

    if (isLineWidthAcceptable) {
      currentLineText = potentialLine;
      // ✅ 들어감 → 다음 단어로
      break; 
    } else {
      // 🔴 (B) 넘침 → 글자 단위로 잘라서 들어갈 만큼만 출력
      for (let i = 1; i <= unprocessedText.length; i++) {
        // 공백 없이 측정
        const testSegment = currentLineText + unprocessedText.slice(0, i);

        if (canvasContext.measureText(testSegment).width > maxLineWidth) {
          const fittingLength = i - 1;
          canvasContext.fillText(currentLineText + unprocessedText.slice(0, fittingLength), ...);
          unprocessedText = unprocessedText.slice(fittingLength);
          textAddedToLine = true;
          break;
        }
      }

      // 🔴 (C) for 루프를 끝까지 돌았는데도 넘치는 지점을 못 찾았을 때
      if (!textAddedToLine) {
        canvasContext.fillText(currentLineText.trim(), ...);
        currentLineText = '';
      }
    }
  }
});

무한 루프의 원인

문제는 (A) 에서와 (B)의 측정 기준이 다르다는 데 있다.

(A) 에서는

plain text
currentLineText + unprocessedText + ' '

공백을 포함해서 측정한다.

(B)의 for 루프에서는

currentLineText + unprocessedText.slice(0, i) 이부분에서 공백 없이 측정한다.

이 차이는 정말 사소하지만, 그렇기에 더욱 발견하기 쉽지 않은 엣지케이스이고, 이번 같은 사태를 낳는다.

만약 이런 상태라고 해보자.

maxLineWidth : 576px (상수) 텍스트 너비 = 570px 공백(' ') 너비 = 10px (그리고 만약 Mac에서 5px 이라면?)

typescript
const potentialLine = currentLineText + unprocessedText + ' ';
// → "쥬베룩2cc+물광22ccM " (너비: 570 + 10 = 580px)

580px > 576px → 넘침! → else 분기로 진입

Step 2. for 루프에서 글자 단위로 잘라가며 체크:

typescript
// 공백 없이 측정
const testSegment = currentLineText + unprocessedText.slice(0, i);

위 블럭에서 보면 알겠지만, 이때는 공백 없이 측정하기때문에,

for 루프가

typescript
if (canvasContext.measureText(testSegment).width > maxLineWidth)

이 if문을 통과하지 않고 종료된다.

Step 3. textAddedToLine은 false인 채로 while 루프 다시 시작 → unprocessedText가 그대로 → Step 1로 돌아감 → 무한 루프!

정리하면 이런 조건이 충족될 때 무한 루프가 발생한다.

plain text
텍스트 너비 ≤ maxLineWidth < 텍스트 너비 + 공백 너비

텍스트 자체는 딱 들어가는데, 공백 한 칸이 붙으면 넘치는 미묘한 경계값. **(A)**는 넘친다고 판단하고, **(B)**는 안 넘친다고 판단하고, **(C)**에서 unprocessedText는 소비되지 않은 채로 while이 다시 시작된다.

개선

수정 포인트는 두 가지였다.

1. "넘치기 직전" 감지

  • *(A)**에서 isLineWidthAcceptable = true로 판단되어 텍스트가 들어갔더라도, 남은 공간이 다음 글자 한 자도 못 담을 정도로 빡빡하다면 거기서 줄을 바꿔버리는 것이다.

typescript

if (isLineWidthAcceptable) {
  currentLineText = potentialLine;
  // 남은 공간이 다음 글자 폭보다 작으면 여기서 줄바꿈
  if (
    maxLineWidth - calculatedWidth <
    canvasContext.measureText(unprocessedText[0]).width
  ) {
    canvasContext.fillText(currentLineText, startX, currentYPosition);
    currentYPosition += lineSpacing;
    currentLineText = '';
  }
  break;
}

이렇게 하면 (A)와 (B) 사이의 비대칭 자체를 피해갈 수 있다.

공백 포함 여부와 상관없이, ’남은 공간이 충분한가?’ 라는 하나의 기준으로 판단하는 것이다.

2. fittingLength 최소값 보장

(B)의 for 루프에서 charIndex = 1일 때 이미 넘치면 fittingLength = 0이 된다.

0글자를 slice하면 unprocessedText가 줄어들지 않으므로 역시 무한 루프다.

typescript
let fittingLength = charIndex - 1;
if (fittingLength <= 0) {
  // 최소 1글자는 무조건 소비
  fittingLength = 1; 
}

3. 무한 루프 안전장치 (Safety Guard)

솔직히 위 두 가지 수정으로 충분하다고 생각했지만, 이미 한 번 터진 이상 믿을 수 없었다. 최후의 보루를 걸었다.

typescript
while (unprocessedText.length > 0) {
  loopCount++;
  if (loopCount > 100) {
    _warn(`무한 루프 방지: '${unprocessedText}' 처리 중 100회 이상 반복됨`);
    canvasContext.fillText(unprocessedText, startX, currentYPosition);
    currentYPosition += lineSpacing;
    break;
  }
  // ...
}

만약 예상치 못한 다른 엣지 케이스가 있더라도, 100회 이상 반복되면 그냥 한 줄에 모조리 텍스트를 찍고 탈출하며 로그를 남긴다.

이 로그는 데이터독에서 잡을수 있고, 하얀 라벨지가 1m씩 출력되는 것 보다 나으리라.

Canvas를 테스트하기

수정은 했는데, 이걸 어떻게 검증할 것인가. 그리고 재발하지 않을 거라고 어떻게 확신할 것인가.

wrapText 함수는 CanvasRenderingContext2D를 인자로 받는다. 이 객체의 measureText()는 브라우저에서만 동작한다. Jest는 Node.js 환경이라 <canvas>가 없다.

하지만 우리가 검증하고 싶은 건 폰트가 정확히 몇 픽셀인가 가 아니라, 측정된 너비를 기반으로 줄바꿈 분기가 올바르게 동작하는가 다.

그래서 measureText를 근사치 공식으로 Mock 했다.

typescript
let fillTextMock: jest.Mock;
let measureTextMock: jest.Mock;
let mockCanvasContext: CanvasRenderingContext2D;

beforeEach(() => {
  fillTextMock = jest.fn();
  measureTextMock = jest.fn();

  mockCanvasContext = {
    fillText: fillTextMock,
    measureText: measureTextMock,
    font: '',
  } as unknown as CanvasRenderingContext2D;
});

typescript
/**
 * canvasContext.measureText 는
 * 글자 수 * (fontSize * 0.6) 로 가정합니다.
 */
measureTextMock.mockImplementation((text) => {
  return { width: text.length * fontSize * 0.6 };
});

완벽한 Mock은 아니다. 실제로는 한글과 영어의 너비가 다르고, 폰트마다 렌더링 결과가 다르다.

하지만 앞서 말한 것처럼 measureText의 반환값은 OS마다 다르다. Mac에서 통과하는 테스트가 Windows CI에서 실패할 수 있고, 그 반대도 마찬가지다.

오히려 일관된 근사치로 Mock하는 것이 테스트의 안정성 면에서 더 나은 선택 이라고 생각한다.

테스트 케이스1: 인입 텍스트

먼저 실제 인입된 텍스트(Windows 에서 발생한 텍스트)와, Mac OS에서 발생한 엣지 케이스 너비 텍스트를 넣었다.

typescript
// MacOS 에서는 11 을 뒤에 더 넣으면 발생했다.
const problemText = `쥬베룩2cc+물광22ccM하이쿡스+LDM6분+팩
쥬베룩2cc+물광22ccM하이쿡스+LDM6분+팩11
쥬베룩2cc+물광1cc+하이쿡스+LDM6분+팩 3-3`;
test('실제 인입된 텍스트 처리', () => {
  const [_, finalY] = wrapText(mockCanvasContext, problemText, 0, 0, 576, fontSize);
  expect(fillTextMock).toHaveBeenCalled();
  expect(finalY).toBeGreaterThan(0);
});

테스트 2: 점진적 길이 증가

한 글자를 버그 트리거로 특정하긴 어렵다. 그래서 1글자부터 200글자까지 점진적으로 늘려가며 어떤 길이에서도 무한 루프가 발생하지 않는지 매트릭스로 검증한다.

typescript
const incrementalTexts = Array.from({ length: 200 }, (_, i) => '1'.repeat(i + 1));

it('1자씩 추가해도 무한루프가 발생하지 않아야 한다', () => {
  incrementalTexts.forEach((text) => {
    wrapText(mockCanvasContext, text, 0, 0, 576, fontSize);

    // _warn이 호출됐다면 = 100회 루프 가드에 걸렸다 = 무한 루프
    expect(_warn).not.toHaveBeenCalled();

    jest.clearAllMocks();
  });
});

테스트 3: 폰트 사이즈별 검증

네모닉 기능은 여러 폰트 사이즈(20px ~ 40px)를 설정할 수 있다.

폰트 사이즈가 바뀌면 한 줄에 들어가는 글자 수가 달라지고 경계값도 달라지므로, 모든 사이즈에 대해 위 테스트를 반복한다.

typescript
const MOCK_FONT_SIZES = { '40px': 40, '35px': 35, '30px': 30, '25px': 25, '20px': 20 };

Object.entries(MOCK_FONT_SIZES).forEach(([fontKey, fontSize]) => {
  describe(`폰트사이즈 ${fontKey}`, () => {
    beforeEach(() => {
      measureTextMock.mockImplementation((text) => ({
        width: text.length * fontSize * 0.6,
      }));
    });

    test('고정 텍스트 처리', () => { /* ... */ });
    test('점진적 텍스트 처리', () => { /* ... */ });
    test('무한루프 미발생', () => { /* ... */ });
  });
});

그리고 수정 전의 코드로 이 테스트 코드를 돌리면 실제로 무한 루프가 발생하여 테스트가 실패한다.

Loading image...
Notion Image

수정된 코드로 jest 실행시 모두 성공하여, 의도된대로 수정되었음을 확신할 수 있다.

Loading image...
Notion Image

끝맺음

Canvas 위에서 텍스트를 다루는 건 한눈에 들어오지 않는다. CSS가 알아서 해주던 줄바꿈을 직접 구현해야 하고, 유지보수에서 피로도가 높은 편이다.

거기다 같은 코드라도 OS에 따라 결과가 달라질 수 있는 등의 변수 또한 만나봤다.

이번 건에서 얻은 교훈이 있다면

  1. 역시 모든 알고리즘은 조건문이 가장 중요하다. 사소한 공백이 추가되고 없고에 따라 완전히 다른 분기를 타고 예상치 못한 결과를 낳는다.
  2. 순수 함수로 분리하면 테스트할 수 있다. wrapText가 컴포넌트 안에 인라인으로 박혀 있었다면 테스트는 훨씬 어려웠을 것이다.
  3. 고정값으로 관리하는 Mock이 반드시 열등한 건 아니다. OS별로 결과가 다른 API를 테스트할 때는, 오히려 일관된 Mock이 더 신뢰할 수 있는 테스트를 만든다.

  • 개요
  • 문제가 된 텍스트: "쥬베룩2cc+물광22ccM하이쿡스+LDM6분+팩"
  • 무한 루프의 원인
  • 개선
  • 1. "넘치기 직전" 감지
  • 2. fittingLength 최소값 보장
  • 3. 무한 루프 안전장치 (Safety Guard)
  • Canvas를 테스트하기
  • 테스트 케이스1: 인입 텍스트
  • 테스트 2: 점진적 길이 증가
  • 테스트 3: 폰트 사이즈별 검증
  • 끝맺음