Skip to content

Conversation

@k01zero
Copy link
Collaborator

@k01zero k01zero commented Jun 22, 2025

요구사항


기본 요구사항

웹소켓 구현하기

  • 웹소켓 환경 구성

    • spring-boot-starter-websocket 의존성을 추가하세요.

      implementation 'org.springframework.boot:spring-boot-starter-websocket'
      
    • 웹소켓 메시지 브로커 설정

      • 메모리 기반 SimpleBroker를 사용하세요.
      • STOMP 엔드포인트는 /ws로 설정하고, SockJS 연결을 지원해야 합니다.
  • 메시지 수신

    • 구독(Subscribe) 엔드포인트: /sub/channels.{channelId}.messages
    • 수신되는 메시지 타입은 MessageDto를 사용하세요.
  • 메시지 송신

    • 클라이언트가 메시지를 전송할 수 있도록 다음과 같이 엔드포인트를 구성하세요.
    • 첨부파일이 없는 텍스트 메시지
      • 송신 방식: 웹소켓
      • 전송 엔드포인트: /pub/messages
      • 요청 페이로드 타입: MessageCreateRequest
    • 첨부파일이 포함된 메시지
      • 기존 HTTP API를 유지하세요.
        image
        image

SSE 구현하기

  • SSE 환경을 구성하세요.

    • SSE 연결을 위한 엔드포인트를 구현하세요.
      • GET /api/sse
    • 다음 요구사항을 만족해야 합니다.
      • 사용자당 N개의 연결을 허용할 수 있어야 합니다 (예: 다중 탭/기기).
      • SseEmitter 객체를 스레드 세이프한 메모리 구조에서 안전하게 관리해야 합니다.
      • [] 메모리 누수 방지를 위해 다음과 같은 처리를 해야 합니다:
        • onCompletiononTimeoutonError 이벤트 핸들러에서 emitter를 제거합니다.
        • [] 주기적 스케줄링 작업을 통해 ping을 보내고, 응답이 없는 연결을 정리합니다.
      • 각 이벤트에는 고유한 ID를 부여하고, 클라이언트에서 Last-Event-ID를 전송해 이벤트 유실 복원이 가능하도록 해야 합니다.
        - 고유한 ID는 userId + "_" + 날짜 = 이벤트id = emitterId 로 했다.
  • 기존에 클라이언트에서 폴링 방식으로 주기적으로 요청하던 데이터를 SSE를 이용해 서버에서 실시간으로 전달하는 방식으로 리팩토링하세요.

  • 새로운 알림 이벤트 전송
    - 새 알림이 생성되었을 때 클라이언트에 이벤트를 전송하세요.
    - 클라이언트는 이 이벤트를 수신하면 알림 목록에 알림을 추가합니다.
    - 이벤트 명세

     
         | id | 이벤트 고유 ID |
         | --- | --- |
         | name | `notifications` |
         | data | `NotificationDto` |
     
    
  • 파일 업로드 상태 변경 이벤트 전송
    - 파일 업로드 상태가 변경될 때 이벤트를 발송하세요.
    - 유저 프로필 업로드/수정, 메세지 이미지 업로드 시 당사자에게 발송한다.
    - 클라이언트는 해당 상태를 수신하여 UI를 다시 렌더링합니다.
    - 이벤트 명세

    
         | id | 이벤트 고유 ID |
         | --- | --- |
         | name | `binaryContents.status` |
         | data | `BinaryContentDto` |
     
    
  • 채널 목록 갱신 이벤트 전송
    - 채널 목록을 업데이트해야 할 경우, 이벤트를 발송하세요.
    - createPublicChannel(), createPrivateChannel(), updateChannel(), deleteChannelById() 메서드가 사용되면 목록이 바뀌어야 한다.
    - 클라이언트는 해당 이벤트를 수신하면 채널 목록을 재조회합니다.
    - 이벤트 명세

     
         | id | 이벤트 고유 ID |
         | --- | --- |
         | name | `channels.refresh` |
         | data | `{channelId: $channelId}` |
     
    

image
image

  • 사용자 목록 갱신 이벤트 전송
    ㄴ 이 부분 조건을 이해하지 못했습니다...
    - 사용자 목록을 업데이트해야 할 경우, 이벤트를 발송하세요.
    - 클라이언트는 해당 이벤트를 수신하면 사용자 목록을 재조회합니다.
    - 이벤트 명세

     
         | id | 이벤트 고유 ID |
         | --- | --- |
         | name | `users.refresh` |
         | data | `{userId: $userId}` |
    

