Skip to content

Conversation

@wangcoJiin
Copy link
Collaborator

@wangcoJiin wangcoJiin commented Sep 14, 2025

기본 요구사항

웹소켓 구현하기

    • 웹소켓 환경 구성
      • spring-boot-starter-websocket 의존성을 추가하세요.

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

        @Configuration
        @EnableWebSocketMessageBroker
        public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {...}
        • 메모리 기반 SimpleBroker를 사용하세요.

          @Override
          public void configureMessageBroker(MessageBrokerRegistry config) {...}
          • SimpleBroker의 Destination Prefix는 /sub 으로 설정하세요.
            • 클라이언트에서 메시지를 구독할 때 사용합니다.
          • Application Destination Prefix는 /pub 으로 설정하세요.
            • 클라이언트에서 메시지를 발행할 때 사용합니다.
          @Override
          public void registerStompEndpoints(StompEndpointRegistry registry) {...}
          • STOMP 엔드포인트는 /ws로 설정하고, SockJS 연결을 지원해야 합니다.
    • 메시지 송신
      • 첨부파일이 없는 단순 텍스트 메시지인 경우 STOMP를 통해 메시지를 전송할 수 있도록 컨트롤러를 구현하세요.

        @Controller
        public class MessageWebSocketController {
            ...
            @MessageMapping(...)
        }
        • 클라이언트는 웹소켓으로 /pub/messages 엔드포인트에 메시지를 전송할 수 있어야 합니다.
          • @MessageMapping을 활용하세요.
        • 메시지 전송 요청의 페이로드 타입은 MessageCreateRequest 를 그대로 활용합니다.
      • 첨부파일이 포함된 메시지는 기존의 API (POST /api/messages)를 그대로 활용합니다.

    • 메시지 수신
      • 클라이언트는 채널 입장 시 웹소켓으로 /sub/channels.{channelId}.messages 를 구독해 메시지를 수신합니다.

      • 이를 고려해 메시지가 생성되면 해당 엔드포인트로 메시지를 보내는 컴포넌트를 구현하세요.

        @Component
        public class WebSocketRequiredEventListener {
            ...
            private final SimpMessagingTemplate messagingTemplate;
        
          @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
          public void handleMessage(MessageCreatedEvent event) {...}
        }
        • MessageCreatedEvent를 통해 새로운 메시지 생성 이벤트를 확인하세요.
        • SimpMessagingTemplate를 통해 적절한 엔드포인트로 메시지를 전송하세요.

SSE 구현하기

    • SSE 환경을 구성하세요.
      • 클라이언트에서 SSE 연결을 위한 엔드포인트를 구현하세요.

        • GET /api/sse
      • 사용자별 SseEmitter 객체를 생성하고 메시지를 전송하는 컴포넌트를 구현하세요.

        @Service
        public class SseService {
        
          public SseEmitter connect(UUID receiverId, UUID lastEventId) {...}
        
          public void send(Collection<UUID> receiverIds, String eventName, Object data) {...}
        
          public void broadcast(String eventName, Object data) {...}
        
          @Scheduled(fixedDelay = 1000 * 60 * 30)
          public void cleanUp() {...}
        
          private boolean ping(SseEmitter sseEmitter) {...}
        }
        • connect: SseEmitter 객체를 생성합니다.
        • sendbroadcast: SseEmitter 객체를 통해 이벤트를 전송합니다.
        • cleanUp: 주기적으로 ping을 보내서 만료된 SseEmitter 객체를 삭제합니다.
        • ping: 최초 연결 또는 만료 여부를 확인하기 위한 용도로 더미 이벤트를 보냅니다.
      • SseEmitter 객체를 메모리에서 저장하는 컴포넌트를 구현하세요.

        @Repository
        public class SseEmitterRepository {
          private final ConcurrentMap<UUID, List<SseEmitter>> data = new ConcurrentHashMap<>();
            ...
        }
        • ConcurrentMap: 스레드 세이프한 자료구조를 사용합니다.
        • List<SseEmitter>: 사용자 당 N개의 연결을 허용할 수 있도록 합니다. (예: 다중 탭)
      • 이벤트 유실 복원을 위해 SSE 메시지를 저장하는 컴포넌트를 구현하세요.

        @Repository
        public class SseMessageRepository {
        
          private final ConcurrentLinkedDeque<UUID> eventIdQueue = new ConcurrentLinkedDeque<>();
          private final Map<UUID, SseMessage> messages = new ConcurrentHashMap<>();
            ...
        }
        • 각 메시지 별로 고유한 ID를 부여합니다.
        • 클라이언트에서 LastEventId를 전송해 이벤트 유실 복원이 가능하도록 해야 합니다.
    • 기존에 클라이언트에서 폴링 방식으로 주기적으로 요청하던 데이터를 SSE를 이용해 서버에서 실시간으로 전달하는 방식으로 리팩토링하세요.
      • 새로운 알림 이벤트 전송

        • 새 알림이 생성되었을 때 클라이언트에 이벤트를 전송하세요.
        • 클라이언트는 이 이벤트를 수신하면 알림 목록에 알림을 추가합니다.
        • 이벤트 명세
        image
      • 파일 업로드 상태 변경 이벤트 전송

        • 파일 업로드 상태가 변경될 때 이벤트를 발송하세요.
        • 클라이언트는 해당 상태를 수신하면 파일 상태 UI를 다시 렌더링합니다.
        • 이벤트 명세
        image
      • 채널 갱신 이벤트 전송

        • 채널 정보가 변경될 때, 이벤트를 발송하세요.
        • 클라이언트는 해당 이벤트를 수신하면 채널 UI를 다시 렌더링합니다.
        • 이벤트 명세
        image
      • 사용자 갱신 이벤트 전송

        • 사용자 정보 또는 로그인 상태가 변경될 때, 이벤트를 발송하세요.
        • 클라이언트는 해당 이벤트를 수신하면 사용자 UI를 다시 렌더링합니다.
        • 이벤트 명세
        image

