[์‹๊ตฌํ•˜์ž_MSA] ๐Ÿš€ ์ฑ„ํŒ…&์•Œ๋ฆผ ๋งˆ์ดํฌ๋กœ ์„œ๋น„์Šค: ์•Œ๋ฆผ ๊ธฐ๋Šฅ ๊ตฌํ˜„(SseEmitter+Redis) - 3ํŽธ

์ด๋ฏผ์šฐยท2024๋…„ 3์›” 15์ผ
3

๐Ÿ€ ์‹๊ตฌํ•˜์ž_MSA

๋ชฉ๋ก ๋ณด๊ธฐ
6/21
post-thumbnail

์ง€๋‚œ ํฌ์ŠคํŒ…์— ์ด์–ด ์ฑ„ํŒ… ๋งˆ์ดํฌ๋กœ ์„œ๋น„์Šค ์•Œ๋ฆผ ๊ธฐ๋Šฅ ๊ณผ์ •์„ ํฌ์ŠคํŒ… ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ฃผ์š” ์ฝ”๋“œ๋งŒ ๋‹ค๋ฃฐ ์˜ˆ์ •์ด๋ผ ๋‹ค๋ฃจ์ง€ ์•Š๋Š” ์ฝ”๋“œ๋“ค์€ ์•„๋ž˜ github๋ฅผ ํ†ตํ•ด ์ฐธ๊ณ ํ•˜์‹œ๊ธธ ๋ฐ”๋ž๋‹ˆ๋‹ค๐Ÿ™๐Ÿ™(msa-master ๋ธŒ๋žœ์น˜)

๐Ÿ‘‰ ์ฐธ๊ณ  ์ฝ”๋“œ : https://github.com/LminWoo99/PlantBackend/tree/msa-master

๐Ÿค” ์•Œ๋ฆผ ๊ธฐ๋Šฅ ๊ตฌํ˜„์— ๋Œ€ํ•œ ๊ณ ๋ฏผ

์š”๊ตฌ์‚ฌํ•ญ ๋ถ„์„

์‹๊ตฌํ•˜์ž ํ”„๋กœ์ ํŠธ์˜ ์•Œ๋ฆผ ๊ธฐ๋Šฅ์€ 1:1 ์ฑ„ํŒ…์—์„œ ์ฝ์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€์— ๋Œ€ํ•ด ์•Œ๋ฆผ์„ ์ œ๊ณตํ•˜๋Š” ๊ฒƒ์ด ํ•ต์‹ฌ์ž…๋‹ˆ๋‹ค.

์ฑ„ํŒ… ํ”„๋กœ์„ธ์Šค ์„ค๊ณ„

  1. ํด๋ผ์ด์–ธํŠธ: A์˜ ๋ฉ”์‹œ์ง€๋ฅผ ์„œ๋ฒ„๋กœ ์ „์†ก
  2. ์„œ๋ฒ„: B์˜ ์ ‘์† ์ƒํƒœ ํ™•์ธ
  3. ์„œ๋ฒ„: ์ ‘์† ์ƒํƒœ์— ๋”ฐ๋ฅธ readcount ์„ค์ • (์ ‘์† ์ค‘: 0, ๋น„์ ‘์†: 1)
  4. ์„œ๋ฒ„: ๋ฉ”์‹œ์ง€ ์ •๋ณด ์ถ”๊ฐ€ ํ›„ ํด๋ผ์ด์–ธํŠธ์— ์ „๋‹ฌ
  5. ํด๋ผ์ด์–ธํŠธ: ๋ฐ›์€ ๋ฉ”์‹œ์ง€๋ฅผ ์„œ๋ฒ„๋กœ ์žฌ์ „์†ก
  6. ์„œ๋ฒ„: ๋ฉ”์‹œ์ง€ DB ์ €์žฅ ๋ฐ ํ•„์š” ์‹œ ์•Œ๋ฆผ ๋ฐœ์†ก (readcount๊ฐ€ 1์ผ ๊ฒฝ์šฐ)

ํ•ต์‹ฌ ์š”๊ตฌ์‚ฌํ•ญ

  1. ์‹ค์‹œ๊ฐ„ ์ฑ„ํŒ…๋ฐฉ ์ ‘์†์ž ์ˆ˜ ํŒŒ์•…
  2. ์‚ฌ์šฉ์ž์˜ ์ฑ„ํŒ…๋ฐฉ ์ž…์žฅ ์‹œ ์ ‘์† ์ •๋ณด ๊ด€๋ฆฌ

๐Ÿคทโ€โ™‚๏ธ ํ•ด๊ฒฐ ๋ฐฉ์•ˆ ๋ชจ์ƒ‰

์ดˆ๊ธฐ ์ ‘๊ทผ: Polling ๋ฐฉ์‹

  • ๊ฐœ๋…: ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ฃผ๊ธฐ์ ์œผ๋กœ ์„œ๋ฒ„์— ๋ฐ์ดํ„ฐ ์š”์ฒญ
  • ์žฅ์ : ๊ตฌํ˜„์ด ๊ฐ„๋‹จํ•จ
  • ๋‹จ์ :
    • ์„œ๋ฒ„ ๋ถ€ํ•˜ ์ฆ๊ฐ€ ๊ฐ€๋Šฅ์„ฑ
    • ์‹ค์‹œ๊ฐ„์„ฑ๊ณผ ํšจ์œจ์„ฑ ์‚ฌ์ด์˜ ํŠธ๋ ˆ์ด๋“œ์˜คํ”„
    • ๋ถˆํ•„์š”ํ•œ ์š”์ฒญ์œผ๋กœ ์ธํ•œ ๋ฆฌ์†Œ์Šค ๋‚ญ๋น„

์ตœ์ข… ํ•ด๊ฒฐ์ฑ…: Stomp, Redis, SSE ์กฐํ•ฉ

1. Stomp์˜ ChannelInterceptor ํ™œ์šฉ

  • Stomp ํ”„๋กœํ† ์ฝœ์˜ ์ฃผ์š” ์ด๋ฒคํŠธ(connect, send, disconnect, subscribe) ๊ฐ์ง€
  • ChannelInterceptor ์ธํ„ฐํŽ˜์ด์Šค ๊ตฌํ˜„์„ ํ†ตํ•œ ์‚ฌ์šฉ์ž ์ •์˜ ๋กœ์ง ์ถ”๊ฐ€
    • preSend: ๋ฉ”์‹œ์ง€ ์ „์†ก ์ „ ์ฒ˜๋ฆฌ (์˜ˆ: ๋ฉ”์‹œ์ง€ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ)
    • postSend: ๋ฉ”์‹œ์ง€ ์ „์†ก ํ›„ ์ฒ˜๋ฆฌ (์˜ˆ: ๋กœ๊น…, ํ†ต๊ณ„ ์—…๋ฐ์ดํŠธ)
    • preReceive: ๋ฉ”์‹œ์ง€ ์ˆ˜์‹  ์ „ ์ฒ˜๋ฆฌ
    • postReceive: ๋ฉ”์‹œ์ง€ ์ˆ˜์‹  ํ›„ ์ฒ˜๋ฆฌ
  • ์ฑ„ํŒ…๋ฐฉ ์—ฐ๊ฒฐ ์‹œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ Redis์— ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ €์žฅ
    • ํ‚ค: ์ฑ„ํŒ…๋ฐฉ ID, ๊ฐ’: ์ ‘์† ์ค‘์ธ ์‚ฌ์šฉ์ž ๋ชฉ๋ก
    • ์‚ฌ์šฉ์ž ์ ‘์†/ํ‡ด์žฅ ์‹œ Redis ๋ฐ์ดํ„ฐ ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธ

2. Redis ์‚ฌ์šฉ ๊ฒฐ์ • ์ด์œ 

  • ์ฑ„ํŒ… ๊ด€๋ จ ๋ฐ์ดํ„ฐ์˜ ํŠน์„ฑ์— ์ตœ์ ํ™”
    • ๋นˆ๋ฒˆํ•œ ์ฝ๊ธฐ/์“ฐ๊ธฐ ์ž‘์—… ์ฒ˜๋ฆฌ์— ํƒ์›”ํ•œ ์„ฑ๋Šฅ
    • ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ์— ์ ํ•ฉํ•œ ์ธ๋ฉ”๋ชจ๋ฆฌ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ
  • ๊ธฐ์กด ๊ด€๊ณ„ํ˜• DB์™€์˜ ๋ถ€ํ•˜ ๋ถ„์‚ฐ
    • ์ฑ„ํŒ… ์„ธ์…˜ ์ •๋ณด์™€ ๊ฐ™์€ ํœ˜๋ฐœ์„ฑ ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ์— ์ ํ•ฉ
    • ์ฃผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ๋ถ€ํ•˜ ๊ฐ์†Œ๋กœ ์ „์ฒด ์‹œ์Šคํ…œ ์„ฑ๋Šฅ ํ–ฅ์ƒ
  • ๋‹ค์–‘ํ•œ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ ์ง€์›
    • Strings, Lists, Sets, Sorted Sets, Hashes ๋“ฑ ๋‹ค์–‘ํ•œ ๋ฐ์ดํ„ฐ ํƒ€์ž… ์ œ๊ณต
    • ์ฑ„ํŒ…๋ฐฉ ๋ชฉ๋ก, ์‚ฌ์šฉ์ž ์„ธ์…˜ ๋“ฑ ๋‹ค์–‘ํ•œ ์ •๋ณด๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์ €์žฅ ๋ฐ ๊ด€๋ฆฌ

3. SSE(Server-Sent Events) ๋„์ž…

  • ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ์˜ ๋‹จ๋ฐฉํ–ฅ ์‹ค์‹œ๊ฐ„ ๋ฐ์ดํ„ฐ ์ „์†ก ๊ตฌํ˜„
    • WebSocket๊ณผ ๋‹ฌ๋ฆฌ ์„œ๋ฒ„์—์„œ ํด๋ผ์ด์–ธํŠธ๋กœ์˜ ๋‹จ๋ฐฉํ–ฅ ํ†ต์‹ ๋งŒ ์ง€์›
    • HTTP ํ”„๋กœํ† ์ฝœ ์‚ฌ์šฉ์œผ๋กœ ์ถ”๊ฐ€ ์„ค์ • ์—†์ด ๋ฐฉํ™”๋ฒฝ ํ†ต๊ณผ ๊ฐ€๋Šฅ
  • ์•Œ๋ฆผ ๊ธฐ๋Šฅ ๊ตฌํ˜„์— ์ตœ์ ํ™”
    • ์ƒˆ ๋ฉ”์‹œ์ง€ ๋„์ฐฉ, ์ฝ์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€ ์ˆ˜ ์—…๋ฐ์ดํŠธ ๋“ฑ ์‹ค์‹œ๊ฐ„ ์•Œ๋ฆผ ์ œ๊ณต
    • ํด๋ผ์ด์–ธํŠธ์˜ EventSource ๊ฐ์ฒด๋ฅผ ํ†ตํ•œ ๊ฐ„ํŽธํ•œ ๊ตฌํ˜„
  • ํšจ์œจ์ ์ธ ๋ฆฌ์†Œ์Šค ์‚ฌ์šฉ
    • ์—ฐ๊ฒฐ ์œ ์ง€์— ํ•„์š”ํ•œ ๋ฆฌ์†Œ์Šค๊ฐ€ WebSocket์— ๋น„ํ•ด ์ ์Œ
    • ์ž๋™ ์žฌ์—ฐ๊ฒฐ ๊ธฐ๋Šฅ ๋‚ด์žฅ์œผ๋กœ ์•ˆ์ •์„ฑ ํ–ฅ์ƒ
  • ๋ธŒ๋ผ์šฐ์ € ํ˜ธํ™˜์„ฑ
    • ๋Œ€๋ถ€๋ถ„์˜ ํ˜„๋Œ€ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ง€์›๋˜์–ด ํญ๋„“์€ ์‚ฌ์šฉ์ž ํ™˜๊ฒฝ ์ปค๋ฒ„ ๊ฐ€๋Šฅ

1๏ธโƒฃ SSE๋ฅผ ํ™œ์šฉํ•œ ์•Œ๋ฆผ ๊ธฐ๋Šฅ

์ปจํŠธ๋กค๋Ÿฌ ๊ตฌํ˜„

@RestController
@RequestMapping("/notification")
@Slf4j
@RequiredArgsConstructor
public class NotificationController {

    private final NotificationService notificationService;

    /**
     * @title ๋กœ๊ทธ์ธ ํ•œ ์œ ์ € SSE ์—ฐ๊ฒฐ
     * @param : String lastEventId,String jwtToken
     **/
    @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> connect(
            @RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "") String lastEventId,
            @RequestHeader("Authorization") String jwtToken) {
        SseEmitter emitter = notificationService.subscribe(lastEventId, jwtToken);

        return ResponseEntity.ok(emitter);
    }

    /**
     * @title ๋กœ๊ทธ์ธ ํ•œ ์œ ์ €์˜ ๋ชจ๋“  ์•Œ๋ฆผ ์กฐํšŒ
     * @param : Long memberNo(ํ˜„์žฌ ์ ‘์†ํ•œ ๋ฉค๋ฒ„)
     **/
    @GetMapping("/all")
    public ResponseEntity<List<NotificationResponse>> notifications(Long memberNo) {
        return ResponseEntity.ok(notificationService.findAllById(memberNo));
    }

    /**
     * @title ์•Œ๋ฆผ ์ฝ์Œ ์ƒํƒœ๋งŒ ๋ณ€๊ฒฝ
     * @param : Long id(์•Œ๋ฆผ id)
     **/
    @PatchMapping("/checked/{id}")
    public ResponseEntity<StatusResponseDto> readNotification(@PathVariable("id") Long id) {
        notificationService.readNotification(id);
        return ResponseEntity.ok(StatusResponseDto.success());
    }

    /**
     * @title ์•Œ๋ฆผ ์‚ญ์ œ
     * @param : NotificationDeleteRequest request
     **/
    @DeleteMapping
    public ResponseEntity<StatusResponseDto> deleteNotification(@RequestBody NotificationDeleteRequest request) {
        notificationService.deleteNotification(request.getIdList());
        return ResponseEntity.ok(StatusResponseDto.success());
    }


}
์„œ๋ฒ„์—์„œ๋Š” EventSource๋ฅผ ํ†ตํ•ด ๋‚ ์•„์˜ค๋Š” ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•  ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ํ•„์š”ํ•˜๋‹ค. sse ํ†ต์‹ ์„ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” MIME ํƒ€์ž…์„ text/event-stream๋กœ ํ•ด์ค˜์•ผํ•œ๋‹ค.

์ถ”๊ฐ€์ ์œผ๋กœ Last-Event-ID๋ผ๋Š” ํ—ค๋”๋ฅผ ๋ฐ›๊ณ  ์žˆ๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. ์ด ํ—ค๋”๋Š” ํ•ญ์ƒ ๋‹ด๊ฒจ์žˆ๋Š” ๊ฒƒ์€ ์•„๋‹ˆ๋‹ค. ๋งŒ์•ฝ sse ์—ฐ๊ฒฐ์ด ์‹œ๊ฐ„ ๋งŒ๋ฃŒ ๋“ฑ์˜ ์ด์œ ๋กœ ๋Š์–ด์กŒ์„ ๊ฒฝ์šฐ์— ์•Œ๋ฆผ์ด ๋ฐœ์ƒํ•˜๋ฉด ์–ด๋–ป๊ฒŒ ๋ ๊นŒ?
๊ทธ ์‹œ๊ฐ„ ๋™์•ˆ ๋ฐœ์ƒํ•œ ์•Œ๋ฆผ์€ ํด๋ผ์ด์–ธํŠธ์— ๋„๋‹ฌํ•˜์ง€ ๋ชปํ•  ๊ฒƒ์ด๋‹ค. ์ด๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•œ ๊ฒƒ์ด Last-Event-ID ํ—ค๋”์ด๋‹ค. ์ด ํ—ค๋”๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งˆ์ง€๋ง‰์œผ๋กœ ์ˆ˜์‹ ํ•œ ๋ฐ์ดํ„ฐ์˜ id๊ฐ’์„ ์˜๋ฏธํ•œ๋‹ค. ์ด๋ฅผ ์ด์šฉํ•˜์—ฌ ์œ ์‹ค๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ๋ณด๋‚ด์ค„ ์ˆ˜ ์žˆ๋‹ค. ๋ฐ‘์—์„œ ์ž์„ธํžˆ ์„ค๋ช…ํ•  ์˜ˆ์ •์ด๋‹ค.

์„œ๋น„์Šค (SSE) ๋กœ์ง

@RequiredArgsConstructor
@Service
@Slf4j
public class NotificationService {
    private static final Long DEFAULT_TIMEOUT = 1000L * 60 * 29 ;// 29๋ถ„
    public static final String PREFIX_URL = "๋„๋ฉ”์ธ url";
    private final NotificationRepository notificationRepository;
    private final EmitterRepository emitterRepository;


    private final PlantServiceClient plantServiceClient;
    private final CircuitBreakerFactory circuitBreakerFactory;
    /**
     * SSE ์—ฐ๊ฒฐ ๋ฉ”์„œ๋“œ
     * @param : MemberDto memberDto, String lastEnventId
     */
    @Transactional
    public SseEmitter subscribe(String lastEventId, String jwtToken) {
        ResponseEntity<MemberDto> joinMember = plantServiceClient.getJoinMember(jwtToken);
        Integer memberNo = joinMember.getBody().getId().intValue();
        //Emitter map์— ์ €์žฅํ•˜๊ธฐ ์œ„ํ•œ key ์ƒ์„ฑ
        String id = memberNo + "_" + System.currentTimeMillis();
        // SseEmitter map์— ์ €์žฅ
        SseEmitter emitter = emitterRepository.save(id, new SseEmitter(DEFAULT_TIMEOUT));

        log.info("emitter add: {}", emitter);
        // emitter์˜ ์™„๋ฃŒ ๋˜๋Š” ํƒ€์ž„์•„์›ƒ Event๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ, ํ•ด๋‹น emitter๋ฅผ ์‚ญ์ œ
        emitter.onCompletion(() -> emitterRepository.deleteById(id));
        emitter.onTimeout(() -> emitterRepository.deleteById(id));

        // 503 ์—๋Ÿฌ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•œ ๋”๋ฏธ Event ์ „์†ก
        sendToClient(emitter, id, "EventStream Created. [userId=" + memberNo + "]");

        // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋ฏธ์ˆ˜์‹ ํ•œ Event ๋ชฉ๋ก์ด ์กด์žฌํ•  ๊ฒฝ์šฐ ์ „์†กํ•˜์—ฌ Event ์œ ์‹ค์„ ์˜ˆ๋ฐฉ
        if (!lastEventId.isEmpty()) {
            // id์— ํ•ด๋‹นํ•˜๋Š” cache ์ฐพ์Œ
            Map<String, Object> events = emitterRepository.findAllCacheStartWithId(String.valueOf(memberNo));

            //๋ฏธ์ˆ˜์‹ ํ•œ Event ๋ชฉ๋ก ์ „์†ก
            events.entrySet().stream()
                    .filter(entry -> lastEventId.compareTo(entry.getKey()) < 0)
                    .forEach(entry -> sendToClient(emitter, entry.getKey(), entry.getValue()));
        }
        return emitter;

    }
    /**
     * ์•Œ๋ฆผ ์ „์†ก ๋ฉ”์„œ๋“œ
     * ์•Œ๋ฆผ ๋ฐ›์„ ์œ ์ € SseEmitter ๊ฐ€์ ธ์™€์„œ ์•Œ๋ฆผ ์ „์†ก
     * @param : MemberDto sender, MemberDto receiver, NotifiTypeEnum type, String resource, String content
     */
    @Transactional
    public void send(MemberDto sender, MemberDto receiver, NotifiTypeEnum type, String resource, String content) {
        // ์•Œ๋ฆผ ์ƒ์„ฑ
        Notification notification = Notification.builder()
                .senderNo(sender.getId().intValue())
                .receiverNo(receiver.getId().intValue())
                .typeEnum(type)
                .url(PREFIX_URL + type.getPath() + resource)
                .content(content)
                .isRead(false)
                .isDel(false)
                .build();
        CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
        //๋ณด๋‚ธ ์‚ฌ๋žŒ ์ด๋ฆ„ ์ฐพ๊ธฐ ์œ„ํ•ด feignClient๋ฅผ ํ†ตํ•ด ํ˜ธ์ถœ
        ResponseEntity<MemberDto> findMember = circuitBreaker.run(() -> plantServiceClient.findById(sender.getId()),
                throwable -> ResponseEntity.ok(null));

        notification.setSenderName(findMember.getBody().getNickname());
        // SseEmitter ์บ์‹œ ์กฐํšŒ๋ฅผ ์œ„ํ•ด key์˜ prefix ์ƒ์„ฑ
        String id = String.valueOf(notification.getReceiverNo());

        //์•Œ๋ฆผ ์ €์žฅ
        notificationRepository.save(notification);

        // ๋กœ๊ทธ์ธ ํ•œ ์œ ์ €์˜ SseEmitter ๋ชจ๋‘ ๊ฐ€์ ธ์˜ค๊ธฐ
        Map<String, SseEmitter> sseEmitterMap = emitterRepository.findAllStartWithById(id);
        sseEmitterMap.forEach(
                (key, emitter) -> {
                    //์บ์‹œ ์ €์žฅ(์œ ์‹คํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ฒ˜๋ฆฌ)
                    emitterRepository.saveCache(key, notification);
                    //๋ฐ์•„ํ„ฐ ์ „์†ก
                    sendToClient(emitter, key, NotificationResponse.toDto(notification));
                }
        );
    }
    /**
     * ํด๋ผ์ด์–ธํŠธ์— SSE + ์•Œ๋ฆผ ๋ฐ์ดํ„ฐ ์ „์†ก ๋ฉ”์„œ๋“œ
     * @param : SseEmitter emitter, String id, Object data
     */
    private void sendToClient(SseEmitter emitter, String id, Object data) {
        try {
            log.info("event : " + data);
            emitter.send(SseEmitter.event()
                    .id(id)
                    .name("sse")
                    .data(data));
            log.info("event call: " + emitter);
        } catch (IOException ex) {
            emitterRepository.deleteById(id);
            log.error("--- SSE ์—ฐ๊ฒฐ ์˜ค๋ฅ˜ ----", ex);
        }
    }

    /**
     * ๋กœ๊ทธ์ธํ•œ ๋ฉค๋ฒ„ ์•Œ๋ฆผ ์ „์ฒด ์กฐํšŒ
     * ์กฐํšŒ์šฉ ๋ฉ”์„œ๋“œ => (readOnly = true)
     * @param : MemberDto memberDto
     */
    public List<NotificationResponse> findAllById(Long memberNo) {
        // ์ฑ„ํŒ…์˜ ๋งˆ์ง€๋ง‰ ์•Œ๋ฆผ ์กฐํšŒ
        List<Notification> chat
                = notificationRepository.findChatByReceiver(memberNo.intValue());

        return chat.stream()
                .map(NotificationResponse::toDto)
                .sorted(Comparator.comparing(NotificationResponse::getId).reversed())
                .collect(Collectors.toList());
    }

    /**
     * ์ผ๋ฆผ ์ฝ์Œ ์ฒ˜๋ฆฌ ๋ฉ”์„œ๋“œ
     * @param : Long id
     */
    @Transactional
    public void readNotification(Long id) {
        notificationRepository.updateIsReadById(id);
    }
    /**
     * ์ผ๋ฆผ ์‚ญ์ œ ์ฒ˜๋ฆฌ ๋ฉ”์„œ๋“œ
     * @param : Long id
     */
    public void deleteNotification(Long[] idList) {
        for (Long id : idList) {
            Notification notification = getNotification(id);
            notificationRepository.delete(notification);
        }


    }
    //== ๊ฐœ๋ณ„ ์•Œ๋ฆผ ์กฐํšŒ ==//
    private Notification getNotification(Long id) {
        return notificationRepository.findById(id)
                .orElseThrow(ErrorCode::throwNotificationNotFound);
    }
}

subscribe()๋ฅผ ๋ณด๋ฉด id๊ฐ’์„ {user_id}_{System.currentTimeMillis()} ํ˜•ํƒœ๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ์‚ฌ์šฉํ•˜๋Š” ์ด์œ ๊ฐ€ Last-Event-ID ํ—ค๋”์™€ ์ƒ๊ด€์ด ์žˆ์Šต๋‹ˆ๋‹ค.
Last-Event-IDํ—ค๋”๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋งˆ์ง€๋ง‰์œผ๋กœ ์ˆ˜์‹ ํ•œ ๋ฐ์ดํ„ฐ์˜ id๊ฐ’์„ ์˜๋ฏธํ•œ๋‹ค๊ณ  ํ–ˆ์Šต๋‹ˆ๋‹ค. id๊ฐ’๊ณผ ์ „์†ก ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๊ณ  ์žˆ์œผ๋ฉด ์ด ๊ฐ’์„ ์ด์šฉํ•˜์—ฌ ์œ ์‹ค๋œ ๋ฐ์ดํ„ฐ ์ „์†ก์„ ๋‹ค์‹œ ํ•ด์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ ๋งŒ์•ฝ id๊ฐ’์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด ์–ด๋–ค ๋ฌธ์ œ๊ฐ€ ์žˆ์„๊นŒ?
id๊ฐ’์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•œ๋‹ค๋ฉด Last-Event-Id๊ฐ’์ด ์˜๋ฏธ๊ฐ€ ์—†์–ด์ง‘๋‹ˆ๋‹ค.
  • SseEmitter emitter = emitterRepository.save(id, new SseEmitter(DEFAULT_TIMEOUT));

