현재 Firebase의 다양한 기능들을 적용하여 모바일 어플리케이션을 개발하고 있습니다.
아시다시피 Firebase는 인증, 데이터베이스, 스토리지와 같은 여러 핵심적인 서버 기능들을 제공하고 있죠.
덕분에 Client 코드만으로도 충분히 Serverless한 서비스를 개발하고 있습니다.
개발을 하면서 Firebase 위력에 하루 하루 감탄/감사(?)하고 있죠.
그런데 지금 제가 진행하고 있는 프로젝트에서는 위 기능들만으로는 조금 부족했습니다.
Firebase Realtime Database는 Json을 베이스로 하기 때문에
기본적으로 일반 관계형 데이터베이스와는 다른 DB 설계를 생각해야 했고요.
이로 인해 데이터 정합성 문제가 발생했습니다.
결론적으로는 Firebase Cloud Function을 통해 해결할 수 있었고
본 글은 Cloud Function의 적용 사례를 소개하는 글입니다.
우선 본 장에서는 Firebase를 사용하면서 어떤 문제 상황을 마주했고,
이를 해결하기 위해 선행적으로 어떤 대안들을 생각했었는지,
그리고 결국에는 왜 Cloud Function을 최종 대안으로 선택했는지 소개하고자 합니다.
일반적으로 관계형 데이터베이스에서는 데이터 중복 저장은 가급적 피해야하고,
이를 달성하기 위해 정규화라는 개념이 적용됩니다.
하지만 Firebase Realtime Database는 관계형 데이터베이스가 아닌 Mongo DB와 같은 Document Store입니다.
우선 제 입장에서 이런 구조는 익숙하지 않은 형태의 데이터베이스였고,
처음 이것을 접했을 때에는 가급적 관계형 데이터베이스와 유사하게 데이터 구조를 가져가려고 노력했습니다.
하지만 이는 생각처럼 쉽지 않았고, 결국 성능을 위해 일부 데이터를 중복 저장하기로 결정했습니다.
실제 우리 서비스에서는 한명의 사용자가 여러 Group에 속할 수 있기 때문에 다대다 관계이지만,
본 글에서는 일대다 관계로 축소하여 소개합니다.
하나의 Group에는 여러 User들이 속할 수 있기 때문에,
아래와 같이 Group에는 UserList가 저장됩니다.
/groups/{groupId}/userlist/{userId_01}
/groups/{groupId}/userlist/{userId_02}
/groups/{groupId}/userlist/{userId_03}
/groups/{groupId}/userlist/{...}
처음에는 데이터를 중복 저장하지 않기 때문에 userId들을 배열로 들고 있었습니다.
/groups/{groupId}/userlist/[{userId_01}, {userId_02}, {userId_03}, ...]
그런데 위 구조에서는 성능 문제가 발생합니다.
모바일 앱에서 Group에 속한 User 리스트를 RecyclerView를 통해 보여줘야 한다면,
즉 User가 100명이라면 /users/{userId}를 100번 호출해야 했습니다.
RecyclerView의 각 Item에 User의 모든 정보를 보여줄 필요는 없습니다.
User의 이름, Profile 이미지와 같은 최소한의 정보들만 보여주면 되므로
이 최소한의 정보들을 대상으로 중복 저장하기로 했습니다.
그리고 이렇게 중복 저장하는 User의 정보를 GroupUser 라고 재정의하였습니다.
정리하면 Group은 아래와 같이 User 데이터를 들고 있게 됩니다.
/groups/{groupId}/userlist/{userId}/{GroupUser Object}
/groups/{groupId}/userlist/{userId_01}/{GroupUser Object for userId_01}
/groups/{groupId}/userlist/{userId_02}/{GroupUser Object for userId_02}
/groups/{groupId}/userlist/{userId_03}/{GroupUser Object for userId_03}
/groups/{groupId}/userlist/{...}/{GroupUser Object for ...}
기존에는 /groups/{groupId}/userlist/[{userId_01}, ...]와 같이 userId만을 배열 형태로 들고 있었다면,
이제는 /groups/{groupId}/userlist/{userId_01}/{GroupUser Object for userId_01} 에서는 GroupUser 객체를 배열 형태로 들고 있습니다.
이렇게 변경하고 나서는 Group의 User 리스트를 보여줄 때
다음 경로에 대한 쿼리 한 번만으로 RecyclerView를 만들기 위해 필요한 모든 정보를 얻을 수 있게 되었습니다.
/groups/{groupId}/userlist
데이터를 중복 저장했기 때문에 동기화 문제가 발생합니다.
/users/{userId} 의 정보가 변경되었을 때,
/groups/{groupId}/userlist/{userId} 의 정보도 동일하게 변경되어야 합니다.
그렇지 않으면 데이터 정합성이 깨지게 됩니다.
[데이터 정합서 문제는 또 어떻게 해결하나...]
데이터 정합성을 유지하기 위해 2가지 방법을 생각해봤습니다.
먼저 Client에서 데이터 정합성을 관리한다는 것은 다음을 의미합니다.
사용자가 앱에서 자신의 정보를 변경하면, Group의 groupUser도 업데이트하는 것입니다.
즉 사용자는 자신의 정보만을 변경하지만 내부적으로는 Firebase에 2개의 update query를 호출하는 거죠.
구현도 아래와 같이 간단합니다.
ref.child("users").child(userId).setValue(user);
ref.child("groups").child(groupId).child("users").child(userId).setValue(groupUser);
하지만 위와 같은 방식은 우리가 생각하는 Best Solution은 아닙니다.
그래서 데이터 정합성은 Server에서 관리하는 것이 여러 모로 더 좋은 Solution이라고 생각했습니다.
하지만 우린 서버 개발을 하지 않는, Serverless 개발을 하고 있습니다.
그럼 Firebase에서는 이 문제를 어떻게 해결할 수 있을 까요?
아주 친절하게도 아래 링크에 Realtime Database Trigger라는 가이드도 있습니다.
https://firebase.google.com/docs/functions/database-events
하지만 아쉽게도 현재 Cloud Function은 Beta 버전이라 그런지 공식 한글 문서를 제공하고 있지는 않습니다.
심지어 한글 공식 홈에서는 Cloud Function Menu도 안 보입니다.
(일반적으로 Alpha 버전은 서비스가 런칭되지 않고 사라질 여지가 있지만, Beta 버전까지 오게 되면 보통은 정식 런칭을 한다고 하니 안심하시고 이용해도 좋을 것 같습니다.)
자동으로 백엔드 코드를 수행시켜주는 기능입니다.
우리가 작성한 코드가 Google의 cloud에 저장되고 그 위에서 돌아가는 거죠.
더 놀라운 점은 이 백엔드 서비스의 스케일 업/다운도 Firebase가 자동으로 관리해준다는 점입니다.
Cloud Function은 다음과 같이 기존 Firebase, Google Cloud 기능들과 함께 활용할 수 있습니다.
- Realtime Database Triggers
- Firebase Authentication Triggers
- Google Analytics for Firebase Triggers
- Cloud Storage Triggers
- Cloud Pub/Sub Triggers
- HTTP Triggers
그리고 우리가 타겟으로 한 것은 바로 Realtime Database Triggers 입니다.
1. 사용자가 모바일 앱에서 자신의 정보를 수정한 뒤 저장 버튼을 누릅니다.
2. Firebase Realtime Databse의 하기 경로에 수정된 데이터가 저장(write)됩니다.
> /users/{userId}
3. Cloud Function Triggers가 write 이벤트를 감지하여 이에 해당하는 function 코드를 찾아 수행합니다.
> /groups/{groupId}/users/{userId} 를 업데이트합니다.
아주 이상적입니다.
Client는 단 한번의 쿼리만 요청하기 때문에 데이터 정합성이 깨질 우려가 없습니다.
개발자는 데이터 정합성을 위해 Client Code를 추가할 필요가 없습니다.
단 한번의 쿼리로 수십/수백개의 데이터 정합성도 대응할 수 있습니다. (추가 네트워크 트래픽이 없습니다.)
우리가 짜야하는 Cloud Function은 구글이 관리해주니 적어도 내가 직접 관리하는 서버보다는 안전하겠죠.
거기에 자동 Scale up/down은 덤입니다.
본 글에서는 서비스 개발 중 데이터 정합성 관련하여 마주하게 된 문제에 대해 소개드렸고,
그 해결 방안으로 Cloud Function을 선택하게 된 이유를 언급했습니다.
좀더 구체적인 Cloud Function 사용기는 다음 장에 이야기하겠습니다.
아시다시피 Firebase는 인증, 데이터베이스, 스토리지와 같은 여러 핵심적인 서버 기능들을 제공하고 있죠.
덕분에 Client 코드만으로도 충분히 Serverless한 서비스를 개발하고 있습니다.
개발을 하면서 Firebase 위력에 하루 하루 감탄/감사(?)하고 있죠.
그런데 지금 제가 진행하고 있는 프로젝트에서는 위 기능들만으로는 조금 부족했습니다.
Firebase Realtime Database는 Json을 베이스로 하기 때문에
기본적으로 일반 관계형 데이터베이스와는 다른 DB 설계를 생각해야 했고요.
이로 인해 데이터 정합성 문제가 발생했습니다.
결론적으로는 Firebase Cloud Function을 통해 해결할 수 있었고
본 글은 Cloud Function의 적용 사례를 소개하는 글입니다.
우선 본 장에서는 Firebase를 사용하면서 어떤 문제 상황을 마주했고,
이를 해결하기 위해 선행적으로 어떤 대안들을 생각했었는지,
그리고 결국에는 왜 Cloud Function을 최종 대안으로 선택했는지 소개하고자 합니다.
일반적으로 관계형 데이터베이스에서는 데이터 중복 저장은 가급적 피해야하고,
이를 달성하기 위해 정규화라는 개념이 적용됩니다.
하지만 Firebase Realtime Database는 관계형 데이터베이스가 아닌 Mongo DB와 같은 Document Store입니다.
우선 제 입장에서 이런 구조는 익숙하지 않은 형태의 데이터베이스였고,
처음 이것을 접했을 때에는 가급적 관계형 데이터베이스와 유사하게 데이터 구조를 가져가려고 노력했습니다.
하지만 이는 생각처럼 쉽지 않았고, 결국 성능을 위해 일부 데이터를 중복 저장하기로 결정했습니다.
[이해를 돕기 위한 예제 소개]
본 글에서는 우리가 직면한 문제를 소개하기 위해 User, Group의 예를 사용합니다.- User: 사용자의 정보를 담은 객체
- Group: 그룹의 정보를 담은 객체로, 하나의 Group에는 1개 이상의 User 객체가 관계를 맺고 있음.
실제 우리 서비스에서는 한명의 사용자가 여러 Group에 속할 수 있기 때문에 다대다 관계이지만,
본 글에서는 일대다 관계로 축소하여 소개합니다.
[Firebase Realtime Database 데이터 저장 구조]
Firebase Realtime Databse에 User와 Group은 하기와 같은 경로에 존재합니다.- User: /users/{userId}
- Group: /groups/{groupId}
하나의 Group에는 여러 User들이 속할 수 있기 때문에,
아래와 같이 Group에는 UserList가 저장됩니다.
/groups/{groupId}/userlist/{userId_01}
/groups/{groupId}/userlist/{userId_02}
/groups/{groupId}/userlist/{userId_03}
/groups/{groupId}/userlist/{...}
처음에는 데이터를 중복 저장하지 않기 때문에 userId들을 배열로 들고 있었습니다.
/groups/{groupId}/userlist/[{userId_01}, {userId_02}, {userId_03}, ...]
[우리가 직면한 문제]
그런데 위 구조에서는 성능 문제가 발생합니다.모바일 앱에서 Group에 속한 User 리스트를 RecyclerView를 통해 보여줘야 한다면,
- /groups/{groupId}/userlist 에서 userId 배열을 가져와서
- 해당 배열을 for문을 돌면서 /users/{userId} 를 순차적으로 조회하고
- 그렇게 얻은 User 객체 RecyclerView에 보여줘야 합니다.
즉 User가 100명이라면 /users/{userId}를 100번 호출해야 했습니다.
[성능 문제를 개선하기 위한 데이터 중복 결정]
결국 RecyclerView에 보여줘야 하는 User 정보들만을 중복 저장하기로 결정했습니다.RecyclerView의 각 Item에 User의 모든 정보를 보여줄 필요는 없습니다.
User의 이름, Profile 이미지와 같은 최소한의 정보들만 보여주면 되므로
이 최소한의 정보들을 대상으로 중복 저장하기로 했습니다.
그리고 이렇게 중복 저장하는 User의 정보를 GroupUser 라고 재정의하였습니다.
정리하면 Group은 아래와 같이 User 데이터를 들고 있게 됩니다.
/groups/{groupId}/userlist/{userId}/{GroupUser Object}
/groups/{groupId}/userlist/{userId_01}/{GroupUser Object for userId_01}
/groups/{groupId}/userlist/{userId_02}/{GroupUser Object for userId_02}
/groups/{groupId}/userlist/{userId_03}/{GroupUser Object for userId_03}
/groups/{groupId}/userlist/{...}/{GroupUser Object for ...}
기존에는 /groups/{groupId}/userlist/[{userId_01}, ...]와 같이 userId만을 배열 형태로 들고 있었다면,
이제는 /groups/{groupId}/userlist/{userId_01}/{GroupUser Object for userId_01} 에서는 GroupUser 객체를 배열 형태로 들고 있습니다.
이렇게 변경하고 나서는 Group의 User 리스트를 보여줄 때
다음 경로에 대한 쿼리 한 번만으로 RecyclerView를 만들기 위해 필요한 모든 정보를 얻을 수 있게 되었습니다.
/groups/{groupId}/userlist
[문제는 여기서 끝이 아니다!]
이제 모든 것이 말끔하게 정리되었나 했지만... 문제는 여기서 부터 시작합니다.데이터를 중복 저장했기 때문에 동기화 문제가 발생합니다.
/users/{userId} 의 정보가 변경되었을 때,
/groups/{groupId}/userlist/{userId} 의 정보도 동일하게 변경되어야 합니다.
그렇지 않으면 데이터 정합성이 깨지게 됩니다.
[데이터 정합서 문제는 또 어떻게 해결하나...]
데이터 정합성을 유지하기 위해 2가지 방법을 생각해봤습니다.
- Client에서 데이터 정합성 관리
- Server에서 데이터 정합성 관리
먼저 Client에서 데이터 정합성을 관리한다는 것은 다음을 의미합니다.
사용자가 앱에서 자신의 정보를 변경하면, Group의 groupUser도 업데이트하는 것입니다.
즉 사용자는 자신의 정보만을 변경하지만 내부적으로는 Firebase에 2개의 update query를 호출하는 거죠.
구현도 아래와 같이 간단합니다.
ref.child("users").child(userId).setValue(user);
ref.child("groups").child(groupId).child("users").child(userId).setValue(groupUser);
하지만 위와 같은 방식은 우리가 생각하는 Best Solution은 아닙니다.
- 만약 첫 번째 구문이 실행된 후, 두 번째 구문이 실행되기 전에 죽는다면 마찬가지로 정합성은 깨집니다.
- 즉 트랜잭션 관리가 필요합니다. (사실 Firebase Realtime Database는 Transaction도 제공하고 있긴 합니다.) - 데이터 정합성을 위한 구문을 개발자가 항상 염두에 두어야 합니다.
- 첫 번째 구문(line 1)은 요구 사항을 구현하기 위한 비즈니스 로직이지만,
두 번째 구문(line 2)은 비즈니스 로직과는 상관 없이 데이터 정합성을 맞춰주기 위한 로직입니다.
개발자 입장에서는 두 번째 구문을 실수로 놓칠 여지가 있죠. - 사용자 입장에서 빈번한 네트워크 트래픽이 발생합니다.
- 위 예제는 단 한번의 쿼리가 추가되었지만, 데이터 정합성을 맞추기 위해 수십, 수백번의 쿼리가 필요하다면 사용자는 그저 자기 정보 하나를 수정했을 뿐인데, 수십/수백 번에 해당하는 네트워크 트래픽이 발생한다면 억울할 수 있겠죠?
그래서 데이터 정합성은 Server에서 관리하는 것이 여러 모로 더 좋은 Solution이라고 생각했습니다.
하지만 우린 서버 개발을 하지 않는, Serverless 개발을 하고 있습니다.
그럼 Firebase에서는 이 문제를 어떻게 해결할 수 있을 까요?
[결론은 Cloud Function]
바로 Firebase Cloud Function이 우리가 찾은 Solution입니다.아주 친절하게도 아래 링크에 Realtime Database Trigger라는 가이드도 있습니다.
https://firebase.google.com/docs/functions/database-events
하지만 아쉽게도 현재 Cloud Function은 Beta 버전이라 그런지 공식 한글 문서를 제공하고 있지는 않습니다.
심지어 한글 공식 홈에서는 Cloud Function Menu도 안 보입니다.
(일반적으로 Alpha 버전은 서비스가 런칭되지 않고 사라질 여지가 있지만, Beta 버전까지 오게 되면 보통은 정식 런칭을 한다고 하니 안심하시고 이용해도 좋을 것 같습니다.)
[Cloud Function으로 무엇을 할 수 있을까]
Cloud Function은 Firebase의 다양한 기능들(인증, 데이터베이스, 스토리지 등)과 HTTPS Request에 대한 event 발생 시자동으로 백엔드 코드를 수행시켜주는 기능입니다.
우리가 작성한 코드가 Google의 cloud에 저장되고 그 위에서 돌아가는 거죠.
더 놀라운 점은 이 백엔드 서비스의 스케일 업/다운도 Firebase가 자동으로 관리해준다는 점입니다.
Cloud Function은 다음과 같이 기존 Firebase, Google Cloud 기능들과 함께 활용할 수 있습니다.
- Realtime Database Triggers
- Firebase Authentication Triggers
- Google Analytics for Firebase Triggers
- Cloud Storage Triggers
- Cloud Pub/Sub Triggers
- HTTP Triggers
그리고 우리가 타겟으로 한 것은 바로 Realtime Database Triggers 입니다.
[Cloud Function 적용 시의 사용자 정보 변경 Flow]
Cloud Function을 적용했을 때의 Flow를 다음과 같이 간략히 설명할 수 있습니다.1. 사용자가 모바일 앱에서 자신의 정보를 수정한 뒤 저장 버튼을 누릅니다.
2. Firebase Realtime Databse의 하기 경로에 수정된 데이터가 저장(write)됩니다.
> /users/{userId}
3. Cloud Function Triggers가 write 이벤트를 감지하여 이에 해당하는 function 코드를 찾아 수행합니다.
> /groups/{groupId}/users/{userId} 를 업데이트합니다.
아주 이상적입니다.
Client는 단 한번의 쿼리만 요청하기 때문에 데이터 정합성이 깨질 우려가 없습니다.
개발자는 데이터 정합성을 위해 Client Code를 추가할 필요가 없습니다.
단 한번의 쿼리로 수십/수백개의 데이터 정합성도 대응할 수 있습니다. (추가 네트워크 트래픽이 없습니다.)
우리가 짜야하는 Cloud Function은 구글이 관리해주니 적어도 내가 직접 관리하는 서버보다는 안전하겠죠.
거기에 자동 Scale up/down은 덤입니다.
본 글에서는 서비스 개발 중 데이터 정합성 관련하여 마주하게 된 문제에 대해 소개드렸고,
그 해결 방안으로 Cloud Function을 선택하게 된 이유를 언급했습니다.
좀더 구체적인 Cloud Function 사용기는 다음 장에 이야기하겠습니다.
댓글
댓글 쓰기