안녕하세요, 개9입니다. 오랜만에 기술 블로그에 글을 쓰게 되었습니다. 이번엔 안드로이드 개발에 관한 내용입니다.

개9 안드로이드 앱은 1.1.1 버전부터 HTTP 라이브러리로 Volley를 사용합니다. Volley는 안드로이드 오픈 소스 프로젝트(AOSP)의 일부로, 구글의 플레이 스토어 앱에 사용된 것으로 알려져 있습니다. Google I/O 2013 행사에서 공개됐지만 아직 참고할만한 문서가 거의 없어 개9 앱에 적용하기 위해 여러 시행착오를 겪었습니다. 그 내용을 이 글에 정리해보려 합니다.

설치

Git 저장소(https://android.googlesource.com/platform/frameworks/volley)를 클론해서 JAR 파일로 컴파일한 뒤 프로젝트에 추가합니다. 처음엔 라이브러리 프로젝트로 포함해서 썼는데, 리소스가 따로 들어있지 않으므로 JAR로 넣어도 무방합니다.

Request Queue

Volley에서 가장 중심이 되는 것이 RequestQueue 객체입니다. 간단하게는 Volley.newRequestQueue(Context) 메소드로 만들 수 있는데, 그 경우 다음과 같은 기본값으로 설정됩니다.

  • 안드로이드 운영체제 버전에 맞게 HTTP 스택을 결정합니다. API level 9 이상에서는 HttpURLConnection을, 그 이전 버전에서는 Apache HttpClient를 사용합니다.
  • DiskBasedCache 객체를 새로 만듭니다. 내장 캐시 디렉토리 밑의 volley 디렉토리에 캐시를 저장하게 되며, 최대 용량은 5메가로 잡힙니다.
  • 네트워크 스레드를 4개 사용합니다.

개9 앱에서는 이 기본값을 그대로 사용하지 않고 다음과 같이 직접 구성했습니다. (Volley 클래스의 소스 코드를 보시면 어렵지 않게 수정할 수 있습니다.)

  • RequestQueue마다 별도의 캐시 객체를 가지는 것이 아니라 공통 캐시를 사용하도록 했습니다.
  • 외장 메모리가 있다면 캐시를 내장 메모리 대신 외장 메모리에 저장합니다.
  • 기본 제공된 DiskBasedCache 대신 일부 문제를 수정한 캐시를 사용했습니다. (다음 부분에서 설명할 내용)
  • 굳이 캐시가 필요 없는 API 요청의 경우 캐시를 거치지 않게 하였습니다.
  • 동시 요청 처리가 필요 없는 경우 네트워크 스레드를 1개만 만들도록 하여 메모리를 절약합니다.

또한, 중요한 요청이 아닌 경우 액티비티/프래그먼트마다 큐를 만들지 않고 공용 큐를 사용하여 메모리를 아꼈습니다. RequestQueue가 제대로 해제되지 않을 경우 스레드 누수가 발생하므로, DDMS를 통해서 스레드가 잘 사라지고 있는지 확인해 볼 필요가 있습니다.

개선된 DiskBasedCache

Volley에 기본 제공되는 DiskBasedCache는 비교적 간단한 구현체지만 약간 문제가 있습니다. 캐시된 오브젝트가 많아질수록 초기화하는 데 시간이 오래 걸리고, 캐시가 초기화되는 동안 요청이 하나도 처리되지 않는 문제입니다.

initialize() 메소드에서 캐시 초기화를 할 때, 캐시 디렉토리에 있는 모든 파일의 헤더를 미리 읽어오게 되어 있는데, 이 과정이 상당히 오래 걸려서 그동안 캐시 요청이 처리되지 못합니다. 또한, 캐시 객체를 여러 RequestQueue에서 공유하면 큐를 만들 때마다 initialize 메소드가 호출되고, 캐시 초기화 과정이 불필요하게 다시 실행됩니다. 게다가 initialize 메소드는 synchronized로 표시가 되어 있어 다른 캐시 요청이 처리되지 못하고 블럭되어 버립니다.

따라서 저희는 초기화 과정을 별도의 스레드에서 처리하도록 수정해서 사용하게 되었습니다. 해당 코드는 Gist에서 내려받으실 수 있습니다. (기존 코드와의 diff 보기) 현재 2,000명 이상의 사용자에게 배포되었으며 특별한 오류는 보고되지 않았으나, 혹시 버그가 있을 수도 있으니 참고하고 사용하시기 바랍니다 :)

현재 AOSP에 관련 수정 사항이 리뷰 중이긴 하지만, 나중에는 DiskLruCache처럼 제대로 된 캐시 솔루션을 적용하면 좋을 것 같습니다.

이미지 로딩

레이아웃에서 ImageView 대신 NetworkImageView를 사용하고, 액티비티나 리스트 어댑터 초기화 시 ImageLoader를 만들어서 씁니다. (자세한 내용은 Google I/O 2013 발표 자료에 잘 나와 있습니다.)

ImageLoader에는 ImageCache 객체를 넘기게 되어있습니다. 이미지 캐시는 이미 한번 불러온 이미지의 Bitmap을 캐싱해서, 다시 불러오게 될 때 디코딩 시간을 절약해줍니다. Volley에서는 기본 이미지 캐시 구현을 제공하지 않아서, 직접 만들어 사용하였습니다.

이미 원본 이미지는 HTTP 캐시를 통해 디스크에 저장되고 있기 때문에, 안드로이드 호환성 라이브러리에 있는 LruCache를 사용하여 메모리에만 캐시하도록 하였습니다. (BitmapCache 코드 보기)

그리고 이미지 로더는 기본적으로 응답을 100ms마다 몰아서 처리하게 되어있습니다. 이러한 배치 처리 기능을 끄면 리스트 뷰 스크롤 성능은 떨어지지만, 이미지가 빨리 불러와지는 것처럼 보이게 할 수 있습니다. setBatchedResponseDelay(0)을 호출하여 끕니다. (애플리케이션 특성에 맞게 적용하시기 바랍니다.)

팁: 디버깅 로그 보기

VolleyLog.DEBUG 값을 수정하면 디버깅 로그를 볼 수 있게 되어 있습니다. 어떤 과정에서 시간이 얼마나 걸렸는지 알 수 있어서 유용합니다. 다음과 같이 static initializer에서 수정할 수 있습니다.

class App extends Application {
    static {
        com.android.volley.VolleyLog.DEBUG = true;
    }
    ...
}


결론

비록 몇가지 문제점이 있고 문서가 부실하긴 하지만, Volley는 깨끗한 코드와 잘 만들어진 API를 가진 좋은 라이브러리입니다. AOSP의 일부이기 때문에 앞으로도 발전 가능성이 높고 계속 관심을 가져볼만 하다고 생각합니다.

참고 자료

안녕하세요, 개9 입니다. 지난번에는 개9 트렌드의 기술적인 디테일에 대해서 소개해 드렸었는데요, 저희 기대 보다도 반응이 좋아 무척 기뻤습니다. 앞으로도 개9에서는 매우 기술적인 이슈뿐 아니라 다양한 분야에서 좋은 글들 나눌 수 있도록 노력하겠습니다 :)

트렌드 검색

오늘은, 개9 트렌드 검색 기능이 추가되어 소개해 드리고자 합니다. 개9 트렌드는 가급적 지금 이 순간 가장 핫한 트렌드를 소개해 드리고자 노력하고 있기 때문에, 기존에는 지난 트렌드를 찾아보기 불편했던 것이 사실인데요. 이런 문제를 해결하기 위해서 검색 기능을 선보이게 되었습니다.

트렌드 검색 옵션

특히, 검색의 재미를 위해서 상세한 분류가 가능하도록 구성했습니다. 예를 들어, 검색 결과 중 6개 이상의 사이트에서 나타난 이미지만 살펴볼 수 있고, 또한 원하는 사이트에서 나온 이미지들만 골라 볼 수도 있습니다. 앞으로도 꾸준히 검색 관련 기능이 보강될 예정이니, 관심깊게 지켜봐 주세요.

개9 트렌드 검색은 지금 바로 이용하실 수 있습니다. http://gae9.com/search/trend

안녕하세요, 개9의 기술 개발을 맡고 있는 강 성훈이라고 합니다. 개9 인터넷 트렌드™를 써 보신 분이라면 이 트렌드가 어떻게 만들어지는지 한 번 쯤은 궁금해하실 수 있겠는데요, 이 글에서 트렌드의 선정 과정을 자세히 살펴 보도록 하겠습니다.

개9 인터넷 트렌드 스크린샷
개9 인터넷 트렌드™ - http://gae9.com/trend/

크롤러와 클러스터

개9 인터넷 트렌드™는 기본적으로 인터넷에서 매일 매일 나타나는 수많은 이미지들을 수집한 뒤 분석해서 선정됩니다. 따라서 최대한 많은 사이트에서 이미지들을 수집할 필요가 있는데, 현재 개9는 수십여개의 주요 웹사이트에서 실시간으로 이미지를 수집하고 있습니다. 많은 사이트들을 다루다 보니 신경쓸 것이 많은데요, 이 중 중요한 것 몇 개를 골라 보면:

  • 최대한 해당 사이트에 부하를 덜 유발하는 방법으로 수집합니다. 예를 들어 일반적으로 모바일 버전이 데스크탑 버전보다 가볍고 파싱할 것도 적기 때문에 모바일 버전이 있으면 그걸 사용하고, 이미 읽은 글은 내용이 바뀌었을 거라는 확신이 서지 않으면 이미지를 새로 가져오지 않습니다. 이미지를 읽을 경우 가능하면 If-Modified-Since 등의 HTTP 헤더를 쓰는 것도 좋습니다.
  • 파싱에는 적절함이 필요합니다. 어떤 사이트는 마크업을 자주 조금씩 바꾸기도 하고, 어떤 사이트는 마크업을 거의 바꾸지 않지만 한 번 바꾸면 크게 바꾸기도 하죠. 전자라면 파싱을 할 때 조금 바뀐 정도로는(이를테면 제목이 <strong>으로 묶여 있다거나) 크롤러를 고칠 필요가 없게 만들고, 후자라면 최대한 정확하게 파싱해서 뭔가 바뀌면 바로 알 수 있게 합니다.
  • 각종 오류 상황에 어떻게든 대처할 필요가 있습니다. 흔한 경우가 글이 사라지는 경우와 페이지 끄트머리에 도달했을 때인데, 해당 경우에 무슨 마크업이 반환되는지를 미리 확인하고 따로 처리하게 하는 게 좋습니다.
  • …사실 짜다 보면 크롤링 전략은 다 엇비슷하기 때문에 일반화를 시도하는 것도 나쁘지 않습니다.

실제 크롤링 과정은 RabbitMQCelery를 통해 병렬로 진행됩니다. 이미지를 제외한 문서만 읽는 메인 크롤러를 빼면 모든 세부 작업은 Celery를 통하게 되는데, 부하를 쉽게 분산시키고 관리가 쉬워진다는 장점이 있습니다.

이렇게 크롤링된 이미지들은 비슷한 이미지를 걸러내기 위해 pHash 알고리즘으로 해시값으로 변환됩니다. pHash로 처리된 해시는 파일 포맷 변환으로 손실 압축이 되거나 이미지 크기가 바뀌었을 때도 해시값이 거의 비슷하다는 장점이 있습니다. 따라서 비슷한 해시를 모아 놓으면 같은 이미지들의 서로 다른 버전들을 하나로 묶을 수 있는데, 이를 클러스터라고 합니다. 클러스터 알고리즘은 개9 인터넷 트렌드™의 핵심적인 부분이자 가장 시간이 많이 걸리는 작업이기도 합니다.

이미지 묶기

앞에서 계산해낸 클러스터는 엄밀히 말하면 이미지 클러스터, 즉 한 이미지의 여러 버전을 묶은 것에 지나지 않습니다. 대부분의 사이트는 이미지 여러 개를 함께 올릴 수 있기 때문에 주변에 있는 다른 이미지를 함께 묶어 주는 과정이 필요합니다. 따라서 개9 인터넷 트렌드™는 이미지 클러스터를 이른바 트렌드 클러스터로 변환하는 알고리즘을 개발하였습니다.

트렌드 클러스터는 서로 비슷한 내용을 담고 있는 글과 대응하는 이미지 클러스터들의 집합으로 이루어져 있습니다. 이미지 클러스터를 구하는 것과 비슷하게, 트렌드 클러스터는 이미지 클러스터들이 가장 많이 공유하는 글들을 중심으로 계산됩니다. 예를 들어, 어떤 트렌드가 여섯 개의 이미지로 구성되어 있고, 이 이미지들이 세 개의 서로 다른 글에서 (순서와 상관 없이) 똑같이 나타났다면 해당 세 개의 글에 나타난, 여섯 개의 트렌드 이미지를 포함한 모든 이미지는 하나의 트렌드 클러스터로 묶입니다.

여기서 궁금증이 생길 수 있겠습니다. 트렌드 이미지를 뺀 나머지 이미지가 트렌드 이미지와 관계가 없다면 어떻게 될까요? 대표적인 예로 글에 붙어 있는 사이트 로고나 업로더 인장 같은 것이 있는데, 사실 트렌드 클러스터 알고리즘은 여기에 대해 고려하지 않습니다. 왜냐하면 이런 로고나 인장들은 하나의 트렌드 클러스터에 국한되지 않기 때문에 이 시점에서 걸러 내기가 어렵습니다. 대신 이미지 클러스터 생성 직후, 주변 이미지와 상관 없이 많이 나타나는 이미지들을 트렌드 클러스터 변환 전에 미리 걸러내는 블랙리스팅을 해서 트렌드 클러스터의 신뢰도를 높이고 있습니다.