ํด๋ผ์ด์–ธํŠธ์˜ sse์—ฐ๊ฒฐ ์š”์ฒญ์— ์‘๋‹ตํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” SseEmitter ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค์–ด ๋ฐ˜ํ™˜ํ•ด์ค˜์•ผํ•ฉ๋‹ˆ๋‹ค. SseEmitter ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค ๋•Œ ์œ ํšจ ์‹œ๊ฐ„์„ ์ค„ ์ˆ˜ ์žˆ๊ณ  ์ด๋•Œ ์ฃผ๋Š” ์‹œ๊ฐ„ ๋งŒํผ sse ์—ฐ๊ฒฐ์ด ์œ ์ง€๋˜๊ณ , ์‹œ๊ฐ„์ด ์ง€๋‚˜๋ฉด ์ž๋™์œผ๋กœ ํด๋ผ์ด์–ธํŠธ์—์„œ ์žฌ์—ฐ๊ฒฐ ์š”์ฒญ์„ ๋ณด๋‚ด๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
id๋ฅผ key๋กœ, SseEmitter๋ฅผ value๋กœ ์ €์žฅํ•˜๊ณ , SseEmitter์˜ ์‹œ๊ฐ„ ์ดˆ๊ณผ ๋ฐ ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜๋ฅผ ํฌํ•จํ•œ ๋ชจ๋“  ์ด์œ ๋กœ ๋น„๋™๊ธฐ ์š”์ฒญ์ด ์ •์ƒ ๋™์ž‘ํ•  ์ˆ˜ ์—†๋‹ค๋ฉด ์ €์žฅํ•ด๋‘” SseEmitter๋ฅผ ์‚ญ์ œํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์œ„์— ์ฝ”๋“œ๋Š” ์‹ค์ œ๋กœ ํด๋ผ์ด์–ธํŠธ์— ๋ฐ์ดํ„ฐ๋ฅผ ์ „์†กํ•˜๋Š” ๋ถ€๋ถ„์ž…๋‹ˆ๋‹ค. ํด๋ผ์ด์–ธํŠธ์— ๋ณด๋‚ผ ๋ฐ์ดํ„ฐ ํ˜•์‹์ธ Notification ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค๊ณ , ํ˜„์žฌ ๋กœ๊ทธ์ธ ํ•œ ๋ชจ๋“  ์œ ์ €์˜ id๊ฐ’์„ ํ†ตํ•ด SseEmitter๋ฅผ ๋ชจ๋‘ ๊ฐ€์ ธ์˜จ ์ดํ›„, ๋ฐ์ดํ„ฐ ์บ์‹œ์—๋„ ์ €์žฅํ•ด์ฃผ๊ณ , ์‹ค์ œ๋กœ ๋ฐ์ดํ„ฐ ์ „์†ก๋„ ํ•ฉ๋‹ˆ๋‹ค.

EmitterRepository

@Repository
@Slf4j
public class EmitterRepository {
    //Map์— ํšŒ์›๊ณผ ์—ฐ๊ฒฐ๋œ SSE SseEmitter ๊ฐ์ฒด๋ฅผ ์ €์žฅ. ๋™์‹œ์„ฑ ๋ณด์žฅ
    public final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
    //Event๋ฅผ ์บ์‹œ์— ์ €์žฅ
    public final Map<String, Object> cache = new ConcurrentHashMap<>();
    //id, sseEmitter map์— ์ €์žฅ
    public SseEmitter save(String id, SseEmitter sseEmitter) {
        emitters.put(id, sseEmitter);
        return sseEmitter;
    }
    //id์™€ event๋ฅผ ๋งค๊ฐœ๋ณ€์ˆ˜๋กœ ๋ฐ›์•„ cache ๋งต์— ์ €์žฅ
    public void saveCache(String id, Object event) {
        cache.put(id, event);
    }
    //id๋กœ ์‹œ์ž‘ํ•˜๋Š” ํ‚ค๋ฅผ ๊ฐ€์ง„ emitters ๋งต ํ•ญ๋ชฉ์„ ํ•„ํ„ฐ๋งํ•˜์—ฌ ๋งต์œผ๋กœ ๋ฐ˜ํ™˜
    public Map<String, SseEmitter> findAllStartWithById(String id) {
        log.info("map: " + emitters);
        return emitters.entrySet().stream()
                .filter(entry -> entry.getKey().startsWith(id))
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }

    //id๋กœ ์‹œ์ž‘ํ•˜๋Š” ํ‚ค๋ฅผ ๊ฐ€์ง„ eventCache ๋งต ํ•ญ๋ชฉ์„ ํ•„ํ„ฐ๋งํ•˜์—ฌ ๋งต์œผ๋กœ ๋ฐ˜ํ™˜
    public Map<String, Object> findAllCacheStartWithId(String id) {
        return cache.entrySet().stream()
                .filter(entry -> entry.getKey().startsWith(id))
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }
    // id๋กœ ์‹œ์ž‘ํ•˜๋Š” ํ‚ค๋ฅผ ๊ฐ€์ง„ emitters ๋งต ํ•ญ๋ชฉ์„ ๋ชจ๋‘ ์ œ๊ฑฐ
    public void deleteAllStartWithId(String id) {
        emitters.forEach(
                (key, emitter) -> {
                    if (key.startsWith(id)) {
                        emitters.remove(key);
                    }
                }
        );
    }
    //  emitters ๋งต์—์„œ ํ•ด๋‹น id๋ฅผ ๊ฐ€์ง„ ํ•ญ๋ชฉ์„ ์‚ญ์ œ
    public void deleteById(String id) {
        emitters.remove(id);
    }


    
}

emitter ๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๊ตฌ๋…์„ ์š”์ฒญํ•˜๋ฉด ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ์‹๋ณ„์ž๋ฅผ ํ‚ค๋กœ ์‚ฌ์šฉํ•˜์—ฌ ๋งต์— ์ €์žฅ, ์ดํ›„ ์•Œ๋ฆผ์„ ์ „์†กํ•  ๋•Œ ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ SseEmitter๋ฅผ ์กฐํšŒํ•˜๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉ
eventCache๋Š” ์•Œ๋ฆผ์„ ๋ฐ›์„ ์‚ฌ์šฉ์ž์˜ ์‹๋ณ„์ž๋ฅผ ํ‚ค๋กœ ์ €์žฅํ•˜๊ณ , ํ•ด๋‹น ์‚ฌ์šฉ์ž์—๊ฒŒ ์ „์†ก๋˜์ง€ ๋ชปํ•œ ์ด๋ฐดํŠธ๋ฅผ ์บ์‹œ๋กœ ์ €์žฅ!!
์ €์žฅ๋œ ์ด๋ฒคํŠธ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ๊ตฌ๋…ํ•  ๋•Œ, ํด๋ผ์ด์–ธํŠธ๋กœ ์ „์†ก๋˜์–ด ์ด๋ฒคํŠธ์˜ ์œ ์‹ค์„ ๋ฐฉ์ง€!

๐Ÿ“œ emitters์™€ eventCache๊ฐ€ ๋งต ํ˜•ํƒœ์ธ ์ด์œ ?

emitters์˜ ๊ฒฝ์šฐ key์™€ value์— ๊ฐ๊ฐ emitterId์™€ SseEmitter๊ฐ์ฒด๋ฅผ ์ €์žฅํ•˜๊ณ 
eventCache๋Š” key์™€ value์— ๊ฐ๊ฐ eventCacheId์™€ euentCache๊ฐ์ฒด๋ฅผ ์ €์žฅํ•œ๋‹ค.
์ด๋ฅผ ํ†ตํ•ด ์ฃผ์–ด์ง„ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋น ๋ฅด๊ฒŒ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ƒ‰ํ•  ์ˆ˜ ์žˆ๊ณ , ์ค‘๋ณต๋œ ํ‚ค๋ฅผ ๊ฐ€์ง„ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•  ์ˆ˜ ์—†๋‹ค.
concurrentHashMap์€ ์—ฌ๋Ÿฌ ์Šค๋ ˆ๋“œ์—์„œ ๋™์‹œ์— ์•ˆ์ „ํ•˜๊ฒŒ ๋ฐ์ดํ„ฐ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” ๋งต ๊ตฌํ˜„์ฒด์ด๋‹ค.
์•Œ๋ฆผ ์‹œ์Šคํ…œ์—์„œ๋Š” ์—ฌ๋Ÿฌ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋™์‹œ์— ๊ตฌ๋…ํ•˜๊ณ  ์ด๋ฒคํŠธ๋ฅผ ์ „์†กํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ๋™์‹œ์„ฑ์„ ์ œ์–ดํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”
๋”ฐ๋ผ์„œ ๋งต ํ˜•ํƒœ๋ฅผ ์‚ฌ์šฉํ•จ์œผ๋กœ์„œ ๋ฐ์ดํ„ฐ ์ €์žฅ, ๊ณ ์œ ์„ฑ ๋ณด์žฅ, ์„ฑ๋Šฅ ๋“ฑ์˜ ์ด์ ์„ ์–ป์œผ๋ฉฐ ํšจ์œจ์ ์œผ๋กœ ๊ฐ์ฒด๋ฅผ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค.

