테크

2026. 06. 11

에브리타임 모임 일정 투표 UI 구현기: 그리드 좌표계 분리와 자동 스크롤

Compose Canvas로 구현한 드래그 선택 인터랙션

에브리타임 모임 일정 투표 UI 구현기: 그리드 좌표계 분리와 자동 스크롤에브리타임 모임 일정 투표 UI 구현기: 그리드 좌표계 분리와 자동 스크롤

대학생활에서 팀 프로젝트나 동아리 모임 등의 그룹 활동이 빠질 수 없습니다. 이때마다 꼭 나오는 질문이 있습니다. "다들 언제 시간 되세요?"라는 질문으로 시작되는 일정 조율입니다. 에브리타임은 대학생들의 이런 번거로움을 해결하기 위해 지난 3월 새 학기 시즌에 맞춰 '모임 일정 투표'라는 새로운 기능을 선보였습니다.


모임 일정 투표 기능은 여럿이 함께하는 일정을 손쉽게 조율할 수 있도록 앱 내 캘린더에서 투표를 생성하고, 링크를 통해 참가자들과 간편하게 공유할 수 있는 것이 특징입니다. 투표가 진행되면 참가 가능한 인원이 많은 시간대일수록 화면상에 음영이 진하게 표기되기 때문에, 최적의 모임 시간을 한눈에 파악할 수 있습니다. 또한 최종 일정이 확정되면 모든 참가자의 에브리타임 캘린더에 일정이 자동으로 등록되어, 일정 조율부터 캘린더 연동까지 편리하게 지원합니다.


이 기능의 핵심인 타임슬롯 보드(TimeSlotBoard)를 만들면서 마주친 구현 과제와 해결 과정을 정리해 보겠습니다.



두 가지 모드로 동작하는 타임슬롯 보드

타임슬롯 보드는 날짜와 시간 그리드 위에서 가능한 시간대를 드래그로 선택하거나, 참여자들이 선택한 시간대를 확인하는 영역입니다. 이 컴포넌트는 투표 현황을 볼 수 있는 결과 조회(ResultView)와 본인 일정을 등록하고, 최종 일정을 선택하기 위한 투표 편집(SelectionEdit) 두 가지 모드로 동작합니다.



완성된 동작은 위 데모처럼 겉보기에는 단순해 보입니다. 하지만 실제 구현에서는 여러 조건을 함께 고려해야 했습니다. 최대 1,344개에 달하는 셀을 안정적으로 그리면서, 가변 헤더 사이에서 픽셀 단위의 터치 좌표를 추적하고, 화면 끝에 닿았을 때 자동 스크롤까지 자연스럽게 이어져야 했기 때문입니다.


이 글에서는 그 과정에서 마주친 두 가지 핵심 문제를 차례로 다룹니다. 먼저 Canvas 좌표를 셀 데이터로 변환하는 방식, 그리고 복잡한 레이아웃에서 뷰포트(Viewport) 경계를 계산해 자동 스크롤로 연결하는 과정입니다.



1,344개 셀을 끊김 없이 그리기 위한 설계

타임슬롯 보드 레이아웃은 15분 단위의 시간 슬롯(하루 최대 96개)과 날짜(최대 14일)를 조합하여, 최대 1,344개의 셀을 렌더링합니다.


선택 상태에 따라 많은 셀의 배경과 경계가 한꺼번에 갱신되고, 드래그 중에도 빠르게 다시 그려져야 했습니다. 만약 셀 하나하나를 개별 Composable로 분리했다면, 잦은 상태 변경으로 대규모 리컴포지션(Recomposition)이 발생해 화면이 끊기는 현상이 발생했을 것입니다. 그래서 하나의 Canvas 위에 전체 그리드를 직접 그리는 방식을 선택했지만, Canvas 위에는 또 다른 레이아웃 제약이 따라왔습니다.



