brunch

You can make anything
by writing

C.S.Lewis

by 깅이 Feb 03. 2017

Firebase 를 쓰실 건가요?

2017년 2월 3일. 지금은 이래요. 

"앱을 출시하다" 라는 글을 쓰고, 과거 회사 생활을 정리하는 연재(?)를 마치고... 간만에 다시 "출시한 앱" 소식으로 돌아왔다. 재미와 감동이 있는 그런 글은 아니고, 매우 순수한 기술적인 내용이니, "Firebase" 라는 단어에 관심이 없으신 분은 여기서 뒤로가기를 누르시면 되겠다. 


앱을 출시한지 두달 반 정도가 지났다. 설치수 20만, 동시 접속 (피크 타임 기준) 400명으로 성장했다. 나름 매출도 Memory leak 이 있는 Java heap 사용량 그래프처럼 꾸물꾸물 위로 올라가고 있다. 


이렇게 빨리 사용자가 증가해 나갈지는 예상하지 못했었다. 그러함에도 불구하고, 나름 큰 서버 사고 없이 (Firebase 에서 서버가 재부팅했다며 미안하다고 한 40분 빼고. 놀랍게도 40분간 시스템이 먹통이 된 다음 3시간 동안 결제 건수가 폭발했기 때문에 너그러이 용서했다.) 순/항/이라고 우기려면 우겨볼 수 있는 여정을 보내고 있다. 


앱 개발이 빨리 끝난 것도 (사실은 준비 기간등 해서 5달은 될텐데, 멋있게 보이기 위해서 3달만에 만들었다고 우기기로 합의했다.) 나름 예상못한 사용자 증가 추세에도 무난히 버틴 것도, Firebase 덕이 컸다고 할 수 있겠다. 흔히 생각하는 DB 를 Firebase 가 담당한 것도 있지만, 그보다 더 큰 게 이 녀석이 통신 event 역할(가령, 새로운 메세지가 도착했다)을 해서 Server/Client 가 (혹은 특별한 Server 없이 Client 끼리) 각자 편하고 빠르게 개발이 이루어졌던 게 중요한 요인이었던 것 같다.  


그렇지만, Firebase 가 생산성을 향상하고 시스템 확장을 용이하게 하는 만능키만은 아니다. 똑똑한 개발자님들께서는 "그런 것도 몰랐어?" 라고 하실지 모르겠지만, 우리는 모르고 시작했다가 서비스가 시작된 이후 어쩌나 싶었던 (혹은 어쩌나 싶은) Firebase 의 문제점들을 공유하고자 한다. 



Firebase 의 Query 는 무식하다. 

 

Firebase 에는 Reference 라는 개념이 있고, Query 라는 개념이 있다. limitToFirst, limitToLast, equalTo 같은 것들이 Query 다. 모든 Query 가 마찬가지인데, 여기서는 limitToFirst 를 예로 설명하겠다. 


limitToFirst 를 하면 Reference 의 child node 에서 주어진 index 를 기준으로 오래된 X 개만을 가지고 올 수 있다. 그러나 실험을 통해, 그리고 Firebase 의 답변을 통해 확인된 limitToFirst 의 숨은 수행 과정은 아래와 같다. 


1. 먼저 Reference 에 해당하는 모든 child node 를 Firebase 내부적으로 DISK IO 를 통해 다 Cache 로 올린다. Firebase rule 로 indexing 을 설정 하든 말든 상관 없다. 다 긁어온다. 

2. 거기서 X 개만 추려서 client 로 보낸다. 


즉, 서버에서 걸리는 시간은 해당 Reference 를 once 로 다 읽는 것과 마찬가지의 overhead 가 걸린다. 다만, client 로 보내는 bandwidth 만 적게 쓸 뿐이다. 


가령 child node 를 100만개를 달고, 거기서 limitToFirst 로 10 개를 읽으면? 거의 1분이 소요된다. (한번 읽고 나면 다음부터는 일정 시간동안 매우 빠르게 끝난다. Cache 에 이미 데이타가 올라가 있기 때문이다.) 


Child node 가 100만개쯤 있으면... 그래... 1분쯤 걸릴 수 있지... 라고 너그럽게 용서할 수도 있을텐데, 너그러움이 발휘될 수 없는 2가지 중요한 비밀이 더 있다. 


첫째, 그 1분 동안, "그 Firebase 서버"  로의 모든 operation ("같은 Reference" 가 아니다. "그 서버" 로의 다른 모든 operation 이다.) 이 정지된다. 즉, 사용자 A 가 100만개 child node 에 대해서 limitToFirst(10) 을 부르면, 10개 찾아서 보내는 1분간 시스템은 down 된다. 
둘째, 100만개에 1분이라는 시간은, 다른 connection 이 없을 때의 얘기다. 경험적으로 Connection 이 많으면 child node 가 5만개만 되어도 상당한 delay 가 있는 것으로 보여졌다. 정확한 시간은 모른다. 앞으로도 모를 거다. 왜냐면, 이 위험한 실험을 우리 실서버에서 해볼 수가 없기 때문이다. 


이 상황을 피해가려면, child node 개수가 일정 이상 늘어나지 않도록 설계를 해야 한다. 날짜를 key 로 써서 layer 를 한 단계 더 올리던지, 화면에 보여줄 recent 정보만 따로 관리하던지... 


우리는 최대 child 를 안전하게 1000 개로 잡고 data 를 migration 시키기로 했다. (지금 이 코딩을 하다가 막막해서 눈물을 흘리며 이 글을 쓰고 있다.)



remove 를 잘못 하면 시스템이 오래 멈춘다. 


위의 얘기와 이어지는 것이다. Child node 가 많은 reference 를 remove 하면, 시간이 오래 걸리는 게 문제가 아니라, 모든 operation 이 멈춘다. 어느 정도로 많으면 문제가 되느냐는 것에 대해서는 대답하기 어렵다. 실서버에서 이런 실험을 할 용기가 안 난다. 저번에 3만개짜리 한번 지워 봤는데 나름 큰 문제 없이 지나가긴 했었다. 


Garbage 잘못 만들면 무서워서 지우지도 못하고 돈은 얘 때매 계속 나가는 상황이 올 수 있으니, garbage child 가 생기지 않도록 조심해야 한다. 


Firebase 가 안 되는 나라들이 있다. 


서비스를 시작하고 나서 로그인에 실패하는 케이스들에 대한 exception 보고들을 분석하던 중, "countryBlocked" 라는 reason 으로 로긴이 안 되는 건수들을 확인했다. 알고 보니, 이란, 시리아 등의 나라는 구글 서비스가 막혀 있고, 그래서 Firebase 를 사용하는 우리 서비스는 해당 나라에서는 서비스가 불가능한 것이었다. 


Firebase 공식 답변은, https://www.google.com/transparencyreport/traffic/disruptions/ 여기를 보라는데, 뭘 어떻게 보라는 건지 사실 잘 모르겠다. 그리고 국가적인 정책이라 오늘은 되는데 내일은 안 될 수도, 혹은 그 반대의 경우도 언제든 가능하다. 


중국에 대해서는 된다는 말도 있고, 안 된다는 말도 있고, 되는데 안정적이지 않다는 말도 있다. 중국이 Main target 이라면, 잘 찾아보고 나서 판단하시길. 



Operation 회수도 Bandwidth 만큼 성능에 영향을 미친다. 


이건 지금 생각해 보면 정말 당연한 것이긴 하다. 


먼저 전제하고 싶은 것은 우리는 Firebase 를 무지하게 heavy 하게 쓰고 있었다는 것이다. 1초에 30번씩 마구마구. 성능이 많이 떨어져서 bandwidth 만 신경 쓰다가, operation 회수에 관심이 가서, 1초에 30번 보내던 걸 1초간 모아서 한꺼번에 보냈더니, 성능이 확 올라갔다. Websocket 이라서 데이터가 쭉쭉 빠지긴 하지만, 짧은 시간에 너무 많은 데이터를 보내야 한다면 모아서 보내는 로직이 크게 도움이 될 수 있다. 



이 녀석 아마 간혹 gc 같은 걸 한다. 


Java 코드를 돌리다보면, 갑자기 팅팅 멈출 때가 있다. Java VM 내부적으로 GC 를 할 때 그렇다. 메모리 확보하겠다고 낑낑대는 동안 코드는 팅 멈추는 것이다. 그런 느낌이 나는 동작들이 종종 보인다. 2-5초 정도 가끔 팅 멈출 때가 있다. 


테스트할 때는 "이건 머 빠르네" 라고 생각한 동작도 간혹 2-5초간 멈출 수가 있으니, firebase action 을 하는 곳에서는 알뜰살뜰 로딩 아이콘을 넣어주는 게 좋다. 



Firebase storage 는 빠르지 않다. 


Firebase storage 는 Google cloud 와 연동되어 돌아간다. Google cloud 에서 데이터를 읽는 것도 (내가 느끼기엔) 그리 빠르지 않은데, Firebase storage 를 통해서 가져오면 그것보다 2배 정도 더 느려진다. 1-2KB 되는 50x50 image file 을 땡겨오는데 700msec 정도 걸린다. 한국에서는 그렇다. 미국에서는 그것보다 빠를 것으로 강하게 추정된다. 작년에 이 속도를 확인하면서 내년에는 빨라지지 않을까? 했는데, 지금도 비슷하다. 


우리는 머... 그냥 쓰고 있다. 대신 late image loading 같은 개념으로, 일단 default image 보여줬다가 로딩되면 이미지 바꾸는 류의 로직을 사용 중이다. 처음엔 아쉬웠는데,  보다보니 이 정도면 어때 싶어서 계속 그냥 쓸 듯 하다. ^^;


Firebase 함수는 밀린 job 을 활성화시킨다. 


Javascript 구현을 할 때는 신경을 써줘야 할 부분일 것 같다. 자... 만일 "test" 라는 node 에 "a" "b" 2개의 노드가 있고, 이 상태에서 아래 코드가 실행되었다고 치자. 


var index = 0;

firebase.database().ref().child('test').on('child_added', function(snap) {

    index++;

    console.log(snap.key)

    snap.ref.remove();

    console.log(snap.key, index);

});


핫! 이정도는 하며!

a

a 1

b

b 2


일 거라고 나도 생각했다. 하지만, 결과는 아래와 같다. 

a

b

b 2

a 2


Javascript 코딩 경력 6개월... 이제 Single thread 가 적응이 되었는데... 이 결과를 보고 "머야? 나 Javascript 를 몰랐던 거야?" 라며 충격과 공포에 휩싸였다. 


냉정을 회복하고 callstack 을 확인한 결과. firebase remove() 안에서, queue 에 job 을 던지고, queue 는 FIFO 로 next job 을 처리한 정황을 확인할 수 있었다. 


즉, 이미 child 가 a/b/ 2개가 있으니, job 에는 a/b 2개가 걸려 있는 상황. remove() 호출이 오자, remove job 을 던지고 (b/remove), 젤 앞에 있는 b 를 실행. 머 이런 상황인 것이다. 당연히 remove() 만 이렇게 동작하는 건 아니다. 


Embedded system 에서 늘 속을 썩이는 "Thread stack size" 라는 것에 익숙하신 분들은 눈치 채셨겠지만, child 많을 때 코드를 위와 같이 짜서 시작하면, 처음 시작할 때, 아무리 thread stack size 가 미친듯이 늘어나는 서버 환경이라고 해도 stack overflow 로 맛 갈 수 있다. 


결론은? firebase callback 안에서 firebase 함수를 호출할 경우는, execution 순서가 틀어질 수 있음을 유념해야 한다. 


Firebase 그리고, offline


Firebase api 를 호출했는데 폰이 offline 상태인 경우, online 이 되면, 밀렸던 operation 이 서버로 요청되는 아름다운 일이 발생한다. 그런데 폰에서 실제로 해 보면, 이 아름다운 일을 2번씩 하기도 한다. 


폰에서 push() 로 key 를 만들어서 data A 를 쓴다. 계속.. 많이.. 빨리...

서버에서는 child_added 받아서 data 받고 key 를 지운다. (일종의 Handshake?)


이렇다고 치자. 이 때 폰을 offline 으로 보냈다가 online 시키면, 서버가 같은 key 로 2번 child_added 를 받는 일이 발생한다. data A set 안에 firebase.database.ServerValue.TIMESTAMP 를 넣어 보면, 서로 다른 서버 시간으로 2번 호출되는 걸로 봐서, 실제로 2번 set 요청을 했다고 봐야 할 듯. 


이건 버그라고 봐야 할테니, 조만간 고쳐지지 않을까? 그냥 적는 김에 적어둔다. 



2017.10.5 추가


아직도 우리 앱은 망하지 않았고 접속자수는 계속 늘어나고 있다. 그러다가 Peak 시간에 시스템이 대단히 느려져서 살펴보니, Firebase CPU 부하가 100% 를 넘어서고 있었다. CPU 부하가 모자라면, CPU 를 늘려야 하는 거 아냐?! 라는 단순한 생각에, Firebase 에 문의를 했더니, 수용 가능한 limit 이 넘친 것이니, Firebase 자체를 쪼개어 (즉 DB 를 2개를 운영) 운영하라는 가이드 (https://firebase.google.com/docs/database/usage/sharding) 를 받았다. 흥.쳇.뿡. 일단은 Firebase 안에서 많이 접근하는 부분을 따로 떼어내어 Redis 로 옮기는 작업을 해서 살짝 한숨을 돌렸다. 


그런데 사용자가 더 늘어나면 결국은 다시 limit 이 넘칠 거라 장기적으로 어떻게 해야 하는지가 숙제인데, 최근에 https://firebase.google.com/docs/firestore/rtdb-vs-firestore?authuser=0 이런 것을 발견했다. Cloud Firestore 로 갈아타면, 주요한 두가지 문제, 


1) 앞에서 여러가지로 지적한 Query 에서 하위트리를 모두 load 하는 문제

2) Limit 이 넘치면 수작업으로 한땀한땀 대응해야 하는 문제


이게 해결된 버전인 것으로 얼핏 보인다. 함정은, 아직 Beta 라는 것. 혹시... 개발을 "계획 중" 에 이 글을 보셨다면, Cloud firestore 라는 걸 한번 검토해 보시는 게 좋을 듯 하여 부랴부랴 원글에 수정을 덧대고 있다. 


우리는 어디로 넘어가야 할지는 좀 고민해 봐야 겠다. Cloud firestore 가 적힌대로만 작동한다면, 지금의 문제를 다 해결할 것 같긴 한데... 중국 시장에 진출을 하려면 어짜피 Firebase 를 버리긴 해야 하니까... (중국에 있는 지인에게 부탁해서 앱을 실행해 봤더니 역시 안 되더라고...ㅠ.ㅠ) 


머... 하여간 접속자 많아져서 생기는 문제들이니까... 그래도 행복한 거라고 해야 하려나. 



2017.11.10 추가: Profiling


Child node 를 1000개 이상 만들지 말라고 그렇게 경고 했지만, 꼭 말 안 듣는 애들이 있다. 우리 서비스의 고질적인 문제가 가끔가다 짧게는 10초, 길게는 몇 분씩 시스템이 멈추는 것이었는데, 누가 하지 말란 짓을 했는지 발견하기가 어려웠다. "1000 개 이상 늘어날 수 있도록 관리하는 애들 있는지 리뷰해 주세요" 라는 말 따위는 불특정 다수에게 늘 하는 잔소리쯤으로 덮혀버렸다. 


할 수 없다. 개선을 하려면, 증거를 찾는 수밖에 없다! 범인을 찾아내기 위해서, 일단 알아낸 것은 Profiling 이었다. 


firebase database:profile -o $filename --project $project


이런 식으로 profile 을 시작시키고 enter 치면 종료가 된다. 그러면 profile 하는 동안 있었던 operation count 들, 걸린 시간 등등이 text 로 다 저장이 된다. 


다만 이걸 돌리면 전체 시스템은 대략 2배 정도 느려진다. 문제는 언제 시스템이 멈추는 일이 발생할지 모르기 때문에 "그 때" 의 프로파일을 돌릴 방법을 찾는 것이다. 일단 간단한 실험을 해 보았다. 


1. Child node 100만개 때려 넣어 두고

2. Query 를 시작한다. 위에 설명한대로, 댑따 오래 걸린다.  (Cache 되면 빠르니까 cache 안 된 상태에서 해야 한다. 노드를 새로 매번 만들던지, 하루에 한번씩 테스트 하던지 ^^;; 난 광고 하면서 틈틈히 하던 일이라 걍 하루에 한번씩 했다. ㅋㅋ)

3. 좀 있다가 Query 가 끝나기 전에, profile 을 시작한다. 

4. Query 가 종료된다. 

5. profile 을 종료한다. 


즉, "System 을 block 시킨 Child node 가 겁나 많은 reference 를 access 하는 operaton" 이 이미 시작된 "이후에" profile 을 시작해도, 이 profile 이 늦어진 operation 을 기록해주는지를 확인하고자 했다. 결과는 놀랍게도, 이 operation 이 전체 걸린 시간을 정확히 찍어주었다. 야호!


그래서, 아래와 같이 프로그램을 짰다. 

1. (starttime 기록하고) watchdog/status/ok 를 쓴다. (endtime 기록하고. - then function 안에서 기록하면 operation 이 종료되었을 때를 알 수 있다.)

2. (starttime 기록하고) watchdog/status/ok 를 지운다. (endtime 기록하고) 

3. 10초 쉰다

4. 1로 돌아간다. 


한편으로, 1초 interval 로,

1. startime 이 endtime 보다 크고, starttime 이 현재 시간보다 10 초 크면 block 되었을 거라고 추정하고, 

2. profile 을 시작한다. (const client = require('firebase-tools'); 하고, client.database.profile 로 시작시키면 된다.)

3. 종료하려면 enter key 를 쳐야 하는데, 젤 좋은 건 endtime 이 기록되었을 때 (const robot = require('robotjs'); robot.keyTap('enter');) 같은 걸로 enter 를 먹이는 방법이겠으나... 이게 제대로 안 되면, profile 이 안 끝나니까 무서워서... profile 을 그냥 1분만 돌게 했다. (그 전에 시간만 찍으면서 모니터링을 오래 했는데, 1분 정도 돌게 하면 범인을 잡을 수 있을 것 같았다.)


이렇게 했더니, 아주 무척 완전히 정확하게 딱!!! Firebase CPU 그래프가 abnormal 하게 치솟는 시점에... 그니까 사용자들은 갑자기 시스템이 멈추고 로딩화면을 바라만 봐야 하는 시점에... 즉, Firebase 에 child node 겁나 많을 걸 누군가 reference 로 access 하여 그 사이에 Firebase 의 모든 operation 이 멈춘 그 시점에!! profile 이 돌아가는 아름다운 모니터링 프로그램이 만들어졌다. 우후~ 야하~ 예~


위에서도 설명했지만, child node 가 겁나 많은 걸 누군가 access 해서 퍼 올리면 Firebase operation 이 모두 멈춘다. 그걸 이용한 것이다. 단순히 read operation 으로 검사를 하면 cache 때문에 detect 가 안될 수 있으니, write/delete operation 을 쓴 것이고. 


그래서 오늘 아침에 범인을 잡았다. 흐흐흐... 같이 일하던 아이가, 이 프로그램은 open source 어디어디에 올리시면 유용하게 쓸 사람들이 있을 것 같다고 제안을 했으나, open source 어디어디 이런 거 잘 몰라서... 그냥 여기에 글을 남겨둔다. 


부록: Profile 이 남겨준 파일에 흔적을 남긴 범인 (Node 의 사생활 보호를 위해 일부 *표 처리함. /g***/req**** 노드로 3번 요청했는데 평균적으로 걸린 시간이 29초라는 것임. 즉, 얘 혼자 다른 애들을 1분 30초 동안 멈추게 함.  문제를 일으킨 녀석은, Server 죽었나 안 죽었나 검사한다고 주기적으로 limitToFirst 를 하는 녀석 이었고, 문제의 저 node 는 지우는 것 없이 계속 데이타를 쌓기만 하고 있었음. 주기적이면 매번 일정 시간에 문제가 일어났을 텐데, 왜 발견을 못 했나? 라고 한다면... cache 가 되어 있는 경우는 문제 없다가 운 나빠서 cache 에서 쫓겨나면, 다시 access 할 때 겁내 오래 걸리는 거라, access 는 주기적이지만, 문제 발생은 주기적이지 않았기 때문... 이라는 설명을 덧붙인다.)


│ /g***/req****           │ 3     │ 29,039.67 ms │ 0                 │







만약에 다시 시스템을 처음 설계하던 때로 돌아가서 서버 구조도를 만든다면, Database 와 통신을 분리해서, 통신은 Firebase 를 사용하고, Database 에 해당하는 역할은 다른 걸 검토할 것 같다. 지금은 일단 Child node 개수 제한하고, 검색 등의 기능은 Elastic search 와 연동시켜서 뽑아내는 식으로 버텨볼 생각이다. 


덕분에 여기까지 온 게 감사하긴 한데... Manageable 한 Software component 들로만 시스템을 구성하던 Embedded system 세계에서, 닥치고 써야 하는 이 세계로 오니 뭔가 모르게 은근한 불안함이 계속 스물스물 피어오르긴 한다. 인앱 결제 하면 구글이 30% 가져가는데, 이걸 20%만 가져가는 글로벌 인앱 결제 시스템을 국내에서 누가 만들 순 없는지, 나름 검색 포털 강국인데, Splunk, Elastic search 같은 bigdata system 을 국내에서 누가 만들 순 없는지... 광고는 Facebook 같은 Feeding Style 의 global platform 이 없으면 안 되겠지만... 하긴... 국내에서 누가 그런 멋진 걸 만들어 봐야, 국내 대기업들은 그런 회사에게 로열티 개념의 돈을 떼어줄 리가 없지... 외국 기술 기업들에게는 돈을 지속적으로 나눠 주는 게 당연하고, 국내 기술 기업들은 용역 회사 취급을 하니... 국내에 소프트웨어 기술 기업이 성장할 수가 있나... 애초에 그런 회사들이 투자를 받을 수도 없을 것이고... 결국 대기업이 스스로 그런 걸 만드는 방법 밖에 없는데, 아무래도 대기업의 한 부속실로 그런 일이 추진되기에는 조직적인 한계가 분명히 있을테고... 그냥 이렇게 올라타서 가는 어색함에 적응하며, Global 대기업이 세금 잘 내도록 하는 수밖에 없는 걸까... 씁쓸하다. 


주절주절 상관 없는 잡설이 길어졌지만, 하여간, Firebase 를 사용하시려는 분에게 작은 참고가 되었기를 바라며... (이제 빨리 코딩하자. =.=;;)


작가의 이전글 어플을 출시하다.
브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari