문제를 해결할 때 사고가 중요한 이유

들어가며

개발자는 문제에 직면했을 때 코드를 살펴보며 원인을 찾습니다. 하지만 그 문제가 로직에 내재된 버그가 아니라면, ‘문제를 어떻게 해결할 것인가?’에 대한 사고(思考)가 더 중요합니다. 저는 기존에 네이티브(native)로 구현되어 있는 ‘친구/대화’ 탭의 검색 화면을 웹 뷰로 마이그레이션하는 작업을 진행하였습니다. 이번 글에선 이 과정에서 발생한 문제를 사례로 사고의 중요성을 전달하고자 합니다.

 

발생한 문제와 개선 방법

 

1. 앱 크래시

웹 뷰에서 대량의 친구 목록을 네이티브 앱으로 요청하는 경우 앱 크래시가 발생하는 문제였습니다. 원인은 ‘프로필 이미지’를 요청하는 부분이었습니다. 코드의 로직에는 버그가 없었지만 성능에 문제가 있어서 여러 가설을 세우고 원인을 찾기 시작했습니다.

가설 및 검증

가설 1. 대량의 Base64 이미지를 브라우저가 렌더링할 때 문제가 될 수 있다.

프로필 이미지는 URL로 호출하는 방식이 아니라 네이티브에서 Base641 이미지를 전달받는 방식입니다. 수많은 항목을 이미지 파일이나 URL 형태가 아닌 Base64 이미지로 렌더링을 하였을 때 어떤 문제가 있을지 확신이 들지 않았습니다. 이미지를 Base64로 인코딩하면 원래보다 크기가 더 커지기에 렌더링에 문제가 있을 수 있다는 가정으로 접근했습니다.

png 이미지Base64 이미지
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAV4AAAFeCAYAAADNK3caAAAAGXRF
WHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54b
XAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8
+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCB
Db3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZ
jpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbn
MjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMu…
9KB12KB
가설 1. 검증

같은 개수의 아이템을 렌더링하는 것을 기준으로 Chrome 브라우저 개발자 도구의 Performance 탭을 활용했습니다. 기본 이미지는 ‘Sprite 이미지’2를 활용하는 방식인데요. Base64 이미지는 API를 통해 전달받은 다 기본 이미지를 덮어쓰는 방식입니다.

아래는 동일한 작업(무한 스크롤을 통해 다음 목록을 1회 렌더링하는 작업)을 수행하고 속도를 비교한 것입니다.

기본 이미지Base64 이미지 적용

비교 결과, 기본 이미지와 Base64 이미지를 렌더링하는 것엔 큰 차이가 없었으며, 앱 크래시가 발생할 정도로 과도하게 메모리를 점유하지는 않는다는 것을 확인하였습니다.

가설 2. 네이티브가 다른 작업을 수행하지 못할 정도로 메모리를 점유하고 있다?

네이티브로 구현한 팀에서는 테스트를 위해 이미지 관련 라이브러리와, 메모리를 과도하게 점유하는 것으로 의심되는 기타 코드를 제거한 테스트 빌드를 받았습니다.

가설 3. 프로필 이미지 요청 횟수가 너무 빠르다?

현재는 웹 뷰에서 항목 하나가 렌더링되는 시점에 필요한 ‘프로필 이미지’를 요청합니다. 렌더링 속도가 매우 빠르기 때문에 동일한 속도로 요청을 보낸다면 응답하는 대상도 준비가 되어 있어야 같은 속도로 응답할 수 있고, 요청을 보내는 요청자도 속도를 제한할 필요가 있습니다. 이처럼 버그가 아니라 성능 개선 문제를 만났을 때는 버그 때와는 다른 사고와 접근 방식이 필요합니다.

이 문제를 개선하기 위해 ‘Queueing List’를 활용하여 한 번에 활성화될 수 있는 요청 수를 한정하여 속도를 제한하는 방식으로 개선했습니다. 항상 5개의 요청만 활성화된 상태를 유지하고 그 이후 요청은 큐(queue)에 쌓여서 자신의 순서를 기다리는 방식입니다.

Queueing List 클래스 다이어그램설명
Queue
  • FIFO(First in First Out) 자료구조 구현체


QueueItem
  • 큐에 추가될 항목의 모델


TaskRunner
  • 큐 컨트롤러
  • 인터페이스 제공
  • 동시에 수행될 수 있는 작업(task) 제한


ProfileImageLoader
  • 프로필 이미지를 로드하기 위한 컴포넌트
Queueing List 적용 결과 API 호출 속도 비교 
BeforeAfter
가설2, 3 검증

