Issuefy 목표
Issuefy는 GitHub 오픈소스 생태계에 기여하고자 하는 주니어 개발자들을 위해 기획된 프로젝트입니다.
주니어 개발자들은 오픈소스 프로젝트에 기여하고 싶지만, 적절한 이슈를 찾는 데 어려움을 겪거나, 'Good First Issue' 레이블을 가진 이슈가 다른 개발자에 의해 빠르게 선점되어 기회를 놓치는 경우가 많습니다.
Issuefy는 이러한 문제를 해결하여 더 많은 주니어 개발자들이 오픈소스 커뮤니티에 쉽게 참여할 수 있도록 돕는 것이 목표입니다. 관심 있는 오픈소스 프로젝트들을 한곳에서 모아볼 수 있게 하고, 해당 프로젝트들에 새로운 이슈가 등록되면 알림을 통해 빠르게 참여할 수 있도록 설계했습니다.
프로젝트의 핵심 기능중 하나는 실시간 알림이었고, 이를 구현하기 위해 Server-Sent Events (SSE)를 활용했습니다. SSE를 이용한 실시간 알림 서비스의 설계, 구현 과정, 트러블슈팅에 대해 소개하고자 합니다.
접근
사용자가 구독하고 있는 리포지토리에 대하여 새로운 이슈가 업데이트되는 것을 파악하고 실시간으로 알려주기 위해서는 크게 3가지를 고려해야 합니다.
- 첫 번째는 Issuefy API 서버와 GitHub API 서버의 통신입니다. 사용자가 리포지토리를 구독한 시점에 해당 리포지토리 정보를 DB에 저장하고, 해당 리포지토리의 데이터를 주기적으로 요청하여 비교하는 작업입니다.
- 두 번째는 Issuefy API 서버와 Issuefy 클라이언트 서버의 통신입니다. 리포지토리에 변화가 있을 때 해당 리포지토리를 등록한 사용자를 찾고, 사용자가 접속 중이라면 실시간으로 알림을 보내고, 그렇지 않다면 알림 엔티티를 만들어 나중에 사용자가 접속했을 때 새로운 알림의 개수를 보여주는 것입니다.
- 세 번째는 분산환경에서의 SSE 커넥션입니다. 애플리케이션 앞단에는 ALB가 있고, 오토스케일링이 적용된 분산 환경에서 운영됩니다. 이는 사용자의 요청이 어느 인스턴스로 전달될지 예측하기 어렵습니다. 물론 alb의 기본 라우팅 알고리즘이 라운드 로빈이기 때문에 어느 정도 예측 가능하지만 이를 고려하기에는 복잡성이 증가합니다. 또한, 인스턴스의 수가 동적으로 변할 수 있어 특정 인스턴스에 의존적인 설계는 피해야 했습니다. 이러한 환경적 특성을 염두하여 설계를 진행해야 했습니다.
설계 및 고민점
Issuefy API 서버와 GitHub API 서버의 통신
GitHub API 서버는 SSE와 롱폴링을 지원하지 않습니다. GitHub Webhook이 존재하지만, 이는 리포지토리 소유자의 허가가 필요하기 때문에 저희 서비스에 적합하지 않았습니다. 따라서 폴링 방식을 선택하였고, 여러 폴링 구현 방식을 고려했습니다.
- Spring Batch: 학습량이 많고 요구사항에 비해 과도하게 복잡하다고 느꼈습니다.
- Spring Scheduler: 인스턴스 스케일 아웃 시 중복 폴링 문제와 서버 장애 시 작업 손실 위험이 있습니다.
분산 환경을 고려할 때, 위 방식들은 적절하지 않다고 판단했습니다. 특히 Spring Scheduler를 사용할 경우, 오토스케일링으로 인해 중복 폴링이 발생하고, 서버 장애 시 이전 작업들이 손실될 가능성이 있기 때문입니다.
이러한 문제들을 고려했을 때, 폴링 작업을 메인 서비스에서 분리하는 것이 적절하다고 판단했습니다. 폴링 작업은 독립적인 서비스처럼 작동하며, 상시 실행될 필요 없이 주기적으로만 실행하면 되기 때문입니다. 이를 위해 AWS Lambda와 EventBridge를 사용하는 방식을 선택했습니다.
먼저 EventBridge의 스케줄러를 통해 Lambda 함수를 주기적으로 실행시킵니다. Lambda 함수는 사용자가 1명 이상 추가한 리포지토리 목록을 DB에서 가져와 각 리포지토리에 대해 GitHub API에 요청을 보냅니다. 받아온 최신 이슈 정보를 DB에 저장된 정보와 비교하여 차이가 있으면 DB 값을 업데이트합니다. 그리고 업데이트된 리포지토리 목록을 API 서버로 전송합니다. 이러한 방식으로 메인 서비스에서 폴링 작업을 분리하여 안정성을 높이고, 분산 환경에서도 일관된 폴링 작업을 수행할 수 있게 되었습니다.
Issuefy API 서버와 Issuefy 클라이언트 서버의 통신
이 부분에서는 폴링, 롱폴링, SSE, 웹소켓 등 다양한 방식을 고려할 수 있었습니다. 각 방식의 특징을 비교 분석한 결과, 다음과 같은 이유로 SSE(Server-Sent Events)를 선택했습니다.
- 폴링은 실시간성이 떨어지고 불필요한 요청이 많아 효율성이 낮아 배제했습니다.
- 웹소켓은 양방향 통신이 가능하지만, 알림 기능에는 과도한 기능이며 리소스 사용량이 상대적으로 높아 적합하지 않았습니다.
- 롱폴링은 SSE에 비해 연속적인 이벤트 전송이 어렵고 실시간성이 다소 떨어진다는 단점이 있었습니다.
SSE는 다음과 같은 장점을 제공했습니다.
- 높은 실시간성
- 서버에서 클라이언트로의 효율적인 단방향 통신
- 필요할 때만 데이터를 전송하여 리소스 효율성 증대
- 연속적인 이벤트 전송이 용이함
또한, SSE는 분산 환경에서도 효과적으로 작동할 수 있었습니다. ALB를 통해 요청이 다른 인스턴스로 전달되는 상황에서도 SSE 연결을 안정적으로 유지할 수 있기 때문입니다.
구현 상세 및 플로우
사용자당 커넥션 개수 제한
- 웹 기반 서비스이므로 동시 접속 필요성이 낮다고 판단했습니다.
- 사용자당 여러 커넥션 허용 시 분산 환경에서 복잡성이 증가하기에 사용자당 하나의 커넥션만 허용하기로 결정했습니다.
- 다른 기기에서 접속 시, 현재 활성 커넥션을 끊고 새로운 커넥션을 생성합니다.
연결 관리 방식
- ALB의 Sticky Sessions 사용을 고려했으나, 로드 밸런싱 효율 저하, 확장성 문제, 장애 대응의 어려움, ALB 부하 증가 등의 문제로 배제했습니다.
- 대신 Cache DB인 Redis를 이용한 중앙 집중식 연결 관리 방식을 채택했습니다.
- Redis를 통해 어느 인스턴스에 사용자가 연결되어 있는지 추적하고 관리합니다.
람다에서 수신한 메시지 알림 전송
- 람다에게 리포지토리 업데이트 메시지를 받으면 모든 인스턴스에게 Redis pub/sub을 이용하여 해당 사용와 리포지토리 정보를 전달하는 방식을 채택했습니다.
- 이를 통해 어떤 인스턴스가 람다로부터 응답을 받더라도 Redis를 통해 모든 인스턴스에 이벤트를 전파하고, 각 인스턴스는 자신이 관리하는 사용자에게 알림을 전달할 수 있게 되었습니다.
- 추가적인 설정이 필요해 현재는 적용하지 못했지만 장기적으로 람다가 직접 Redis에 퍼블리싱하는 방법을 고려하고 있습니다.
- 사용자 커넥션 요청
- 사용자에게 새로운 커넥션 요청이 오면, Redis에서 다른 인스턴스가 현재 사용자와 커넥션을 맺고 있는지 확인합니다.
- 기존 커넥션이 없다면
- 현재 인스턴스에서 사용자와 새로운 SSE 커넥션을 맺습니다.
- Redis와 로컬 저장소에 커넥션 정보를 등록합니다.
- 기존 커넥션이 있다면
- 다른 인스턴스에 해당 사용자의 커넥션 종료를 요청합니다.
- 현재 인스턴스에 새로운 커넥션을 설정하고 정보를 업데이트합니다.
- 람다에서 메시지 수신
- 업데이트된 리포지토리 목록을 받으면 해당 리포지토리를 구독하는 사용자들을 찾습니다.
- 각 구독 사용자에 대해 현재 인스턴스가 해당 사용자의 커넥션을 관리하고 있다면 직접 알림을 전송합니다.
- 현재 인스턴스가 해당 사용자의 커넥션을 관리하고 있지 않다면 Redis를 통해 알림을 퍼블리시합니다.
- 다른 인스턴스에서는 Redis를 통해 퍼블리시된 알림을 수신합니다.
- 수신한 알림이 현재 인스턴스에서 관리하는 사용자에 대한 것이라면 해당 사용자에게 알림을 전송합니다.
- 업데이트된 리포지토리 목록을 받으면 해당 리포지토리를 구독하는 사용자들을 찾습니다.
트러블슈팅
문제 상황
초기에는 Sse 커넥션에 keep-alive 옵션을 사용하지 않는 방식으로 구현하였는데, 다음과 같은 로그가 지속적으로 관찰되었습니다.
[INFO] [] [] o.a.c.c.CoyoteAdapter - Encountered a non-recycled request and recycled it forcedly.
org.apache.catalina.connector.CoyoteAdapter$RecycleRequiredException: null
원인 및 결과
이 문제의 근본 원인을 파악하기 위해 브라우저 개발자 도구의 네트워크 탭을 활용하여 커넥션을 모니터링 했습니다.
그 결과, 다음과 같은 시나리오에서 문제가 발생하는 것을 확인했습니다.
- 사용자가 탭을 전환하는 등의 액션이 발생하면 브라우저가 자동으로 SSE 커넥션을 취소합니다.
- 이때 서버는 아직 커넥션 초기화 과정 중이었고 브라우저의 갑작스러운 연결 취소로 인해 서버가 응답을 완료하지 못하게 됩니다.
- 그 결과, 서버에서 위와 같은 에러 메시지가 발생하게 됩니다.
이러한 문제를 해결하기 위해 커넥션시 keep-alive 옵션을 추가하였고 해당 문제는 해결되었습니다.
그러나 대규모 트래픽 상황에서 keep-alive 옵션의 효율성에 의문이 생겼습니다. 사용자가 서비스를 이용하지 않을 때도 연결을 유지하는 것이 서버 리소스에 부담이 될 수 있다고 판단해 일시적으로 keep-alive 옵션을 제거했습니다.
이 결정은 팀 내 코드 리뷰에서 논의되었고, 재검토 결과 다음 결론에 도달했습니다.
- 우리 서비스에서 알림은 실시간성이 중요하다.
- 클라이언트에서 연결 끊김을 제어하기 어렵다.
- 트래픽 증가 시 인스턴스 스케일 아웃으로 대응 가능하다.
- 타임아웃 동안 안정적인 연결 유지가 SSE의 주요 장점이다.
이를 종합적으로 고려해 keep-alive 방식을 유지하기로 결정하고, 지속적으로 모니터링할 계획입니다.
구현 결과
최종적으로, 프로젝트의 주요 목표였던 실시간 알림을 성공적으로 구현했습니다. 특히, 가변적이고 분산된 환경에서 대규모 트래픽을 염두에 두고실시간 알림 서비스를 구축하는 과정은 어려웠지만, 분산 시스템 설계에 대해 조금 더 학습할 수 있었던 계기가 되었습니다.
이 과정에서 분산 환경에서 발생할 수 있는 다양한 장애를 직접 겪으며, 알림 서비스의 장애가 전체 시스템에까지 영향을 미칠 가능성이 있다는 점을 알게 되었습니다. 이를 통해 장애에 견고한 아키텍처의 필요성을 느끼게 되었으며 많은 기업들이 마이크로서비스 아키텍처로 전환하는 이유에 대해 공감하게 되었습니다.
개선점
- 균등한 커넥션 분배의 어려움
- 현재 아키텍처에서는 인스턴스 간의 커넥션 부하가 고르지 않게 분배될 가능성이 있습니다. 특정 인스턴스에 커넥션이 집중되는 문제를 해결하기 위해 SQS와 같은 메시지큐를 도입하면 보다 균등하게 부하를 분산할 수 있을 것으로 예상되며 도입을 검토할 예정입니다.
- Redis 동시성 문제
- 레디스 접근 시 발생할 수 있는 동시성 문제와 레이스컨디션을 방지하기 위해 트렌잭션과 락 메커니즘에 대하여 좀 더 학습하여 보완할 예정입니다.
- 테스트 코드 부족
- 분산 환경에서 테스트 코드를 작성하는 데 어려움을 겪었습니다. 특히 동시성 문제나 분산 처리 상황을 효과적으로 검증하는 방법에 대한 학습이 더 필요함을 느꼈으며, 이를 통해 테스트 환경을 강화할 계획입니다.
'Issuefy' 카테고리의 다른 글
[Issuefy] Jmeter를 사용한 성능 테스트 및 스케일링 분석 (0) | 2024.09.23 |
---|---|
[Issuefy]Loki와 LogQL을 활용한 실시간 사용자 활동 대시보드 구현 (2) | 2024.09.05 |
[Issuefy] 의심스러운 로그 탐지 및 대응기 (3) | 2024.06.06 |
[Issuefy] 통합 모니터링 도입기 (2) | 2024.06.05 |