배포 아키텍처 구성하기

    • 다음의 다이어그램에 부합하는 배포 아키텍처를 Docker Compose를 통해 구현하세요.
image
  • 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

멘토에게

  • 셀프 코드 리뷰를 통해 질문 이어가겠습니다.

''배포 아키텍처 구성하기'' 파트에서 생성된 Spring Boot 인스턴스가 자꾸 재실행 되는 문제가 있어서 해결중입니다.

볼륨으로 넣어둔 로그를 확인해봤을 때

  1. 새로 생성한 application-docker.yaml을 사용하지 않고 prod 프로필을 사용함 -> prod 하드 코딩 되어 있는 부분 수정 (해결)
  2. S3 빈이 등록되는데 value가 없음 -> 조건부 등록하도록 수정해서 해결함
  3. 현재 파악중인 건 데이터베이스 테이블 관련 문제인 것 같은데 init을 설정해줘도 application이 자꾸 재실행 되고 있어 다시 확인중입니다.

- 첨부파일이 없는 단순 텍스트 메시지인 경우 STOMP를 통해 메시지를 전송할 수 있는 컨트롤러
- 이벤트 유실 복원을 위해 SSE 메시지를 저장하는 컴포넌트를 구현
@wangcoJiin wangcoJiin requested a review from ssjf409 September 14, 2025 14:42
@wangcoJiin wangcoJiin added 매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다. 미완성🛠️ 스프린트 미션 제출일이지만 미완성했습니다. 죄송합니다. labels Sep 14, 2025
@ssjf409
Copy link
Collaborator

ssjf409 commented Sep 14, 2025

변경 사항이 너무 많이 잡혀서 리뷰하기가 힘들어요 ㅠㅠ
베이스 브랜치에 이미 10, 11때 머지된 변경한 커밋들이 들어가도록 부탁드려요. 아마 wangcoJiin:황지인-sprint12 를 머지하면 될거에요.

@wangcoJiin
Copy link
Collaborator Author

변경 사항이 너무 많이 잡혀서 리뷰하기가 힘들어요 ㅠㅠ 베이스 브랜치에 이미 10, 11때 머지된 변경한 커밋들이 들어가도록 부탁드려요. 아마 wangcoJiin:황지인-sprint12 를 머지하면 될거에요.

넵!! 베이스 수정했습니다!

Copy link
Collaborator

@ssjf409 ssjf409 left a comment

Choose a reason for hiding this comment

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

다른 코드에서는 딱히 코멘트 달게 없었습니다. 고생하셨습니다.

Comment on lines +33 to +55
public List<SseEmitter> findAllByUserId (UUID userId) {
List<SseEmitter> list = data.get(userId);
if (list == null) {
return List.of();
}
synchronized (list) {
return List.copyOf(list);
}
}

// emitter 제거
public void delete(UUID userId, SseEmitter emitter) {
List<SseEmitter> list = data.get(userId);
if(list == null) {
return;
}
synchronized (list) {
list.remove(emitter);
if (list.isEmpty()) {
data.remove(userId, list);
}
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

findAllByUserId, delete랑 data.get 호출해서 list를 가져오는 순간 동시성이 깨집니다.
이 부분을 computeIfPresent로 해결 하시면 좋을거 같아요.

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