티스토리 뷰

시작하며
최근 가맹점 결제 시스템에 실시간 알림 기능을 구현하는 프로젝트를 진행했습니다. 가맹점주가 상품과 가격을 설정하여 QR 코드를 생성하면, 고객이 해당 QR 코드를 스캔하여 결제를 진행하고, 결제가 완료되는 즉시 가맹점주의 화면에 거래내역이 실시간으로 표시되는 시스템입니다.
이 글에서는 실시간 통신 기술 선택 과정과 초기 구현 방법에 대해 공유하려고 합니다.
프로젝트 요구사항
프로젝트의 핵심 요구사항은 다음과 같았습니다:
- 가맹점주가 상품명과 가격을 입력하여 결제용 QR 코드 생성
- 고객이 QR 코드를 스캔하여 결제 진행
- 결제 완료 시 가맹점주 화면에 실시간으로 거래내역 표시
- 거래내역은 RabbitMQ를 통해 전달됨
중요한 점은 서버에서 클라이언트로의 단방향 데이터 전송만 필요했다는 것입니다. 가맹점주는 거래내역을 받기만 하면 되고, 별도로 서버에 데이터를 전송할 필요가 없었습니다.
SSE vs WebSocket 비교
실시간 통신을 구현하기 위해 두 가지 주요 기술을 검토했습니다.
WebSocket의 특징
장점:
- 양방향 통신 지원
- 실시간성이 매우 뛰어남
- 클라이언트와 서버 간 지속적인 상호작용이 필요한 경우 적합 (채팅, 게임 등)
단점:
- 양방향 연결 유지로 인한 리소스 사용
- 구현 복잡도가 상대적으로 높음
- HTTP가 아닌 별도 프로토콜 사용
SSE(Server-Sent Events)의 특징
장점:
- HTTP 기반으로 구현이 간단함
- 서버에서 클라이언트로의 단방향 통신에 최적화
- 자동 재연결 기능 내장
- EventSource API로 클라이언트 구현이 간단함
단점:
- 단방향 통신만 가능
- HTTP/1.1에서는 브라우저당 연결 수 제한 (6~8개)
SSE 선택 이유
우리 프로젝트의 요구사항을 분석한 결과, SSE가 더 적합하다고 판단했습니다.
1. 단방향 통신으로 충분
가맹점주는 결제 완료 알림을 받기만 하면 됩니다. 실시간으로 서버에 데이터를 보낼 필요가 없었기 때문에 양방향 통신 기능은 불필요했습니다.
고객 결제 → 결제 시스템 → RabbitMQ → 서버 → [SSE] → 가맹점주 화면
만약 WebSocket을 사용했다면, 사용하지도 않을 클라이언트→서버 채널을 위해 추가 리소스를 소비하게 됩니다.
2. 구현 복잡도
SSE는 HTTP 기반이기 때문에 기존 인프라와의 통합이 쉬웠습니다. 별도의 프로토콜 핸들러나 복잡한 설정 없이 Spring의 SseEmitter만으로 간단하게 구현할 수 있었습니다.
3. 리소스 효율성
양방향 연결을 유지하는 것은 서버 리소스 측면에서 오버헤드입니다. 우리 시스템에서는 거래가 발생할 때만 데이터를 전송하면 되므로, SSE의 단방향 특성이 더 효율적이었습니다.
기본 구현
서버 사이드 - SSE Emitter 관리
@Service
public class TransactionNotificationService {
// 가맹점별로 SSE 연결 관리
private final ConcurrentHashMap<String, List<SseEmitter>> emitters =
new ConcurrentHashMap<>();
public SseEmitter subscribe(String merchantId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
// 가맹점 ID별로 emitter 저장
emitters.computeIfAbsent(merchantId, k -> new CopyOnWriteArrayList<>())
.add(emitter);
// 연결 종료 시 정리
emitter.onCompletion(() -> removeEmitter(merchantId, emitter));
emitter.onTimeout(() -> removeEmitter(merchantId, emitter));
emitter.onError(e -> removeEmitter(merchantId, emitter));
return emitter;
}
private void removeEmitter(String merchantId, SseEmitter emitter) {
List<SseEmitter> merchantEmitters = emitters.get(merchantId);
if (merchantEmitters != null) {
merchantEmitters.remove(emitter);
if (merchantEmitters.isEmpty()) {
emitters.remove(merchantId);
}
}
}
}
ConcurrentHashMap을 사용한 이유:
- 멀티스레드 환경에서 안전한 동시성 제어
- 가맹점 ID를 키로 하여 해당 가맹점에 연결된 모든 클라이언트 관리
- 여러 탭이나 디바이스에서 동시 접속 가능
RabbitMQ 리스너 - 거래내역 수신
@Component
public class TransactionQueueListener {
private final TransactionNotificationService notificationService;
@RabbitListener(queues = "transaction.queue")
public void handleTransaction(TransactionMessage message) {
String merchantId = message.getMerchantId();
// 해당 가맹점에 연결된 클라이언트들에게 전송
notificationService.sendToMerchant(merchantId, message);
}
}
public void sendToMerchant(String merchantId, TransactionMessage message) {
List<SseEmitter> merchantEmitters = emitters.get(merchantId);
if (merchantEmitters != null) {
List<SseEmitter> deadEmitters = new ArrayList<>();
for (SseEmitter emitter : merchantEmitters) {
try {
emitter.send(SseEmitter.event()
.name("transaction")
.data(message));
} catch (IOException e) {
deadEmitters.add(emitter);
}
}
// 전송 실패한 emitter 제거
deadEmitters.forEach(emitter -> removeEmitter(merchantId, emitter));
}
}
컨트롤러 - SSE 엔드포인트
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final TransactionNotificationService notificationService;
@GetMapping(value = "/subscribe/{merchantId}",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe(@PathVariable String merchantId) {
return notificationService.subscribe(merchantId);
}
}
클라이언트 사이드
// SSE 연결 생성
const eventSource = new EventSource(`/api/notifications/subscribe/${merchantId}`);
// 거래 이벤트 수신
eventSource.addEventListener('transaction', (event) => {
const transaction = JSON.parse(event.data);
// 화면에 거래내역 표시
displayTransaction(transaction);
});
// 연결 에러 처리
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
};
클라이언트 구현이 매우 간단합니다. EventSource API는 자동 재연결 기능까지 제공하여 네트워크 불안정 상황에서도 안정적으로 동작합니다.
단일 서버에서의 동작
초기 개발 단계에서는 단일 서버 환경에서 테스트를 진행했습니다. 이 경우 시스템은 예상대로 완벽하게 동작했습니다:
- 고객이 QR 코드로 결제
- 결제 시스템에서 RabbitMQ에 거래 메시지 발행
- 서버의 RabbitMQ 리스너가 메시지 수신
- 해당 가맹점 ID에 연결된 SSE Emitter를 찾아 전송
- 가맹점주 화면에 실시간으로 거래내역 표시
모든 컴포넌트가 같은 서버에 있기 때문에 메시지 전달이 직관적이고 문제가 없었습니다.
마치며
SSE는 서버에서 클라이언트로의 단방향 실시간 통신이 필요한 경우 매우 효과적인 선택입니다. 우리 프로젝트처럼 알림, 피드 업데이트, 실시간 대시보드 등의 사용 사례에서 WebSocket보다 간단하면서도 충분한 성능을 제공합니다.
하지만 이 구현은 단일 서버 환경에서만 완벽하게 동작했습니다. 실제 운영 환경은 로드밸런싱된 다중 서버 구조였고, 이 부분에서는 다른 기술스택에 대한 고려가 필요했습니다.
다음 글에서는 로드밸런싱 환경에서 발견한 문제와 Redis Pub/Sub을 활용한 해결 과정을 다루겠습니다.
'개발 지식 > Spring' 카테고리의 다른 글
| SSE | 실시간 결제 알림 시스템 개발기 (2) - 로드밸런싱 환경에서의 문제와 Redis Pub/Sub 해결 (1) | 2026.01.17 |
|---|
- Total
- Today
- Yesterday
- ServerSentEvents
- db성능개선
- read-tracker
- SQL
- 기술부채
- 쿼리
- 개발자
- grammarly
- 개발회고
- 개발지식
- SSE
- 실시간통신
- LoggingFilter
- 크롬
- db
- 생산성
- pub/sub
- 확장프로그램
- 삭제쿼리
- 쿼리최적화
- 레기서시스템
- 페이지네이션
- 개발
- 데이터베이스
- 인덱스
- keyset
- Ai
- Spring
- 데이터베이스삭제
- readtracker
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 31 |
