최근 제작 중인 프로그램에서 페이지 활동 기록을 보여주는 기능을 구현하며, 한가지 의문이 생겼다.
활동 기록은 사용자가 직접 개입하는 것이 아닌 사용자의 활동으로 부터 자동 생성되는 로그 데이터로, 활동 기록 생성에 필요한 정보는 다음과 같다.
- 행위자
- 변경 발생 위치 (컴포넌트)
- 변경 내용
여기서 변경 발생 위치, 변경 내용 데이터를 처리하는 방식에는 2가지가 있다.
- 클라이언트에서 해당 데이터를 DOM API를 사용하여 조회하고, 이를 API 호출할 때 전달한다.
- 클라이언트에서 API 호출 할 때 식별자만 전달하고, 서버가 DB에서 해당 데이터를 조회하여 사용한다.
언뜻 보기에는 DB I/O보다, DOM API의 오버헤드가 적으니 1번 방법이 좋아보인다.
하지만, 이 성능 차이가 정말 유의미한 차이인지, 성능 이외에 고려해야 할 사항들은 더 없는지 알아볼 필요가 있어보인다.
고민하다보니, 실제 운영 중인 서비스들은 활동 기록 데이터를 어떻게 처리하고 있을지 궁금해졌다.
참고 자료로 사용할 겸, 활동 기록 확인 기능을 제공하는 대표적인 서비스인 Notion의 처리방식을 알아보았다.
Notion의 처리 방식을 알아보기 위해, 직접 테스트를 진행해봤다.
페이지를 업데이트 할 때, 다음과 같은 API 호출이 발생했다.
syncRecordValues
는 클라이언트가 특정 페이지나 블록에서 수정된 컴포넌트의 식별자를 서버에 보내고, 서버에서 해당 업데이트 기록을 저장하고 반환하는 역할을 하는 API다.
추가적으로 여러 사용자 간의 데이터 일관성을 유지하기 위한 버전 관리 기능도 포함하고 있었다.
pointer
: 변경된 위치를 특정할 수 있는 값들
version
: 클라이언트가 보유한 데이터의 버전
{
"requests": [
{
"pointer": {
"table": "activity",
"id": "68159e88-ed74-4bb4-8e36-b381507100d1",
"spaceId": "4840fce7-af9e-4d82-a173-a8dcb8761d6a"
},
"version": 4
}
]
}
type
: block의 변경 방식(추가, 삭제, 편집)
block_data.before
: 변경 전 상태
block_data.after
: 변경 후 상태
{
"edits": [
{
"type": "block-changed",
"authors": [
{
"id": "1a22dd41-895f-4ed1-b5ff-cb46b86c9c81",
"table": "notion_user"
}
],
"block_id": "84ff09bf-53c8-4195-8421-d738462f5dc4",
"space_id": "4840fce7-af9e-4d82-a173-a8dcb8761d6a",
"timestamp": 1725950258320,
"block_data": {
"after": {
"block_value": {
"id": "84ff09bf-53c8-4195-8421-d738462f5dc4",
"type": "page",
"alive": true,
"version": 12,
"space_id": "4840fce7-af9e-4d82-a173-a8dcb8761d6a",
"parent_id": "4840fce7-af9e-4d82-a173-a8dcb8761d6a",
"properties": {
"title": [
["Test"]
]
},
"permissions": [
{
"role": "editor",
"type": "space_permission"
}
],
"created_time": 1725950255403,
"parent_table": "space",
"created_by_id": "1a22dd41-895f-4ed1-b5ff-cb46b86c9c81",
"created_by_table": "notion_user",
"last_edited_time": 1725950257820,
"last_edited_by_id": "1a22dd41-895f-4ed1-b5ff-cb46b86c9c81",
"last_edited_by_table": "notion_user"
}
},
"before": {
"block_value": {
"id": "84ff09bf-53c8-4195-8421-d738462f5dc4",
"type": "page",
"alive": true,
"version": 4,
"space_id": "4840fce7-af9e-4d82-a173-a8dcb8761d6a",
"parent_id": "4840fce7-af9e-4d82-a173-a8dcb8761d6a",
"permissions": [
{
"role": "editor",
"type": "space_permission"
}
],
"created_time": 1725950255403,
"parent_table": "space",
"created_by_id": "1a22dd41-895f-4ed1-b5ff-cb46b86c9c81",
"created_by_table": "notion_user",
"last_edited_time": 1725950255411,
"last_edited_by_id": "1a22dd41-895f-4ed1-b5ff-cb46b86c9c81",
"last_edited_by_table": "notion_user"
}
}
},
"navigable_block_id": "84ff09bf-53c8-4195-8421-d738462f5dc4"
},
{
"type": "block-created",
"authors": [
{
"id": "1a22dd41-895f-4ed1-b5ff-cb46b86c9c81",
"table": "notion_user"
}
],
"block_id": "9443d5e9-1eaa-4708-9f82-85d610aaf19f",
"space_id": "4840fce7-af9e-4d82-a173-a8dcb8761d6a",
"timestamp": 1725950282485,
"block_data": {
"block_value": {
"id": "9443d5e9-1eaa-4708-9f82-85d610aaf19f",
"type": "sub_sub_header",
"alive": true,
"version": 67,
"space_id": "4840fce7-af9e-4d82-a173-a8dcb8761d6a",
"parent_id": "84ff09bf-53c8-4195-8421-d738462f5dc4",
"properties": {
"title": [
["업데이트 테스트"]
]
},
"created_time": 1725950276624,
"parent_table": "block",
"created_by_id": "1a22dd41-895f-4ed1-b5ff-cb46b86c9c81",
"created_by_table": "notion_user",
"last_edited_time": 1725950281931,
"last_edited_by_id": "1a22dd41-895f-4ed1-b5ff-cb46b86c9c81",
"last_edited_by_table": "notion_user"
}
},
"navigable_block_id": "84ff09bf-53c8-4195-8421-d738462f5dc4"
}
]
}
위 API를 분석해보니, Notion은 2번 방식을 사용하고 있음을 알 수 있었다.
- 클라이언트에서 API 호출 할 때 식별자만 전달하고, 서버가 DB에서 해당 데이터를 조회하여 사용한다.
그렇다면, Notion은 왜 위와 같은 방식을 채택했을까 ?
나는 이번 프로젝트에서 어떤 방식을 채택해야 할까 ?
위 2가지 처리 방식은 크게 4가지 측면에서 차이가 있어보인다.
따라서, 각 처리 방식의 장단점을 비교하여 그 해답을 찾아보자.
우선, 각 방식이 어떤 flow로 동작하는지 살펴보자.
- 변경 발생
- 클라이언트에서 DOM API를 사용해 변경 위치, 변경 내용 등을 조회
- 조회한 데이터로 UI에 활동 기록 최신화
- 조회한 데이터로 활동 기록 생성 API 호출
- 서버는 Request로 받은 데이터를 활용해, 활동 기록 생성하여 저장
- 변경 발생
- 클라이언트는 변경 발생 위치의 id만 조회
- 조회한 id로 활동 기록 생성 API 호출
- 서버는 Request로 받은 id를 활용해 DB에서 활동 기록에 필요한 데이터 조회
- DB에서 조회한 데이터로 활동 기록 생성하여 저장
- 클라이언트는 활동 기록 조회 API 호출하여 UI 최신화
전체적인 동작 flow 측면에서는 서버에서 처리하는 방식이 매번 활동 기록을 조회하는 API를 추가적으로 호출해줘야 하므로 조금 더 복잡하다.
활동 기록은 데이터 일관성이 매우 중요하다.
실제 변경 사항과 활동 기록이 일치해야 하고, 여러 사람이 공유하는 문서의 경우에는 모든 사용자가 같은 활동 기록을 볼 수 있어야 한다.
하지만, 클라이언트에서 처리하는 방식은 서버로 전달되는 과정에서 위・변조될 수 있기 때문에 데이터의 무결성을 보장하기 힘들다.
이에 반해 서버에서 처리하는 방식은 DB에 저장된 데이터를 활용하므로 데이터 일관성을 유지할 수 있다.
두 방식의 성능적 차이를 알아보기 위해, 우선 아래 작업들의 실행 시간을 측정해볼 필요가 있었다.
현재 진행 중인 프로젝트의 MySQL DB에서 프로파일링을 통해 1000개, 1만개, 10만개, 100만개의 데이터가 있는 경우에 식별자를 통한 조회 시간이 얼마나 걸리는지 측정해봤다.
SET profiling = 1;
SELECT * FROM card WHERE id = 1000000;
SHOW PROFILES;
매 실행마다 결과 값에 조금씩 차이가 있었기에 10번씩 실행 후 평균값을 계산해보니 다음과 같았다.
Record 수 | 조회 쿼리 처리 시간 (sec) |
---|---|
1000 | 0.00049475 |
10,000 | 0.00166800 |
100,000 | 0.00278575 |
1000,000 | 0.03403975 |
1000개의 경우에는 약 0.5ms 정도로 무시할 수 있을 정도의 시간이었다.
심지어 10만개까지도 처리시간이 3ms 미만인 것을 확인할 수 있었다.
100만개까지 데이터 수가 늘어나면 약 34ms 정도로 눈에 띄게 처리 시간이 늘어났지만, 이 역시 사용자 입장에서 성능 차이를 느낄 만큼 유의미한 차이는 아니었다.
현재 프로젝트는 데이터가 100개 이상으로 많아지기 힘든 점을 고려하면 DB I/O로 인한 유의미한 성능 차이는 없다고 볼 수 있을 것 같다.
정보 조회를 위한 대표적인 DOM API인 closest
, querySelector
의 실행 시간을 측정 해보았다.
현재 프로젝트에서 performance.now()
를 사용하여 측정했다.
const start = performance.now();
const column = event.target.closest(".column");
const end = performance.now();
console.log(end - start);
const start = performance.now();
const cardInputForm = column.querySelector(".card_input_form");
const end = performance.now();
console.log(end - start);
그 결과는 다음과 같았다.
DOM API | 처리 시간 (ms) |
---|---|
closest | 0 |
querySelector | 0.19999998807907104 |
closest
는 performance.now()
로는 측정하기 힘들 정도로 0에 가까운 처리 시간이 나왔고, querySelector
의 경우 0.2ms 정도의 처리 시간이 나왔다.
일반적으로 DOM API가 DB I/O에 비해 시간은 많이 걸리지만, 현재 프로젝트의 경우에는 유의미한 차이가 있다고 보기 힘들었다.
유지보수 용이성을 비교하기 위해 가상의 요구사항 변경 상황을 가정해보자.
활동 기록에 필요한 데이터의 형식이 변경될 경우를 상상해보자.
이 경우, 클라이언트에서 처리하는 방식은 클라이언트 코드뿐만 아니라 API spec 자체가 변경되어야 한다.
이에 반해, 서버에서 처리하는 방식을 사용한다면, API spec을 유지하고 서버 코드만 수정해주면 된다.
유지보수 측면에서 API spec의 변경을 가능한 피해야 한다는 것을 생각하면, 클라이언트에서 처리하는 방식이 유지보수에 유리하다는 것을 알 수 있다.
유지보수, 데이터 일관성 측면에서 '서버에서 처리하는 방식'이 유리했다.
'클라이언트에서 처리하는 방식'의 유일한 이점은 성능이었지만, 이 또한 1,000,000개의 데이터까지는 유의미한 차이가 있다고 보기 힘들었다.
또한, DB I/O의 성능은 추가적인 쿼리 성능 최적화를 통해 더욱 향상 시킬 수 있는 여지가 있는 반면에 유지보수, 데이터 일관성은 다른 방법으로 개선하기 힘든 부분임을 생각하면 '서버에서 처리하는 방식'을 채택하는 것이 적절해보인다.
Notion이 굉장히 많은 수의 데이터를 처리하고 있음에도 '서버에서 처리하는 방식'을 채택한 데에는 유지보수를 용이하게 하고, 데이터 일관성을 유지하여 오류를 줄이고 사용자 경험을 향상시키기 위함이 아니었을까 싶다.
물론 최적화를 통해 DB I/O의 처리 시간으로 부터 오는 성능 차이도 내가 진행한 테스트보다 더욱 적었을 것이다.