처음에는 그 날 바로 만들어 배포할 수 있을 거라 생각했는데 최종적으로는 3일 정도 걸렸다. 오래 걸렸던 이유를 분석해 보면 다음과 같다.
1. 알라딘 API 동작이 생각과는 달랐다.
2. 알고리즘 짜는 과정이 생각보다 어려웠다.
처음에는 알라딘 API가 있고, 상품 검색 API도 존재하길래 이걸 가져다 쓰면 생각보다 쉽게 해결할 수 있을 듯 싶었다. 그러나, 반환값이 생각보다 달라 결국 모든 항목에 대해 크롤링해서 값들을 읽어오도록 로직을 짰다.
알고리즘은 완전탐색을 사용할 경우, O(N^M)꼴이 된다. N은 평균 중고 책 수, M은 중고 책 종류 수를 의미한다. 베스트 셀러들을 조회하니 평균 100 ~ 150권 정도의 중고 제품들이 존재하는 걸 확인했다.
그러면 베스트 셀러만 담을 경우, 책을 6권 정도 담게 되면 100 * 100 * 100 * 100 * 100 * 100 = 1,000,000,000,000번의 탐색을 하게 된다.
따라서 완전탐색이 아니라 다른 방법으로 해결할 수 있지 않을까 고민하다 하루를 넘게 썼다. 그리디, dp 등을 고려하다 결국 완전탐색으로 구현하는 방법 뿐이라는 걸 알게 되었다.
예를 들어, A(4000)-가(3000), A(3500)-나(4000), B(4000)-가(3000), B(3500)-다(3000)의 제품 쌍이 존재한다. "A", "B"는 책과 가격이고, "가", "나", "다"는 각각 판매자와 배송비를 의미한다.
지금은 A-가, B-가 조합으로 구입하는 편이 가장 유리할 것이다. 그런데 이때, "C"라는 책을 추가로 구입하고 싶은데 C의 제품은 C(500)-나(4000), C(4000)-가(4000)이 존재할 경우, A-나, B-다, C-나의 조합으로 구입하는 경우 가장 합리적으로 구입할 수 있게 된다.
따라서 이전 정보가 다음 정보에 따라 상황이 바뀔 수 있기 때문에 완전탐색으로 풀이가 가능하다.
프로젝트를 1차 완성한 이후, 수동으로 테스트를 해보았다.
시나리오 : 평균 중고책 서적을 150여권 가지고 있는 베스트 셀러 4권에 대해 최저가 조합을 요청한다
결과는 평균 46s 정도 걸렸다. 생각보다 훨씬 느린 수치라 이때 문제점을 2가지로 정의해 보았다.
1. 크롤링 과정에서 발생하는 bound (IO bound라고 정의했다. 모든 페이지를 모두 탐색하기 때문)
2. 중고책 최저가를 탐색하는 bound (CPU bound라고 정의했다.)
따라서 각각에 대해서 개선을 해보았다.
IO bound에는 캐싱을 해서 처리했다. 처음엔 이번 기회에 Redis를 한 번 써볼까 생각했지만, 어차피 결국 단일 WAS인데 굳이 외부 캐시를 사용할 필요가 있을까? 하는 생각이 들어 로컬 캐시로 처리했다.
스프링의 CacheManager을 처음 사용해서 처리했는데, 사용 방법이 생각보다 정말 간단해서 쉽게 적용할 수 있었으나, 아쉬운 점이라면 키 각각에 대하여 TTL을 걸 수 없다는 점이다.
따라서 아쉬운 대로 12시간 마다 캐시를 비우도록 설정했다. 뭔가 이 부분에 대한 니즈가 나 말고도 많았을 것 같은데, 지원해주지 않는 이유가 궁금하고 아쉽다.
캐시에 대해서 heap이나 기타 다른 설정은 따로 하지 않았다. 어차피 크롤링해서 저장하는 객체 수는 그렇게 많지 않기 때문이다. 저장하는 Books는 내부에 Book을 가지는데, 최종적인 타입들을 보면 String, int, String, int이고, 각 String의 길이도 10글자가 안 되기 때문에 잘 쳐줘봤자 50byte가 넘지 않는다.
그리고 베스트 셀러 한 권 종류당 150개 정도의 객체가 생성되는데, 7.5kb 정도 된다. 베스트셀러 수도 얼마 되지 않고, 검색되는 책 종류 수도 딱히 많지 않겠다는 생각이 들어서 지금은 별다른 세팅을 하지 않았다.
이 밖에도 아예 크롤링을 하지 않도록 비즈니스적으로 해결하는 방법도 생각해 보았다. 알라딘에 API를 요청하는 방법이다. 이 방법이 사실 가장 효과적으로 문제를 해결하는 방법이라고 생각한다.
일단 이 API를 제공해 주어 이 프로덕트가 완성된다면 알라딘에 어떤 이득이 생길지를 적어서 메일로 보내 요청했는데, 답변을 줄지는 모르겠다...
2. 알고리즘에서 계속 삽질을 하다가, 데이터 자체를 필터링해서 해결할 수 있지 않을까 하는 아이디어가 떠올랐다.
이 알고리즘의 핵심은, 같은 판매자의 제품이라면 배송비를 할인해 주는 것이다. 그렇다면 한 판매자가 여러 책에 대해 재고를 가지고 있지 않다면, 그 판매자가 파는 책이 최저가가 아닐 경우 비교 대상에서 제외해도 상관 없는 것 아닐까? 하는 생각이 들었다.
이 생각은 유효했고, UsedBookFilter라는 객체를 만들어 수행했다. 위 시나리오에서 사용한 베스트 셀러들을 사용했더니 다음과 같은 결과가 나왔다.
위는 필터링 전 재고의 수, 아래는 필터링 후 재고의 수다. 즉 O(N^M)에서 N 자체를 훨씬 줄여 성능의 이득을 본 것이다. 따라서 연산 속도도 말도 안되게 빨라졌다. (cache hit일 때)평균 23s에서 1s 미만의 응답이 가능해진 것이다.
느낀점
이 프로젝트를 하면서 느낀 점이 몇가지 있다.
1. 이 프로덕트가 수정이 안 될지는 모르는 법이다.
처음에는 한 번 만들고 필요할 때만 써먹는 용도를 생각해서, 객체명도 대충짓고, 테스트도 별로 짜지 않았다. 그런데 작업을 수행할 수록 여기에 뭘 붙이고, 어떤 성능 개선을 할 수 있고 이러한 수단들이 많이 보이기 시작했다. 이럴줄 알았으면 처음에 설계할 때 신경을 좀더 쓰는건데 하는 생각이 들었다.
2. 기본적인 코딩 능력은 아주 중요하다.
알고리즘 구현에 있어 많은 시간을 소모했다. 아 이래서 기업들에서 알고리즘 코딩 테스트를 보는구나 하는 생각이 많이 들었고, 코드를 많이 짜봐야 한다는 말이 이래서 있나보다 싶었다.