테스트 빌드
+ 큐
큐 없는 테스트 빌드기존 빌드
+ 큐
큐 없는 기존 빌드
Galaxy S9
(OS 9)
앱 크래시 발생하지 않음 앱 크래시 발생하지 않음앱 크래시 발생하지 않음앱 크래시 발생하지 않음
Galaxy Note 4
(OS 6)
앱 크래시 발생하지 않음앱 크래시 발생하지 않음앱 크래시 발생앱 크래시 발생
iPhone 7+앱 크래시 발생하지 않음 앱 크래시 발생하지 않음
iPhone 6앱 크래시 발생하지 않음앱 크래시 발생하지 않음

가설을 기반으로 테스트한 결과, 기존 빌드에는 웹 뷰에 큐를 적용해도 여전히 앱 크래시가 발생했습니다. 반면에 메모리 과점유를 해결한 테스트 빌드에는 웹 뷰에 큐를 적용하지 않더라도 앱 크래시가 발생하지 않았습니다. 즉, 앱 크래시의 원인이 네이티브 앱의 메모리 과점유에 있다는 것을 확인할 수 있었습니다.

 

2. UI 스레드 지연(blocking)

목록에 항목이 많으면 스크롤을 작동할 때 공백 화면이 보이는 문제가 있었습니다.

원인

스크롤이 작동할 때 UI 스레드가 지연(blocking)되면서 브라우저가 렌더링을 수행하지 못하는 게 원인입니다. 앞서 말씀드린 Queueing List을 활용하여 요청 수를 제한했지만, 요청 수가 많을 경우에는 렌더링이 수행되어야 할 시점에도 계속 요청을 처리하고 있었기 때문입니다. 첫 번째 문제를 처리하는 과정에서 확인된 것처럼, 웹 뷰 환경에선 네이티브 클라이언트와 메모리를 공유하기 때문에 이런 상황이 발생할 수 있습니다.

개선

Queueing List를 이용해 한 차례 개선 작업을 진행한 이후여서 어떻게 더 개선해야 할지 많이 고민했습니다. 요청에 대한 이미지 처리 과정이 계속 문제로 나타났기 때문에 요청을 처리하지 않도록 바꾸는 것이 적합해 보였습니다. 그래서 스크롤을 작동할 때는 사용자가 프로필 이미지에 초점을 두지 않는다는 전제로, 문제의 원인인 이미지 처리를 막기 위해 요청을 중단하는 방식으로 개선했습니다. 이를 위해 Queueing List를 수행하는 TaskRunner를 다시 개선했습니다.

TaskRunner설명
인터페이스 추가
  • stop (): 수행되는 작업을 중지할 수 있는 메서드
  • run (): 중지된 작업을 다시 실행할 수 있는 메서드

이미 한 번 개선한 이후라서 더 이상 성능을 개선할 수 없다고 생각했지만, 실제로 속도를 올리는 게 아니라 사고의 전환으로 문제를 해결했다고 볼 수 있습니다. 이처럼 사고의 전환으로도 문제를 해결할 수 있으니, 유사한 문제에 직면했을 때 기술적인 해결 방법에만 집중할 게 아니라 다른 측면에서도 문제를 바라보며 괜찮은 아이디어가 없는지 고민해 볼 필요가 있습니다.

그 밖의 이야기: 기술이 전부가 아니다.

위 방식으로 개선하기에 앞서, 문제를 해결하기 위해 가장 먼저 떠올린 방법은 ‘웹 워커’를 사용하는 것이었습니다. 웹 워커는 스크립트 실행을 메인 스레드가 아니라 백그라운드에서 실행할 수 있도록 도와주는 기술입니다. 즉, 부하가 큰 작업을 별도의 스레드에서 진행하여 메인 스레드는 영향을 받지 않고 작업을 수행할 수 있습니다. 그러나 현재 수행되는 작업들은 네이티브 클라이언트의 웹 뷰에서 동작하기 때문에 기기와 메모리를 공유합니다. 첫 번째 문제에서 검증된 것처럼 네이티브 클라이언트에서 프로필 이미지 요청을 처리하기 위해 메모리를 과점유하는 상황에서는 웹 뷰가 웹 워커를 사용하여 메인 스레드와 별도 스레드를 분리한다 해도 의미없는 작업이었습니다. 웹 워커가 적용되기 위해서는 먼저 메모리의 가용 용량이 충분히 확보되어야 했기 때문입니다. 결국 웹 워커를 사용하는 대신 생각하는 방식을 달리하여 문제를 해결할 수 있었습니다.

개발자는 여러 기술을 조합하고 활용하여 문제를 해결하는 사람입니다. 하지만 모든 문제를 기술로만 해결하려고 한다면, 간단히 해결할 수 있는 문제도 멀리 돌아가게 됩니다. 어떤 문제가 발생했을 때, 사고를 달리하여 기술이 아닌 다른 방식으로 접근해 보는 것도 문제 해결에 도움이 될 수 있습니다.

 

3. Priority

목록이 길어질 때 스크롤을 많이 내리면 현재 보이는 항목의 프로필 이미지가 매우 늦게 나타난다는 문제가 있었습니다.

원인

