티스토리 뷰

오늘은 현재 진행중인 애완동물 분양 플랫폼 프로젝트의 알림기능을 구현시 발생한 문제와 3주간의 추적을 통한 해결과정에 대해 공유드리려 합니다.

 

알림 기능 구현을 위한 백엔드 인프라 환경 및 사용기술은 아래와 같습니다.

Sever Infra : AWS EC2/Linux
Proxy Server
: Nginx 1.22.

WAS : tomcat (scale out - blue/green)
Language : Java 11
Framwork : Spring Boot 2.7.11
ORM : Spring Data JPA
DB : MySQL(Master/Slave)
CI/CD : Github Actions, AWS S3&CodeDeploy

 

 

비즈니스 요구사항 및 목표

현재 프로젝트에서는 기본 커뮤니티 게시판, 분양 소통 게시판, 채팅, 쪽지 서비스를 대표적으로 제공하고 있으며 이를 바탕으로 도출한 알림 전송 조건은 아래와 같습니다.

 

  • 회원이 작성한 게시글에 새로운 댓글이 추가될 경우
  • 회원이 작성한 댓글에 새로운 대댓글이 추가될 경우
  • 작성한 게시글이 인기글로 선정될 경우
  • 쪽지를 수신 받았을 경우
  • 채팅이 발생 했을 경우

 

위 조건을 통해 서비스별 알림을 정상적으로 전송하되 통합으로 처리 가능한 서비스를 만드는 것을 목표로 잡았습니다.

 

 

기술 선정

알림 기능을 구현하기 앞서 대표적으로 사용되는 기술이 무엇인지에 대한 리서치가 이루어졌으며 여러 방향으로 비교해본 결과 WebsoketSSE 통신 기술을 선정 후보군으로 뽑게되었습니다.

 


 

websoket

Transport protocol의 하나로 서버와 클라이언트 간의 실시간 양방향 통신을 실현하기 위한 구조로 이루어져있습니다.
주로 채팅, 주식차트 등 실시간성이 높게 유지되어야하는 비즈니스에 사용되며 주기적으로 HTTP 요청이 수행되어야하는 polling 방법과 달리 websoket은 연결된 상태를 유지하기 때문에 지속적 연결을 위해 필요한 리소스의 낭비를 줄이되 실시간성을 유지할 수 있습니다.

 

SSE(Server-Sent-Events)

위키피디아에 따르면 구글의 Ian Hickson에 의해 처음 지정된 기술로, 서버와 클라이언트 첫 연결이 이루어진 이후 특정한 조건이 갖추어지면 서버에서 이벤트를 단방향으로 보내는 통신 기술입니다. 클라이언트의 별도의 추가 요청 없이 실시간 데이터를 서버에서 자체적으로 전송할 수 있다는 점이 주요 특징입니다. 

 

 


 

 

알림 기능의 경우 서버에서 이벤트 발생시에만 데이터 전송해야 하는 비즈니스 조건임으로 양방향 통신을 위한 soket 연결을 유지해야 하는 websoket은 유지비용 및 구현 난이도 측면에서 SSE 보다는 효율적이지 않다는 판단이 들었습니다.

 

위와 같은 근거로 최종적으로 SSE 기술을 채택하게 되었으며 Spring framwork 4.2부터 SSE 기술을 위해 지원하는 SseEmitter API와 클라이언트에서 SSE 연결을 컨트롤하는 EventSourcePolyfill 라이브러리를 통해 구현을 했습니다.

 

EventSourcePolyfill 라이브러리 사용 이유
- EventSource 인터페이스는 헤더 전달을 지원하지 않습니다.
EventSourcePolyfill을 사용해 access token을 담은 header를 SSE 요청에 포함시킬 수 있었습니다.

 

 

문제사항 및 해결결과

조건에 해당하는 비즈니스로직을 모두 작성하고 로컬에서 postman과 React를 통해 연결테스트까지 정상 확인 후 배포를 진행했습니다.

 

배포 후 front와 통합 과정에서 기존에 로컬에서는 발생하지 않던 net::ERR_HTTP2_PROTOCOL_ERROR 200 라는 에러가 console을 통해 확인되었습니다. 클라이언트를 통해 최초 연결에서 문제는 없었으나 연결 이후 설정해둔 기본 timeout을 채우지 못하고 연결이 끊어져 버리는 현상이였습니다.

 

배포 후 console 에러 발생

 

우선 timeout이 유지되지 못한다는 사실을 기반으로 서버와 클라이언트 timeout 시간을 일치시켜 보았습니다.

 

SseEmitter 객체를 통해 기본 timeout 설정
EventSource 기본 timeout 설정

 

하지만 이러한 시도에도 똑같은 문제 현상이 발생되었습니다.
이후 Nginx 설정 및 WAS 기본 설정 변경 등 다양한 시도를 추가적으로 시도했으며 그 과정에서 특정한 사실들을 도출하게 되었습니다.

 

당시 SSL 인증서 발급 절차를 간소화하고자 cloudflare를 이용중이였으며 브라우저 ResponseHearder의 Server : cloudeflare라는 점과 최초 연결 후 연결 강제 종료가 100초마다 발생한다는 사실을 깨달았습니다.

이러한 사실을 도출하기까지 3주라는 오랜시간이 걸렸습니다. cloudflare를 처음부터 의심해보았지만 제가 Infra 구축을 하지 않았기에  확신이 없었으며 구축을 담당한 팀원이 여러번의 아닐 것이라는 의견에 혹하여 의심을 접었던 것이 패착이였던 것 같습니다...(이자리를 빌려 재밌는 스토리를 제공해주신 팀원 서모씨에게 감사인사를 드립니다^^)

 

로컬에서 SSE 연결시 1분 40초 이상 유지되는 장면

 

배포된 API로 요청 시 100초마다 연결 종료되는 장면

 

위와 같은 사실을 통해 추가적으로 리서치한 결과는 아래와 같습니다.

 

cloudflare의 proxy read timout 제한

좌 - 공식문서, 우 - 유저문의 답변

 

cloudflare의 공식문서특정 유저의 문의에 대한 cloudflare의 답변내용을 통해 기본 무료 버전을 이용하는 계정은 100초의 연결시간 동안 HTTP 응답을 원본서버가 제공하지 않을 경우 timeout 되는 제한을 걸어두었으며 Entetprise 요금제를 이용하는 계정에 한하여 제한시간을 6000초까지 늘릴 수있다는 점을 깨닫게되었습니다.

 

현재 proxy server로 Nginx를 이용하고 있음으로 cloudflare를 필수적으로 유지할 필요가 없었으며, SSL 인증서는 추가 리소스가 투입되지만 별도의 간단한 작업을 통해 발급이 가능함으로 cloudflare를 대체할 수 있다고 판단하게 되었습니다.

이러한 근거를 바탕으로 Let's Encrypt 라는 비영리 인증 기관에서 제공하는 무료 SSL인증서를 발급 받은 후 재배포를 진행했습니다. 이번 게시글에 SSL인증서를 발급받는 과정을 담기에는 핵심 내용과 무관하다고 판단되어 참고 링크는 하단에 기재하도록 하겠습니다. 

 

 

Nginx 설정

재배포 후 정상 작동을 기대했으나 아래와 같은 새로운 에러가 발생했습니다. 

 

net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 라는 에러코드로 변경되었으나 최초 연결은 기존과 같다는 점과 일정시간 뒤 연결이 종료된다는 점을 기준으로 서버와 클라이언트에서 코드로 설정한 timout 시간은 여전히 무의미하다는 것을 인지했으며 proxy server 역할을 하는 Nginx 설정을 수정해야한다는 결론에 도달하게 되었습니다.

 

nginx.conf

백엔드 서버의 proxy 역할을 수행하는 nginx 설정에서 빨간색 부분이 노란색 부분에서 SSE 연결 유지를 위해 추가된 사항입니다.

 

추가된 사항에서 연결 유지를 위해 주요한 역할을 하는 설정은  proxy_read_timeout 이며,
해당 설정 이후 비즈니스 요구사항에 맞게 최종적으로 정상 배포할 수 있게 되었습니다.

 

그외 SSE 연결 위한 Nginx 몇가지 주요 설정의 사유는 아래와 같습니다.

proxy_http_version 1.1;
proxy_set_header Connection '';

Nginx는 기본적으로 Upstream으로 요청을 전달 시 HTTP/1.0 버전을 사용합니다.
이 경우 Nginx에서 WAS로 요청을 전달 시 HTTP/1.0의 기본 사항으로 인해 Connetion : close 헤더를 사용하게 됩니다.

SSE 연결을 위한 조건과는 맞지 않기 때문에  Connection: keep-alive가 기본값인 HTTP 1.1 버전으로 변경했습니다. 

 

proxy buffering : off

Nginx는 서버의 응답을 버퍼에 저장해둔 뒤 버퍼가 차거나 서버가 응답 데이터를 모두 보내면 클라이언트로 전송하게 됩니다.
해당 버퍼링 기능이 SSE 연결시 원하는 동작을 하지 못하게 하거나 실시간성을 떨어트릴 수 있습니다.

하여 이러한 설정을 버퍼링 설정을 꺼야하는데 모든 API 응답에 대해서도 버퍼링을 하지 않게될 경우 비효율적임으로
다른 API 응답과 분리하기 위해 빨간색 부분과 같이 SSE 연결 API 경로만 타겟으로 설정을 분리했습니다.

 

이외 SSE 응답 시 발생하는 503 Sevice Unavalialbe 에러를 방지하기 위한 기본 값 전송, 네트워크 불안정으로 인한 이벤트 유실 방지를 위한 마지막 이벤트 정보 In-memory 저장 등을 통해 기능 작동시 문제 발생을 최소화할 수 있었습니다.

 

 

추가 개선사항

위 문제 사항 해결을 통해 큰 문제없이 알림 기능을 정상적으로 배포 운영중입니다. 하지만 아직 특정 사항 개선이 필요하며 앞으로 개선해나갈 사항들을 정리해보았습니다.

 

서비스 로직간의 강결합 해소

현재 서비스 로직 중 댓글 작성을 예로 들었을 때 '댓글 작성 요청 -> 댓글 서비스 로직 처리 -> 알림 생성'  절차로 알림 전송이 이루어 지고있습니다.

댓글 저장 후 알림 생성 로직

하지만 이러한 프로그래밍 로직은 객체 지향 원칙 중 '클래스가 변경되어야 하는 이유는 오직 하나뿐이어야 한다'는 단일 책임 원칙의 원론과 비교해보았을 때 원칙에 위배된다는 점을 알게되었습니다.

 

저는 이러한 원칙에 위배되는 사항을 '강결합' 상태라고 판단하고 있으며, Spring Event를 이용해 특정 이벤트 발생시 서비스 로직에 참여하지 않고 알림이 전송되도록 강결합을 해소시키는 것이 다음 개선 목표 중 하나입니다.

 

 

Scale out으로 발생될 문제 개선

현재 blue/green 전략으로 WAS를 두 대 운영중인 상태입니다. 이 경우 클라이언트와 SSE 연결 상태를 모든 인스턴스와 동기화가 필요합니다. 클라이언트의 연결상태를 추적하고 모든 인스턴스 간에 동기화를 위해서 상태를 저장하기 위해 WAS In-memory 방식이 아닌 별도의 상태저장소로 분리가 필요하다고 판단됩니다. 하여 Redis, 로컬 캐시 등 분산 캐시를 도입하여 개선할 예정입니다.

또한, 여러 인스턴스간에 메시지를 동기화하기 위해 Redis Pub/Sub, Kafka, RabbitMQ 등 비교 후 채택하여 분산 메시징 시스템을 도입하는 것이 좋은 챌린지가 될 것으로 판단됩니다.

 

 

참고 자료

알림 기능 서비스 로직 참고 - https://velog.io/@max9106/Spring-SSE-Server-Sent-Events를-이용한-실시간-알림
기본 개념 및 고려사항 참고 - https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/
AWS EC2/Linux 2023 SSL 인증서 발급 방법 참고 - 
https://dev-jwblog.tistory.com/57
Nginx 설정 참고
- http://nginx.org/en/docs/http/ngx_http_proxy_module.html
- https://nginxstore.com/blog/nginx/가장-많이-실수하는-nginx-설정-에러-10가지/

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday