본문 바로가기
프로그래밍

병목은 OCR이 아니었다: 요청의 53%를 쓰던 폰트 추론 2.2배 줄이기

by me_in_sk 2026. 8. 9.
반응형
이미지 편집 엔진

병목은 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인지는 알려주지 않는다. 그런데 편집 가능한 텍스트 레이어가 원본과 비슷하게 보이려면 그 값을 역추정해야 한다.

현재 방식은 그려서 맞춰보는 것이다.

각 텍스트 영역마다: 각 폰트 후보마다 (10개) 각 후보 크기마다 (보통 20~40개) ──▶ 그 폰트·크기로 텍스트를 비트맵에 실제로 렌더 ──▶ 원본 영역과 픽셀 단위로 유사도 채점 가장 잘 맞는 조합 채택

측정해 보니 픽스처 하나에 렌더+채점 719회, 레이어당 71.9회였다. 회당 3.6ms. 개별 연산이 느린 게 아니라 탐색 공간이 큰 것이다.

2.어느 축이 비싼가 — 짐작이 틀렸다

줄일 축은 둘이다. 폰트 후보 수와 크기 후보 수. 후보 목록이 10개니 거기부터 줄이면 될 것 같았다. 각 축을 따로 바꿔가며 재 봤다.

폰트 후보 수렌더 호출배속
10 (기존)7191.01x
56490.99x
36211.01x
12072.63x

10개를 3개로 줄여도 이득이 0이다. 코드를 보니 이유가 있었다 — 한글 텍스트에는 한글을 지원하는 폰트만 후보로 통과시키는 필터가 이미 있고, 그게 사실상 3개로 제한하고 있었다. 뒤쪽 7개는 애초에 거의 돌지 않는다.

크기 탐색 간격렌더 호출배속
1 (기존, 전수)7191.00x
23861.74x
42212.91x
81353.89x

비용은 거의 전부 크기 축에 있었다. 짐작대로 폰트 후보를 깎았다면 시간만 쓰고 아무것도 못 얻었을 것이다.

3.성긴 훑기 + 국소 정밀화 (이분탐색이 아니다)

크기 간격을 그냥 늘리면 빨라지지만 정밀도가 떨어진다 — 최종적으로 고르는 크기 자체가 성겨진다. 그래서 두 단계로 나눴다.

크기 탐색python
① 간격 4로 범위를 훑어 승자를 찾는다
coarse = sizes[::4]
best   = best_render_match(coarse, evaluate, candidate)

② 승자 주변 ±(간격-1)만 정밀 탐색
   더 먼 크기는 더 가까운 성긴 표본이 있었고 그게 졌으므로 볼 필요가 없다
centre = sizes.index(best["fontSize"])
window = sizes[centre - 3 : centre + 4]
후보 크기 41개 · 전수탐색이라면 41회 렌더 ① 간격 4로 전 범위 훑기 — 11회 ② 이긴 지점 주변만 정밀화 — 6회 합계 17회 — 전 범위를 한 번은 훑는다. 절반씩 버리지 않는다.
이건 이분탐색이 아니다

이분탐색은 정렬된 데이터에서 매번 후보를 절반으로 버리는 알고리즘이고, 매 단계에서 "답이 어느 쪽에 있는지" 확정할 수 있어야 성립한다. 여기서는 그 판정이 불가능하다 — 점수는 크기에 대해 단조롭지 않고, 어느 방향이 더 나은지 한 번의 비교로 알 수 없다.

그래서 쓴 건 격자 표본 추출 후 국소 정밀화다. 성긴 격자로 전 범위를 보고, 이긴 지점의 이웃만 촘촘히 본다. 절반씩 버리지 않고 전 범위를 한 번은 훑는다는 점이 이분탐색과 결정적으로 다르다.

정확성은 점수가 크기에 대해 충분히 매끄러운가에 달려 있다. 성긴 승자의 이웃 안에 전수탐색 승자가 들어 있어야 한다. 이건 논증할 게 아니라 확인할 문제다.

4.동등성을 먼저 고정하고 구현했다

빨라졌는데 결과가 달라지면 그건 개선이 아니다. 그래서 구현보다 테스트를 먼저 썼다.

탐색 동등성 테스트python
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개에서 전수탐색과 출력을 통째로 비교했다. 폰트·굵기·크기·자간 배율·점수까지 전부.

픽스처 전수 비교 결과text
총 레이어 154개 중 상이 0개
style 합계 223,930ms ──▶ 84,654ms (2.65x)

5.매력적이지만 기각한 두 가지

더 줄일 방법이 있어 보였고, 둘 다 측정이 기각했다.

① 폰트 metric으로 크기를 계산하기. 렌더 높이는 폰트 크기에 거의 비례하니, 기준 크기로 한 번 재서 비례식으로 예측하면 탐색이 필요 없다는 발상이다. 예측 정확도를 재 봤다.

예측값과 전수탐색 최적값의 차이 (879 샘플)text
중앙값 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개에서 다른 크기를 골랐다.

기각 근거 — 차이의 방향text
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.가장 큰 절감은 탐색이 아니라 계산 시점에서 나왔다

레이어별 비용을 뜯어보니 다른 게 보였다.

레이어별 스타일 추론 시간text
666 / 457 / 434 / 206 / 197 / 190 / 173 / 127 / 107 / 29 ms
합계 2,585ms
사용자가 10개 중 1개만 적용하면 1,919ms(74%)가 버려짐

