cavas API는 어떻게 테스트 코드를 짜야할까
병원에서 환자 정보를 영수증 프린터(네모닉 이라고 한다)로 출력하는 기능이 있다. 이 출력은 HTML이 아닌 <canvas>로 그려진다.
Canvas에는 CSS의 word-wrap이나 overflow 같은 것이 없다. 텍스트 줄바꿈을 직접 구현해야 한다. 글자를 한 자씩 찍으면서 "이 줄에 더 들어갈 공간이 있는가?"를 계산하고, 넘치면 다음 줄로 내리는 로직을 손수 짜야 한다.
이 네모닉 출력 부분을 개발한 사람은 나름대로 합리적이고 잘 구현했다. 인입이 들어오기 전까지는.
문제는 특정 상황에서 무한 루프에 빠졌다는 것이다.
실제 병원에서 출력하려던 시술 이름이다.
기존 wrapText 함수의 줄바꿈 로직은 대략 이런 흐름이다.
\n) 기준으로 분리measureText()로 너비 계산
The CanvasRenderingContext2D.measureText() method returns a TextMetrics object that contains information about the measured text (such as its width, for example).
그리고 이measureText의 리턴값은 OS 별로 다르다는 사실도 알게 되었다. 이 메서드는 브라우저가 아니라 OS의 폰트 렌더링 엔진에 위임되기 때문에 원인을 찾기 더 힘들었다.”제 Mac에선 잘 뽑히는데요?”
핵심 기능만 추리면 이렇다.
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) 에서는
currentLineText + unprocessedText + ' '공백을 포함해서 측정한다.
(B)의 for 루프에서는
currentLineText + unprocessedText.slice(0, i) 이부분에서 공백 없이 측정한다.
이 차이는 정말 사소하지만, 그렇기에 더욱 발견하기 쉽지 않은 엣지케이스이고, 이번 같은 사태를 낳는다.
만약 이런 상태라고 해보자.
maxLineWidth : 576px (상수)
텍스트 너비 = 570px
공백(' ') 너비 = 10px (그리고 만약 Mac에서 5px 이라면?)
const potentialLine = currentLineText + unprocessedText + ' ';
// → "쥬베룩2cc+물광22ccM " (너비: 570 + 10 = 580px)580px > 576px → 넘침! → else 분기로 진입
Step 2. for 루프에서 글자 단위로 잘라가며 체크:
// 공백 없이 측정
const testSegment = currentLineText + unprocessedText.slice(0, i);위 블럭에서 보면 알겠지만, 이때는 공백 없이 측정하기때문에,
for 루프가
if (canvasContext.measureText(testSegment).width > maxLineWidth)이 if문을 통과하지 않고 종료된다.
Step 3. textAddedToLine은 false인 채로 while 루프 다시 시작 → unprocessedText가 그대로 → Step 1로 돌아감 → 무한 루프!
정리하면 이런 조건이 충족될 때 무한 루프가 발생한다.
텍스트 너비 ≤ maxLineWidth < 텍스트 너비 + 공백 너비텍스트 자체는 딱 들어가는데, 공백 한 칸이 붙으면 넘치는 미묘한 경계값. **(A)**는 넘친다고 판단하고, **(B)**는 안 넘친다고 판단하고, **(C)**에서 unprocessedText는 소비되지 않은 채로 while이 다시 시작된다.
수정 포인트는 두 가지였다.
isLineWidthAcceptable = true로 판단되어 텍스트가 들어갔더라도, 남은 공간이 다음 글자 한 자도 못 담을 정도로 빡빡하다면 거기서 줄을 바꿔버리는 것이다.
if (isLineWidthAcceptable) {
currentLineText = potentialLine;
// 남은 공간이 다음 글자 폭보다 작으면 여기서 줄바꿈
if (
maxLineWidth - calculatedWidth <
canvasContext.measureText(unprocessedText[0]).width
) {
canvasContext.fillText(currentLineText, startX, currentYPosition);
currentYPosition += lineSpacing;
currentLineText = '';
}
break;
}이렇게 하면 (A)와 (B) 사이의 비대칭 자체를 피해갈 수 있다.
공백 포함 여부와 상관없이, ’남은 공간이 충분한가?’ 라는 하나의 기준으로 판단하는 것이다.
fittingLength 최소값 보장(B)의 for 루프에서 charIndex = 1일 때 이미 넘치면 fittingLength = 0이 된다.
0글자를 slice하면 unprocessedText가 줄어들지 않으므로 역시 무한 루프다.
let fittingLength = charIndex - 1;
if (fittingLength <= 0) {
// 최소 1글자는 무조건 소비
fittingLength = 1;
}솔직히 위 두 가지 수정으로 충분하다고 생각했지만, 이미 한 번 터진 이상 믿을 수 없었다. 최후의 보루를 걸었다.
while (unprocessedText.length > 0) {
loopCount++;
if (loopCount > 100) {
_warn(`무한 루프 방지: '${unprocessedText}' 처리 중 100회 이상 반복됨`);
canvasContext.fillText(unprocessedText, startX, currentYPosition);
currentYPosition += lineSpacing;
break;
}
// ...
}만약 예상치 못한 다른 엣지 케이스가 있더라도, 100회 이상 반복되면 그냥 한 줄에 모조리 텍스트를 찍고 탈출하며 로그를 남긴다.
이 로그는 데이터독에서 잡을수 있고, 하얀 라벨지가 1m씩 출력되는 것 보다 나으리라.
수정은 했는데, 이걸 어떻게 검증할 것인가. 그리고 재발하지 않을 거라고 어떻게 확신할 것인가.
wrapText 함수는 CanvasRenderingContext2D를 인자로 받는다. 이 객체의 measureText()는 브라우저에서만 동작한다. Jest는 Node.js 환경이라 <canvas>가 없다.
하지만 우리가 검증하고 싶은 건 폰트가 정확히 몇 픽셀인가 가 아니라, 측정된 너비를 기반으로 줄바꿈 분기가 올바르게 동작하는가 다.
그래서 measureText를 근사치 공식으로 Mock 했다.
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;
});
/**
* canvasContext.measureText 는
* 글자 수 * (fontSize * 0.6) 로 가정합니다.
*/
measureTextMock.mockImplementation((text) => {
return { width: text.length * fontSize * 0.6 };
});완벽한 Mock은 아니다. 실제로는 한글과 영어의 너비가 다르고, 폰트마다 렌더링 결과가 다르다.
하지만 앞서 말한 것처럼 measureText의 반환값은 OS마다 다르다. Mac에서 통과하는 테스트가 Windows CI에서 실패할 수 있고, 그 반대도 마찬가지다.
오히려 일관된 근사치로 Mock하는 것이 테스트의 안정성 면에서 더 나은 선택 이라고 생각한다.
먼저 실제 인입된 텍스트(Windows 에서 발생한 텍스트)와, Mac OS에서 발생한 엣지 케이스 너비 텍스트를 넣었다.
// 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);
});한 글자를 버그 트리거로 특정하긴 어렵다. 그래서 1글자부터 200글자까지 점진적으로 늘려가며 어떤 길이에서도 무한 루프가 발생하지 않는지 매트릭스로 검증한다.
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();
});
});
네모닉 기능은 여러 폰트 사이즈(20px ~ 40px)를 설정할 수 있다.
폰트 사이즈가 바뀌면 한 줄에 들어가는 글자 수가 달라지고 경계값도 달라지므로, 모든 사이즈에 대해 위 테스트를 반복한다.
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('무한루프 미발생', () => { /* ... */ });
});
});
그리고 수정 전의 코드로 이 테스트 코드를 돌리면 실제로 무한 루프가 발생하여 테스트가 실패한다.

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

Canvas 위에서 텍스트를 다루는 건 한눈에 들어오지 않는다. CSS가 알아서 해주던 줄바꿈을 직접 구현해야 하고, 유지보수에서 피로도가 높은 편이다.
거기다 같은 코드라도 OS에 따라 결과가 달라질 수 있는 등의 변수 또한 만나봤다.
이번 건에서 얻은 교훈이 있다면
wrapText가 컴포넌트 안에 인라인으로 박혀 있었다면 테스트는 훨씬 어려웠을 것이다.