일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 공공데이터 포털
- 프로그래머스
- apk 빌드
- 고고학 최고의 발견
- Redux
- php-1
- Dreamhack
- 티스토리챌린지
- 드림핵
- 개발
- 꿀팁 환영
- react-router-dom
- web-view
- 부산 맛집 OPEN API
- 사업계획서
- API 활용 신청
- 오블완
- redux state값 유지
- expo
- level3
- 코딩테스트
- python
- React
- url 랜더링
- 블로그 뉴비
- 창업 300
- 훈수 가능
- 새로고침
- 보안
- Today
- Total
1223v
명확한 근거를 통한 불필요한 polling 점진적으로 제거하기(Long polling, SSE, Websocket)[feat.레디베리] 본문
명확한 근거를 통한 불필요한 polling 점진적으로 제거하기(Long polling, SSE, Websocket)[feat.레디베리]
1223v 2024. 10. 31. 19:28고민의 시작
레디베리 점주의 주문접수 창이다.
우리는 기본적으로 준 실시간성으로 데이터를 가져가기 위해서 폴링으로 데이터를 가져가고 있었다.
그런데 어느날 학교 축제 주점의 결제 시스템으로 도입되게 되었고, 폴링에 따른 사용자도 늘어남에 따라서 CPU 사용량도 점점 올라가고 있는 상황이었다.
또한, 축제가 끝난 뒤 기존으로 돌아와도 커피 스마트오더 특성상 점심시간 때, 혹은 2~3시에 주문이 피크였고, 그 이후에는 주문 수 적거나 아예 안들어오는 경우도 존재했다.
해결 가능한 방법론
해당 글에서는 실시간 서버 푸시 요청, 응답에 대한 방법론 이야기이기에 Kafka, Redis, RabbitMQ 와 같은 메시지큐는 다음 글에서 다뤄볼 것이다.
손님이 주문 API를 요청할 때 이벤트가 발행될 때만 요청을 해도 되는 상황이라 생각했다
하지만, 주문 이벤트가 발행되었을 때는 사장님에게 내려주어야 되기 때문에, 중간에 푸시 레이어를 둬서 사장님에게 데이터를 가져가게끔 구성을 고민해봤다.
우리는 방법론이 3가지 존재한다.
- Long Polling
- SSE (Server-Sent Events)
- WebSocket
1️⃣ 기본 개념
방식 | 연결 방식 | 실시간성 | 양방향 통신 | 서버 부하 |
---|---|---|---|---|
Long Polling | 요청-응답 반복 | 중간 (딜레이 있음) | ✅ 가능 (클라이언트 → 서버 재요청 필요) | ⬆️ 높음 (반복 요청) |
SSE (Server-Sent Events) | 지속 연결 유지 | 높음 | ❌ 단방향 (서버 → 클라이언트) | ⬇️ 낮음 (연결 유지) |
WebSocket | 지속 연결 유지 | 매우 높음 | ✅ 완전한 양방향 | ⬇️ 낮음 (효율적) |
2️⃣ 작동 방식
Long Polling (롱 폴링)
- 클라이언트가 서버에 요청을 보내면 서버는 새로운 데이터가 있을 때까지 응답을 보류
- 데이터가 생기면 응답을 보내고 클라이언트는 다시 요청을 보냄
특징
- 기존 HTTP 기반으로 모든 브라우저에서 지원됨
- REST API와 호환 가능
- 불필요한 요청이 많아 서버 부하 증가
사용사례
- 일반 HTTP 환경에서 실시간 데이터가 필요한 경우
- SSE 또는 WebSocket을 사용할 수 없는 환경
SSE
- 클라이언트가 한번 요청을 보내면 서버는 지속적으로 데이터를 전송(서버 - 클라이언트 단방향 통신)
- 일반 HTTP 연결을 유지하면서 서버가 이벤트를 Push할 수 있음.
특징
- HTTP 기반이므로 구현이 쉬움
- 자동 재연결 기능 내장
- 클라이언트 → 서버 메시지 전송 불가 (단방향)
- 브라우저에서만 지원
사용 사례
- 실시간 알림 시스템
- 로그 모니터링
- 주식 시세 표시
WebSocket
- 초기 핸드셰이크 후, 양방향 연결을 유지하며 데이터를 주고 받을 수 있음.
- TCP 기반이므로 속도가 빠르고, 서버와 클라이언트 간 실시간 데이터 전송 가능
특징
- 완전한 양방향 통신 가능 ( 클라이언트 ↔ 서버)
- 가장 효율적인 실시간 통신 방식(낮은 대기 시간, 빠른 응답 속도)
- 모든 최신 부라우저 및 모바일 환경 지원
- 방화벽이 WebSocket을 차단할 가능성 있음
- 일반 HTTP 서버보다 구현이 어려울 수 있음
사용사례
- 실시간 채팅(카카오톡, 페이스북 메신저)
- 주식 거래 시스템
- IoT(센서 데이터 실시간 전송)
기존 방식
주문 - 접수 프로세스
- 유저가 장바구니의 물건을 담는다
- 유저가 결제를 진행하여 주문을 생성한다.
- 점주는 3초간격으로 주문이 들어왔는지 서버에 조회를 하여 데이터를 받아간다.
- 점주가 주문을 접수한다.
- 유저의 카카오톡으로 주문 접수가 되었다고 알림이 간다.
- 유저는 주문 상태 페이지에서 10초 간격으로 주문의 상태를 가지고 간다.
위 프로세스를 가진다. 우리가 주요하게 봐야할 부분은 바로 3번이다.
- 현재 방식은 위 사진처럼 꾸준히 서버에 데이터를 요청하고 있었다.
변경 방식
- 우리는 위 방법론들 중 SSE를 이용했다.
현재 문제 및 요구 사항
- 주문이 없는 중에도 polling으로 계속 서버에 요청을 날리고 있음.
- 사장님이 늘기 시작하면 polling에 따른 CPU 사용량과 네트워크 대역폭이 늘어남
⇒ 손님이 주문 이벤트 발생시킬 때만 주문 리스트를 받아와도 충분.
⇒ Side Effect (다른 API에 영향이 가지 않도록) 가 많이 발생하지 않도록 점진적 개선 요구
⇒ 주문 손실이 없어야 함.
결론적으로 불필요한 요청을 줄이는데, 타 API에는 영향을 주지 않는 방식이 요구
SSE를 선택한 이유
- 단순하게 서버에서 이벤트가 발생하면 사장님 클라이언트에서 주문 내역을 확인하기만 하면된다.
- 양방향 데이터 송신 과정이 없어도 된다고 판단
- HTTP 기반이라 구현이 쉽다.
- 우리는 현재 서비스 운영 중에 있고, Side effect 발생이 줄어야 한다.
때문에 우리는 기존의 API 요청은 두되 불필요한 polling만 제거해 SSE로 변경하는 병행 방식을 선택했다.
SSE + API 요청 병행
주문 접수/완료/취소 로직과 기존 컨트롤러 코드는 그대로 두고, 주문 조회만 SSE로 제공
1️⃣ 기본 아이디어
- 기존
OrderServiceImpl
getOrders(Long id, Progress progress)
메서드는 그대로 사용(데이터 조회 로직 변경 없음).
- 새로운 SSE 전용 컨트롤러
GET /v1/order/stream
→ SSE로 주문 목록을 실시간(또는 일정 주기)으로 전송.- 기존
GET /v1/order
API는 그대로 두되, 프론트에서 Polling 대신 SSE를 쓰고 싶다면 이 새 엔드포인트를 사용.
- SSEEmitter 관리
SseEmitter
를 통해 지속 연결을 유지하고, 주문 목록(데이터)을 전송.- 이벤트 발생 시(예: 주문 상태 변경) 또는 주기적(타이머)으로
orderServiceImpl.getOrders()
호출 → Emitter로 푸시.
주문이 새로 들어오거나, 상태가 바뀔 때마다 실시간으로 갱신하려면, 주문 이벤트가 발생하는 곳(주문 완료/취소 로직 등)에서 SseEmitter들에게 알림(푸시)하는 구조를 추가할 수도 있다.
2️⃣ SSE Controller 예시
(1) 새 컨트롤러 추가
package com.readyvery.readyverydemo.src.order;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import com.readyvery.readyverydemo.domain.Progress;
import com.readyvery.readyverydemo.security.jwt.dto.CustomUserDetails;
import com.readyvery.readyverydemo.src.order.dto.OrderRegisterRes;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;
@Log4j2
@RestController
@RequiredArgsConstructor
public class OrderSseController {
private final OrderService orderServiceImpl;
// 현재 연결된 SSE Emitter들을 관리 (멀티 연결 가능)
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
/**
* 기존 Polling(10초 간격)을 대체하는 SSE 엔드포인트
* - 클라이언트(사장님 측)에서 한번 연결하면, 지속적으로 주문 목록을 받아볼 수 있음.
*/
@GetMapping(value = "/v1/order/stream", produces = "text/event-stream")
public SseEmitter streamOrders(@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestParam Progress status) {
// 1) SSE 연결 객체 생성 (타임아웃: 5분 예시)
SseEmitter emitter = new SseEmitter(300_000L);
// 2) 연결 종료, 에러, 타임아웃 시 Emitter 제거
emitter.onCompletion(() -> emitters.remove(emitter));
emitter.onError(e -> {
log.error("SSE 연결 오류 발생: {}", e.getMessage());
emitters.remove(emitter);
});
emitter.onTimeout(() -> {
log.info("SSE 타임아웃 발생");
emitters.remove(emitter);
emitter.complete();
});
// 3) 현재 Emitter를 목록에 저장
emitters.add(emitter);
// 4) 연결 직후 바로 현재 주문 목록을 한 번 전송 (원하면)
sendOrderData(emitter, userDetails.getId(), status);
return emitter; // 연결 유지
}
/**
* Emitter를 통해 주문 목록을 전송하는 메서드
*/
private void sendOrderData(SseEmitter emitter, Long userId, Progress status) {
try {
// 기존 OrderServiceImpl.getOrders() 그대로 활용
OrderRegisterRes orderData = orderServiceImpl.getOrders(userId, status);
// SSE 형식으로 데이터 전송
emitter.send(SseEmitter.event()
.name("order-list") // 이벤트 이름
.data(orderData)); // 실제 전송할 데이터
} catch (IOException e) {
log.error("주문 목록 전송 중 오류: {}", e.getMessage());
emitter.completeWithError(e);
emitters.remove(emitter);
}
}
/**
* 테스트/예시) 주기적으로 모든 Emitter에게 주문 목록 갱신 전송
* (실제로는 주문 이벤트 발생 시 호출하여 푸시하는 방식을 권장)
*/
// @Scheduled(fixedRate = 10000) // 10초마다 실행 예시
public void broadcastOrderData() {
for (SseEmitter emitter : emitters) {
// Emitter마다 userId, status를 어떻게 구분할지 설계 필요
// 여기서는 단순 예시로 임의 값 (실제로는 Emitter별로 정보 저장 필요)
// sendOrderData(emitter, someUserId, someProgress);
}
}
/**
* 주문 완료/취소 시점에도 즉시 갱신 원하면, 아래처럼 메서드 제공 후
* OrderServiceImpl에서 상태 변경 후 notify
*/
public void notifyOrderUpdate(Long userId, Progress status) {
for (SseEmitter emitter : emitters) {
sendOrderData(emitter, userId, status);
}
}
}
주요 포인트
GET /v1/order/stream
: 새로운 SSE 엔드포인트.- 프론트(사장님 화면)에서
new EventSource('/v1/order/stream?status=INTEGRATION')
형태로 연결. - 기존
GET /v1/order
은 그대로 둬도 됨. (또는 대체 가능)
- 프론트(사장님 화면)에서
- 주기적 or 이벤트 발생 시
sendOrderData()
호출 → 실시간 푸시. notifyOrderUpdate()
: 주문 상태가 바뀔 때 즉시 갱신을 푸시하고 싶다면, 주문 완료/취소 메서드에서 이 메서드를 호출.
4️⃣ 프론트엔드(사장님 화면)에서 SSE 연결 예시
<script>
// 기존에 10초마다 fetch("/v1/order?status=INTEGRATION")를 쓰던 부분 대신:
const eventSource = new EventSource("/v1/order/stream?status=INTEGRATION");
// 서버에서 "order-list"라는 이름의 이벤트가 올 때마다 처리
eventSource.addEventListener("order-list", (event) => {
const data = JSON.parse(event.data);
console.log("실시간 주문 목록:", data);
// TODO: 화면에 주문 목록 갱신
});
// 에러 처리
eventSource.onerror = (error) => {
console.error("SSE 연결 에러:", error);
// 필요하면 재연결 로직 추가
};
</script>
- 단 한 번
new EventSource(...)
를 호출하면, 연결이 지속되면서
서버(OrderSseController
)가emitter.send(...)
를 호출할 때마다 자동으로 데이터가 수신 - 기존 10초 Polling 로직은 제거(또는 주석 처리) 가능.
5️⃣ (선택) 주문 상태 변경 시점에 실시간 푸시
위 예시는 단순히 연결 직후 1회 전송하고, 이후에는 별도 로직이 없으면 갱신이 안 됨.
실시간 갱신을 위해서는, 주문 완료/취소 메서드에서 notifyOrderUpdate()
를 호출하여 연결 중인 Emitter들에게 데이터를 푸시해야 함
@Override
@Transactional
public OrderStatusRes completeOrder(Long id, OrderStatusUpdateReq request) {
// 1) 기존 로직
CeoInfo ceoInfo = ceoServiceFacade.getCeoInfo(id);
Order order = getOrder(request.getOrderId());
verifyPostOrder(ceoInfo, order);
verifyPostProgress(order, request);
order.completeOrder(request.getStatus());
orderRepository.save(order);
sendCompleteMessage(order, request.getStatus());
// 2) SSE로 실시간 갱신 알림
// (Progress.INTEGRATION 등, 실제로 프론트에서 보고 싶은 상태 값 전달)
orderSseController.notifyOrderUpdate(id, Progress.INTEGRATION);
return OrderStatusRes.builder().success(true).build();
}
- 이렇게 하면, 주문 상태가 완료될 때마다, 연결 중인 SSE Emitter들에게 최신 주문 목록을 전송하여
프론트가 새 주문/수정된 주문을 즉시 알 수 있게 된다.
(위 코드는 예시이므로, 실제로는 의존성 순환을 피하기 위해 OrderSseController
대신 OrderSseService
에 notifyOrderUpdate
메서드를 두고 주입받는 방식을 권장)
6️⃣ 결론
- 기존
OrderServiceImpl
와OrderController
는 그대로 - 새로
OrderSseController
를 만들어 SSE 전용 엔드포인트(/v1/order/stream
) 추가 getOrders(...)
는 원래 로직 그대로 사용하되, SseEmitter로 결과를 실시간 전송- 프론트: 기존 3초 Polling 코드 제거 후,
new EventSource(...)
로 한 번 연결 - 주문 상태가 변경될 때마다
notifyOrderUpdate(...)
등으로 Emitter에 푸시하면 자동 갱신
핵심: “SseEmitter를 통해 기존 getOrders() 결과를 이벤트 형태로 지속적으로 보내주기”
이렇게 하면 서버와 클라이언트 모두 불필요한 3초 Polling이 사라지고, 주문 변화 시점에 즉시 데이터가 반영되어 실시간성과 서버 효율이 동시에 향상된다!
평가
Apache JMeter 성능 테스트
→ HTTP 부하 테스트 도구 (Polling vs SSE 성능 비교 가능)
그래서 이 방식이 왜 우리 서비스에 좋아?
우선 내가 집중한 것은 우리 서비스의 “상태” 였다.
- 당시 이미 기존의 폴링 방식으로 진행되는 API들은 다른 API들과도 유기적으로 엮여있었다.
물론 이는 OCP에 어긋나지만, 그래도 기간내에 완성을 목표로 하기 위해 유기적인 코드가 존재할 수 밖에 없었다…(trade off,,,,)
- 추가로 손님도 없는데 의미 없는 API (같은 데이터를 3초마다 폴링해서 데이터를 받아오는 행위)는 낭비로 판단되었다.
- 손님이 많든 적든 비효율적임
“다른 API에 대한 Side Effect 최소화” 와 “불필요한 Polling을 개선“
이 내용이 키워드였다.
즉, Read에 대한 문제를 최소한으로 개선하는 방법이 필요했던 것이었다.
때문에 Read / Write 방식이 지원되는 양방향 통신인 WebSocket이 필요하지 않다고 판단했다.
또한, HTTP 프로토콜이기에 기존의 아키텍쳐에 추가적 외부 설정이 필요없을 것이라 판단했다.
그리고 대부분의 고객이 window 기반 POS기를 사용해 최신 크롬 브라우저가 가능했기에 SSE가 괜찮을 것이라 판단했다.
고찰
SSE가 과연 정답일까?
A : 절대 아니다. 우선 SSE에는 명확한 제약 조건이 있다. “높은 버전의 브라우저” 라는 점이다.
즉, 익스플로어와 같은 구버전 브라우저 같은 경우에는 sse 사용에 제약이 존재한다.
기존의 고객인 카페 사장님들이 높은 버전의 브라우저를 사용하지 않고 익스플로어와 같은 구버전 브라우저를 사용하는 POS기라면, 우리는 Long Polling이 더 맞는 판단인 것이다.
즉, 구버전 브라우저인 pos기기를 사용하는 사장님들은 sse를 사용하는 순간 우리 고객에서 제외된다는 것이다. 때문에 이를 고려해서 우리는 호환이 되는 pos 기기(아이패드)를 대여해주는 방식으로 정책적 부분을 변경했었다.
그럼에도 SSE를 밀고 나간 이유는
Long Polling이 “사장님이 늘기 시작하면 polling에 따른 CPU 사용량과 네트워크 대역폭이 늘어남”
이 문제를 해결하지 못했기 때문이다…
느낀점
늘 느끼는 것은 기술만으로 모든 것을 해결하려 하는 것은 어리석다는 것이다.
위 말은 결국 모든 자연현상을 함수와 한다는 것과 동일하다.
어차피 정말 최적의 솔루션은 존재하지 않는다.
어떤 기술간에 도입 시, side effect가 생길 수 밖에 없다.
그럼 우린 어떻게 해야할까?
정답은 간단하다. 기술이 아닌 정책, 팀을 의지하는 것이다. 위처럼 우리는 우리 서비스에 호환되지 않는 기기를 가진 사장님들을 위한 솔루션으로 구버전은 Long polling이 될 수 있도록 수정할 수 도 있었다.
하지만, 이는 결국 추가적인 비용 및 다양한 side effect가 더 발생할 수 있는 요소이다.
우리는 이를 정책(기기를 빌려주는 방식)으로 해결할 수 있었다.
또한 기기를 빌려줌으로써 완벽히 호환되는 기기를 만들 수 있는 장점도 생겼다는 점이다.
때문에 특수한 상황(호환되는 브라우저임에도 의문의 에러가 발생하는 경우)에도 기기를 빌려줌으로써 cs관리가 가능해졌다.
정책을 변경함으로써 오히려 side effect가 생기기보다는 이점이 더 생긴 것이다.
이처럼 다른 영역의 능력을 의지함으로써 우리는 보다 더 쉽게 문제를 해결할 수 있었다.
'개발 > 개발 고찰' 카테고리의 다른 글
낙관적 락(Optimistic Lock) 비관적 락(Pessimistic Lock) [feat. 레디베리 Race Condition 문제] (0) | 2025.02.13 |
---|---|
Redis 데이터 분산 [redis 죽으면 어카노…?] (0) | 2025.02.05 |
[SpringBoot] Spring Security 로그인 시, 세션 유지 안되는 현상 (1) | 2024.09.30 |
[React] 음원, 녹음 동시 작업 실행 시, 녹음 품질 저하 문제 해결 및 고찰 (0) | 2024.09.23 |
단위테스트 적응기 (1) | 2024.07.16 |