배포 아키텍처 구성하기

  • 다음의 다이어그램에 부합하는 배포 아키텍처를 Docker Compose를 통해 구현하세요.
  • Reverse Proxy
    • Nginx 기반의 리버스 프록시 컨테이너를 구성하세요.
    • 역할 및 설정은 다음과 같습니다:
      • /api/*/ws/* 요청은 Backend 컨테이너로 프록시 처리합니다.
      • 이 외의 모든 요청은 **정적 리소스(프론트엔드 빌드 결과)**를 서빙합니다.
        • 프론트엔드 정적 리소스는 Nginx 컨테이너 내부의 적절한 경로(/usr/share/nginx/html 등)에 복사하세요.
    • 외부에서 접근 가능한 유일한 컨테이너이며, 3000번 포트를 통해 접근할 수 있어야 합니다.
  • Backend
    • Spring Boot 기반의 백엔드 서버를 Docker 컨테이너로 구성하세요.
    • Reverse Proxy를 통해 /api/*/ws/* 요청이 이 서버로 전달됩니다.
  • DBMemory DBMessage Broker
    • Backend 컨테이너가 접근 가능한 다음의 인프라 컨테이너들을 구성하세요
      • DB: PostgreSQL
      • Memory DB: Redis
      • Message Broker: Kafka
    • 각 컨테이너는 Docker Compose 네트워크를 통해 백엔드에서 통신할 수 있어야 합니다.
    • 별도로 외부에 노출하지 않아도 됩니다.

image

제 프로젝트에는 Redis와 Kafka 가 적용되어 있지 않지만 도커를 통해 띄우는 건 해두었습니다!

심화 요구사항


주요 변경사항


스크린샷


멘토에게

  1. 비공개 채널을 생성한 당사자에게는 뜨지 않는 오류가 있습니다 ...!

  2. 로컬에서 SSE와 WebSocket 이 정상 작동하는데, 리버스 프록시를 통해 3000번 포트로 들어가니 SSE와 WebSocket 연결이 되지 않고 있습니다.

image

리버스 프록시 연결(=포트 번호 변경)이후 SSE와 WebSocket의 설정을 다룰 때 변경해야 하는 게 있을까요?

k01zero added 30 commits June 15, 2025 20:38
…개변수로 받지 않고 내내부에서 변경하도록 변경
…ê´ë메서드를 구현한 구현체 수정, 단위 테스트 코드 수정
…s.get().toString() 으로 변êã경, ìFileUserRepository와 FileChannelRepository 생성자ì� ì수정
k01zero added 24 commits June 15, 2025 20:45
- XorCsrfTokenRequestAttributeHandler 대신 CsrfTokenRequestAttributeHandler (XOR 기능이 없는 기본 핸들러)로 교체
- 비동기 설정및 재시도 설정
- 재시도 횟수 초과 후 복구 로직 추가
- 실패 정보 AsyncTaskFailure 추가
- 메서드 시그니처 변경 : 반환값 UUID -> CompletableFuture<UUID>
- TransactionTemplate 을 통해 트랜잭션 관리
- 알림 관련 API 구현
- ReadStatus 엔티티 리팩토링
- 비동기 방식 + 이벤트 리스너로 알림 생성
- Spring Cache, Caffeine Cache 이용을 위한 셋업
- 사용자별 채널 목록 조회, 사용자별 알림 목록 조회, 사용자 목록 조회에 로컬 캐시 적용
- @AuthenticationPrincipal 어노테이션을 통해 받아오는 객체 변경
- 웹소켓 + STOMP 설정
- ChatController 구현, 발행자의 송신, 저장, 구독자에게 수신
@k01zero k01zero requested a review from yeongunheo June 22, 2025 04:15
@k01zero k01zero self-assigned this Jun 22, 2025
@k01zero k01zero added the 매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다. label Jun 22, 2025
@yeongunheo
Copy link
Collaborator

안녕하세요 경린님,

제가 리뷰가 조금 늦어지고 있는데요,

6/26(목) 멘토링 전까지 코드 한번 살펴보고 리뷰 남겨볼게요.

Copy link
Collaborator

@yeongunheo yeongunheo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

경린님, 안녕하세요.

웹소켓 & SSE 설정 잘 해주셔서 몇가지 참고할만한 코멘트만 남겨두었습니다.

그리고 질문 주신 내용 중에 아래 내용은 제가 로컬에서 테스트해봤는데 원인을 잘 모르겠네요ㅠ

비공개 채널을 생성한 당사자에게는 뜨지 않는 오류가 있습니다 ...!

경린님 코드 단에서는 별다른 특이한 점을 발견하지 못했습니다.

프론트엔드 문제일 수 있으니 우선 넘어가도 좋을 것 같아요.

감사합니다.

// Subscribe 엔드 포인트 : /sub/channels.{channelId}.messages
String destination = "/sub/channels." + request.channelId() + ".messages";
log.info("구독자에게 메세지 수신 시도 : destination={}, content={}", destination, messageDto.content());
template.convertAndSend(destination, messageDto);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

웹소켓을 이용한 메시지 송신 잘 구현해주셨네요 👍

Comment on lines +2 to +28
# 통신할 백엔드 서버 그룹 정의
upstream backend_servers{
server backend:8080;
}

# 실제 웹 서버의 동작을 정의

server{
listen 80;

# 1. API 요청 처리 (/api/*)
location /api/ {
proxy_pass http://backend_servers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

# 2. 웹소켓 요청 처리 (/ws/*)
location /ws/ {
proxy_pass http://backend_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로컬에서 SSE와 WebSocket 이 정상 작동하는데, 리버스 프록시를 통해 3000번 포트로 들어가니 SSE와 WebSocket 연결이 되지 않고 있습니다.

웹브라우저 -> 리버스프록시 -> 웹서버

리버스프록시 -> 웹서버 이 구간에서 리버스프록시가 웹서버로 보내는 요청을 일정시간이 지나면 종료하는게 원인이라고 생각해요.

그래서 SSE/웹소켓 요청에서 타임아웃을 늘려줘야합니다!

# 통신할 백엔드 서버 그룹 정의
upstream backend_servers {
    server backend:8080;
    keepalive 32;  # 연결 풀링으로 성능 향상
}

# 실제 웹 서버의 동작을 정의
server {
    listen 80;

    # 3. SSE 요청 처리 (/api/sse) - 먼저 처리해야 함
    location /api/sse {
        proxy_pass http://backend_servers;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # SSE 전용 설정 (WebSocket 설정과 다름!)
        proxy_set_header Connection '';  # 빈 문자열로 설정
        proxy_set_header Cache-Control 'no-cache';
        proxy_set_header Content-Type 'text/event-stream';
        proxy_set_header X-Accel-Buffering 'no';
        
        # 버퍼링 완전 비활성화
        proxy_buffering off;
        proxy_cache off;
        
        # 타임아웃 설정 (SSE는 장시간 연결)
        proxy_read_timeout 86400s; // 추가
        proxy_send_timeout 86400s; // 추가
        proxy_connect_timeout 60s; // 추가
        
        # 청크 인코딩 활성화
        chunked_transfer_encoding on;
    }

    # 1. API 요청 처리 (/api/*)
    location /api/ {
        proxy_pass http://backend_servers;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection '';  # Keep-alive 연결 유지
    }

    # 2. 웹소켓 요청 처리 (/ws/*)
    location /ws/ {
        proxy_pass http://backend_servers;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # WebSocket 전용 설정
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        
        # 버퍼링 비활성화
        proxy_buffering off;
        proxy_cache off;
        
        # WebSocket 타임아웃 설정
        proxy_read_timeout 86400s; // 추가
        proxy_send_timeout 86400s; // 추가
        proxy_connect_timeout 60s; // 추가
    }

    # 4. 그 외 모든 요청 처리 (프론트엔드)
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
        
        # 정적 파일 캐싱
        expires 1h;
        add_header Cache-Control "public, immutable";
    }
}

Comment on lines +14 to +20
/**
* SseEmitter 객체를 Thread-safe한 메모리 구조에서 안전하게 관리
* <p>
* Thread-safe한 메모리 구조 = ConcurrentHashMap
**/
private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
private final Map<String, Object> eventCache = new ConcurrentHashMap<>();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConcurrentHashMap 사용해서 Thread-safe하게 잘 만들어주셨네요 💯

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants