SSE(Server-Sent-Event)를 사용하면서 겪었던 이슈

ᄋᄌᄒ·2025년 9월 2일

Spring_Project

목록 보기
10/11
post-thumbnail

✏️ 글 쓰기 전에

그동안 스택을 이것 저것 사용해봤었는데, 이제는 좀 알고 쓰려고 노력중이다. 이번 마이메디 프로젝트에서 새롭게 시도하거나 이전에 해봤던 스택을 다시한번 활용해보는 등의 경험을 살려서 글을 정리해보고자 한다.

📄 본문

📌 SSE?

SSE는 서버에서 클라이언트에게 실시간으로 데이터를 전송하는 기술이다.

client가 server에게 연결(혹은 구독)을 요청하면 그 연결이 지속된 상태에서 이벤트가 발생하는 것을 기준으로 실시간으로 결과를 조회할 수 있다.

일반적인 HTTP 요청과 달리 Connection을 유지하는 것. 그리고 양방향 통신이 아닌 서버가 클라이언트에게 제공하는 일방적인 통신이라는 것이 특징이다.

(이번 글에서는 sse에 대한 설명이 중요한 것이 아니므로 짧게 작성하고 넘어가겠다)

sse 자료 : https://matt1235.tistory.com/79

📌 무엇을 위해 선택했는가

마이메디는 "사용자"와 "전문가" 페이지가 별도로 존재한다. 그리고 서로에게 일방적인 이벤트가 발생할 수 있도록 되어있다.

  1. 사용자가 전문가에게 매칭을 요청
  2. 전문가가 사용자 매칭 요청을 수락
  3. 전문가가 사용자 매칭 요청을 거절
  4. 전문가가 매칭된 사용자에게 스케줄 등록

위와 같은 이벤트가 발생했을 때, 각 "사용자"와 "전문가"에게 실시간으로 이벤트를 알려주고자 했기 때문에 sse를 선택했다.

접속되지 않았을 때는 고려하지 않으며 알림의 활용도를 높이고자 선택한 방향성이다. 실시간 기술이라면 여러가지가 있겠지만, 페이지를 리프레시 하지 않아도 되며 단방향이어도 가능하기 때문에 sse가 적합하다 판단했고 러닝커브 또한 반영이 되었다.

📌 구조

  1. Sse connect
  2. event 발생
  3. target의 view로 조회

우선 알림의 데이터의 저장과 sse로의 조회는 별개로 취급하고자 했다. 물론 동시에 이뤄지는 task이지만, 두 과정의 유기성을 낮추는 것이 유지보수에도 도움이 될 것이라고 판단했다.

그렇기 때문에 save와 event 발생은 동시에 이뤄지지만 event는 비동기적으로 처리하기로 했다. 다만, 트랜잭션이 실패했을 때는 event 또한 발생하지 않도록 설정을 맞추었다. 아래 코드는 이에 대한 반영이다.

public void approveConsultationRequestAndSendNotificationToUser(Expert expert, Long consultationId) {
       consultationRequestCommandService.approveConsultation(consultationId, expert);
       ConsultationRequest request = consultationRequestQueryService.getRequestById(consultationId);

       String notificationComment = NotificationMessage
               .CONSULTATION_APPROVED.format(expert.getName());

       UserNotification userNotification = userNotificationCommandService.sendNotificationToUser(request.getUser().getId(),
               consultationId, notificationComment, NotificationType.CONSULTATION_RESPONSE);

       //sse
       applicationEventPublisher.publishEvent(
               new NotificationEventDto.UserNotificationEventDto(
                       userNotification.getUser().getUsername(),
                       UserNotificationConverter.toUserNotification(userNotification)
               )
       );
   }

위는 2번 케이스에서 "알림 저장"과 "알림 이벤트 발생"을 모두 추가한 로직이다.

@Component
@RequiredArgsConstructor
public class UserNotificationEventListener {

   private final SseService sseService;
   @Async
   @TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution=true)
   public void handleSendingNotificationEvent(UserNotificationEventDto userNotification){
       sseService.sendToUser(
               userNotification.getUsername(),
               userNotification
       );
   }
}

@TransactionalEventListner를 통해 같은 트랜잭션이 실패를 겪게 되면(알림 저장 실패) 이벤트 또한 발생하지 않는 것이다.

📌 문제 발생

커넥션 풀이 말라버린다.

