포스트

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). image

OSIV와 SSE를 함께 사용할 때의 문제점:

  1. 데이터베이스 커넥션 고갈
    • OSIV는 요청이 끝날 때까지 데이터베이스 커넥션을 유지한다.
    • SSE는 장시간 연결을 유지하는 특성이 있어, 연결이 끊어질 때까지 데이터베이스 커넥션을 계속 점유한다.
    • 결과적으로 다수의 SSE 클라이언트가 연결되면 데이터베이스 커넥션 풀이 빠르게 소진될 수 있다.
  2. 메모리 누수 위험
    • 영속성 컨텍스트가 오랫동안 유지되면서 캐시된 엔티티들이 메모리에 계속 쌓일 수 있다.
    • SSE 연결이 많아질수록 이 문제는 더욱 심각해진다.
  3. 성능 저하
    • 각 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 라이센스를 따릅니다.