2๏ธโƒฃ Stomp+Redis ํ™œ์šฉํ•œ ์•ˆ์ฝ์Œ ๊ธฐ๋Šฅ ๊ตฌํ˜„

StompHadnler ๊ตฌํ˜„

  @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
        // StompCommand์— ๋”ฐ๋ผ์„œ ๋กœ์ง์„ ๋ถ„๊ธฐํ•ด์„œ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค.
        String username = verifyAccessToken(getAccessToken(accessor));
        log.info("StompAccessor = {}", accessor);
        handleMessage(accessor.getCommand(), accessor, username);
        return message;
    }
  • ChannelInterceptor์˜ preSend ๋ฉ”์„œ๋“œ๋ฅผ ๋จผ์ € Overriding
  • Stomp์˜ ์ด๋ฒคํŠธ์— ๋”ฐ๋ผ์„œ ํŠน์ • ์ž‘์—…์„ ํ•ด์ฃผ๋Š” handleMessage ๋ฉ”์„œ๋“œ๋ฅผ ์ž‘์„ฑ
private void handleMessage(StompCommand stompCommand, StompHeaderAccessor accessor, String username) {
        log.info(stompCommand.toString());
        switch (stompCommand) {

            case CONNECT:
                connectToChatRoom(accessor, username);
                break;
            case SUBSCRIBE:
            case SEND:
                verifyAccessToken(getAccessToken(accessor));
                break;
        }
    }
stompCommand๊ฐ€ CONNECT(์—ฐ๊ฒฐ ์‹œ๋„)์ผ๋•Œ connectToChatRoom ๋ฉ”์„œ๋“œ๊ฐ€ ์‹คํ–‰๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
 private void connectToChatRoom(StompHeaderAccessor accessor, String username) {
        // ์ฑ„ํŒ…๋ฐฉ ๋ฒˆํ˜ธ๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค.
        Integer chatRoomNo = getChatRoomNo(accessor);

        // ์ฑ„ํŒ…๋ฐฉ ์ž…์žฅ ์ฒ˜๋ฆฌ -> Redis์— ์ž…์žฅ ๋‚ด์—ญ ์ €์žฅ
        chatRoomService.connectChatRoom(chatRoomNo, username);
//        // ์ฝ์ง€ ์•Š์€ ์ฑ„ํŒ…์„ ์ „๋ถ€ ์ฝ์Œ ์ฒ˜๋ฆฌ
        chatService.updateCountAllZero(chatRoomNo, username);
        // ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†์ค‘์ธ ์ธ์›์ด ์žˆ๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
        boolean isConnected = chatRoomService.isConnected(chatRoomNo);

        if (isConnected) {
            chatService.updateMessage(username, chatRoomNo);
        }
    }

๐Ÿ“• Note

connectToChatRoom ๋ฉ”์„œ๋“œ๋Š” ํฌ๊ฒŒ 3๊ฐ€์ง€ ๋™์ž‘์„ ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
1. Redis์— ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†ํ•˜๋ ค๋Š” ํšŒ์›์„ ์ €์žฅํ•œ๋‹ค.
2. ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†ํ•˜๋ ค๋Š” ํšŒ์›์ด ์ฝ์ง€ ์•Š์€ ์ฑ„ํŒ…์ด ์žˆ๋‹ค๋ฉด, ์ „๋ถ€ ์ฝ์Œ ์ฒ˜๋ฆฌ ํ•ด์ค€๋‹ค(readcount 0์œผ๋กœ ์—…๋ฐ์ดํŠธ)
3. ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†์ค‘์ธ ํšŒ์›์ด ์žˆ๋‹ค๋ฉด, ์ฑ„ํŒ… ๋ฆฌ์ŠคํŠธ ๋‹ค์‹œ ์„œ๋ฒ„์— ์š”์ฒญํ•ด์„œ ๋ฐ›๋„๋ก ์ฒ˜๋ฆฌํ•œ๋‹ค.
ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†์ค‘์ธ ํšŒ์›์ด ์žˆ๋‹ค๋ฉด, ์ฑ„ํŒ… ๋ฆฌ์ŠคํŠธ๋ฅผ ๋‹ค์‹œ ์„œ๋ฒ„์— ์š”์ฒญํ•ด์„œ ๋ฐ›๋„๋ก ํ•œ ์ด์œ ๋Š” ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.
ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†์ค‘์ธ ํšŒ์›์„ A, ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†ํ•˜๋ ค๋Š” ํšŒ์›์„ B๋ผ๊ณ  ๊ฐ€์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.
A์˜ ํ™”๋ฉด์—์„œ B๊ฐ€ ์ฝ์ง€ ์•Š์€ ์ฑ„ํŒ…๋“ค์ด ์•ˆ์ฝ์Œ ํ‘œ์‹œ๋˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
B๊ฐ€ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†์„ ์‹œ๋„ํ•˜๋ฉด, ChannelInterceptor์— Connect ์ด๋ฒคํŠธ๊ฐ€ ์žกํžˆ๊ฒŒ ๋˜๊ณ , B๊ฐ€ ์ฝ์ง€ ์•Š์€ ์ฑ„ํŒ…๋“ค์€ ๋ชจ๋‘ ์ฝ์Œ ์ฒ˜๋ฆฌ ๋ฉ๋‹ˆ๋‹ค.
์ฆ‰, A๋Š” B๊ฐ€ ์ฑ„ํŒ…๋ฐฉ์— ์ ‘์†ํ•˜๋ฉด์„œ ์ฑ„ํŒ…์„ ๋ชจ๋‘ ์ฝ์—ˆ๋Š”๋ฐ ์ฝ์ง€ ์•Š์€ ์ƒํƒœ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์•„์ง ๊ฐ€์ง€๊ณ  ์žˆ๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค.
๋”ฐ๋ผ์„œ B๊ฐ€ ์ ‘์†ํ•˜๋ฉด์„œ ๋ชจ๋“  ์ฑ„ํŒ…์„ ์ฝ์Œ์ฒ˜๋ฆฌ ํ–ˆ๊ธฐ๋•Œ๋ฌธ์—, A์—๊ฒŒ B๊ฐ€ ๋ชจ๋“  ์ฑ„ํŒ…์„ ์ฝ์—ˆ์œผ๋‹ˆ ์ตœ์‹  ๋ฒ„์ „์˜ ์ฑ„ํŒ… ๋ฐ์ดํ„ฐ๋ฅผ ์„œ๋ฒ„์—์„œ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์žˆ๋„๋ก ์„œ๋ฒ„์—์„œ ์•Œ๋ ค์ฃผ๊ฒŒ ์ฒ˜๋ฆฌํ•˜์˜€์Šต๋‹ˆ๋‹ค.
    /**
     * ์ฑ„ํŒ…๋ฐฉ์— 1๋ช… ์—ฐ๊ฒฐ๋๋Š”์ง€ ํ™•์ธ ๋ฉ”์„œ๋“œ
     * @param : Long chatRoomNo
     */
    public boolean isConnected(Integer chatRoomNo) {
        List<ChatRoom> connectedList = chatRoomRepository.findByChatroomNo(chatRoomNo);
        return connectedList.size() == 1;
    }
- isConnected ๋ฉ”์„œ๋“œ๋Š” ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ์— ํ•œ๋ช…์ด ์ ‘์†์ค‘์ผ๋•Œ true๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

2๏ธโƒฃ Redis๋ฅผ ํ†ตํ•ด ๊ด€๋ฆฌํ•  ์ฑ„ํŒ…๋ฐฉ ์ •๋ณด

@Getter
@AllArgsConstructor
@NoArgsConstructor
@ToString
@RedisHash(value = "chatRoom")
public class ChatRoom {
    @Id
    private String id;

    @Indexed
    private Integer chatroomNo;

    @Indexed
    private String nickname;

    @Builder
    public ChatRoom(Integer chatroomNo, String nickname) {
        this.chatroomNo = chatroomNo;
        this.nickname = nickname;
    }
}
@RedisHash์— ์˜ํ•ด ๋ ˆ๋””์Šค chatRoom ํ•ด์‹œ ๋‚ด์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. ๊ฐ ์ธ์Šคํ„ด์Šค๋Š” ๊ณ ์œ ํ•œ ID(id ํ•„๋“œ)๋ฅผ ํ‚ค๋กœ ๊ฐ€์ง€๋ฉฐ, chatroomNo์™€ nickname ํ•„๋“œ๋Š” 2์ฐจ ์ธ๋ฑ์Šค๋กœ ์‚ฌ์šฉ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ํ†ตํ•ด Redis์—์„œ ํšจ์œจ์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๊ณ  ์กฐํšŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค!

3๏ธโƒฃ ChatService ์ˆ˜์ •

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {

    private final ChatRepository chatRepository;
    private final MongoChatRepository mongoChatRepository;
    private final MessageSender sender;
    private final AggregationSender aggregationSender;
    private final MongoTemplate mongoTemplate;
    private final ChatRoomService chatRoomService;
    private final PlantServiceClient plantServiceClient;
    private final CircuitBreakerFactory circuitBreakerFactory;
    private final TokenHandler tokenHandler;

    private final NotificationService notificationService;

    /**
     * ์ฑ„ํŒ…๋ฐฉ ์ƒ์„ฑ ๋ฉ”์„œ๋“œ
     * ๊ฑฐ๋ž˜ ๊ฒŒ์‹œ๊ธ€์„ ์˜ฌ๋ฆฌ์ง€ ์•Š์€ ์‚ฌ๋žŒ๋งŒ ํ˜ธ์ถœํ•˜๋Š” ๋ฉ”์„œ๋“œ
     * ๊ตฌ๋งค ํฌ๋ง์ž๋งŒ ์ฑ„ํŒ…๋ฐฉ์„ ์ƒ์„ฑ ๊ฐ€๋Šฅ
     * FeignCLient๋ฅผ ํ†ตํ•ด plant-service์—์„œ ๊ฑฐ๋ž˜๊ฐ€๋Šฅ ์—ฌ๋ถ€ ํ™•์ธ
     * @param : MemberDto memberDto, ChatRequestDto requestDto
     */
    @Transactional
    public Chat makeChatRoom(Integer memberNo, ChatRequestDto requestDto) {
        CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
        // ์ฑ„ํŒ…์„ ๊ฑธ๋ ค๊ณ  ํ•˜๋Š” ๊ฑฐ๋ž˜๊ธ€์ด ๊ฑฐ๋ž˜ ๊ฐ€๋Šฅ ์ƒํƒœ์ธ์ง€ ์กฐํšŒํ•ด๋ณธ๋‹ค.
        ResponseEntity<ResponseTradeBoardDto> tradeBoardDto = circuitBreaker.run(() ->
                        plantServiceClient.boardContent(requestDto.getTradeBoardNo().longValue()),
                throwable -> ResponseEntity.ok(null));

        // ์กฐํšŒํ•ด์˜จ ๊ฑฐ๋ž˜๊ธ€ ์ƒํƒœ๊ฐ€ ๊ฑฐ๋ž˜์™„๋ฃŒ ์ด๋ผ๋ฉด ๊ฑฐ๋ž˜๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์ด๋‹ค.
        if (tradeBoardDto.getBody().getStatus().equals("๊ฑฐ๋ž˜์™„๋ฃŒ")) {
            throw new IllegalStateException("ํ˜„์žฌ ๊ฑฐ๋ž˜๊ฐ€๋Šฅ ์ƒํƒœ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.");
        }
        Integer tradeBoardNo = tradeBoardDto.getBody().getId().intValue();

        //์ด๋ฏธ ํ•ด๋‹น๊ธ€ ๊ธฐ์ค€์œผ๋กœ ์ฑ„ํŒ…์„ ์š”์ฒญํ•œ ์‚ฌ๋žŒ๊ณผ ๋ฐ›๋Š” ์‚ฌ๋žŒ์ด ์ผ์น˜ํ•  ๊ฒฝ์šฐ ์ฒดํฌ
        if (chatRepository.existChatRoomByBuyer(tradeBoardNo, requestDto.getCreateMember(), memberNo)) {
            Chat existedChat = chatRepository.findByTradeBoardNoAndChatNo(tradeBoardNo, requestDto.getCreateMember());
            return existedChat;

        }
        if (!chatRepository.existChatRoomByBuyer(tradeBoardNo, requestDto.getCreateMember(), memberNo)) {
            Chat chat = Chat.builder()
                    .tradeBoardNo(requestDto.getTradeBoardNo())
                    .createMember(requestDto.getCreateMember())
                    .joinMember(memberNo)
                    .regDate(LocalDateTime.now())
                    .build();

            Chat savedChat = chatRepository.save(chat);


            // ์ฑ„ํŒ…๋ฐฉ ์นด์šดํŠธ ์ฆ๊ฐ€
            AggregationDto aggregationDto = AggregationDto
                    .builder()
                    .isIncrease("true")
                    .target(AggregationTarget.CHAT)
                    .tradeBoardNo(requestDto.getTradeBoardNo())
                    .build();

            aggregationSender.send(KafkaUtil.KAFKA_AGGREGATION, aggregationDto);
            return savedChat;
        }
        throw new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒŒ์‹œ๊ธ€ ์ž…๋‹ˆ๋‹ค");
    }
    /**
     * ์ฑ„ํŒ…๋ฐฉ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ ๋ฉ”์„œ๋“œ
     * FeignCLient๋ฅผ ํ†ตํ•ด plant-service์—์„œ ์œ ์ € ์ •๋ณด ์กฐํšŒํ›„ ์ฑ„ํŒ…๋ฐฉ ๋งŒ๋“  ์‚ฌ๋žŒ์ธ์ง€ ํ™•์ธ
     * mongodb์—์„œ ์ฑ„ํŒ… ๋ฉ”์„ธ์ง€ ๋ณด๋‚ธ ์‹œ๊ฐ„์„ ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌํ›„ ์ฒซ๋ฒˆ์งธ ๊ฐ’ ๋งˆ์ง€๋ง‰ ๋ฉ”์„ธ์ง€๋กœ ์„ธํŒ…
     * @param : Integer memberNo, Integer tradeBoardNo
     */
    public List<ChatRoomResponseDto> getChatList(Integer memberNo, Integer tradeBoardNo) {
        List<ChatRoomResponseDto> chatRoomList = chatRepository.getChattingList(memberNo, tradeBoardNo);
        //Participant ์ฑ„์›Œ์•ผ๋จ(username)
            chatRoomList
                    .forEach(chatRoomDto -> {
                        //param์œผ๋กœ ๋„˜์–ด์˜จ ๋ฉค๋ฒ„๊ฐ€ ์ฑ„ํŒ… ๋งŒ๋“  ๋ฉค๋ฒ„์ผ ๊ฒฝ์šฐ => Participant์— ์ฐธ๊ฐ€ํ•œ ๋ฉค๋ฒ„
//                        ResponseEntity<MemberDto> byId = plantServiceClient.findById(chatRoomDto.getCreateMember().longValue());
                        if (memberNo.equals(chatRoomDto.getCreateMember())) {
                            ResponseEntity<MemberDto> memberDtoResponse = plantServiceClient.findById(chatRoomDto.getJoinMember().longValue());

                            chatRoomDto.setParticipant(new ChatRoomResponseDto.Participant(memberDtoResponse.getBody().getUsername(), memberDtoResponse.getBody().getNickname()));
                        }
                        //param์œผ๋กœ ๋„˜์–ด์˜จ ๋ฉค๋ฒ„๊ฐ€ ์ฑ„ํŒ…๋ฐฉ์— ์ฐธ๊ฐ€ํ•œ ๋ฉค๋ฒ„์ผ ๊ฒฝ์šฐ => Participant์— ์ฑ„ํŒ…๋ฐฉ ๋งŒ๋“  ๋ฉค๋ฒ„
                        if (!memberNo.equals(chatRoomDto.getCreateMember())){
                            ResponseEntity<MemberDto> memberDtoResponse = plantServiceClient.findById(chatRoomDto.getCreateMember().longValue());
                            chatRoomDto.setParticipant(new ChatRoomResponseDto.Participant(memberDtoResponse.getBody().getUsername(), memberDtoResponse.getBody().getNickname()));
                        }
//                      // ์ฑ„ํŒ…๋ฐฉ๋ณ„๋กœ ์ฝ์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€ ๊ฐœ์ˆ˜๋ฅผ ์…‹ํŒ…
                        long unReadCount = countUnReadMessage(chatRoomDto.getChatNo(), memberNo);
                        chatRoomDto.setUnReadCount(unReadCount);

                        // ์ฑ„ํŒ…๋ฐฉ๋ณ„๋กœ ๋งˆ์ง€๋ง‰ ์ฑ„ํŒ…๋‚ด์šฉ๊ณผ ์‹œ๊ฐ„์„ ์…‹ํŒ…
                        Page<Chatting> chatting =
                                mongoChatRepository.findByChatRoomNoOrderBySendDateDesc(chatRoomDto.getChatNo(), PageRequest.of(0, 1));
                        if (chatting.hasContent()) {
                            Chatting chat = chatting.getContent().get(0);
                            ChatRoomResponseDto.LatestMessage latestMessage = ChatRoomResponseDto.LatestMessage.builder()
                                    .context(chat.getContent())
                                    .sendAt(chat.getSendDate())
                                    .build();
                            chatRoomDto.setLatestMessage(latestMessage);
                        }
                    });

        return chatRoomList;
    }
    /**
     * ์ฑ„ํŒ… ๋ฉ”์„ธ์ง€ ์กฐํšŒ ๋ฉ”์„œ๋“œ
     * ์ฑ„ํŒ… ๋ฉ”์„ธ์ง€ ์กฐํšŒ์‹œ ํ•ด๋‹น ๋ฉ”์„ธ์ง€๋ฅผ ์ฝ์€ ๊ฒƒ์ด๋ฏ€๋กœ ๋ฉ”์„ธ์ง€ ์ฝ์Œ ์ฒ˜๋ฆฌ๋„ ์ง„ํ–‰
     * @param : Integer chatRoomNo, Integer memberNo
     */
    public ChattingHistoryResponseDto getChattingList(Integer chatRoomNo, Integer memberNo) {
        CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
        // member id๋กœ ์กฐํ™”
        ResponseEntity<MemberDto> memberDto = circuitBreaker.run(() -> plantServiceClient.findById(memberNo.longValue()),
                throwable -> ResponseEntity.ok(null));

        updateCountAllZero(chatRoomNo, memberDto.getBody().getUsername());
        List<ChatResponseDto> chattingList = mongoChatRepository.findByChatRoomNo(chatRoomNo)
                .stream()
                .map(chat -> new ChatResponseDto(chat, memberNo)
                )
                .collect(Collectors.toList());

        return ChattingHistoryResponseDto.builder()
                .chatList(chattingList)
                .email(memberDto.getBody().getEmail())
                .build();
    }
    /**
     * ๋ฉ”์„ธ์ง€ ์ „์†ก ๋ฉ”์„œ๋“œ
     * jwt ํ† ํฐ์—์„œ username ์ถ”์ถœ
     * ์นดํ”„์นด ํ† ํ”ฝ์œผ๋กœ ๋ฉ”์„ธ ์ „์†ก
     * @param : Message message, String accessToken
     */
    public void sendMessage(Message message, String accessToken) {
        // member id๋กœ ์กฐํ™”
        ResponseEntity<MemberDto> memberDto = plantServiceClient.findByUsername(tokenHandler.getUid(accessToken));

        // ์ฑ„ํŒ…๋ฐฉ์— ๋ชจ๋“  ์œ ์ €๊ฐ€ ์ฐธ์—ฌ์ค‘์ธ์ง€ ํ™•์ธํ•œ๋‹ค.
        boolean isConnectedAll = chatRoomService.isAllConnected(message.getChatNo());
        // 1:1 ์ฑ„ํŒ…์ด๋ฏ€๋กœ 2๋ช… ์ ‘์†์‹œ readCount 0, ํ•œ๋ช… ์ ‘์†์‹œ 1
        Integer readCount = isConnectedAll ? 0 : 1;
        // message ๊ฐ์ฒด์— ๋ณด๋‚ธ์‹œ๊ฐ„, ๋ณด๋‚ธ์‚ฌ๋žŒ memberNo, ๋‹‰๋„ค์ž„์„ ์…‹ํŒ…ํ•ด์ค€๋‹ค.
        message.setSendTimeAndSender(LocalDateTime.now(), memberDto.getBody().getId().intValue(), memberDto.getBody().getNickname(), readCount);

        // ๋ฉ”์‹œ์ง€๋ฅผ ์ „์†กํ•œ๋‹ค.
        sender.send(KafkaUtil.KAFKA_TOPIC, message);
    }
    /**
     * ์•Œ๋ฆผ ์ „์†ก ๋ฐ ๋ฉ”์„ธ์ง€ ์ €์žฅ ๋ฉ”์„œ๋“œ
     * FeignCLient๋ฅผ ํ†ตํ•ด plant-service์—์„œ ๋ฉ”์„ธ์ง€ ๋ณด๋‚ธ ์œ ์ € ์ •๋ณด ์กฐํšŒ
     * ์•Œ๋ฆผ์€ ์ƒ๋Œ€๋ฐฉ์ด ์ฝ์ง€ ์•Š์€ ๊ฒฝ์šฐ๋งŒ ์ „์†ก
     * @param : Message message
     */
    @Transactional
    public Message sendNotificationAndSaveMessage(Message message) {

        CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
        //๋ฉ”์„ธ์ง€ ์ €์žฅ๊ณผ ์•Œ๋ฆผ ๋ฐœ์†ก์„ ์œ„ํ•ด ๋ฉ”์„ธ์ง€ ๋ณด๋‚ธ ํšŒ์› ์กฐํšŒ
        ResponseEntity<MemberDto> memberDto = circuitBreaker.run(() -> plantServiceClient.findById(message.getSenderNo().longValue()),
                throwable -> ResponseEntity.ok(null));
        // ์ƒ๋Œ€๋ฐฉ์ด ์ฝ์ง€ ์•Š์€ ๊ฒฝ์šฐ์—๋งŒ ์•Œ๋ฆผ ์ „์†ก
        if (message.getReadCount().equals(1)) {
            // ์•Œ๋žŒ ์ „์†ก์„ ์œ„ํ•ด ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ›๋Š” ์‚ฌ๋žŒ์„ ์กฐํšŒํ•œ๋‹ค.
            Integer memberNo = chatRepository.getReceiverMember(message.getChatNo(), message.getSenderNo());
            ResponseEntity<MemberDto> receiveMember = circuitBreaker.run(() -> plantServiceClient.findById(memberNo.longValue()),
                    throwable -> ResponseEntity.ok(null));
            String content = message.getContentType().equals("image")
                                    ? "image" : message.getContent();
            // ์•Œ๋žŒ์„ ๋ณด๋‚ผ URL์„ ์ƒ์„ฑํ•œ๋‹ค.
            String sendUrl = getNotificationUrl(message.getTradeBoardNo(), message.getChatNo());

            //์•Œ๋ฆผ ์ „์†ก
            notificationService.send(memberDto.getBody(), receiveMember.getBody(), NotifiTypeEnum.CHAT, sendUrl, content);
        }
        // ๋ณด๋‚ธ ์‚ฌ๋žŒ์ผ ๊ฒฝ์šฐ์—๋งŒ ๋ฉ”์‹œ์ง€๋ฅผ ์ €์žฅ -> ์ค‘๋ณต ์ €์žฅ ๋ฐฉ์ง€
        if (message.getSenderEmail().equals(memberDto.getBody().getEmail())) {
            // Message ๊ฐ์ฒด๋ฅผ ์ฑ„ํŒ… ์—”ํ‹ฐํ‹ฐ๋กœ ๋ณ€ํ™˜
            Chatting chatting = message.convertEntity();
            // ์ฑ„ํŒ… ๋‚ด์šฉ์„ ์ €์žฅ
            Chatting savedChat = mongoChatRepository.save(chatting);
            // ์ €์žฅ๋œ ๊ณ ์œ  ID๋ฅผ ๋ฐ˜ํ™˜
            message.setId(savedChat.getId());
        }
        return message;
    }
    /**
     * ์ฐธ๊ฐ€์ž ์ž…์žฅ ์•Œ๋ฆผ ๋ฉ”์„œ๋“œ
     * @param : String email, Integer chatRoomNo
     */
    public void updateMessage(String email, Integer chatRoomNo) {
        Message message = Message.builder()
                .contentType("notice")
                .chatNo(chatRoomNo)
                .content(email + " ๋‹˜์ด ๋Œ์•„์˜ค์…จ์Šต๋‹ˆ๋‹ค.")
                .build();

        sender.send(KafkaUtil.KAFKA_TOPIC, message);
    }
    /**
     * ์ฝ์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€ ์ฑ„ํŒ…์žฅ ์ž…์žฅ์‹œ ์ฝ์Œ ์ฒ˜๋ฆฌ ๋ฉ”์„œ๋“œ
     * @param : Integer chatNo, String username
     */
    public void updateCountAllZero(Integer chatNo, String username) {
        CircuitBreaker circuitBreaker = circuitBreakerFactory.create("circuitbreaker");
        ResponseEntity<MemberDto> findMember = circuitBreaker.run(() -> plantServiceClient.findByUsername(username),
                throwable -> ResponseEntity.ok(null));
        //MongoDb Update Query
        Update update = new Update().set("readCount", 0);
        //ne-> not equal
        Query query = new Query(where("chatRoomNo").is(chatNo)
                .and("senderNo").ne(findMember.getBody().getId().intValue()));

        mongoTemplate.updateMulti(query, update, Chatting.class);
    }
    /**
     * ์ฝ์ง€ ์•Š์€ ๋ฉ”์‹œ์ง€ ์นด์šดํŠธ ๋ฉ”์„œ๋“œ
     * @param : Integer chatNo, Integer senderNo
     */
    long countUnReadMessage(Integer chatRoomNo, Integer senderNo) {
        Query query = new Query(where("chatRoomNo").is(chatRoomNo)
                .and("readCount").is(1)
                .and("senderNo").ne(senderNo));

        return mongoTemplate.count(query, Chatting.class);
    }

    private String getNotificationUrl(Integer tradeBoardNo, Integer chatNo) {
        return chatNo +
                "/" +
                tradeBoardNo;
    }


    /**
     * ํŒ๋งค์ž๊ฐ€ ์ฐธ๊ฐ€ํ•œ  ์ฑ„ํŒ…๋ฐฉ์ด ์กด์žฌํ•˜๋Š”์ง€ ์œ ๋ฌด ์ฒ˜๋ฆฌ ๋ฉ”์„œ๋“œ
     * ๋‹จ์ˆœ ์กฐํšŒ์šฉ ๋ฉ”์„œ๋“œ๋ผ readOnly = true
     *
     * @param : Integer tradeBoardNo,  Integer memberNo
     */
    @Transactional(readOnly = true)
    public Boolean existChatRoomBySeller(Integer tradeBoardNo, Integer memberNo) {
        return chatRepository.existChatRoomBySeller(tradeBoardNo, memberNo);
    }

    /**
     * ์ฑ„ํŒ…๋ฐฉ ์‚ญ์ œ ๋ฉ”์„œ๋“œ
     * plant-service์—์„œ kafka๋ฅผ ํ†ตํ•ด ๊ฑฐ๋ž˜ ๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ ์š”์ฒญ์„ ๋ฐ›์œผ๋ฉด ์‚ญ์ œ
     *
     * @param : Integer tradeBoardNo
     */
    @Transactional
    public void deleteChatRoom(Integer tradeBoardNo) {
        List<Integer> chatRoomNoList = chatRepository.deleteChatRoomAndReturnChatNo(tradeBoardNo);
        deleteChatting(chatRoomNoList);
    }
    @Transactional
    public void deleteChatting(List<Integer> chatRoomNoList) {
        // ์ค‘๋ณต๋œ chatNo ์ œ๊ฑฐ
        Set<Integer> uniqueChatNoSet = new HashSet<>(chatRoomNoList);

        // ์ค‘๋ณต ์ œ๊ฑฐ ํ›„์˜ chatNo์— ํ•ด๋‹นํ•˜๋Š” ์ฑ„ํŒ… ๋ฐ์ดํ„ฐ ์‚ญ์ œ
        mongoTemplate.remove(query(where("chatRoomNo").in(uniqueChatNoSet)), Chatting.class);
    }
}

  • ChatService์—์„œ ๋ฉ”์‹œ์ง€๋ฅผ DB์— ์ €์žฅํ•˜๊ณ  ํ•„์š”์‹œ ์•Œ๋žŒ์„ ๋ณด๋‚ด๋Š” sendNotificationAndSaveMessage ๋ฉ”์„œ๋“œ์˜ ๊ตฌํ˜„ ๋ถ€๋ถ„

 // ์ฑ„ํŒ…๋ฐฉ์— ๋ชจ๋“  ์œ ์ €๊ฐ€ ์ฐธ์—ฌ์ค‘์ธ์ง€ ํ™•์ธํ•œ๋‹ค.
   boolean isConnectedAll = chatRoomService.isAllConnected(message.getChatNo());
        // 1:1 ์ฑ„ํŒ…์ด๋ฏ€๋กœ 2๋ช… ์ ‘์†์‹œ readCount 0, ํ•œ๋ช… ์ ‘์†์‹œ 1
        Integer readCount = isConnectedAll ? 0 : 1;
        
   /**
     * ์ฑ„ํŒ…๋ฐฉ ์ •์› 2๋ช… ์ฐผ๋Š”์ง€ ํ™•์ธ ๋ฉ”์„œ๋“œ
     * @param : Long chatRoomNo
     */
    public boolean isAllConnected(Integer chatRoomNo) {
        List<ChatRoom> connectedList = chatRoomRepository.findByChatroomNo(chatRoomNo);
        return connectedList.size() == 2;
    }
  • ์ด ๋ถ€๋ถ„์„ ํ†ตํ•ด redis์— ์ €์žฅ๋œ ์ฑ„ํŒ…๋ฐฉ ์ธ์›์„ ์กฐํšŒํ•˜๋ฉด readCount์— ๊ฐ’์„ ๋„ฃ์–ด์ค๋‹ˆ๋‹ค

ํ…Œ์ŠคํŠธ

์ฑ„ํŒ…๋ฐฉ์— ํ•œ๋ช…๋งŒ ์ ‘์†ํ•ด์žˆ์„๋•Œ

gif ๋ณ€ํ™˜ํ•˜๋Š๋ผ ํ™”์งˆ์ด ์•ˆ ์ข‹์€์  ์–‘ํ•ด๋ถ€ํƒ๋“œ๋ฆฝ๋‹ˆ๋‹ค,,๐Ÿ™๐Ÿ™

์œ„์— ์˜์ƒ์„ ๋ณด๋ฉด ํ•œ๋ช…๋งŒ ์ ‘์†์ค‘์ด๊ธฐ ๋•Œ๋ฌธ์— ์•Œ๋žŒ์ด ๋™์ž‘ํ•˜๊ณ  ์žˆ๋Š” ๊ฑธ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ฑ„ํŒ…๋ฐฉ์— ๋‘๋ช… ๋‹ค ์ ‘์†ํ•ด์žˆ์„๋•Œ


๋‘๋ช… ๋‹ค ์ ‘์†ํ–ˆ์„๋•, ์•Œ๋ฆผ์ด ๊ฐ€์ง€ ์•Š๋Š” ๊ฑธ ํ™•์ธํ•˜์‹ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค!

์ •๋ฆฌ

์ด๋ฒˆ ํฌ์ŠคํŒ…๊นŒ์ง€ ์ง„ํ–‰ํ•˜์—ฌ ์ฑ„ํŒ… ๋งˆ์ดํฌ๋กœ ์„œ๋น„์Šค ๊ตฌํ˜„ ๊ณผ์ •์„ ๋ชจ๋‘ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ ํฌ์ŠคํŒ… ๋ถ€ํ„ฐ๋Š” ๋˜ ๋‹ค๋ฅธ ๋งˆ์ดํฌ๋กœ ์„œ๋น„์Šค์ธ ๊ฒฐ์ œ ์„œ๋น„์Šค ๊ตฌํ˜„๊ณผ์ •์—์„œ ํฌ์ŠคํŒ… ํ•˜๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค ๐Ÿ™๐Ÿ™

์ฐธ๊ณ 

https://develoyummer.tistory.com/112#3.2.%20Emitter%20RepositoryImpl
https://velog.io/@max9106/Spring-SSE-Server-Sent-Events%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC

profile
๋ฐฑ์—”๋“œ ๊ณต๋ถ€์ค‘์ž…๋‹ˆ๋‹ค!

0๊ฐœ์˜ ๋Œ“๊ธ€

๊ด€๋ จ ์ฑ„์šฉ ์ •๋ณด