세상에 나쁜 코드는 없다
[프로젝트] 함께 만들어나가는 지식의 별자리, Stelligence 본문
개요
함께 만들어나가는 지식의 별자리, Stelligence
는 구름톤 트레이닝 파이널 프로젝트에서 수행한 사용자 참여형 그래프 기반 지식 공유 애플리케이션입니다. 저는 백엔드 팀장으로 이 프로젝트에 참여했습니다.
사이트 주소
github
프로젝트 소개
저희 프로젝트의 목표는 정보 검색의 새로운 패러다임과 함께 사용자 참여형 정보 공유의 장을 제공하는 것이었습니다.
해결하고자 하는 문제
저희 팀은 기존 검색엔진이 제공하는 단순한 목록형 정보 제공을 문제 지점으로 삼고, 이를 해결하기 위한 방법을 도입했습니다.
해결 방법
위 한계를 해결하기 위한 새로운 패러다임으로 세컨드 브레인이라는 개념을 도입하였습니다. 정보를 제공하는 방법으로 그래프 구조를 채택하여 사용자에게 1차원적인 정보 전달이 아닌 계층 및 맥락과 관련한 정보를 함께 제공할 수 있게 하였습니다.
핵심 가치
또한 검색될 내용들의 대상이 될 문서들을 제공하는 주체를 관리자 측이 아닌 사용자 측에 열어줌으로써, 사용자가 자유롭게 본인이 원하는 글을 작성하고 이를 기존의 그래프에 편입시킬 수 있도록 만들었습니다.
수정과 토론
모든 사용자가 문서의 변경에 참여할 수 있어야 한다는 핵심 가치 아래에, 저희는 이와 관련된 규칙을 부여할 필요가 있었습니다.
수정요청
어떠한 사용자도 임의로 문서를 수정할 수 없습니다. 사용자는 기존 문서의 내용을 변경 및 추가하고자 하는 경우 ‘수정요청’을 보내야 하며, 이후 다른 사용자들의 투표를 통해 원본에 반영될지에 대한 여부가 결정됩니다. 수정 요청으로는 문서의 내용 뿐만 아니라 제목 및 연관관계 역시 변경할 수 있습니다.
투표
수정요청에 대한 찬성 비율을 기준으로 반영, 토론, 반려의 상태로 전환됩니다.
반영은 수정요청에 대해 다수의 사용자들이 찬성하는 경우로 투표 기간이 끝남과 동시에 원본 문서에 변경 내용이 반영됩니다.
반려는 수정요청에 대해 다수의 사용자들이 반대하는 경우로 해당 변경 내용은 원본에 반영되지 않습니다.
토론은 수정요청에 대한 투표 찬반 의견이 대립하는 경우로, 토론 페이지에 해당 수정요청에 대한 내용과 함께 이에 대한 의견을 나눌 수 있는 공간이 생성됩니다.
토론 페이지에서는 댓글을 통해 찬성 의견과 반대 의견을 나눌 수 있습니다. 이 과정에서 합의안이 도출된다면, 토론이 종료되고 이 내용을 기반으로 새롭게 수정요청을 생성하여 문서를 변경할 수 있습니다.
아키텍처 다이어그램
구현 핵심 포인트
문서의 저장 이중화
문서 정보는 크게 두 부분으로 나눌 수 있었습니다.
- 문서의 내용 : 문서가 담고 있는 글
- 문서의 연관관계 : 문서가 가리키고 있는 상위 문서
이 중 문서의 연관관계는 범용적으로 사용하는 RDB에서 관리했을 때 비효율적인 측면이 분명히 있었습니다. RDB에서 필드를 하나 추가하여 상위 문서를 가리키게 할 때, 특정 문서를 시작으로하는 하위 문서를 가져와야하는 기능을 구현하기 위해서는 계층의 깊이와 비례하여 join 문장이 추가되어야 할 필요가 있었습니다.
이는 성능에 악영향을 줄 것으로 판단하여, 문서의 연관관계를 저장하는 부분은 Graph DB에 저장하고 내용을 저장하는 부분은 RDB를 사용하는 방식으로 문제를 해결했습니다.
RDB와 Graph DB의 일관성을 유지하는 것이 개발의 포인트 중 하나였습니다. 따라서 DocumentContentService와 DocumentGraphService로 각각의 DB를 조작하는 비즈니스 로직을 처리하는 서비스 클래스를 만들고, 이를 트랜잭션 단위로 묶어서 관리하는 DocumentService를 만들고 공개된 API는 DocumentService로 한정하게 만들었습니다. 그러나 . . .
…사실은 이렇게 하지 않았습니다.
package-private를 사용하기 위해서는 DocumentService 뒤에 숨겨야할 각 클래스를 하나의 패키지에 몰아놓아야하는 문제가 있었습니다. Content 파트와 Graph 파트는 Service와 Repository 및 다양한 클래스가 협업하면서 동작해야하는데, package-private 에 의해 패키지가 하나로 제한되면서 구조적으로 관리하기 복잡한 상태가 되었습니다. 따라서 패키지는 분리하되, 팀 내부 규칙으로 DocumentContentService와 DocumentGraphService를 외부에서 따로 호출하는 일을 막자는 약속을 공유하여 정합성 관리를 하게 되었습니다.
문서의 버전 관리
문서는 수정요청이 반영됨에 따라 점진적으로 진화합니다. 저희는 변경된 문서의 각 snapshot을 제공해야하는 요구사항을 가지고 있었습니다. 이를 구현하기 위한 핵심 아이디어는 다음과 같습니다.
- 문서를 섹션으로 나누어 관리한다 — 섹션은 헤딩 태그와 내용의 조합이며, 하나의 문서는 여러개의 섹션으로 구성된다.
- 문서는 ‘버전정보’ 필드를 가져야하며, 이는 수정요청이 반영될 때마다 증가해야한다.
- 한 수정 요청에서 변경 혹은 추가된 섹션은 그 당시의 문서 버전정보를 갖고 있는다.
예시
-------------------------------------------------
| documentId | title | latestRevision |
-------------------------------------------------
| 1 | Context Switch | 2 |
-------------------------------------------------
-------------------------------------------------------------------
| sectionId | revision | documentId | content | orders |
-------------------------------------------------------------------
| 1 | 1 | 1 | CS란... | 1 | **A**
-------------------------------------------------------------------
| 2 | 1 | 1 | CS로 얻는 이점은.. | 2 | **B**
-------------------------------------------------------------------
| 3 | 2 | 1 | Process란.. | 3 | **C**
-------------------------------------------------------------------
위 상태는 1번 문서가 최초에 생성되었을 때에 A와 B 섹션으로만 구성되었으며, C 섹션이 수정요청에 의해 생성되었음을 보여줍니다. 항상 문서의 조회는 다음과 같은 규칙을 따릅니다.
N 버전의 글을 조회하기 위해서는, revision이 N보다 같거나 작은 section만이 선택의 대상이 되어야하고, sectionId가 중복될 때에는 revision이 가장 큰 것을 골라라
위 규칙을 따른다면 2버전의 글을 조회할 때에는 sectionId의 revision이 2와 같거나 작은 A B C 모두가 선택의 대상이 되며 이들은 중복되지 않기 때문에 모든 섹션이 선택됩니다. 한 편 1 버전의 글을 조회할 때에는 A B 만 선택의 대상이 되며, 이들 역시 중복되지 않기 때문에 A B 가 조회의 결과가 됩니다.
만약 위 상태에서 A 섹션의 내용을 변경하고 B 섹션 뒤에 D 섹션을 추가하고자 하는 수정요청이 반영된다면, DB 테이블은 다음과 같이 변화됩니다.
-------------------------------------------------
| documentId | title | latestRevision |
-------------------------------------------------
| 1 | Context Switch | 3 |
-------------------------------------------------
-------------------------------------------------------------------
| sectionId | revision | documentId | content | orders |
-------------------------------------------------------------------
| 1 | 1 | 1 | CS란... | 1 | **A**
-------------------------------------------------------------------
| 2 | 1 | 1 | CS로 얻는 이점은.. | 2 | **B**
-------------------------------------------------------------------
| 3 | 2 | 1 | Process란.. | **4** | **C**
-------------------------------------------------------------------
| 1 | 3 | 1 | 변경된 A 섹션 | 1 | **A`**
-------------------------------------------------------------------
| 4 | 3 | 1 | 추가된 D 섹션 | 3 | **D**
-------------------------------------------------------------------
위 상태에서도 문서 조회 규칙이 알맞게 동작합니다. 3버전의 글을 조회하고자 하는 경우 테이블 내 모든 섹션이 선택의 대상이 되고, 1번 sectionId는 중복되므로 revision이 가장 높은 A’가 채택됩니다. 최종적으로 선택되는 섹션은 B, C, A’, D 일 것이며 이들은 orders에 의해서 재배열 된 뒤 사용자에게 전달됩니다.
2버전의 글 조회도 여전히 잘 동작하는데, 그 이유는 3번 revision을 가진 섹션들은 최초에 필터링되기 때문입니다. 2버전의 글은 A B C 가 최종적으로 선택될 것입니다. 1버전의 글 역시 A B 가 정상적으로 선택됩니다.
이러한 구조가 정상적으로 동작하기 위해서는 orders 필드를 놓고 섹션 간의 순서를 유지해주어야 합니다. 새로운 섹션이 중간에 삽입된 경우 ‘최신버전에서 선택되는 섹션’ 중 ‘revision이 삽입하고자 하는 섹션의 orders보다 같거나 큰 경우’에 해당하는 섹션들의 orders를 1씩 올려주어야 합니다. 위 예시에서는 C가 이에 해당하는 경우입니다. (추가된 섹션은 B의 뒤에 들어와야 하므로 3이 되고, 기존에 orders가 3이었던 C는 orders를 증가시켜야함)
왜 연결리스트 형태의 자료구조를 사용하지 않았는가?
위 상황은 비효율적으로 보일 수 있습니다. 섹션이 도중에 삽입되면 그 이후의 모든 섹션의 orders 필드를 업데이트해주어야 하기 때문입니다. 일반적으로 이러한 문제를 해결하기 위해서는 연결리스트 형태의 자료구조를 채택할 수 있습니다. 연결리스트는 순서를 가진 요소 목록 중 중간에 요소를 삽입할 때에 시간복잡도가 O(1)로 효율적으로 위 문제를 해결할 수 있어 보입니다. 연결리스트는 RDB에서 참조 필드를 놓는 방법으로 구현할 수 있습니다.
하지만 위 구조에는 연결리스트 형태로 구현하지 않았는데, 그 이유는 과거 버전 데이터의 정합성에 문제를 줄 수 있기 때문입니다. 2버전에서 B는 C를 가리키고 있습니다. 만약 3버전의 반영을 위해 B가 D를 가리키게 만들고 D가 C를 가리키게 만든다면, 2버전에서 글을 조회할 때에 B는 더 이상 C를 가리키고 있지 않게 됩니다. 이를 해결하기 위해서는 3버전에서 B 섹션도 변경된 것으로 처리하여 새로운 레코드를 만들고 기존 정보들과의 연결성을 끊어주어야 합니다.
결국 이러한 방법은 추가적인 공간복잡도를 사용하게 됩니다. 또한 생기는 추가적인 문제는 모든 섹션이 orders를 유지하지 않고 다음 섹션만을 가리키고 있을 때, 어떤 섹션이 시작 섹션인지를 찾기 위한 추가적인 로직을 작성해주어야 한다는 점입니다. 이러한 것들을 종합적으로 고려했을 때, 약간의 비효율이 발생하더라도 orders를 유지하는 편이 좋겠다고 판단했습니다.
문서 캐싱
위에서 언급했다시피 특정 버전의 문서를 조회하는 것은 단순한 쿼리로 해결되는 일이 아닙니다. 애플리케이션의 대부분의 요청이 특정 글의 현재 버전을 조회하는 일이라고 가정할 때, 조회의 부하가 상당할 것으로 예상할 수 있습니다. 따라서 읽기 부하를 줄이기 위해 글을 캐싱하는 전략이 필요합니다.
한 편, 문서 정보는 여러 섹션들을 포함하므로 데이터의 크기가 작지 않습니다. 상황에 따라서는 글의 크기가 너무 커서 캐시에 충분한 만큼의 글이 저장되지 않아 cache miss의 비율이 높아지고 성능에 악영향을 줄 수 있습니다. 따라서 효과적으로 캐싱을 사용하기 위해서는 메모리에 들어가는 글 정보를 압축해야한다는 결론을 냈습니다.
문서를 캐시에 저장하는 효율적인 방법은 정말 많겠지만, 저희는 Protocol Buffer 직렬화 방식을 통해 메모리 공간을 효율적으로 사용하고자 했습니다. Protocol Buffer를 통해 직렬화를 하여 캐시에 저장한다면 평균적으로 JSON 직렬화에 비해 40%의 공간을 사용할 수 있다는 것을 확인했습니다. 이를 통해 같은 공간을 쓰더라도 기존의 2배가 넘는 양의 문서를 메모리에 저장하여 캐싱 성능을 최적화 할 수 있었습니다. 그런데 ,,
…사실은 이렇게 하지 않았습니다.
엄밀히 말하면 잠시동안만 사용했는데, 그 이유는 Protocol Buffer를 사용하고 나서는 데이터 타입이 변경될 때 마다 새롭게 변경된 내용을 컴파일하고 해당 내용을 프로젝트에 옮겨야 한다는 번거로움이 있었기 때문입니다. 저는 이 기능을 첫 스프린트 때 도입했는데, 이후 발생하는 요구사항의 변경과 데이터 타입의 필드에 추가가 Protocol Buffer를 사용하는데에 큰 불편함을 주었습니다. Protocol Buffer의 대상이 되는 직렬화 오브젝트는 변경이 덜 일어나는 지점에 사용하는 것이 좋을 것 같습니다.
이벤트 프로그래밍
저희 애플리케이션은 다음과 같은 요구사항을 가집니다.
- 사용자의 활동에 따라 ‘배지’를 지급해야한다.
- 애플리케이션에서 발생한 일들에 대해 적절한 사용자에게 알림을 전송해야한다.
배지 도메인, 알림 도메인은 여러 다른 도메인으로부터 의존해야하는 문제가 있었습니다. 가령 수정요청이 반영되었을 때에는 해당 작성자의 조건을 판단하여 배지를 지급해야하며, 수정요청에 투표를 했던 모든 사용자에게 알림을 보내야하며, 글을 북마크한 사용자에게 글이 변경되었음에 관한 내용을 알림으로 보내야합니다. 수정요청 도메인은 배지와 알림 모듈에 강하게 결합되며, 메서드의 책임이 너무 비대해지는 결과를 낳았습니다. 비단 수정요청 뿐만 아니라 문서 파트, 토론 파트에서도 이러한 상황은 동일합니다.
위와 같은 문제를 해결하기 위해 이벤트 프로그래밍을 도입하였습니다. 이를 통해 각각의 도메인 서비스 코드에서 배지와 알림에 대한 모듈을 직접적으로 호출하지 않고, 단순히 각각의 도메인 이벤트를 발행하고 배지와 알림에서는 이벤트를 구독하는 형태로 동작하게 되어 모듈 간 결합도를 줄였습니다.
테스트 커버리지 관리
저희 팀은 프로덕트 안정성을 위해 테스트 코드를 적극적으로 작성하여 총 300개가 넘는 테스트 케이스를 만들었습니다. 저희가 만든 테스트코드를 수치적으로 평가하고자 Jacoco 라이브러리를 도입하였고 프로젝트를 진행하는 도중 라인 커버리지가 70% 아래로 떨어지지 않도록 지속적으로 관리했습니다.
저희 팀이 고민한 테스트코드와 관련된 내용은 추후에 별도의 글로 작성해 볼 예정입니다.
모니터링
지난 구름톤 1차 프로젝트에서는 별도의 모니터링 툴을 사용하지 않아서 에러 로그를 확인하고 분석하는데에 많은 어려움이 있었습니다. 이 문제를 해결하기 위해 이번 프로젝트에서는 Micrometer, Prometheus, Loki, Grafana를 사용하여 모니터링 시스템을 구축했습니다.
Grafana와 Loki만으로 로그를 분석하는데에는 훨씬 편리했지만, 모니터링 툴을 틀어놓고 매순간 보고 있는 상황이 아니면 에러 상황을 실시간으로 인식하기 어렵다는 문제가 있었습니다. 따라서 저희는 모니터링 툴을 Slack과 연동하여 error 수준의 로그가 발생할 때에 실시간으로 알림을 받을 수 있는 시스템을 통해 오류 상황에 대한 더 빠른 대처를 할 수 있었습니다.
약 두 달 동안 수행한 프로젝트인 만큼 디테일한 구현 포인트들이 굉장히 많습니다.
- 어떻게 투표 기간이 종료된 수정 요청들을 식별하고 투표 결과에 따른 로직을 구현했는지,
- 알림의 성능을 어떻게 최적화했는지,
- 수정요청을 원본에 반영하는 코드를 어떻게 추상화하여 좋은 품질의 코드를 만들기 위해 노력했는지,
- 여러개의 DBMS를 사용하는 환경에서 어떻게 개발자들의 local 환경을 통일시켰는지,
- 테스트 코드는 어떻게 구성했는지
등등 할 이야기가 정말 많지만 이는 이후에 차차 별도의 글로 남길 수 있었으면 좋겠습니다.
소개 영상
유능한 팀원분이 감사하게도 며칠 밤을 새워서 멋있는 영상을 만들어주셨습니다.
마무리
지난 두 달간 매일 같이 모든 팀원들이 3~4시간 씩 자면서 몰입한 프로젝트여서 너무나도 애착이 많이 갑니다. 두달이 지나니 몸은 지쳤지만 단 한순간도 즐겁지 않은 적이 없었습니다. 오히려 끝나서 아쉬울 정도입니다. 팀원들의 이야기를 들으면 비단 저만 그렇게 생각하는 것이 아닌것 같아 애틋하기만 합니다.
좋게 봐주시고 유튜브에도 올려주신 John Ahn 강사님께도 감사의 말씀드립니다.
'웹개발 > 백엔드' 카테고리의 다른 글
연관관계 매핑, 꼭 해야할까요? (1) | 2024.02.09 |
---|---|
Web IDE Backend Architecture (0) | 2023.12.24 |
[Toy Project] 대규모 데이터를 처리하는 게시판(5) - DB Connection (0) | 2023.11.07 |
[Toy Project] 대규모 데이터를 처리하는 게시판(4) - Index(2) (0) | 2023.11.05 |
[Toy Project] 대규모 데이터를 처리하는 게시판(3) - Index(1) (1) | 2023.11.01 |