핵심 요구사항은 드래그가 화면 끝에 닿았을 때 자동으로 스크롤이 되어야 한다는 점입니다. 화면에 보이지 않는 영역의 셀까지 제스처 한 번으로 선택할 수 있어야 하기 때문입니다.


Canvas로 성능을 잡은 것까지는 좋았지만, 그 위에서 제스처 좌표를 처리하고 자동 스크롤을 구현하는 것은 별개의 문제였습니다.



문제① 좌표 변환의 함정

타임슬롯 보드에서 셀을 선택하는 제스처의 기본 구조는 '롱프레스(Long Press) 후 드래그'입니다. Canvas에 적용하는 Modifier 확장 함수로 로직을 분리했고, 제스처 감지에는 Compose의 detectDragGesturesAfterLongPress를 사용했습니다.


private fun Modifier.dragGestureModifier(
    // ...
): Modifier = this.pointerInput(/* keys */) {
    detectDragGesturesAfterLongPress(
        onDragStart = { offset ->
            performHapticFeedback()
            val (dateIndex, timeIndex) = calculateCellIndex(
                x = offset.x, y = offset.y,
                cellWidthPx = cellWidthPx, cellHeightPx = cellHeightPx,
                datesSize = datesSize, timeSlotsSize = timeSlotsSize
            )
            dragState.handleDragStart(dateIndex, timeIndex)
        },
        onDrag = { change, _ ->
            val (dateIndex, timeIndex) = calculateCellIndex(
                x = change.position.x, y = change.position.y,
                // ...
            )
            dragState.handleDragMove(dateIndex, timeIndex)
            // ...
        },
        onDragEnd = {
            dragState.clearAutoScroll()
            dragState.resetDragState()
        }
    )
}


롱프레스가 감지되면 진동 피드백을 호출해 사용자에게 드래그가 시작되었음을 명확히 인지시킵니다. 이후 Canvas가 제공하는 로컬 좌표(전체 그리드 기준 절대 위치)를 calculateCellIndex를 통해 셀 인덱스로 변환합니다.


private fun calculateCellIndex(
    x: Float,
    y: Float,
    cellWidthPx: Float,
    cellHeightPx: Float,
    datesSize: Int,
    timeSlotsSize: Int
): Pair<Int, Int> {
    val dateIndex = (x / cellWidthPx).toInt().coerceIn(0, datesSize - 1)
    val timeIndex = (y / cellHeightPx).toInt().coerceIn(0, timeSlotsSize - 1)
    return dateIndex to timeIndex
}


픽셀 좌표를 셀 크기로 나누고 coerceIn으로 클램핑하여 인덱스를 구합니다. 인덱스 계산 자체는 단순합니다.


다만 이 방식만으로는 드래그가 현재 화면(뷰포트) 밖으로 나갈 때 문제가 생깁니다. 제스처만으로는 스크롤이 되지 않기 때문에, 화면에 보이지 않는 셀은 선택할 수 없습니다. 그래서 자동 스크롤이 필요합니다.


자동 스크롤을 구현하려면 우선 "지금 손가락이 뷰포트 경계에 가까운가?"를 알아야 합니다. Canvas 좌표는 전체 영역 기준이므로, 이를 현재 화면에 보이는 뷰포트 기준으로 변환해야 합니다. 가로 방향은 캔버스 X 좌표에서 수평 스크롤 오프셋을 빼면 되므로 단순합니다.


val viewportX = positionX - horizontalScrollValue


그러나 세로 방향은 단순하지 않았습니다. 만약 가로와 똑같이 단순 오프셋만 보정하면 어떻게 될까요?


// 첫 번째 시도: 단순 오프셋 보정
val viewportY = positionY - verticalScrollValue


스크롤 전에는 정상 동작했지만, 스크롤을 시작하자 자동 스크롤 경계를 오판정하거나 터치 좌표가 어긋나는 현상이 발생했습니다. Sticky 영역(DateHeader)과 스크롤되어 사라지는 영역(TopHeader)을 고려하지 못했기 때문입니다. 스크롤이 진행될수록 오차는 커졌습니다.