sse 기능을 신나게 추가해놓고 서버에 올려두었더니, 갑자기 프론트에서 서버가 안된다고 연락이 왔다. 급하게 문제점을 찾아보려고 했는데 커넥션 풀이 마르고 있다는 로그를 확인하게 되었다.

커넥션 풀이 마를 이유가 없다고 생각했다. 아무리 실시간 연결을 해두었다고 하더라도 DB와 지속적인 연결을 할 로직은 없었다. 그렇다면 지금까지 이벤트와 관련된 것이 아닌 다른 곳에서 sse와 결합되면서 문제가 발생하는 거 아닐까?
.
.
.

필자는 보통 @AuthenticationPrincipal 어노테이션을 사용하기보다는 커스텀해서 직접 entity를 불러오는 편이다. 즉, 인증 api 하나를 사용하게 되면 무조건 조회 sql문이 하나 발생하는 것이다.

이 문제가 sse와 결합하면서 문제가 생긴것이다. 로컬환경에서야 램이 스펙이 좋기 때문에 문제가 없었겠지만 배포된 서버는 프리티어니 문제없이 커넥션을 되돌려 놓을 속도가 되지 못한 것이다. 따라서 sse커넥션 연결 api는 @AuthenticationPrincipal을 사용하며 sse에서 커넥션 또한 UserDetails.getName()으로 구독하는 형식으로 변경했다.

커넥션이 유지되지 않는다.

로컬에서는 문제없이 테스트를 마쳤기 때문에 서버에 올려놓았었다. 막상 배포된 서버에서 연결을 시도하니 1분이 지나면 자동으로 종료되는 것을 확인했다.

이는 배포된 서버가 https를 위해 로드밸런싱 적용이 되어있기 때문에 발생한 문제였다.

SSE는 클라이언트와 서버 간의 HTTP 연결을 계속 열어두는 방식이다. 그런데 중간에 있는 로드 밸런서(AWS의 ALB, Nginx 등)나 방화벽 같은 네트워크 장비들은 자원 낭비를 막기 위해, 일정 시간 동안 아무런 데이터 교환이 없는 "유휴(idle)" 상태의 연결을 자동으로 끊어버리는 기능이 있다. 이 시간을 [유휴 타임아웃]이라고 한다.

많은 로드 밸런서의 기본 유휴 타임아웃 값이 60초(1분)로 설정되어 있으며 이 때문에 서버에서 보낼 이벤트가 없어서 1분 동안 아무런 데이터도 전송되지 않으면, 로드 밸런서는 이 연결이 불필요하다고 판단하고 끊어버리는 것이다.

현 배포 서버에는 AWS에 직접 로드밸런스 설정을 해두었기 때문에 발생한 거였으며 이는 하트비트를 스케줄링으로 설정하여 해결하였다. 대략 20초로 더미 이벤트를 보내는 것이다.

아래 코드를 참고하면 된다.

	@Scheduled(fixedRate = 30000)
    public void sendHeartbeatToUser() {
        List<String> deadEmitters = new ArrayList<>();
        userEmitters.forEach((key, emitter) -> {
            try {
                // 주석(comment)을 보내면 클라이언트에서 별도의 이벤트를 발생시키지 않음
                emitter.send(SseEmitter.event().comment("heartbeat"));
            } catch (IOException e) {
                // IOException 발생 시 연결이 끊어졌다고 판단하고 맵에서 제거
                deadEmitters.add(key);
            }
            deadEmitters.forEach(userEmitters::remove);
        });
    }

📚 글을 마치면서

이번 트러블은 반가웠다. 로컬에서만 되지만 서버에서 안되는 이슈들이 진짜 문제들이라고 생각한다. 우리의 컴퓨터는 매우 짱짱한 스펙이니까 웬만한 것들이 작동되지만, 프리티어나 낮은 스펙으로 구성한 인스턴스에서 발생한 문제들이 진짜 엔지니어링의 핵심이다. 이제는 이런 경험이 더 중요한 때이다. 단순 코더가 아니라 엔지니어가 되기 위해 리소스를 잘 다룰 수 있도록 역량을 키워야할 때가 왔다. 그런점에서 매우매우 반가운 트러블들.

그리고 sse를 활용해보면서 웹 소켓으로 만들었던 채팅 기능이 떠올랐다. 잘만하면 메세지 기능도 sse로 가능할텐데 왜 사람들은 sse말고 web socket으로 채팅 기능을 구현할까? 이것에 대한 의문도 해결해볼까 한다.

0개의 댓글