제품 흐름은 이렇다 — 전체 글자를 감지해 보여주고, 사용자가 고른 영역만 배경에서 지운 뒤 편집 가능한 텍스트를 올린다. 그런데 감지 단계가 모든 영역의 폰트를 추론하고 있었다. 대부분 쓰이지 않는다.

코드에서 전환점은 인자 하나였다. 스타일 생성 함수는 원본 이미지를 받으면 렌더 매칭을 돌리고, 안 받으면 경계 기반의 값싼 스타일만 만든다. 감지에서는 후자를, 사용자가 영역을 적용하는 호출에서는 전자를 쓰면 된다.

이전: 감지 ──▶ 전체 영역 폰트 추론 ──▶ 사용자가 1~2개 선택 (나머지 버림) 이후: 감지 ──▶ 경계 기반 값싼 스타일 사용자 선택 ──▶ 배경 정리 + 그 영역만 폰트 추론
이전 감지 단계 10개 전부 추론 2,585ms 74% 버려짐 이후 감지 단계 경계 기반만 5ms 적용 시 253ms ▲ 사용자가 고른 영역
위: 감지가 10개 전부를 추론한다. 아래: 사용자가 고른 한 개만 추론한다. 나머지의 계산은 이전에는 그대로 버려졌다.
순서가 뒤집히면 조용히 망가진다

스타일 추론은 배경을 지우기 전에 해야 한다. 인페인팅이 대조 대상 픽셀을 없애기 때문이다. 순서가 뒤집혀도 응답은 정상으로 보이고 폰트만 기본값이 된다 — 아무도 알려주지 않는 종류의 버그라, 그 순서를 지키는지 확인하는 테스트를 따로 뒀다.

결과: 감지 요청의 스타일 단계가 2,585ms → 5ms, 렌더 호출 719회 → 0회.

7.마지막 4배는 자료구조에서 나왔다

큰 글자 이미지 하나가 유독 비쌌다. 다른 픽스처의 50배. 원인을 재 봤다.

워터마크 이미지보통 이미지배수
최대 잉크 높이350px69px5x
회당 렌더 픽셀1.00M0.07M14x
총 렌더 픽셀158.7M7.4M21x

마스크가 전부 중첩 파이썬 리스트로 오가고 있었다. 1.58억 픽셀이 파이썬 객체로 만들어지고 순회된다. 배열로 바꾸면 될 일인데, 한 곳이 걸렸다 — 획 두께를 재는 거리 변환이다.

체임퍼 거리 변환 — 행 내부 의존python
# 왼쪽에서 오른쪽으로 순차 의존이 있어 그대로는 벡터화가 안 된다
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로 줄었다.

패딩을 빼먹어 값이 6배 틀어졌다

첫 구현에서 얇은 선의 획 두께가 1.0 대신 6.0으로 나왔다. 원본은 마스크 둘레에 0으로 채운 테두리를 두는데, 그 0이 거리의 기준점 역할을 한다. 테두리를 없애면 가장자리에 닿은 획이 잴 대상을 잃는다. 기존 테스트가 잡았고, 이후 스칼라 참조 구현과 8가지 형태로 대조하는 테스트를 추가했다 — 이 치환이 정확하다는 게 자명하지 않기 때문이다.

8.결과와 트레이드오프

단계효과출력 변화
크기 탐색 성긴 훑기 + 정밀화style 2.65x154/154 동일
추론 시점 이동감지에서 2,585ms → 5ms동일 (계산 위치만 이동)
마스크 배열 연산화style 4.13x, 최악 케이스 6.2x154/154 동일
감지 요청 전체 (x86 실측)16,369ms → 7,333ms

정직하게 적을 비용이 있다. 시점 이동은 폰트 추론을 없앤 게 아니라 옮긴 것이라, 사용자가 영역을 적용할 때마다 그만큼이 붙는다. 이동 직후 실측으로 영역당 804ms였고, 손익분기는 적용 영역 8개 근처였다 — 전체를 적용하는 사용자에게는 손해였다는 뜻이다. 배열 연산화가 그 값을 253ms로 줄이면서 손익분기가 36개로 밀렸고, 한 요청의 감지 레이어가 보통 3~14개라 사실상 항상 이득이 됐다.

남은 비용도 있다. 큰 글자 이미지는 여전히 보통의 5~8배다. 50배에서 좁혀졌을 뿐 구조는 그대로다 — 탐색 범위와 렌더 크기가 둘 다 글자 크기에 비례하기 때문이다.

9.정리

제안한 최적화 네 개 중 세 개를 측정이 기각했다. 폰트 후보 축소는 이득이 0이었고, metric 예측은 꼬리가 컸고, 범위 비례 간격은 빨랐지만 답이 나빠졌다. 살아남은 건 처음 넣은 성긴 훑기 하나, 그리고 계산 시점을 옮긴 것이었다.

세 단계에 공통으로 적용한 기준은 하나다 — 바꾸기 전에 전수탐색 결과를 기준으로 잡고, 바꾼 뒤 출력이 같은지 확인한다. 이게 없으면 "3.56배 빨라졌다"와 "3.9%의 레이어에서 폰트 크기가 틀어졌다"를 구분할 방법이 없다. 그리고 후자는 런타임에 아무 신호도 남기지 않는다.

이미지 편집 엔진 · 성능 최적화

반응형

댓글