77import lombok .extern .slf4j .Slf4j ;
88import org .springframework .scheduling .annotation .Scheduled ;
99import org .springframework .stereotype .Service ;
10+ import org .springframework .web .context .request .async .AsyncRequestNotUsableException ;
1011import org .springframework .web .servlet .mvc .method .annotation .SseEmitter ;
1112
1213import java .io .IOException ;
@@ -22,40 +23,65 @@ public class SseService {
2223 private final ConcurrentHashMap <Long , SseEmitter > connections = new ConcurrentHashMap <>();
2324 private final UserService userService ;
2425
25- @ Scheduled (fixedRate = DEFAULT_HEARTBEAT_INTERVAL ) // 30초
26+ @ Scheduled (fixedRate = DEFAULT_HEARTBEAT_INTERVAL )
2627 public void sendHeartbeat () {
2728 if (connections .isEmpty ()) {
2829 return ;
2930 }
3031
31- connections .forEach ((userId , emitter ) -> {
32+ connections .entrySet ().removeIf (entry -> {
33+ Long userId = entry .getKey ();
34+ SseEmitter emitter = entry .getValue ();
35+
3236 try {
3337 emitter .send (SseEmitter .event ()
3438 .name ("heartbeat" )
3539 .data ("ping" ));
40+ return false ; // 정상 전송, 유지
41+ } catch (IOException e ) {
42+ // 클라이언트 연결 종료 - 정상적인 상황이므로 조용히 처리
43+ closeEmitterSafely (emitter );
44+ return true ; // 제거
3645 } catch (Exception e ) {
37- connections .remove (userId );
46+ // 예상치 못한 오류만 로깅
47+ log .warn ("SSE 하트비트 전송 실패: userId={}, error={}" , userId , e .getMessage ());
48+ closeEmitterSafely (emitter );
49+ return true ; // 제거
3850 }
3951 });
4052 }
4153
4254 public SseEmitter connect () {
4355 long currentUserId = userService .getCurrentLoginUser ().getId ();
44- connections .remove (currentUserId );
56+
57+ // 기존 연결이 있다면 안전하게 정리
58+ SseEmitter existingEmitter = connections .remove (currentUserId );
59+ if (existingEmitter != null ) {
60+ closeEmitterSafely (existingEmitter );
61+ }
4562
4663 SseEmitter emitter = new SseEmitter (DEFAULT_TIMEOUT );
4764 connections .put (currentUserId , emitter );
4865
66+ // 연결 수명주기 이벤트 핸들러 등록
4967 emitter .onCompletion (() -> connections .remove (currentUserId ));
5068 emitter .onTimeout (() -> connections .remove (currentUserId ));
51- emitter .onError (e -> connections .remove (currentUserId ));
69+ emitter .onError (throwable -> {
70+ connections .remove (currentUserId );
71+ // 예상치 못한 오류만 로깅
72+ if (!isExpectedConnectionError (throwable )) {
73+ log .warn ("SSE 연결 예상치 못한 오류: userId={}, error={}" ,
74+ currentUserId , throwable .getMessage ());
75+ }
76+ });
5277
5378 try {
5479 emitter .send (SseEmitter .event ()
5580 .name ("connect" )
5681 .data ("Connected successfully" ));
5782 } catch (IOException e ) {
5883 connections .remove (currentUserId );
84+ log .error ("SSE 초기 연결 메시지 전송 실패: userId={}" , currentUserId , e );
5985 }
6086
6187 return emitter ;
@@ -65,24 +91,53 @@ public void disconnect() {
6591 long currentUserId = userService .getCurrentLoginUser ().getId ();
6692 SseEmitter emitter = connections .remove (currentUserId );
6793 if (emitter != null ) {
68- emitter . complete ( );
94+ closeEmitterSafely ( emitter );
6995 }
7096 }
7197
7298 public void sendAlarmToUser (long userId , Alarm alarm ) {
7399 SseEmitter emitter = connections .get (userId );
74100 if (emitter == null ) {
75- return ;
101+ return ; // 연결 없음 - 조용히 무시
76102 }
77103
78104 try {
79105 AlarmDTO alarmDTO = AlarmDTO .from (alarm );
80106 emitter .send (SseEmitter .event ()
81- .id (alarm .getId ())
107+ .id (String . valueOf ( alarm .getId () ))
82108 .name ("alarm" )
83109 .data (alarmDTO ));
84110 } catch (IOException e ) {
111+ // 클라이언트 연결 종료 - 정상적인 상황이므로 조용히 처리
112+ connections .remove (userId );
113+ closeEmitterSafely (emitter );
114+ } catch (Exception e ) {
115+ // 예상치 못한 오류만 로깅
116+ log .warn ("알람 전송 예상치 못한 오류: userId={}, alarmId={}, error={}" ,
117+ userId , alarm .getId (), e .getMessage ());
85118 connections .remove (userId );
119+ closeEmitterSafely (emitter );
86120 }
87121 }
88- }
122+
123+ /**
124+ * SseEmitter를 안전하게 종료하는 유틸리티 메서드
125+ */
126+ private void closeEmitterSafely (SseEmitter emitter ) {
127+ try {
128+ emitter .complete ();
129+ } catch (Exception ignored ) {
130+ // emitter 종료 중 예외는 무시 (이미 종료된 상태일 수 있음)
131+ }
132+ }
133+
134+ /**
135+ * 예상되는 연결 종료 오류인지 판단
136+ */
137+ private boolean isExpectedConnectionError (Throwable throwable ) {
138+ return throwable instanceof IOException ||
139+ throwable instanceof AsyncRequestNotUsableException ||
140+ (throwable .getMessage () != null &&
141+ throwable .getMessage ().contains ("Broken pipe" ));
142+ }
143+ }
0 commit comments