병목은 OCR이 아니었다: 요청의 53%를 쓰던 폰트 추론 2.2배 줄이기
- 이미지에서 글자를 뽑아 편집 가능한 텍스트로 만드는 서비스에서, 병목은 OCR이 아니었다. 폰트 스타일 추론이 요청의 53%(16,369ms 중 8,676ms)로 OCR 추론(6,468ms)보다 컸다.
- 이 단계는 규칙 기반이 아니라 후보 폰트를 실제로 렌더해서 원본 픽셀과 대조하는 완전탐색이다. 비용은 전부 "크기" 축에 있었고 "폰트 후보" 축은 평평했다 — 측정 전에는 반대로 짐작했다.
- 세 단계로 줄였다: ① 크기 탐색을 성긴 훑기 + 국소 정밀화로(2.65배) ② 추론 시점을 감지 → 사용자가 적용하는 순간으로 이동(감지에서 제거) ③ 마스크 연산을 배열 연산화(4.13배).
- 결과: 감지 요청 16,369ms → 7,333ms(2.23배). 세 단계 모두 154개 레이어에서 출력이 완전탐색과 동일함을 확인하고 채택했다.
1.왜 폰트 추론이 탐색인가
원본 이미지에는 폰트 정보가 없다. 평평한 픽셀뿐이다. OCR은 무슨 글자인지는 알려주지만 무슨 폰트로 몇 pt인지는 알려주지 않는다. 그런데 편집 가능한 텍스트 레이어가 원본과 비슷하게 보이려면 그 값을 역추정해야 한다.
현재 방식은 그려서 맞춰보는 것이다.
측정해 보니 픽스처 하나에 렌더+채점 719회, 레이어당 71.9회였다. 회당 3.6ms. 개별 연산이 느린 게 아니라 탐색 공간이 큰 것이다.
2.어느 축이 비싼가 — 짐작이 틀렸다
줄일 축은 둘이다. 폰트 후보 수와 크기 후보 수. 후보 목록이 10개니 거기부터 줄이면 될 것 같았다. 각 축을 따로 바꿔가며 재 봤다.
| 폰트 후보 수 | 렌더 호출 | 배속 |
|---|---|---|
| 10 (기존) | 719 | 1.01x |
| 5 | 649 | 0.99x |
| 3 | 621 | 1.01x |
| 1 | 207 | 2.63x |
10개를 3개로 줄여도 이득이 0이다. 코드를 보니 이유가 있었다 — 한글 텍스트에는 한글을 지원하는 폰트만 후보로 통과시키는 필터가 이미 있고, 그게 사실상 3개로 제한하고 있었다. 뒤쪽 7개는 애초에 거의 돌지 않는다.
| 크기 탐색 간격 | 렌더 호출 | 배속 |
|---|---|---|
| 1 (기존, 전수) | 719 | 1.00x |
| 2 | 386 | 1.74x |
| 4 | 221 | 2.91x |
| 8 | 135 | 3.89x |
비용은 거의 전부 크기 축에 있었다. 짐작대로 폰트 후보를 깎았다면 시간만 쓰고 아무것도 못 얻었을 것이다.
3.성긴 훑기 + 국소 정밀화 (이분탐색이 아니다)
크기 간격을 그냥 늘리면 빨라지지만 정밀도가 떨어진다 — 최종적으로 고르는 크기 자체가 성겨진다. 그래서 두 단계로 나눴다.
① 간격 4로 범위를 훑어 승자를 찾는다 coarse = sizes[::4] best = best_render_match(coarse, evaluate, candidate) ② 승자 주변 ±(간격-1)만 정밀 탐색 더 먼 크기는 더 가까운 성긴 표본이 있었고 그게 졌으므로 볼 필요가 없다 centre = sizes.index(best["fontSize"]) window = sizes[centre - 3 : centre + 4]
이분탐색은 정렬된 데이터에서 매번 후보를 절반으로 버리는 알고리즘이고, 매 단계에서 "답이 어느 쪽에 있는지" 확정할 수 있어야 성립한다. 여기서는 그 판정이 불가능하다 — 점수는 크기에 대해 단조롭지 않고, 어느 방향이 더 나은지 한 번의 비교로 알 수 없다.
그래서 쓴 건 격자 표본 추출 후 국소 정밀화다. 성긴 격자로 전 범위를 보고, 이긴 지점의 이웃만 촘촘히 본다. 절반씩 버리지 않고 전 범위를 한 번은 훑는다는 점이 이분탐색과 결정적으로 다르다.
정확성은 점수가 크기에 대해 충분히 매끄러운가에 달려 있다. 성긴 승자의 이웃 안에 전수탐색 승자가 들어 있어야 한다. 이건 논증할 게 아니라 확인할 문제다.
4.동등성을 먼저 고정하고 구현했다
빨라졌는데 결과가 달라지면 그건 개선이 아니다. 그래서 구현보다 테스트를 먼저 썼다.
def test_coarse_search_finds_the_exhaustive_optimum_at_every_offset(): # 격자 위에 딱 떨어지는 최적값만 테스트하면 # 정밀화가 아예 없어도 통과한다. 41개 위치를 전부 본다. sizes = list(range(10, 51)) for optimum in sizes: evaluate = evaluator(v_curve(sizes, optimum)) fast = search_candidate_font_size(sizes, evaluate, CANDIDATE) exhaustive = best_render_match(sizes, evaluate, CANDIDATE) assert fast["fontSize"] == exhaustive["fontSize"] == optimum
합성 점수 곡선으로 통과시킨 뒤, 실제 이미지 20장·레이어 154개에서 전수탐색과 출력을 통째로 비교했다. 폰트·굵기·크기·자간 배율·점수까지 전부.
총 레이어 154개 중 상이 0개 style 합계 223,930ms ──▶ 84,654ms (2.65x)
5.매력적이지만 기각한 두 가지
더 줄일 방법이 있어 보였고, 둘 다 측정이 기각했다.
① 폰트 metric으로 크기를 계산하기. 렌더 높이는 폰트 크기에 거의 비례하니, 기준 크기로 한 번 재서 비례식으로 예측하면 탐색이 필요 없다는 발상이다. 예측 정확도를 재 봤다.
중앙값 2.0 p90 9.0 p99 340.8 최대 400.8 창 ±12 이면 93.1% 포함 ← 현재 탐색 범위 중앙값이 24개
중앙값 오차는 2로 훌륭한데 꼬리가 파괴적이다. 93%를 담으려면 ±12 창이 필요하고 그건 25개 — 현재 전 범위와 같다. 절감이 0이고 7%는 여전히 틀린다. 점수가 높이만 보는 게 아니라 획 두께·채움률도 함께 보기 때문이다.
② 탐색 간격을 범위 크기에 비례시키기. 큰 글자일수록 탐색 범위가 넓어지니 간격도 같이 키우면 비용이 평평해진다. 실제로 3.56배가 나왔고 최악 케이스는 38초에서 14초로 줄었다. 그런데 154개 중 6개에서 다른 크기를 골랐다.
layer 5: 전수 fontSize=57 score=0.3064 고속 fontSize=53 score=0.3349 ← 더 나쁨 layer 7: 전수 fontSize=28 score=0.7003 고속 fontSize=25 score=0.7359 ← 더 나쁨
폰트와 굵기는 항상 같고 크기만 어긋나는데, 점수가 예외 없이 더 나빴다. 동점 처리 차이가 아니라 실제로 못한 답을 고른 것이다. 3.9%의 레이어에서 조용히 품질을 내주고 속도를 얻는 거래라 받지 않았다.
6.가장 큰 절감은 탐색이 아니라 계산 시점에서 나왔다
레이어별 비용을 뜯어보니 다른 게 보였다.
666 / 457 / 434 / 206 / 197 / 190 / 173 / 127 / 107 / 29 ms 합계 2,585ms 사용자가 10개 중 1개만 적용하면 1,919ms(74%)가 버려짐
제품 흐름은 이렇다 — 전체 글자를 감지해 보여주고, 사용자가 고른 영역만 배경에서 지운 뒤 편집 가능한 텍스트를 올린다. 그런데 감지 단계가 모든 영역의 폰트를 추론하고 있었다. 대부분 쓰이지 않는다.
코드에서 전환점은 인자 하나였다. 스타일 생성 함수는 원본 이미지를 받으면 렌더 매칭을 돌리고, 안 받으면 경계 기반의 값싼 스타일만 만든다. 감지에서는 후자를, 사용자가 영역을 적용하는 호출에서는 전자를 쓰면 된다.
스타일 추론은 배경을 지우기 전에 해야 한다. 인페인팅이 대조 대상 픽셀을 없애기 때문이다. 순서가 뒤집혀도 응답은 정상으로 보이고 폰트만 기본값이 된다 — 아무도 알려주지 않는 종류의 버그라, 그 순서를 지키는지 확인하는 테스트를 따로 뒀다.
결과: 감지 요청의 스타일 단계가 2,585ms → 5ms, 렌더 호출 719회 → 0회.
7.마지막 4배는 자료구조에서 나왔다
큰 글자 이미지 하나가 유독 비쌌다. 다른 픽스처의 50배. 원인을 재 봤다.
| 워터마크 이미지 | 보통 이미지 | 배수 | |
|---|---|---|---|
| 최대 잉크 높이 | 350px | 69px | 5x |
| 회당 렌더 픽셀 | 1.00M | 0.07M | 14x |
| 총 렌더 픽셀 | 158.7M | 7.4M | 21x |
마스크가 전부 중첩 파이썬 리스트로 오가고 있었다. 1.58억 픽셀이 파이썬 객체로 만들어지고 순회된다. 배열로 바꾸면 될 일인데, 한 곳이 걸렸다 — 획 두께를 재는 거리 변환이다.
# 왼쪽에서 오른쪽으로 순차 의존이 있어 그대로는 벡터화가 안 된다 d[x] = min(d[x], d[x-1] + 1) # 전개하면 누적 최소로 바뀐다 # d[x] = min over k ≤ x of (d[k] + (x - k)) # = min(d[k] - k) + x row = np.minimum.accumulate(row - columns) + columns
근사가 아니라 같은 값이다. 세로 방향 항은 이전 행이 이미 확정돼 있어 벡터화되고,
가로 방향은 위 항등식으로 처리된다. 파이썬 반복이 W×H에서
H로 줄었다.
첫 구현에서 얇은 선의 획 두께가 1.0 대신 6.0으로 나왔다. 원본은 마스크 둘레에 0으로 채운 테두리를 두는데, 그 0이 거리의 기준점 역할을 한다. 테두리를 없애면 가장자리에 닿은 획이 잴 대상을 잃는다. 기존 테스트가 잡았고, 이후 스칼라 참조 구현과 8가지 형태로 대조하는 테스트를 추가했다 — 이 치환이 정확하다는 게 자명하지 않기 때문이다.
8.결과와 트레이드오프
| 단계 | 효과 | 출력 변화 |
|---|---|---|
| 크기 탐색 성긴 훑기 + 정밀화 | style 2.65x | 154/154 동일 |
| 추론 시점 이동 | 감지에서 2,585ms → 5ms | 동일 (계산 위치만 이동) |
| 마스크 배열 연산화 | style 4.13x, 최악 케이스 6.2x | 154/154 동일 |
| 감지 요청 전체 (x86 실측) | 16,369ms → 7,333ms | |
정직하게 적을 비용이 있다. 시점 이동은 폰트 추론을 없앤 게 아니라 옮긴 것이라, 사용자가 영역을 적용할 때마다 그만큼이 붙는다. 이동 직후 실측으로 영역당 804ms였고, 손익분기는 적용 영역 8개 근처였다 — 전체를 적용하는 사용자에게는 손해였다는 뜻이다. 배열 연산화가 그 값을 253ms로 줄이면서 손익분기가 36개로 밀렸고, 한 요청의 감지 레이어가 보통 3~14개라 사실상 항상 이득이 됐다.
남은 비용도 있다. 큰 글자 이미지는 여전히 보통의 5~8배다. 50배에서 좁혀졌을 뿐 구조는 그대로다 — 탐색 범위와 렌더 크기가 둘 다 글자 크기에 비례하기 때문이다.
9.정리
제안한 최적화 네 개 중 세 개를 측정이 기각했다. 폰트 후보 축소는 이득이 0이었고, metric 예측은 꼬리가 컸고, 범위 비례 간격은 빨랐지만 답이 나빠졌다. 살아남은 건 처음 넣은 성긴 훑기 하나, 그리고 계산 시점을 옮긴 것이었다.
세 단계에 공통으로 적용한 기준은 하나다 — 바꾸기 전에 전수탐색 결과를 기준으로 잡고, 바꾼 뒤 출력이 같은지 확인한다. 이게 없으면 "3.56배 빨라졌다"와 "3.9%의 레이어에서 폰트 크기가 틀어졌다"를 구분할 방법이 없다. 그리고 후자는 런타임에 아무 신호도 남기지 않는다.
'프로그래밍' 카테고리의 다른 글
| GitHub Actions 러너로 VPS 사양 재는 법: 36% 편차와 측정 함정 (0) | 2026.08.09 |
|---|---|
| x86에 배포하고 나서야 보인 결함 둘: 추론 런타임과 Docker WORKDIR (0) | 2026.08.09 |
댓글