[Dalendar DevLog 5] 달력 앱의 보이지 않는 적, 성능 저하와 싸운 기록 (트러블슈팅)
날짜 계산에서 발생하는 성능 저하 문제를 발견하고, 이를 해결하기 위해 나눗셈 연산을 최적화한 트러블슈팅 과정을 공유합니다.
![[Dalendar DevLog 5] 달력 앱의 보이지 않는 적, 성능 저하와 싸운 기록 (트러블슈팅)](/images/blog/dalendar_dev_5_performance.png)
5편: 달력 앱의 보이지 않는 적, 성능 저하와 싸운 기록 (트러블슈팅)
1. 시작하며: 잘 동작’하는 것처럼’ 보였던 달력
이 글은 개발 과정에서 겪었던 ‘위기와 극복’을 다루는 시리즈의 다섯 번째 기록입니다.
처음 ‘그저 달력’ 앱 개발을 시작했을 때는 모든 것이 순조로워 보였습니다. 목표는 명확했습니다. 한 화면에 한 달이 보이고, 좌우로 스크롤하면 이전 달과 다음 달이 나타나는 직관적인 달력을 만드는 것이었습니다. 시스템 자원을 효율적으로 사용하는 RecyclerView의 재활용 매커니즘 덕분에 초기 프로토타입은 60fps를 안정적으로 유지하며, 사용자 경험에는 전혀 문제가 없는 듯 보였습니다.
하지만 일정 추가, 반복 일정 계산 등 기능 구현이 깊어질수록 예상치 못한 문제에 부딪혔습니다. 이것은 앱이 비정상 종료되는 명백한 ‘버그’가 아니었습니다. 그보다 더 교활하고 눈에 잘 띄지 않는 ‘보이지 않는 성능 저하’라는 적이었습니다.
2. 문제 (Problem): 특정 날짜 계산 시 발생하는 심각한 속도 저하
문제는 특정 상황에서 명확하게 드러났습니다. 사용자가 스크롤을 통해 수십 년 후의 날짜로 빠르게 이동하거나, 아주 먼 미래의 반복 일정을 계산하는 기능을 테스트할 때 앱이 눈에 띄게 느려지거나 심할 경우 몇 초간 멈추는 현상이 발생했습니다.
처음에는 이 현상이 복잡해진 UI의 렌더링 지연 문제라고 생각했습니다. 하지만 프로파일링 결과, 문제는 뷰(View)가 아닌 달력의 핵심 로직, 즉 날짜 계산 자체의 비효율성에서 비롯되고 있었습니다. 특정 날짜를 구하기 위한 연산 과정이 예상보다 훨씬 많은 CPU 자원을 소모하며 병목 현상(bottleneck)을 일으키고 있었던 것입니다.
3. 원인 (Cause): 달력 계산에 숨겨진 복잡성과 비싼 연산
문제의 근본 원인을 파고들자, 우리가 당연하게 사용하던 그레고리력의 계산이 생각보다 단순하지 않다는 사실을 마주했습니다.
일반적으로 달력 로직을 구현할 때 사용하는 방식들의 문제점은 다음과 같이 분석할 수 있었습니다.
- 조회 테이블(Look-up tables) 사용: 월별 날짜 수나 특정 월까지의 누적 날짜 수를 배열(look-up table)에 미리 저장해두고 필요할 때 조회하는 방식은 흔히 사용됩니다. 하지만 이 방법은 L1 캐시가 차가울 때(cold cache) 접근 비용이 많이 들 수 있으며, 분기(branching)를 유발하여 실행 파이프라인에 지연을 일으킬 수 있습니다.
- 느린 나눗셈 연산: 날짜 계산은 본질적으로 많은 나눗셈(division)과 나머지(remainder) 연산을 포함합니다. 문제는 나눗셈이 덧셈, 뺄셈, 곱셈과 비교해 네 가지 기본 산술 연산 중 가장 느리고 CPU 사이클을 많이 소모하는 ‘비싼’ 연산이라는 점입니다. 수천, 수만 번의 연산이 누적될 때 이 비용은 무시할 수 없는 성능 저하로 이어집니다.
4. 해결 (Solution): 나눗셈을 곱셈으로 바꾸는 마법, EAF
문제의 근본 원인을 파고들자, 해결책은 스택 오버플로우나 기술 블로그가 아닌, 훨씬 더 근원적인 곳에 있었습니다. 바로 “Euclidean Affine Functions and Applications to Calendar Algorithms”라는 논문이었습니다.
핵심 아이디어는 간단명료합니다. ‘비용이 많이 드는 나눗셈 연산을 수학적으로는 동일하지만, CPU 입장에서는 훨씬 빠른 곱셈과 비트 시프트(bit shift) 연산으로 대체하는 것’ 입니다.
개선 전 코드 (개념)
// 개념적 코드: '세기의 날(day of the century)'로부터 '세기의 해(year of the century)'와 '해의 날(day of the year)' 계산
// 이 방식은 내부적으로 비효율적인 나눗셈(n2 / 1461)과 나머지(n2 % 1461) 연산을 수행한다.
val n2 = 4 * r1 + 3
val q2 = n2 / 1461 // 몫(year) 계산
val r2 = (n2 % 1461) / 4 // 나머지(day of year) 계산
개선 후 코드 (최적화)
// 최적화된 코드: 나눗셈을 곱셈과 비트 시프트로 대체
// 미리 계산된 '마법의 숫자(magic number)'를 사용한다.
val n2: Long = 4 * r1 + 3
val u2: Long = 2939745 * n2
val q2: Long = u2 ushr 32 // u2 / 2^32 와 동일 (부호 없는 오른쪽 시프트)
// 나머지 계산 역시 의존성 없이 병렬 처리가 가능해진다.
val r2_numerator: Long = (u2 and 0xFFFFFFFFL) * 1461 // u2 % 2^32 와 유사
val r2: Long = (r2_numerator ushr 32) / 4
최적화의 핵심 원리 설명
개선된 코드는 두 가지 핵심적인 장점을 가집니다.
첫째, 느린 나눗셈 연산이 빠른 곱셈과 비트 시프트 연산으로 대체되어 연산 속도가 크게 향상됩니다. 둘째, ‘데이터 의존성 제거(breaking a data dependency)’ 입니다. 기존 방식에서는 몫(q2)을 계산해야만 나머지(r2)를 계산할 수 있었지만, 최적화된 방식에서는 중간값 u2가 계산되면 q2와 r2를 서로 기다릴 필요 없이 동시에(concurrently) 계산할 수 있습니다. 이는 최신 CPU의 명령어 수준 병렬 처리 능력을 극대화합니다.
5. 결론: 보이지 않는 버그를 잡는 개발자의 자세
단순히 잘 동작하는 것처럼 보였던 달력 앱의 이면에는 ‘느린 나눗셈’과 ‘데이터 의존성’이라는 보이지 않는 성능의 적이 숨어 있었습니다. 이번 트러블슈팅은 ‘문제-원인-해결’의 과정을 통해 이 문제를 해결한 기록입니다.
이번 경험을 통해 단순히 ‘동작하는’ 코드를 작성하는 것을 넘어, 그 이면에 숨겨진 성능 문제의 근본 원인을 파고드는 자세가 얼마나 중요한지 다시 한번 깨달았습니다.
![[Dalendar DevLog 1] 프로젝트의 시작과 기술 스택 선정](/images/blog/dalendar_dev_1_ideation.png)
![[Dalendar DevLog 2] 견고한 앱의 뼈대 - 아키텍처와 데이터 구조 설계](/images/blog/dalendar_dev_2_architecture.png)
![[Dalendar DevLog 3] 핵심 기능 구현 1 (백엔드 & 로직)](/images/blog/dalendar_dev_3_math_logic.png)