문제① 원인 분석

세로 스크롤 값에 따라 뷰포트 기준선이 어떻게 바뀌는지 세 개의 다이어그램을 통해 살펴보겠습니다.


A. 스크롤 전

TopHeader가 모두 보이는 상태입니다. Canvas 자체는 아직 잘리지 않았지만, TopHeader가 보이는 만큼 드래그 가능한 뷰포트 높이가 줄어듭니다.

B. TopHeader 일부 노출

Canvas는 아직 잘리지 않은 상태입니다. TopHeader 높이만 줄어들었기 때문에, Canvas 시작점은 그대로 0입니다.

C. Canvas 스크롤 중

위쪽이 뷰포트 밖으로 밀려난 상태입니다. 이때부터 Canvas 좌표와 뷰포트 좌표가 달라지므로, 초과 스크롤분을 빼서 보정해야 합니다.

세로 좌표 변환이 복잡해지는 이유는 뷰포트의 시작점과 높이가 스크롤 상태에 따라 실시간으로 변하기 때문입니다.


정리하자면 TopHeader(가변 높이)는 스크롤에 따라 서서히 사라지며 Canvas의 가용 시작점을 바꾸고, Sticky 헤더인 DateHeader는 화면 상단에 고정되어 드래그 높이 계산에서 항상 제외되어야 합니다. 하단의 ParticipantsSheet는 표시되면 외부 레이아웃 사이즈(BoxWithConstraintsmaxHeight) 자체가 줄어들어 하단 경계 판정 기준이 바뀝니다.


내부의 스크롤 위치와 외부의 레이아웃 구성 양쪽에 의해 경계 판정의 기준선이 매 순간 달라지고 있었던 것입니다.



문제① 해결법: 뷰포트 좌표계 분리와 동적 계산

이를 해결하기 위해 스크롤 오프셋과 헤더들의 실시간 높이를 종합하여, 실제 뷰포트 좌표를 오차 없이 계산해내는 로직을 구현했습니다.


private data class ViewportPosition(
    val x: Float,
    val y: Float,
    val height: Float
)

private fun calculateViewportPosition(
    positionX: Float,
    positionY: Float,
    horizontalScrollValue: Int,
    verticalScrollValue: Int,
    topHeaderHeightPx: Float,
    dateHeaderHeightPx: Float,
    viewportHeightPx: Float
): ViewportPosition {
    val viewportX = positionX - horizontalScrollValue

    // Canvas 앞의 헤더 공간을 지나치기 전까지는 Canvas 자체가 잘리지 않음
    val canvasVisibleStartY =
        (verticalScrollValue - topHeaderHeightPx - dateHeaderHeightPx).coerceAtLeast(0f)
    val viewportY = positionY - canvasVisibleStartY

    // 화면에 남아있는 TopHeader의 실시간 높이 계산
    val topHeaderVisible =
        (topHeaderHeightPx - verticalScrollValue).coerceAtLeast(0f)
    val actualViewportHeightPx = viewportHeightPx - topHeaderVisible

    return ViewportPosition(viewportX, viewportY, actualViewportHeightPx)
}


핵심 아이디어는 세로 스크롤 컨테이너 내에서 Canvas 앞에 놓인 TopHeader와 DateHeader 만큼의 공간을 스크롤 오프셋이 넘어서기 전까지는 Canvas가 잘리지 않는다는 점입니다. 스크롤 값이 그 높이를 넘어선 뒤에야, 초과분(canvasVisibleStartY)만큼 Canvas의 위쪽이 뷰포트 밖으로 밀려나게 됩니다. 이 초과분을 Canvas 절대 좌표에서 빼서 현재 화면 기준의 Y 좌표(viewportY)를 얻습니다.


또한, 이미 DateHeader 높이가 제외된 viewportHeightPx에서 스크롤 후에도 화면에 남아 있는 TopHeader 높이를 추가로 빼서, 실제 드래그 가능한 뷰포트 높이를 갱신합니다.


텍스트와 코드로만 보면 이 조건식들이 다소 복잡해 보일 수 있습니다. 이해를 돕기 위해 TopHeader 높이가 200px, Sticky 헤더인 DateHeader 높이가 60px인 가상 상황을 예로 들어보겠습니다. 스크롤 값의 변화에 따라 수식 내 핵심 변수들이 어떻게 달라지는지 표로 정리하면 다음과 같습니다.


주목할 지점은 표 마지막의 "Canvas 스크롤 중" 행입니다. verticalScrollValue가 400이 되면 각 변수가 어떻게 계산되는지 차례로 살펴보겠습니다.

우선 canvasVisibleStartY 계산식을 보면 스크롤 값 400에서 TopHeader 높이 200과 DateHeader 높이 60을 빼서 140이 됩니다. 이는 스크롤 컨테이너 안에서 Canvas 앞에 놓여 있던 헤더들의 자리를 지나고 남은 140px만큼 Canvas의 위쪽 영역이 뷰포트 밖으로 밀려났다는 뜻입니다.


따라서 다음 단계인 viewportY를 구할 때, Canvas 기준의 절대 좌표(positionY)에서 이 화면 밖으로 밀려 나간 초과분 140px를 빼줍니다. 이 보정이 있어야 자동 스크롤 경계 판정을 전체 그리드가 아닌 현재 뷰포트 안의 손가락 위치 기준으로 할 수 있습니다.


마지막으로 topHeaderVisible은 스크롤 값이 이미 200을 넘어섰기 때문에 0이 되는데, 이는 TopHeader가 가용 화면에서 사라진 상태를 의미합니다. 결과적으로 TopHeader가 차지하던 공간만큼 Canvas가 쓸 수 있는 뷰포트 높이가 확보되면서, 자동 스크롤을 트리거하는 하단 경계선도 최대로 넓어집니다.


결국 같은 Canvas 절대 Y 좌표를 터치하더라도, 스크롤 상태에 따라 변환된 뷰포트 Y 좌표와 가용 뷰포트 높이가 매 순간 달라집니다. 단순 오프셋(positionY - verticalScrollValue)만 뺐을 때 발생하던 누적 오차가 상쇄되는 원리가 바로 이것입니다. 이 보정 덕분에 스크롤 상태가 어떻게 변하든 손가락의 뷰포트 좌표를 일관되게 계산할 수 있습니다.



문제② 자동 스크롤의 끊김과 속도 제어

뷰포트 좌표를 정확하게 계산해 냈으니, 이제 손가락이 화면 가장자리 경계(Threshold, 100px) 이내에 진입했을 때 해당 방향으로 스크롤을 트리거할 차례입니다.


처음에는 가장 대중적이고 친숙한 API인 ScrollState.animateScrollTo()를 사용해 스크롤을 트리거했습니다. 하지만 여기서 두 번째 난관에 부딪혔습니다. animateScrollTo는 목표 위치까지 감속 커브를 그리며 자연스럽게 도달하는 일회성 애니메이션 용도로 설계된 API입니다. 반면 드래그 중 자동 스크롤은 유저가 손가락을 떼지 않는 한 매 프레임 일정량을 연속으로 밀어야 하는 작업입니다. 이 애니메이션을 매 프레임 반복 호출하자, 이전 애니메이션이 취소되면서 다음 애니메이션이 시작되는 과정이 겹쳐 화면이 끊기는 현상이 발생했습니다.


이를 해결하려면 매 프레임 연속 스크롤을 다룰 다른 접근이 필요했습니다.



문제② 해결법: low-level API와 프레임 동기화

화면이 끊기는 현상을 잡기 위해서는 두 가지가 필요했습니다. 매 프레임 연속해서 보드를 밀어주는 스크롤 도구, 그리고 그 호출을 화면 갱신 주기에 맞춰 제어하는 방법입니다. 이를 위해 상위 레벨 애니메이션 API 대신 low-level 제어를 도입하고, 코루틴을 디스플레이 주사율에 동기화하는 방향으로 구현했습니다.



[STEP 1] 자동 스크롤 방향 결정을 위한 경계 판정


드래그 상태를 관리하는 TimeSlotBoardState 클래스에서, 뷰포트 좌표가 경계에서 Threshold(100px) 이내에 들어오면 해당 방향으로 스크롤을 시작합니다.


fun updateAutoScrollDirection(
    viewportX: Float,
    viewportY: Float,
    viewportWidthPx: Float,
    viewportHeightPx: Float
) {
    val dx = if (includeHorizontalScroll) {
        when {
            viewportX < AUTO_SCROLL_THRESHOLD -> -AUTO_SCROLL_SPEED
            viewportX > viewportWidthPx - AUTO_SCROLL_THRESHOLD -> AUTO_SCROLL_SPEED
            else -> 0f
        }
    } else 0f

    val dy = when {
        viewportY < AUTO_SCROLL_THRESHOLD -> -AUTO_SCROLL_SPEED
        viewportY > viewportHeightPx - AUTO_SCROLL_THRESHOLD -> AUTO_SCROLL_SPEED
        else -> 0f
    }

    _autoScrollDirection = if (dx != 0f || dy != 0f) dx to dy else null
}


투표 편집 모드(SelectionEdit)에서는 가로와 세로 양방향 자동 스크롤이 모두 동작하고, 결과 조회 모드(ResultView)에서는 세로 스크롤만 동작합니다. 이 구분은 TimeSlotBoardModeincludeHorizontalAutoScroll 프로퍼티로 모델에 미리 녹여 두었습니다.


참고로 스크롤 이동량(AUTO_SCROLL_SPEED)은 고정값으로 설정했습니다. 경계까지의 거리에 비례해 속도가 가속하는 방식도 고려했으나, 타임슬롯 셀의 크기가 작고 정밀한 터치 선택이 중요한 UI 특성상 속도가 갑자기 빨라지면 오히려 원하는 칸을 지나치기 쉽습니다. 따라서 구현을 단순하게 유지하고, 드래그 중 셀 단위 선택이 과하게 튀지 않도록 프레임마다 일정 delta를 동일하게 적용했습니다.



[STEP 2] 연속 밀어내기를 위한 dispatchRawDelta로의 전환


화면 끊김을 해결하기 위해 상위 레벨 애니메이션 API 대신, 현재 위치에서 주어진 픽셀만큼 즉시 이동시키는 low-level API인 dispatchRawDelta를 선택했습니다.


애니메이션 커브 없이 raw pixel을 직접 밀어내기 때문에 프레임 단위 연속 스크롤에 적합합니다. 스크롤이 끝에 도달하면 소비하지 못한 delta를 반환하는데, 이 경우 더 이상 스크롤되지 않을 뿐 별도의 예외 처리는 필요하지 않았습니다.


다만 dispatchRawDelta는 일반적인 스크롤 API라기보다 low-level API에 가깝습니다. nested scroll, scroll priority, reverse direction 같은 스크롤 메커니즘을 우회하기 때문에 모든 상황에 기본 선택지로 쓰기에는 적합하지 않습니다. 본 구현에서는 사용자의 드래그 제스처가 이미 선택 동작으로 확실히 해석된 특수한 상황에서 보드를 밀어주는 제한된 용도로만 활용했습니다.



[STEP 3] withFrameMillis를 이용한 프레임 동기화


만약 dispatchRawDelta를 프레임 제어 없이 단순 루프로 호출하면 어떻게 될까요?


// 이렇게 하면 안 됩니다
while (isActive) {
    dispatchRawDelta(5f)
}


이 루프는 코루틴 디스패처가 허용하는 최대 속도로 실행됩니다. 기기의 CPU 성능에 따라 스크롤 속도가 달라질 수 있는 구조입니다. API 특성상 프레임 동기화 없이는 안정적인 동작을 보장할 수 없다고 판단하여, 다음 디스플레이 프레임 콜백이 올 때까지 코루틴을 suspend 시켜주는 withFrameMillis를 루프 내에 적용했습니다. 이 LaunchedEffect는 타임슬롯 보드 Composable 함수 내부에 위치합니다.


LaunchedEffect(dragState.autoScrollDirection) {
    dragState.autoScrollDirection?.let { (dx, dy) ->
        while (isActive) {
            withFrameMillis {
                if (dx != 0f) horizontalScrollState.dispatchRawDelta(dx)
                if (dy != 0f) verticalScrollState.dispatchRawDelta(dy)
            }
        }
    }
}


withFrameMillis 덕분에 while 루프가 CPU가 허용하는 최대 속도로 실행되는 것을 막고, 스크롤 처리를 화면 갱신 주기에 맞춰 실행할 수 있습니다.

다만 현재 구현은 프레임마다 고정된 픽셀 값을 이동시키는 방식이므로, 기기의 디스플레이 주사율(60Hz, 120Hz 등)에 따라 초당 총 이동량은 조금씩 달라질 수 있습니다. 이번 구현에서는 시간 기반 속도 보정 알고리즘을 복잡하게 더하기보다, 드래그 중 과도하게 빠른 루프를 막고 화면 갱신 주기에 맞춰 부드럽게 밀어주는 것을 우선순위로 삼았습니다.


(참고: 모든 기기에서 물리적으로 동일한 스크롤 속도를 맞추려면, withFrameMillis { timeNanos -> } 블록이 제공하는 타임스탬프를 활용해 이전 프레임과의 시간 차이(Delta time)를 계산해 이동량을 보정하면 됩니다.)



[STEP 4] LaunchedEffect key를 통한 선언적 생명주기 관리


위 코드에서 LaunchedEffect의 key가 dragState.autoScrollDirection인 점을 주목해 주세요.


key로 지정된 방향 상태 값이 변경될 때마다 기존 코루틴 루프가 취소되고 새 코루틴이 시작됩니다. 따라서 방향 값이 유효할 때는 while 루프가 돌며 화면을 밀어주고, 경계를 벗어나 null이 될 때는 새 코루틴이 시작되더라도 내부의 let 블록을 타지 않아 스크롤이 자연스럽게 멈춥니다. 명령형으로 구현했다면 start/stop 메서드를 직접 호출해야 했을 텐데, 상태 값의 변경만으로 자동 스크롤의 시작과 종료가 제어되는 선언적 패턴입니다.


또한 제스처 API나 구현 방식에 따라, 드래그 종료뿐 아니라 제스처가 취소되는 경로에서도 같은 초기화가 필요할 수 있습니다. 예시 코드에서는 핵심 흐름을 보여 주기 위해 onDragEnd만 표현했지만, 구현할 때는 취소 케이스에서도 autoScrollDirection이 남지 않는지 함께 확인해야 합니다.


Canvas를 선택해 1,344개 셀의 렌더링 성능을 확보했지만, 그 대가로 좌표 변환과 low-level 제어라는 새로운 복잡도를 다뤄야 했습니다. 가변 헤더와 스크롤 상태에 따라 변하는 뷰포트 좌표를 계산하고 자동 스크롤을 구현하기까지, 이번 구현에서 얻은 교훈은 다음과 같습니다.



에브리타임 모임 일정 투표 UI의 핵심인 타임슬롯 보드는 이처럼 좌표와 프레임 단위의 작은 차이를 조정해 가며 완성한 결과물입니다. Canvas 기반의 복잡한 그리드 UI와 인터랙션을 고민하는 개발자분들께 이 글이 참고 자료가 되었으면 좋겠습니다.


-


Written by 한지희 | 비누랩스 소프트웨어 엔지니어

유저의 일상과 만나는 설레는 순간을 위해 고민하며, 생각을 현실로 만드는 개발자입니다.