맥락 살리기

일단 만들어진 트렌드 클러스터는 이미지와 원본 글 정보만으로 이루어져 있습니다. 하지만 실제로 트렌드를 보여 주려면 이미지를 어느 순서로 출력할지, 이미지 클러스터 중 정확히 어떤 이미지를 보여 줄지, 그리고 제목을 뭘로 정할지 결정해야 합니다. 개9 인터넷 트렌드™는 이 과정 또한 사람의 손을 거치지 않고 자동으로 진행합니다.

먼저 이미지 순서를 살펴 봅시다. 크롤러는 이미지를 수집하면서 이미지가 나타났던 글에서 이미지가 어떤 순서대로 나타났는지 저장하고 있습니다. 이제 어떤 트렌드 클러스터에 글과 이미지가 다음 순서대로 나타났다고 칩시다.

트렌드 클러스터의 예. 각각 3개의 글에 A B C, A C D E, C D F 순서로 이미지가 배치됨.

이 경우 최적의 이미지 순서는 A B C D E F 또는 A B C D F E가 될 것입니다. 알고리즘을 공부하신 분이라면 아실 수 있듯, 이는 위상 정렬로 해결할 수 있습니다. 위상 정렬은 어떤 이미지가 다른 이미지보다 뒤에 와야 한다는 조건들을 가지고 조건들을 모두 만족하는 이미지의 순서를 계산해 내거나, 조건이 서로 모순되면 오류를 냅니다. 그래프로 따지자면 이미지는 정점이고 조건들은 간선이겠지요.

안타깝게도 위상 정렬은 사이클이 있는 그래프, 즉 어느 두 이미지가 서로 다른 두 글에 나오는데 순서가 서로 다른 경우에는 적용할 수 없습니다. 그래서 그래프에서 간선들을 최대한 적게 빼서 위상 정렬이 가능하도록 만드는 문제를 생각할 수 있는데, 이렇게 뺀 간선들을 되먹임 간선 집합이라고 합니다. 다만 가장 작은 되먹임 간선 집합을 구하는 알고리즘은 지수시간(NP-Complete)이 걸리기 때문에, 굳이 가장 작은 걸 구하려 하지 않고 위상 정렬을 여러 번 실행하면서 적절한 수준의 결과를 내는 방법을 쓰고 있습니다.

제목 선정과 클러스터 내에서의 이미지 선정은 각 제목·이미지가 다른 이미지들과 비교했을 때 전반적으로 얼마나 비슷한지를 가지고 이루어집니다. 즉, 어떤 이미지가 대부분의 이미지와 거의 비슷하다면 몇몇 좀 많이 다른 이미지는 예외적인 경우로 취급하고 무시할 수 있습니다. 이미지의 경우에는 선술했듯 pHash 해시 값의 차이(Hamming distance)를, 제목의 경우에는 문자열에 한 글자 삽입·수정·삭제를 몇 번이나 해야 다른 문자열이 되는지 알려 주는 편집 거리(Levenshtein edit distance)를 “비슷함”의 기준으로 삼습니다.

트렌드 선정

트렌드는 트렌드 클러스터 중 점수가 가장 높은 것들로 이루어집니다. 트렌드 클러스터의 점수를 정하는 방법은 복잡하여 일일이 설명할 수는 없지만, 다음과 같은 기본 규칙을 바탕으로 개발되었습니다.

  • 첫 등장 이후 시간이 지날수록 점수는 지수적으로 감소합니다.
  • 같은 사이트 내에서 여러 번 나타난 트렌드 클러스터는 점수에 제한적으로만 반영합니다. 다른 게 같다면 두 사이트에서 한 번씩 나타난 트렌드 클러스터는 한 사이트에서 수천번 나타난 트렌드 클러스터보다 점수가 높습니다. 이는 크롤링 대상이 되는 사이트들의 성격 때문에 생긴 방침입니다.
  • 여러 사이트에서 비슷하게 인기가 있을수록 점수가 높습니다. 개9 인터넷 트렌드™는 성별, 연령, 성향과 상관 없는 보편적인 트렌드를 지향하기 때문에 인기의 절대값 뿐만 아니라 편차도 중요한 기준으로 사용합니다.

일단 트렌드에 한 번 이상 등장한 트렌드 클러스터에는 고유한 주소가 부여됩니다. 이 주소는 나중에 트렌드 클러스터에서 새로운 이미지나 글이 등장해도 유지되어야 하기 때문에, 주소를 부여하기 전에 이미지가 기존에 등장했던 클러스터가 있으면 그 클러스터의 주소를 대신 사용하도록 되어 있습니다.