안녕하세요, 리멤버에서 Platform Crew에 속해있는 서버 개발자 신선영입니다. 플랫폼 크루에서는 다양한 업무를 하고 있지만, 그중 하나는 성능에 문제가 발생하는 부분을 수정하여 유저분들이 더욱 쾌적하게 서비스를 사용할 수 있도록 개선하는 것인데요.
최근에는 알림 기능에 병목이 발생하는 것을 파악했고, Ruby로 만들어진 모놀리틱 서비스에서 알림 기능만 Java 기반의 별도 API로 분리해 성능을 개선하는 작업을 진행했습니다. 이번 포스팅에서는 이 경험을 공유해보려고 합니다.
알림 서비스를 소개합니다
알림 서비스는 최근에 받았던 소식들을 모아두는 공간입니다. 푸시와 함께 보낼 수도, 알림만 단독적으로 추가할 수도 있는데요. 여기에 몇 가지 비즈니스 요구사항이 있습니다.
- 최근 30일의 알림만 보여주면 된다.
- 추후 고도화를 위해 필터(인맥 소식/커리어/커뮤니티 …)로 분리할 수 있어야 한다.
분리하게 된 배경
기존 알림에서의 DB는 RDB를 사용하고 있었습니다.
비즈니스 요구사항에 따르면 30일 치의 알림까지만 보여주면 되지만, 30일이 지난 데이터도 삭제하지 않고 계속 쌓고 있었습니다. 더군다나 확장성 있게 설계를 하다 보니 알림을 여러 테이블로 분리하게 되었고, 알림 1개당 평균 7개의 행을 쌓고 있었습니다.
그러다 보니 알림 관련 테이블에는 도합 21억 개라는 어마어마한 행이 쌓여있었는데요. 이렇게 많은 데이터가 쌓이니 당연히 성능 문제가 발생했습니다.
리멤버 앱을 켜면 첫 화면에 보여줄 정보를 가져오기 위한 API를 호출하고 있습니다. 여기에서는 읽지 않은 알림의 개수를 가져오는 API도 호출하고 있는데요. 알림의 데이터가 너무 많다 보니 조회할 때 슬로우 쿼리가 발생했고, 그로 인해 DB에서 읽기 전용 스레드 할당이 제대로 되지 않아 병목 구간이 발생하여 에러가 나는 경우가 종종 있었습니다.
저희 팀에서도 이런 문제를 인식하고 있었고, 성능을 개선하기 위해 알림 도메인을 분리하게 되었습니다.
데이터베이스 설정
RDB vs NoSQL
기존과 같이 RDB를 사용할지, NoSQL을 사용할지 많은 논의를 거친 결과 알림 도메인에는 RDB보다는 NoSQL이 더 적합하다는 결론이 났습니다.
- TTL
- 알림은 한 달 이후 건은 보이지 않기 때문에 계속 알림을 쌓아둘 필요가 없습니다.
- 즉, 일정 시간이 지난 데이터에 대한 스키마 관리를 안 해도 되는 도메인입니다.
- TTL을 기본적으로 지원해주는 NoSQL이 RDB보다 적합합니다.
- 알림은 한 달 이후 건은 보이지 않기 때문에 계속 알림을 쌓아둘 필요가 없습니다.
- 유연한 스키마 대응
- Read/Write가 굉장히 빈번하게 일어나는 도메인 특성상 한 번에 최대한 적은 행이 추가되어야 합니다.
- 알림 도메인의 큰 틀은 고정되어있지만, 알림의 행위를 정의하는 부분은 구조의 변경이 잦기 때문에 유연한 스키마를 가질 수 있어야 합니다.
- Schema-less한 NoSQL이 RDB보다 적합합니다.
- 성능
- 알림 도메인에는 트랜잭션 관리가 필요 없습니다.
- 트랜잭션 관리를 하지 않고 성능을 챙길 수 있는 NoSQL이 RDB보다 적합합니다.
- 알림 도메인에는 트랜잭션 관리가 필요 없습니다.
MongoDB vs Redis vs DynamoDB
다음 단계로는 어떤 NoSQL을 사용할지 정해야 합니다. 저희 크루는 여러 NoSQL 후보 중에서도 MongoDB, Redis, DynamoDB 중에 고려하기로 했습니다.
- DynamoDB
- 고가용성을 보장하지만, 가격이 비싸 Read/Write가 잦은 알림 시스템에 적합하지 않다고 생각했습니다.
- Redis
- Key-Value로 조합을 하는데 그 과정에서 설계의 복잡성이 올라가 추후에 유지보수가 어려울 것이라고 생각했습니다.
이러한 이유로 알림 도메인의 데이터베이스는 MongoDB가 가장 적합하다고 생각이 들었고, 리멤버에서는 AWS를 활발하게 사용하고 있기 때문에 MongoDB와 호환되는 AWS의 NoSQL인 DocumentDB를 사용하기로 했습니다.
RDB 다시 한번 고려해 보기
최종적으로 데이터베이스를 결정하기 전에, RDB와 NoSQL을 사용하기로 결정한 게 너무 빠르게 결정된 것 같아 다음과 같은 이유로 다시 한번 RDB도 고려해 보기로 했습니다.
- 배치 스케줄러 또는 DB 내에서 이벤트 처리를 하면 RDB로도 TTL을 구현할 수 있다.
- 스키마를 유연하게 사용하는 게 오히려 독이 될 수도 있다.
- 트랜잭션 처리가 정말 필요 없을지 다시 한번 고려해 보아야 한다.
TTL 수동으로 구현
위에서 서술한 대로 RDB에서도 수동으로 구현이 가능합니다. 하지만 수동으로 구현하게 되면 어쩔 수 없이 기본적으로 제공해 주는 기능보다 추가적인 컴퓨팅 리소스를 사용해야 합니다. 또한 배치로 TTL을 구현할경우, 배치 애플리케이션에서 장애가 나게 되면 고스란히 비용으로 남게 됩니다.
그렇기 때문에 안전하고 빠른 TTL을 위해서는 NoSQL이 더 적합하다는 의견으로 결정이 되었습니다.
스키마의 유연성
기존의 데이터베이스 구조는 그림처럼 3개의 테이블로 나누어져 있습니다.
notifications
- 알림의 정보
notification_messages
- 알림의 메시지에 들어갈 값
notification_data
- 알림의 이미지 정보 (링크, 모양)
- 알림을 누르면 이동할 경로
- 알림에서 보여줄 추가적인 정보들
NoSQL은 RDB보다 많은 타입을 지원하고 있고, 스키마도 유연하게 변경이 가능합니다. 그에 비해 RDB는 한정적인 타입을 지원하고 스키마의 유연성도 떨어지기 때문에 기존의 테이블 구조처럼 조인이 들어가야 하고, 구조의 변경이 잦은 부분은 JSON 문자열로 해야 하는 등 한눈에 알아보기 어렵습니다. 또한 알림 자체가 복잡한 테이블 구조를 가질 필요가 없기 때문에 NoSQL이 더 적합하다는 의견으로 통일 되었습니다.
하지만, 스키마를 무분별하게 사용하다가 장애가 난 경험을 가지고 있는 동료분이 이에 대해 우려를 가지고 계셨습니다.
다른 요소들(TTL, 직관성, 성능 …)을 생각해 보았을 때 RDB보다 NoSQL이 알림 도메인이 훨씬 적합하다고 생각이 들었고, 엔티티로 만들어 필드를 고정한 뒤에 고정된 필드에 대해서는 간단한 Validation이라도 적용하면 충분히 예방 가능한 일이었습니다. 또한 고정된 스키마 틀을 바꾸는 것을 최대한 지양해서 무분별하게 사용할 일이 없도록 만들었습니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED) @Document(collection = "notifications") public class Notification { @Id private String id; @NotNull private Long userId; @NotBlank private String transactionId; @Valid private NotificationImage image; // ... } public static class NotificationImage { @NotBlank @URL private final String url; @NotNull private final Shape shape; }
성능
RDB에서는 ACID 트랜잭션 관리를 위해 성능이 NoSQL보다 상대적으로 느릴 수밖에 없습니다. 하지만 알림 도메인의 특성상 트랜잭션 관리가 중요하지 않다고 판단하여 성능이 우선순위가 높다면 NoSQL을 선택하는 게 맞다는 의견으로 통일되었습니다.
그래서, 이 선택이 도움이 되었을까?
개발을 끝내고 안정화가 된 후에 선택을 돌아보면 NoSQL은 좋은 선택이었습니다. 실제로 성능을 올리는 데에도 많은 부분을 차지했습니다. RDB를 채택하지는 않았지만, NoSQL 선택을 부정적으로 바라보고 다시 한번 의논을 거친 과정에서 고려할 점을 찾게 되는 과정도 많은 도움이 되었습니다.
다만, DocumentDB를 사용하며 몇 가지 아쉬웠던 점이 있었습니다.
- DocumentDB와 MongoDB의 다른 부분이 있는데, MongoDB를 지원하는 라이브러리(Spring Data MongoDB, Mongoid …)에서는 DocumentDB의 지원하지 않는 기능이 있는 점
- 관련 자료가 많지 않다 보니 문제 해결에 있어 많은 어려움을 겪은 점
- NoSQL은 auto-increment가 기본적으로 불가능하기 때문에 auto-increment용 collection인 counter를 추가로 만들어야 했던 점
- 이렇게 되니 insert가 원자적으로 쿼리가 짜여지지 않아 동시성 문제가 발생하기도 했습니다.
// 아토믹하지 않은 코드 😭 private Notification insert(final NotificationUpsertRequest request) { final long seq = sequenceGeneratorService.generateSequence(Notification.SEQUENCE_NAME); return notificationRepository.save(Notification.create(request, seq)); }
public long generateSequence(final String seqName) { final Counter counter = mongoOperations.findAndModify(query(where(NAME).is(seqName)), new Update().inc("value", 1), options() .returnNew(true) .upsert(true), Counter.class); return Objects.isNull(counter) ? INIT_VALUE : counter.getValue(); }
설계
알림 아키텍처를 설계할 때 고려해야 했던 점이 몇 가지 있었습니다.
앱에서의 호출 하위호환성
리멤버에서는 사용자 경험을 위해 앱을 강제 업데이트하지 않고 있습니다. 그래서 이미 배포된 앱에서는 호출하는 URI를 바꿀 수 없는데요. 기존에 호출하는 API로 요청을 받되, API에서 처리하는 것이 아닌 알림 도메인으로 포워딩만 하도록 했습니다.
이벤트
기존에는 생성, 수정, 삭제, 조회 모두 API로 요청을 받아 처리하고 응답을 보내고 있었습니다. 하지만 분리를 하며 다음과 같은 이유로 조회를 제외한 나머지 동작들은 모두 이벤트로 동작하도록 수정했습니다.
- 조회 외에는 응답을 받을 필요가 없다.
- 조회 외에는 실시간으로 처리가 될 필요가 없다.
- 이벤트 방식으로 구현하면 외부 서비스의 의존성을 격리 시킬 수 있다.
개발
기술 스택
기술 스택은 분리 작업을 하기 전에 미리 Java + Spring으로 개발을 하자고 결정되어 있었기 때문에 어렵지 않게 기술 스택을 정할 수 있었습니다.
- Java 11
- Spring Boot 2.5
- Spring Data MongoDB
코드리뷰
개발할 때에는 이슈를 최대한 잘게 쪼개서 어느 정도 진행이 되었는지 명확하게 파악할 수 있도록 했고, 코드리뷰를 진행하며 더 나은 코드를 함께 고민했습니다.
페어 프로그래밍
또한 개발 진행 중에 디버깅이 잘 안되거나, 로직을 짜는 도중에 고민이 생기면 페어 프로그래밍을 통해 함께 문제를 해결하며 개발을 진행했습니다.
마이그레이션
DB를 옮겼다고 기존에 쌓아둔 알림을 무시하고 새로 쌓을 수는 없으니 마이그레이션을 진행해야 했습니다. 이때 마이그레이션 대상 데이터는 700만 건이었는데요. 추가로 정보를 조회해야 하는 테이블을 포함하면 약 5,000만 건의 데이터를 읽어 마이그레이션을 진행해야 했습니다.
RDB에서 NoSQL로 전환을 해야 했기 때문에 별도의 실행 스크립트를 짜서 돌리기로 했습니다. 또한 읽기 전용 레플리카 DB에 연결하여 마이그레이션의 이유로 실제 운영 중인 서비스에 영향이 가지 않게 했습니다.
계산해보니 한 달 치 데이터를 한 번에 옮기는데 24시간이 넘어가기 때문에 하루 만에 마이그레이션은 불가능하다고 생각했습니다. 그래서 새로운 알림 도메인을 개발하기 시작함과 동시에 테스트 환경에서 마이그레이션 스크립트를 미리 돌려보고, 문제가 없다고 판단되면 조금씩 마이그레이션을 하기로 결론이 났습니다.
그런데 …
순탄할 줄만 알았던 마이그레이션 작업에 문제가 생겼습니다. 동기적으로 돌리려고 하니 시간이 너무 오래 걸렸습니다. 그래서 이를 해결하기 위해 멀티 스레드로 작업을 하기로 했습니다.
처음에는 스레드의 I/O를 고려해서 스레드 개수를 정했는데 여러 스레드에서 한꺼번에 많은 객체를 생성해 메모리가 부족해져서 프로그램이 비정상적으로 종료되는 상황이 발생했고, 해결을 위해 여러 가지 방법을 사용했습니다.
- 스레드 개수 조정
- Ruby에서 제공하는 Garbage Collector 메소드인
GC.start
를 사용해서 강제적으로 Garbage Collecting을 하여 메모리의 용량도 확보 - 데이터베이스에서 데이터를 가져올 때 청크 단위 조정
def migration(start_range, end_range) # 마이그레이션 로직 begin insert_document(insert_documents) rescue StandardError => e @logger.error ... ensure @logger.info ... GC.start end end end end def start_migration thread_pool = Concurrent::FixedThreadPool.new(6) Time.new(yyyy, mm, dd).to_date.upto(Time.current.to_date).each do |date| thread_pool.post do start_of_date = date.beginning_of_day end_of_day = date.end_of_day migration(start_of_date, end_of_day) end end thread_pool.wait_for_termination end
처음에는 CS 처리용으로 운영 DB에 임의의 작업이 허용된 인스턴스에 돌렸는데, 인스턴스에 무리가 가는 작업을 하다 보니 서버가 느려졌고, 이 인스턴스를 사용하는 다른 개발자분들이 인스턴스를 사용하지 못하는 상황이 발생했습니다. 그래서 마이그레이션용 인스턴스를 하나 만들고 그 인스턴스에서 작업하도록 수정했습니다.
또한, 마이그레이션 과정 중에는 총작업량에 대비하여 얼마나 남았는지 파악하고, 중간에 에러가 났을 때 재시작한 부분을 파악하기 위해 현재 상태를 파악할 수 있는 프로그레스 로그를 찍었는데, 이 로그가 많은 도움이 되었습니다.
성능테스트
성능 테스트에서는 가장 중요한 응답시간 측정을 위해 더미 알림을 쌓고, 응답 시간을 측정해보았습니다. 생성, 삭제, 수정은 이벤트로 발행하기 때문에 성능 테스트에서 제외하고 조회 API의 응답속도만 측정했습니다.
- 알림을 400개 가지고 있는 유저 데이터 2개 생성 (최대 알림을 가지고 있는 유저의 알림 개수)
- 1K request/per second로 조회 요청 (평균적인 요청량)
- NewRelic에서 확인 결과 40ms 이내의 응답 속도
최대 40ms정도면 준수한 속도였기 때문에 추가적인 성능 개선 작업 없이 배포하기로 했습니다.
배포
배포는 롤링 배포 방식을 선택했습니다. 배포 도중에 저장은 새로운 DB에 저장이 되고, 조회는 배포되지 않은 서버에 조회하는 경우 제대로 데이터가 보이지 않는 이슈가 생길 것 같다고 생각했지만, 마이너한 이슈이기 때문에 감안하기로 했습니다.
단, 최대한 이러한 영향을 줄이기 위해 사용자 수가 가장 적은 새벽 시간대에 배포했고, 배포 이후에는 증분값에 대해서 마이그레이션을 한 번 더 했습니다.
그리고 열심히 오류를 잡은 결과, 다행히 롤백하지 않아도 될 만큼 안정화가 되었습니다! 여러 이슈를 처리하는 과정에서 많은 레슨을 얻었고, 그 내용도 공유하고 싶지만 글이 너무 길어질 것 같아 다음에 기회가 되면 새로운 글 공유드리겠습니다.
서버를 분리하며 도합 21억 건 정도의 데이터를 삭제할 수 있었고, 성능도 많이 개선할 수 있었습니다. 평균적으로는 응답 시간이 3배 이상 줄어들었고, 분리 이전에는 예전부터 알림이 많이 쌓인 일부 유저의 경우 최대 30초까지 걸리던 처리 시간이 현재는 최대 17ms가 되었습니다.
마치며
아직 모놀리틱으로 되어있는 부분이 남아있어 저희는 앞으로도 점진적으로 MSA로 옮기며 성능을 개선해 나아갈 예정입니다. 현재 리멤버에서는 활발한 채용이 진행 중이니, 많은 관심 부탁드립니다!
읽어주셔서 감사합니다! 🙂
와우 엄청나네요!! 다음편도 기대가 됩니다
감사합니다 지젤 제노글로시님 🙂
SEQUENCE 의 용도가 궁금합니다. 동시성 이슈를 해결해야 해서 결국 DocumentDB의 성능을 저하시키는 요인처럼 느껴지는데, 꼭 필요한 부분이었을까요? 정확한 용도를 모르는 상황이라서, { create_at: new Date() } 필드와 함께 1번의 insert 로 충분한 문제 아닌가? 싶은 부분이 있어요.
안녕하세요 성혁님! 댓글 감사합니다.
말씀해주신대로 이에 대해서 많은 논의가 있었는데요. 따로 Sequence를 만든 이유는 앱에서의 하위 호환성을 지키기 위해서였습니다.
앱에서는 알림의 id를 int로 받고 있었고, id를 사용해서 요청을 보내고 있었습니다. 그런데 MongoDB에서 자동으로 생성되는 id는 Oid 타입이었고, 안에 들어가는 파라미터가 String 타입이라 그대로 보내줄 수 없었는데요. 이를 해결하기 위해 몇 가지 방안이 나왔습니다.
1. MongoDB에서 생성되는 oid를 byte단위로 변환해서 int 형태로 변환 -> int값을 넘어감
2. 만들어진 날짜 기반으로 int 형태로 변경 -> 중복 값이 나올 확률이 있음
결국 마땅한 방안을 찾지 못해 auto-increment되는 별도의 Collection를 만들고, Sequence도 만들게 되었습니다 ㅠㅠ
처음에는 MongoDB의 function을 사용해서 내부적으로 동작하게 하려고 했습니다. 하지만 DocumentDB에서 함수를 지원하지 않는 것인지 함수를 추가하고 인스턴스를 재시작하면 함수가 사라지는 현상이 발생했고, 결국 코드로 해결하게 되었습니다. 😭
자세한 설명 감사합니다. 그러면 말씀 주신 동시성 이슈는 어떻게 해결하신건지 궁금합니다. 확실히 findAndModify 메소드 때 동일 id가 두 번 발급될 수 있겠는데요?
…라고 질문하려고 했는데 https://stackoverflow.com/a/26359924/8556340 와 https://docs.mongodb.com/manual/reference/method/db.collection.findAndModify/ 에 따르면 findAndModify 에서 항상 중복되지 않은 id가 발급될 것 같습니다. 동시성 이슈는 없었다, 고 이해하면 될까요? (제가 본문의 문장 톤을 정확하게 이해하지 못한 것 같습니다)
네! 동시성 이슈는 findAndModify()에서 발생된 것이 아닌, 다른 곳에서 발생된것이었는데 이제보니 제가 오해할 소지가 있을 수 있게 글을 작성했었네요 ㅠㅠ
findAndModify()에서는 동시성 문제가 발생하지 않았습니다.
안녕하세요, 좋은 글 감사합니다!
읽다가 궁금한 점이 생겨서 댓글 남깁니다.
알림 데이터는 읽음/읽지 않음 이란 status가 존재할텐데 해당 status때문에 update가 매우 빈번할 것으로 예상됩니다.
nosql은 update의 performance가 좋지 않은데, 혹시 이에 대해선 어떻게 생각하셨나요?
예를 들면 해당 update를 감안하고도 mongodb를 선택하는 것이 더 큰 이점이 있다고 생각하셨는지요?
안녕하세요 권준님! 댓글 감사합니다.
일반적으로 NoSQL이 일관성보장(ACID)을 하지 않는 대신 쓰기 성능이 RDB보다 좋기 때문에, 업데이트의 성능 역시 더 좋은것으로 알고 있습니다!
알림 리스트를 가져오는 동시에 읽음 처리를 하고, 한 번에 읽음 처리가 되는 개수도 적기 때문에 따로 업데이트 성능에 대해 우려하지 않았습니다. 🙏
좋은 글 잘 읽었습니다 🙂
저도 질문 하나 스윽 남겨봅니다.
// 기술 스택은 분리 작업을 하기 전에 미리 Java + Spring으로 개발을 하자고 결정되어 있었기 때문에 어렵지 않게 기술 스택을 정할 수 있었습니다.
혹시 기술 스텍을 JAVA + Spring 환경으로 결정하신 이유가 있을까요?
다른 언어나 프레임워크와 비교했을때 어떤 이유로 이 서비스에서 자바를 선택하게 된것인지 궁금합니다.
안녕하세요 성진님! 댓글 감사합니다.
기술 스택을 정할 때 여러 후보군을 정해서 결정하진 않았고, 이 조합이 크루원들이 가장 익숙하며 잘 사용할 수 있는 언어 + 프레임워크였습니다.
이미 너무나 메이저한 조합이기도 하고, 안정적이라는 기술이라는 사실 역시도 입증된 상태였기 때문에 굳이 다른 대안을 고려해보진 않았던 것 같아요! 또한 메이저한 조합인 만큼 알림 도메인을 개발한 사람이 아니더라도 추후에 유지 보수하기에도 용이하다는 것도 한몫했습니다. 🙂
마이그레이션이 며칠 내에 끝날 수준이라 다행인데, 30일 보관이 아닌 영구저장 또는 연 단위 저장 데이터를 마이그레이션 하려면 어떻게 해야될까요?
또, 스키마가 자주 변할 수 있는데.. 매번 배치를 돌리는건 좀 부담스러워 보이네요..
좋은 방법이 없을까요 nosql은 거의 안써봐서 모르겠네요;
안녕하세요! 리멤버에서도 기존의 알림 데이터를 영구저장 방식으로 저장하고 있었습니다. 많은 데이터를 다루기 위해 루비에서 제공해주는 배치성 작업을 위해 제공해주는 모듈인 ActiveRecord::Batches를 사용해 작업을 진행했습니다. 이외에 마이그레이션 속도를 올리기 위해 FixedThreadPool(multi thread), screen 등을 사용했습니다. 저희는 여러 문제들때문에 사용하지 못한 방법이지만 정말 큰 용량이라면 csv 파일로 추출 후에 업로드하는 방법도 고려해볼 수 있을 것 같습니다. (관련해서 참고했던 링크 : https://whatsupkorea.com/2018/06/02/mongodb-%EC%9D%98-%EC%84%A0%ED%83%9D%EA%B3%BC-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98/)
스키마의 경우에는 말씀주신대로 알림의 특성상 변화 가능성이 크기 때문에 schemaless한 nosql을 사용했고, 스키마가 변경된다고 별도의 배치를 돌리지는 않았습니다. 그래서 validation도 알림의 제목, 내용같이 변경될 일이 없는 부분에만 적용해두었습니다.
답변이 잘 되었는지 모르겠네요. 혹시나 추가로 궁금한 점이 있으시면 답글 부탁드립니다. 읽어주셔서 감사합니다!