발음 오류를 문장 위에 그리기 — Canvas와 getBoundingBox로 좌표 맞추기

study·35 min read·2026-08-22(Updated 2026-08-22)

이 글은 ShadowEng 프로젝트에서 작성되었습니다.

목차

한 줄 요약

Compose Canvas와 TextLayoutResult.getBoundingBox로 발음 평가 결과(dragged/rushed/missed)를 문장 위에 화살표·곡선·하이라이트로 그린 방법을 정리하고, 이 렌더링이 실제로는 서버 쪽 평가 스텁 때문에 끝까지 이어지지 않는다는 한계도 함께 짚는다.

왜 (배경/문제 상황)

발음 평가 결과를 “72점”처럼 숫자로만 보여주면 사용자는 정확히 어디를 고쳐야 하는지 알기 어렵다. ShadowEng은 이 문제를 “단어별로 무엇이 잘못됐는지 문장 위에 직접 그려서 보여주기”로 풀었다. 그런데 Compose Text는 글자 하나하나가 화면 어디에 그려졌는지를 호출부에 그냥 알려주지 않는다 — 이 좌표를 직접 구해야 그 위에 화살표나 곡선을 겹쳐 그릴 수 있다.

본문

1. 평가 결과 → 시각 요소 매핑

EvaluationAnnotationMapper.kt가 서버가 내려주는 단어별 상태를 시각 요소 타입으로 변환한다.

상태시각 요소표현
dragged (길게 끌었다)CURVE_LONG완만한 물결 곡선
rushed (급하게 지나갔다)CURVE_SHORT짧은 V자 곡선
missed (아예 빠졌다)HIGHLIGHT배경 하이라이트
good (문제 없음)없음표시하지 않음

곡선·화살표(CURVE_LONG/CURVE_SHORT/ARROW_UP/ARROW_DOWN)는 Canvas에 Path로 직접 그리고, HIGHLIGHT는 Canvas가 아니라 SpanStyle의 배경색으로 처리한다. 같은 “표시”라도 구현 계층이 다르기 때문이다 — 하이라이트는 텍스트 배경일 뿐이라 Text의 표준 스타일링 기능으로 충분하지만, 곡선·화살표는 문장 흐름 위에 임의의 곡선을 그려야 해서 텍스트 API로는 표현할 수 없고 별도 캔버스가 필요하다.

2. Text는 글자 좌표를 모른다 — TextLayoutResult가 다리를 놓는다

Text composable은 “무엇을 그릴지”는 알지만, 그 결과가 화면 어디에 그려졌는지는 기본적으로 호출부에 넘겨주지 않는다. onTextLayout 콜백으로 받는 TextLayoutResult가 이 간극을 메운다.

var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }

Text(
    text = sentence,
    onTextLayout = { layoutResult = it },
)

TextLayoutResult.getBoundingBox(charIndex)를 부르면 그 문자 인덱스가 텍스트 레이아웃의 로컬 좌표계에서 차지하는 사각형(Rect, 픽셀 단위)을 돌려준다. Text와 Canvas가 같은 위치에서 시작하는 경우(이 글의 Box 겹침 구조처럼)라면 이 좌표를 그대로 써도 되지만, 서로 원점이 다르면 LayoutCoordinates로 좌표계를 변환해야 한다. 어노테이션의 시작 글자와 끝 글자 각각에 이 함수를 불러 두 사각형을 얻으면, 그 사이에 곡선이나 화살표를 그릴 좌표 기준이 생긴다.

val startRect = layoutResult.getBoundingBox(annotation.startIndex)
val endRect = layoutResult.getBoundingBox(annotation.endIndex - 1)
val mid = (startRect.top + startRect.bottom) / 2
val centerX = (startRect.left + endRect.right) / 2

3. 그 위에 Canvas로 겹쳐 그리기

Text 위에 같은 크기의 Canvas를 겹쳐두고, 위에서 구한 좌표로 Path를 그린다.

Box {
    Text(text = sentence, onTextLayout = { layoutResult = it })
    Canvas(modifier = Modifier.matchParentSize()) {
        annotations.forEach { ann ->
            val startRect = layoutResult?.getBoundingBox(ann.startIndex) ?: return@forEach
            val endRect = layoutResult?.getBoundingBox(ann.endIndex - 1) ?: return@forEach
            when (ann.type) {
                AnnotationType.CURVE_LONG -> drawLongCurve(startRect, endRect)
                AnnotationType.CURVE_SHORT -> drawShortCurve(startRect, endRect)
                AnnotationType.ARROW_UP -> drawArrow(startRect, endRect, pointingUp = true)
                AnnotationType.ARROW_DOWN -> drawArrow(startRect, endRect, pointingUp = false)
                AnnotationType.HIGHLIGHT -> Unit // SpanStyle에서 처리 (Canvas 아님)
            }
        }
    }
}

Canvas는 Text가 실제로 어떤 글자를 그렸는지 전혀 모른다 — TextLayoutResult가 넘겨주는 좌표만 갖고 그 위에 도형을 그릴 뿐이다. 이 둘을 잇는 다리가 getBoundingBox(charIndex)다.

4. 직접 눌러보기 — 좌표를 구하는 과정 그대로 재현

아래 데모는 위 2·3번 과정에서 좌표를 구하는 흐름(시작/끝 인덱스 → 두 사각형 → 중점·중심 X좌표 → Path)을 웹 브라우저에서 그대로 흉내 낸 것이다. 실제로는 Compose TextLayoutResult.getBoundingBox()를 호출하는 게 아니라, CanvasRenderingContext2D.measureText()로 글자별 사각형을 직접 계산해 boxes[startIndex]/boxes[endIndex - 1]로 근사한다 — 글꼴 셰이핑·줄바꿈·유니코드 폭 처리 방식이 달라서 실제 Compose 결과와 정확히 같은 수치가 나오지는 않지만, “좌표를 구해서 그 위에 그린다”는 흐름 자체는 동일하다.

// 재생하면 좌표를 구하는 순서(웹 Canvas 근사)가 여기 표시됩니다

5. 결함: 같은 단어가 두 번 나오면

어노테이션의 시작 위치는 sentence.indexOf(word)로 찾는데, 이 함수는 문장에서 첫 번째로 일치하는 위치만 돌려준다. 같은 단어가 문장에 두 번 이상 나오면, 서버가 두 번째 등장 위치를 가리켰더라도 화면에는 첫 번째 등장 위치에 곡선이 그려진다. Canvas 렌더링 로직 자체는 좌표만 정확하면 문제없이 동작하지만, 그 좌표를 찾는 단계에서 이미 잘못된 인덱스를 넘겨받으면 엉뚱한 곳에 그려진다 — 렌더링과 좌표 탐색은 별개 문제라는 걸 보여주는 사례다.

예제

세 요소를 하나로 합치면, 어노테이션 하나를 그리는 전체 흐름은 다음과 같다.

@Composable
fun AnnotatedSentenceView(sentence: String, annotations: List<Annotation>) {
    var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }

    Box {
        Text(text = sentence, onTextLayout = { layoutResult = it })

        Canvas(modifier = Modifier.matchParentSize()) {
            val layout = layoutResult ?: return@Canvas
            annotations.forEach { ann ->
                val startRect = layout.getBoundingBox(ann.startIndex)
                val endRect = layout.getBoundingBox(ann.endIndex - 1)
                val mid = (startRect.top + startRect.bottom) / 2

                when (ann.type) {
                    AnnotationType.CURVE_LONG -> drawPath(
                        path = buildWaveCurve(startRect.left, endRect.right, mid, amplitude = 6f),
                        color = Color(0xFFFFB800),
                        style = Stroke(width = 3f),
                    )
                    AnnotationType.CURVE_SHORT -> drawPath(
                        path = buildVCurve(startRect.left, endRect.right, mid),
                        color = Color(0xFF1565C0),
                        style = Stroke(width = 3f),
                    )
                    AnnotationType.ARROW_UP, AnnotationType.ARROW_DOWN -> drawPath(
                        // 시작/끝 사각형 사이 중심에 위·아래를 가리키는 화살표 Path를 그린다 (곡선과 같은 좌표 기준 사용)
                        path = buildArrowPath(startRect, endRect, mid, pointingUp = ann.type == AnnotationType.ARROW_UP),
                        color = Color(0xFF2EB872),
                        style = Stroke(width = 3f),
                    )
                    AnnotationType.HIGHLIGHT -> Unit // Canvas가 아니라 SpanStyle에서 처리
                }
            }
        }
    }
}

주의사항

  • 이 Canvas 시각화는 클라이언트 쪽에서는 완성돼 있지만, 이 스냅샷 기준으로 서버의 발음 평가 로직은 점수가 하드코딩된 스텁이었다(EvaluationService.kt — // TODO: AI 분석 연동 시 실제 점수로 교체). “화면에 정확히 그려지는가”와 “그 평가 결과 자체가 진짜인가”는 별개 문제이고, 이 글은 전자만 다룬다.
  • sentence.indexOf(word)는 첫 번째 일치 위치만 찾으므로, 같은 단어가 문장에 반복되면 좌표가 어긋날 수 있다. 정확히 하려면 서버가 인덱스를 함께 내려주거나, 이전에 이미 매칭된 위치 이후부터 다시 탐색해야 한다.
  • 하이라이트(SpanStyle)와 곡선·화살표(Canvas)는 같은 “시각 요소”로 뭉뚱그리기 쉽지만 구현 계층이 다르다. 텍스트 스타일링으로 충분한 것까지 Canvas로 옮기면 코드만 복잡해진다.
  • getBoundingBox() 자체는 Rect를 반환하며 null이 아니다. 다만 onTextLayout이 아직 한 번도 호출되지 않은 첫 프레임에는 layoutResult 변수 자체가 null이므로, getBoundingBox를 부르기 전에 layoutResult를 null 체크해야 한다.

참고자료