위 두 번째 문제를 해결하기 위해 스크롤을 작동할 때 요청을 중단한 Queueing List가 원인이었습니다. 스크롤이 멈춘 시점부터 Queueing List가 다시 진행되기 때문에, 큐에는 현재 보이는 지점의 작업 요청에 앞서 이미 많은 요청이 쌓여 있었습니다.

개선

현재 사용자의 화면에서 보이는 항목의 프로필 이미지를 먼저 요청해야 했습니다. 그래서 큐에 렌더링 순서대로 프로필 이미지 요청 작업을 넣는(enqueue) 것이 아니라, 우선 순위를 설정해 먼저 렌더링된 항목의 프로필 이미지의 요청을 먼저 수행하는 방식으로 변경해야 했습니다. 그런데 당시 사용하던 큐에서는 그게 불가능했습니다. 

그래서 앞과 뒤 양쪽에서 요청을 추가할 수 있는 deque(double ended queue)로 변경하였습니다.

Deque설명
Deque
  • deque 자료구조의 구현체
  • push, pop, unshift, shift로 인터페이스 추가, 변경


DequeItem
  • deque에 추가될 항목의 모델

이 작업으로 먼저 렌더링된 항목과 관련된 작업을 먼저 수행하는 방식으로 우선 순위를 정하고 작업 순서를 제어할 수 있었습니다.

그런데 이 작업 말고도 사용자 경험을 개선할 수 있는 아이디어가 또 하나 도출되었습니다. 우선 deque를 활용하여 화면에 보이는 부분을 먼저 요청하여 표시할 수 있도록 개선했지만, 그 이외의 부분은 전부 작업이 중단된 것과 다름이 없었습니다. 그래서 화면에 보이는 부분은 deque의 앞에 추가하여 스크롤이 멈춘 시점에 먼저 진행하면서, 기존 요청은 deque의 뒤로 차례대로 넣어놓고 화면에 보이는 부분의 처리가 끝나면 순서대로 요청을 수행하도록 개선했습니다.

이렇게 되면 이후 스크롤을 움직였을 때 별도의 요청없이 프로필 이미지를 바로 표시할 수 있으므로 사용자에게 더 나은 사용자 경험을 제공할 수 있습니다. 이처럼 특별한 기술이 아니라 작은 아이디어만으로도 사용자 경험을 개선할 수 있습니다.

 

개선 결과

아래 이미지를 비교해 보면 개선 후 UX가 훨씬 좋아졌다는 걸 확인할 수 있습니다.

네이티브

 

마치며

전체 과정 대부분이 ‘LINE의 웹 기반 서비스와 기술 – LINE은 앱 만드는 회사 아닌가요?‘라는 글에서 LINE의 웹 기반 서비스에 대해 친절하게 설명해 주었던 허석 님의 아이디어였습니다(이 지면을 빌려 허석 님께 감사드립니다 🙂 ).

문제 해결을 위해 빠르게 코드를 구현하는 것도 중요하지만, 개선을 위해 자료구조를 직접 활용하거나 참신한 아이디어를 바탕으로 여러 방향으로 생각해 보는 사고가 훨씬 더 어렵고 중요하다는 것을 또 한 번 알게 되었습니다. 다음에는 현재의 코드에서 해결 방안을 찾기 전에, 여러 각도로 사고해 보면서 아이디어를 떠올리는데 좀 더 많은 관심을 두어야겠다고 생각했습니다.

 


 

1. Base64 이미지란?

Base64로 인코딩하면 파일의 크기가 더 커지는데도 불구하고 이를 사용하는 이유는, 다른 시스템에서도 동일한 이미지를 보여주기 위해서입니다. 이미지 파일은 해당 시스템에서 ‘이미지’로 보여줄 수 있는 특별한 문자로 구성되어 있습니다. 하지만 어떤 시스템에서는 다른 시스템에서 사용하는 특별한 문자의 형태를 읽지 못해서 이미지 파일을 이미지로 보여줄 수 없는 경우가 있습니다. 그래서 Base64 인코딩 방식을 통해 모든 시스템에서 공통으로 사용하는 ASCII 형태로 변환하여, 다른 시스템에서도 동일한 ‘이미지’를 보여줄 수 있습니다.

2. Sprite 이미지란?

이미지를 사용할 땐 서버로 요청을 주고 받아야 하기 때문에 네트워크의 대역폭을 사용하게 됩니다. 웹 서비스를 최적화하기 위해서는 네트워크 대역폭을 최소화해야 하는데요. 특정 이미지를 자주 사용하거나 사용하는 이미지가 많을 경우 그때마다 서버로 요청하게 되면 대역폭을 비효율적으로 사용하게 됩니다. 그때 여러 이미지를 단일 이미지로 합치면 대역폭을 효율적으로 사용할 수 있는데요. 여기서 단일 이미지로 합친 이미지를 ‘Sprite 이미지’라고 합니다.