Day03 SSE, WebSocket(STOMP)
SSE(Server-Sent Events)방식 구현
SSE 개념
- 서버에서 클라이언트로 단방향으로 메시지를 보내는 기술
- 클라이언트에서 서버로 요청을 보내면 서버는 연결을 유지한 채로 데이터를 주기적으로 보내준다.
- HTTP/1.1 스펙에 포함되어 있어, 별도의 라이브러리 없이 사용 가능하다.
- 주로 실시간 업데이트가 필요한 웹 페이지에서 사용된다.
코드구현(핵심)
sseController
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
@Controller
@RequestMapping("/sse")
@RequiredArgsConstructor
public class SseController {
// SSE 연결들을 관리하는 컴포넌트
private final SseEmitters sseEmitters;
// 클라이언트의 SSE 연결 요청을 처리하는 엔드포인트
// /sse/connect로 GET 요청이 오면 SSE 스트림을 생성
@GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> connect() {
// 새로운 SSE 연결 생성 (기본 타임아웃 30초)
SseEmitter emitter = new SseEmitter();
// 생성된 emitter를 컬렉션에 추가하여 관리
sseEmitters.add(emitter);
try {
// 연결된 클라이언트에게 초기 연결 성공 메시지 전송
emitter.send(SseEmitter.event()
.name("connect") // 이벤트 이름을 "connect"로 지정
.data("connected!")); // 전송할 데이터
} catch (IOException e) {
throw new RuntimeException(e);
}
return ResponseEntity.ok(emitter);
}
}
sseEmitters
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Component
@Slf4j
public class SseEmitters {
// Thread-safe한 List를 사용하여 다중 클라이언트의 SSE 연결들을 관리
private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
// 새로운 SSE 연결을 추가하고 관련 콜백을 설정하는 메서드
public SseEmitter add(SseEmitter emitter) {
this.emitters.add(emitter);
// 클라이언트와의 연결이 완료되면 컬렉션에서 제거하는 콜백
emitter.onCompletion(() -> {
this.emitters.remove(emitter);
});
// 연결이 타임아웃되면 완료 처리하는 콜백
emitter.onTimeout(() -> {
emitter.complete();
});
return emitter;
}
// 데이터 없이 이벤트 이름만으로 알림을 보내는 간편 메서드
public void noti(String eventName) {
noti(eventName, Ut.mapOf()); // 빈 Map으로 알림 전송
}
// 모든 연결된 클라이언트들에게 이벤트를 전송하는 메서드
public void noti(String eventName, Map<String, Object> data) {
// 모든 emitter에 대해 반복하며 이벤트 전송
emitters.forEach(emitter -> {
try {
emitter.send(
SseEmitter.event()
.name(eventName) // 이벤트 이름 설정
.data(data) // 전송할 데이터 설정
);
} catch (ClientAbortException e) {
// 클라이언트가 연결을 강제로 종료한 경우 무시
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
}
javaScript
1
2
3
4
5
6
7
8
9
// SSE 연결
// SSE는 단방향 무전기
// 방향 : 서버 -> 클라이언트
const sse = new EventSource("/sse/connect");
// 서버로부터 "chat__messageAdded" 라는 명령이 내려오면 Chat__loadMore 함수를 실행
sse.addEventListener('chat__messageAdded', e => {
Chat__loadMore();
});
참고. OSIV(Open Session In View)문제
OSIV 개념
- JPA/Hibernate에서 사용되는 패턴으로, 영속성 컨텍스트(데이터베이스 세션)를 HTTP 요청이 끝날 때까지 열어두는 방식
- 컨트롤러와 뷰에서 지연 로딩(lazy loading)을 사용할 수 있게 해주어 편리하다.
- Spring에서는 기본적으로 활성화되어 있다(spring.jpa.open-in-view=true).
OSIV와 SSE를 함께 사용할 때의 문제점:
- 데이터베이스 커넥션 고갈
- OSIV는 요청이 끝날 때까지 데이터베이스 커넥션을 유지한다.
- SSE는 장시간 연결을 유지하는 특성이 있어, 연결이 끊어질 때까지 데이터베이스 커넥션을 계속 점유한다.
- 결과적으로 다수의 SSE 클라이언트가 연결되면 데이터베이스 커넥션 풀이 빠르게 소진될 수 있다.
- 메모리 누수 위험
- 영속성 컨텍스트가 오랫동안 유지되면서 캐시된 엔티티들이 메모리에 계속 쌓일 수 있다.
- SSE 연결이 많아질수록 이 문제는 더욱 심각해진다.
- 성능 저하
- 각 SSE 연결마다 데이터베이스 커넥션과 영속성 컨텍스트를 유지해야 하므로 서버 리소스 사용이 증가한다.
- 동시 처리할 수 있는 요청의 수가 제한된다.
WebSocket(STOMP)방식 구현
STOMP 개념
- Simple Text Oriented Messaging Protocol의 약자로, 메시지 기반의 통신 프로토콜
- WebSocket을 기반으로 동작하며, 메시지를 전송하는 프로토콜
- 서버와 클라이언트 간의 양방향 통신을 지원한다.
코드구현(핵심)
WebSocketConfig
- 클라이언트 -> /app/* -> @MessageMapping -> /topic/* -> 구독 클라이언트들
- registry.enableSimpleBroker(“/topic”) :
- 메시지 브로커는 메시지를 구독자들에게 전달하는 중간 매개체입니다
- /topic으로 시작하는 destination으로 메시지가 발행되면, 해당 destination을 구독하고 있는 모든 클라이언트에게 메시지를 전달합니다
- 주로 한 곳에서 발행된 메시지를 여러 구독자에게 전달할 때 사용됩니다 (1:N)
- 예: /topic/chat/room/1을 구독하는 모든 클라이언트는 해당 채팅방의 메시지를 받게 됩니다
- registry.setApplicationDestinationPrefixes(“/app”)
- 클라이언트가 서버로 메시지를 보낼 때 사용하는 prefix입니다
- /app으로 시작하는 destination으로 메시지를 보내면, 해당 메시지는 @MessageMapping이 달린 메서드로 라우팅됩니다
- 클라이언트 → 서버로의 단방향 통신에 사용됩니다
1 2 3 4 5 6 7 8 9 10 11 12 13
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").withSockJS(); //ws 연결을 지원하는 endpoint 등록 } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic"); //메시지 브로커가 /topic으로 시작하는 메시지를 클라이언트로 라우팅 registry.setApplicationDestinationPrefixes("/app"); //클라이언트에서 서버로 메시지를 보낼 때 /app으로 시작하는 메시지를 라우팅 } }
javaScript
- const socket = new SockJS(“/ws”);
- WebSocket 연결을 생성합니다
- “/ws”는 서버의 WebSocket 엔드포인트를 가리킴
- SockJS는 WebSocket을 지원하지 않는 브라우저를 위한 폴백(fallback) 메커니즘을 제공
- const StompClient = Stomp.over(socket);
- 생성된 WebSocket 연결 위에 STOMP 프로토콜을 입힙니다
- STOMP(Simple Text Oriented Messaging Protocol)는 메시징을 위한 프로토콜
- 이를 통해 subscribe/publish 같은 메시징 패턴을 사용할 수 있음
1 2 3 4 5 6 7 8 9 10
const socket = new SockJS("/ws"); const StompClient = Stomp.over(socket); StompClient.connect({}, frame => { console.log(frame); StompClient.subscribe("/topic/chat/writeMessage", (data) => { console.log((data.body)); Chat__loadMore(); }); });
MultiChat 구현
1. 코드구조 및 기본 설정
- 프로젝트 구조
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
com.ll.chat_pr ├── domain │ ├── chat │ ├── chatroom │ │ ├── entity │ │ ├── repository │ │ ├── controller │ │ └── service │ └── chatmessage │ ├── entity │ ├── repository │ ├── controller │ └── service └── global └── jpa └── BaseEntity └── Config
- yml설정 ```yaml server: port: 8090 # 서버가 실행될 포트 번호 지정
spring: thymeleaf: cache: false # 개발 환경에서 템플릿 캐시 비활성화 (실시간 변경 반영) prefix: file:src/main/resources/templates/ # 템플릿 파일 위치 지정, file: 접두사로 실시간 반영 output: ansi: enabled: always # 콘솔 출력시 ANSI 색상 활성화
h2: console: enabled: true # H2 데이터베이스 웹 콘솔 활성화 path: /h2-console # H2 콘솔 접속 경로 설정
datasource: url: jdbc:h2:mem:chat_dev # 메모리 DB 사용, DB 이름은 chat_dev username: sa # 데이터베이스 접속 아이디 password: # 데이터베이스 접속 비밀번호 (기본값 없음) driver-class-name: org.h2.Driver # H2 데이터베이스 드라이버 설정
jpa: hibernate: ddl-auto: create # 애플리케이션 시작시 테이블 새로 생성 (개발환경용)
1
2
3
4
5
6
properties:
hibernate:
default_batch_fetch_size: 100 # N+1 문제 해결을 위한 배치 사이즈 설정
format_sql: true # SQL 쿼리 포맷팅
highlight_sql: true # SQL 쿼리 하이라이팅
use_sql_comments: true # SQL 쿼리에 주석 포함
logging: level: org.hibernate.SQL: DEBUG # SQL 쿼리 로깅 org.hibernate.orm.jdbc.bind: TRACE # SQL 바인딩 파라미터 로깅 org.hibernate.orm.jdbc.extract: TRACE # SQL 결과 추출 로깅 org.springframework.transaction.interceptor: TRACE # 트랜잭션 로깅
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
### 2. Entity 구현
#### Entity의 개념
- 데이터베이스 테이블에 대응하는 자바 객체
-
#### Entity 구현
- BaseEntity
```java
@MappedSuperclass // JPA Entity 클래스들이 이 클래스를 상속할 경우 필드들도 컬럼으로 인식
@NoArgsConstructor(access = PROTECTED) // 기본 생성자 생성, protected 접근 제어
@AllArgsConstructor(access = PROTECTED) // 모든 필드 값을 파라미터로 받는 생성자 생성
@Getter // 모든 필드의 getter 메서드 생성
@SuperBuilder // 상속된 클래스에서 빌더 패턴 사용 가능
@EntityListeners(AuditingEntityListener.class) // JPA Auditing 기능 사용
@ToString // toString 메서드 자동 생성
@EqualsAndHashCode // equals와 hashCode 메서드 자동 생성
public class BaseEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
@EqualsAndHashCode.Include
private Long id;
@CreatedDate
@Getter
private LocalDateTime createDate;
@LastModifiedDate
@Getter
private LocalDateTime modifyDate;
}
3. 코드 구현
- 코드 구현은 레포참고
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.