๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
  • What would life be If we had no courage to attemp anything?
๐“๐จ๐๐š๐ฒ ๐ˆ ๐‹๐ž๐š๐ซ๐ง

์‹ค์‹œ๊ฐ„ ์•Œ๋ฆผ ๊ตฌํ˜„ ํ•˜๊ธฐ : 2. SSE ๋ฐฑ์—”๋“œ ๋กœ์ปฌ ์„œ๋ฒ„์™€ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ (feat. next.js)

by DevIseo 2025. 12. 7.

์šฐ๋ฆฌ ์„œ๋น„์Šค๋Š” ํ”„๋ก ํŠธ(next.js - pages router)์™€ ๋ฐฑ์—”๋“œ(spring boot)๋กœ ๋œ ํ”„๋กœ์ ํŠธ์ด๋‹ค. ๊ธฐ์ˆ ์„ ๋„์ž…ํ•˜๊ธฐ ์ „ ๋ฐฑ์—”๋“œ ๊ฐœ๋ฐœ์ž์™€ SSE ํ†ต์‹ ์ด ๊ฐ€๋Šฅํ•œ ์ง€ ํ™•์ธ์„ ์œ„ํ•ด ํ”„๋กœํ†  ํƒ€์ž… ํ…Œ์ŠคํŠธ๋ฅผ ์‹œํ–‰ํ•˜์˜€๋‹ค.

 

 

 

ํ”„๋ก ํŠธ ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ž‘์„ฑํ•˜์˜€๋‹ค. EventSource ๊ฐ์ฒด๋ฅผ ํ™œ์šฉํ•ด SSE ์ฑ„๋„์„ ์—ด๊ณ , ์„œ๋ฒ„๊ฐ€ ๋ณด๋‚ด๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์‹ ํ•˜๋Š” ๊ฐ„๋‹จํ•œ ์ฝ”๋“œ์ด๋‹ค. ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ๋ฐฑ์—”๋“œ์™€์˜ ํ†ต์‹ ์„ next.config.js์˜ rewrites ์„ค์ •์„ ํ†ตํ•ด ํ”„๋ก์‹œํ•˜๊ณ  ์žˆ๊ธฐ ๋•Œ๋ฌธ์—, SSE ์—”๋“œํฌ์ธํŠธ(/sse/test)๋„ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ์ž‘์„ฑํ•˜์˜€๋‹ค.

// pages/sse-example.jsx

import { useEffect } from 'react';

export default function SseExamplePage() {
  useEffect(() => {
    const es = new EventSource('/sse/test');

    es.onopen = () => console.log('SSE ์—ฐ๊ฒฐ ์„ฑ๊ณต');
    es.onmessage = (e) => console.log(e.data);
    es.onerror = () => console.log('SSE ์—ฐ๊ฒฐ ์—๋Ÿฌ');

    return () => {
      console.log('SSE ์—ฐ๊ฒฐ ํ•ด์ œ');
      es.close();
    };
  }, []);

  return <div>SSE ํ…Œ์ŠคํŠธ ํŽ˜์ด์ง€</div>;
}
//next.config.js

async rewrites() {
    return [
      {
        source: '/sse/:path*',
        destination: `${process.env.CLOUD_BACKEND_URL}/sse/:path*`,
      },
    ]
 }

 

 

ํ•˜์ง€๋งŒ, SSE ์—ฐ๊ฒฐ์€ ์„ฑ๊ณตํ•˜์ง€๋งŒ ๋ฐฑ์—”๋“œ์—์„œ ์ „์†กํ•˜๋Š” ๋ฐ์ดํ„ฐ์— ๋Œ€ํ•ด์„œ๋Š” ๋„คํŠธ์›Œํฌ ์ฐฝ์—์„œ ์†ก์‹ ๋ฐ›์ง€ ๋ชปํ•˜๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค.

 

์šฐ๋ฆฌ์˜ ๊ธฐ๋Œ€๊ฒฐ๊ณผ
SSE ์—ฐ๊ฒฐ์ด ๋œ ํ›„ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ด๋ฉด ๋ฐ›๋Š” ์ฆ‰์‹œ ์ŠคํŠธ๋ฆฌ๋ฐ ๋˜์–ด์•ผ ํ•œ๋‹ค.

 

ํฌ์ŠคํŠธ๋งจ(๊ธฐ๋Œ€๊ฒฐ๊ณผ ์ผ์น˜) : 

๋ฐ”๋กœ ๋ฐฑ์—”๋“œ ์„œ๋ฒ„์— ์š”์ฒญํ–ˆ์„ ๋•Œ์—๋Š” ์—ฐ๊ฒฐ์ด ๋˜์ž๋งˆ์ž ๋ฉ”์‹œ์ง€๊ฐ€ ์˜ค๊ณ , ์ดํ›„ ์—ฐ๊ฒฐ์„ ์œ ์ง€ํ•˜๋Š” ๋ฉ”์‹œ์ง€๋„ ์˜จ๋‹ค. ์ฆ‰ SSE๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ผ ๋•Œ๋งˆ๋‹ค ์‘๋‹ต์ด ๋„์ฐฉํ•œ๋‹ค.

 

๋ธŒ๋ผ์šฐ์ €(๊ธฐ๋Œ€๊ฒฐ๊ณผ ๋ถˆ์ผ์น˜) : 

๋ฐฑ์—”๋“œ ์„œ๋ฒ„ ์ข…๋ฃŒ ํ›„ ๋ฐ์ดํ„ฐ๋ฅผ ํ•œ๊บผ๋ฒˆ์— ์‘๋‹ต๋ฐ›๋Š”๋‹ค.


ํ”„๋ก ํŠธ์—”๋“œ ์„œ๋ฒ„์™€ ์—ฐ๊ฒฐํ•˜์—ฌ ํ…Œ์ŠคํŠธํ•  ๋•Œ์—๋Š” ์—ฐ๊ฒฐ ๋‚ด๋‚ด eventstream์— ๋ฐ์ดํ„ฐ๊ฐ€ ์—†๋‹ค๊ฐ€ ๋ฐฑ์—”๋“œ ์„œ๋ฒ„ ์ข…๋ฃŒ ํ›„์— ๋ฐ์ดํ„ฐ๊ฐ€ ๋‚˜ํƒ€๋‚œ๋‹ค.
์ฝ˜์†”์ฐฝ์—๋„ ๋ฐ์ดํ„ฐ์— ๊ด€๋ จ๋œ ๋กœ๊ทธ๊ฐ€ ๋‚จ์ง€ ์•Š๋Š”๋‹ค. ์‹œ๊ฐ„๋Œ€๋ฅผ ๋ณด๋ฉด SSE๊ฐ€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ผ ๋•Œ ์‘๋‹ต ๋ฐ›๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ ์ข…๋ฃŒํ•œ ํƒ€์ด๋ฐ์— ํ•œ๊บผ๋ฒˆ์— ๋ฐ›๊ณ  ์žˆ๋‹ค.

 

trouble shooting ๐Ÿ”ฅ

backend URI endpoint๋กœ ์ง์ ‘ ๋‚ ๋ฆฌ์ง€ ์•Š๊ณ  rewrites๋ฅผ ์„ค์ •ํ•  ๊ฒฝ์šฐ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ›์ง€ ๋ชปํ•˜๋Š” ํ˜„์ƒ

 

 

1. ๋ฌธ์ œ ๊ฐœ์š”

CORS ์—๋Ÿฌ์™€ proxy๋ฅผ ์œ„ํ•ด rewrites ์„ค์ •์„ ํ†ตํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ ์šฉํ•˜์˜€๋‹ค.

       {
         source: '/sse/:path*',
         destination: `${process.env.CLOUD_BACKEND_URL}/sse/:path*`,
       },

ํ•˜์ง€๋งŒ ํ•ด๋‹น ์ ์šฉ์œผ๋กœ ์ธํ•ด ์ฑ„๋„์€ ์—ด๋ฆฌ์ง€๋งŒ ๋ฐ์ดํ„ฐ๋ฅผ ์†ก์‹  ๋ฐ›๋Š”๋ฐ๋Š” ์‹คํŒจํ•˜์˜€๋‹ค.

Postman - ํ•œ ๊ธ€์ž์”ฉ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์‘๋‹ต์ด ๋„์ฐฉ
๋ธŒ๋ผ์šฐ์ € (rewrites ๊ฒฝ์œ ) - ๋ชจ๋“  ๋‚ด์šฉ์ด ํ•œ๊บผ๋ฒˆ์— ๋„์ฐฉ

์ฒ˜์Œ์—๋Š” ๋ธŒ๋ผ์šฐ์ €๋‚˜ ํด๋ผ์ด์–ธํŠธ ๋ฌธ์ œ์ธ ์ค„ ์•Œ์•˜๋Š”๋ฐ,

rewrites๋ฅผ ๊ฑฐ์น˜์ง€ ์•Š๊ณ  ๋ฐฑ์—”๋“œ ์—”๋“œํฌ์ธํŠธ๋กœ ์ง์ ‘ ์š”์ฒญํ•˜๋ฉด ์ •์ƒ์ ์œผ๋กœ ์ŠคํŠธ๋ฆฌ๋ฐ์ด ๋˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

 

 

2. ์˜ค๋ฅ˜ ์ƒ์„ธ ๋ถ„์„

๊ฒ€์ƒ‰์„ ํ†ตํ•ด ํ•ด๋‹น ๋ธ”๋กœ๊ทธ์—์„œ ๋ฌธ์ œ์˜ ์›์ธ์— ๋Œ€ํ•ด ์•Œ ์ˆ˜ ์žˆ์—ˆ๋‹ค.

next.js ๊ณต์‹ ํŽ˜์ด์ง€์˜ next.config.js์˜ ์„ค์ • ์ค‘ compress์— ๋Œ€ํ•œ ๊ธ€์„ ๋ณด๋ฉด ์••์ถ•๊ณผ ๊ด€๋ จ๋œ ์„ค์ •์— ๋Œ€ํ•œ ์ด์•ผ๊ธฐ๊ฐ€ ์ž‘์„ฑ๋˜์–ด ์žˆ๋‹ค.

๊ธฐ๋ณธ์ ์œผ๋กœ next.js๋ฅผ ์„ค์น˜์‹œ ์ด ์˜ต์…˜์€ true๋กœ default ๋œ๋‹ค. ํ•ด๋‹น ์˜ต์…˜์˜ ๊ธฐ๋Šฅ์€ next start์‹œ ์‚ฌ์šฉ์ž ์ง€์ • ์„œ๋ฒ„๋ฅผ gzip์œผ๋กœ rendered content and static files๋ฅผ ๋Œ€์ƒ์œผ๋กœ ์••์ถ•ํ•˜์—ฌ ์‚ฌ์šฉํ•˜๋Š” ๋ฐ, ์ด ์••์ถ•์ด ํ™œ์„ฑํ™” ๋˜๊ฒŒ ๋˜๋ฉด ์ถฉ๋ถ„ํ•œ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ชจ์ผ ๋•Œ ๊นŒ์ง€ ์‘๋‹ต์„ ๋ฒ„ํผ๋งํ•˜๊ฒŒ ๋œ๋‹ค. ๋”ฐ๋ผ์„œ, ์ผ๋ฐ˜์ ์ธ HTTP์‘๋‹ต์—์„œ๋Š” ์„ฑ๋Šฅ์ƒ ์ด์ ์ด ์žˆ์ง€๋งŒ, SSE ์ŠคํŠธ๋ฆฌ๋ฐ์—์„œ๋Š” ์‹ค์‹œ๊ฐ„์„ฑ์„ ํ—ค์น˜๋Š” ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.

 

 

3. ๋ฌธ์ œ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•

3.1 next.config.js์˜ compress ์˜ต์…˜์„ ๋„๋Š” ๋ฐฉ๋ฒ•

// next.config.js
module.exports = {
  compress: false, // ์••์ถ• ๋น„ํ™œ์„ฑํ™”
};

ํ•ด๋‹น ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉ ํ›„ rewrites ์„ค์ •์œผ๋กœ SSEํ†ต์‹ ์„ ํ•˜๊ฒŒ ๋˜๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์˜ค๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ๋‹ค.

๐Ÿšจ๊ทธ๋ ‡์ง€๋งŒ, next.js ๊ณต์‹ ๋ฌธ์„œ์—์„œ๋Š” ํ•ด๋‹น ์˜ต์…˜์„ ๋„์ง€ ์•Š๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•˜๊ณ  ์žˆ๋‹ค. ์••์ถ•์€ ๋Œ€์—ญํญ ์‚ฌ์šฉ๋Ÿ‰์„ ์ค„์ด๊ณ  ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ ์‹œํ‚ค๊ธฐ ๋•Œ๋ฌธ

๋”ฐ๋ผ์„œ ํ•ด๋‹น ์—”๋“œํฌ์ธํŠธ๋ฅผ ์œ„ํ•ด ๊ณต์‹ ๋ฌธ์„œ์—์„œ๋„ ๊ถŒ์žฅํ•˜์ง€ ์•Š๋Š” ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์ง€์–‘ํ•˜๋Š” ํŽธ์ด ์ข‹๋‹ค๊ณ  ํŒ๋‹จํ–ˆ๋‹ค.

 

 

3.2 API Routes๋ฅผ proxy ๋กœ ํ™œ์šฉํ•˜์—ฌ ๋ฐฑ์—”๋“œ์™€ ํ†ต์‹ 

github issue์—์„œ ํ™•์ธ๋œ ๋ฐ”๋กœ๋Š” Next.js๋Š” API Routes ์‘๋‹ต์„ ๊ธฐ๋ณธ์ ์œผ๋กœ gzip์ด ์—†๋‹ค.

์ •์  ํŒŒ์ผ, ํŽ˜์ด์ง€ ์‘๋‹ต → Next.js๊ฐ€ ๊ธฐ๋ณธ gzip (→ SSE ๊ฐ™์ด chunk ๋‹จ์œ„ ์ŠคํŠธ๋ฆฌ๋ฐ์ด๋ฉด ๋ฒ„ํผ๋ง ์ด์Šˆ ๊ฐ€๋Šฅ)
API Routes ์‘๋‹ต → ๊ธฐ๋ณธ gzip ์—†์Œ (→ ์ง€๊ธˆ ์“ฐ๋Š” ํ”„๋ก์‹œ SSE์—์„œ๋Š” gzip ๋ฒ„ํผ๋ง ์ด์Šˆ๊ฐ€ ์•ˆ ์ƒ๊น€)

์ถ”๊ฐ€๋กœ, API Routes์—์„œ ์š”์ฒญ(Request) ์ชฝ ์••์ถ•์„ ๋‹ค๋ฃจ๋Š” Discussion ๋„ ์žˆ์—ˆ๋‹ค.

 “As it is, API routes seem to ignore content-encoding entirely…” GitHub

 

์ด๊ฑด “์š”์ฒญ ๋ฐ”๋””๊ฐ€ gzip ์ด๋”๋ผ๋„ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ฒ˜๋ฆฌ ์•ˆ ํ•ด์ค€๋‹ค”๋Š” ์–˜๊ธฐ๋ผ, API Routes๊ฐ€ ์••์ถ•/๋””์ฝ”๋”ฉ ๊ด€๋ จ ๊ธฐ๋Šฅ์„ ๊ธฐ๋ณธ ์ œ๊ณตํ•˜์ง€ ์•Š๋Š”๋‹ค๋Š” ๊ฑธ ๊ฐ„์ ‘์ ์œผ๋กœ ๋˜ ํ•œ ๋ฒˆ ๋ณด์—ฌ์ฃผ๋Š” ์‚ฌ๋ก€์ด๋‹ค.

 

4. ๊ฒฐ๋ก 

API Routes๋ฅผ ํ†ตํ•ด ํ”„๋ก ํŠธ๋Š” spring boot ์„œ๋ฒ„์—์„œ event-stream์„ ๊ฐ€์ ธ์˜ค๋Š” ๋ฐฉ์‹์„ ์ฑ„ํƒํ•˜๊ธฐ๋กœ ํ–ˆ๋‹ค.

 

์˜ˆ์‹œ ์ฝ”๋“œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™๋‹ค.

 

 

ํด๋ผ์ด์–ธํŠธ: SSE ์—ฐ๊ฒฐ ์ฝ”๋“œ (Next.js Page)

 

  • ๋ธŒ๋ผ์šฐ์ €๋Š” ๋ฐฑ์—”๋“œ๊ฐ€ ์•„๋‹ˆ๋ผ Next.js API Routes(/api/sse)์— ์—ฐ๊ฒฐ
  • CORS, ์ธ์ฆ, gzip ๋ฌธ์ œ๋ฅผ ํ”„๋ก ํŠธ์—์„œ ์‹ ๊ฒฝ ์“ฐ์ง€ ์•Š์•„๋„ ๋จ
  • ์‹ค์ œ ์‹ค๋ฌด์—์„œ ๊ฐ€์žฅ ์•ˆ์ •์ ์ธ ๊ตฌ์กฐ

 

// pages/sse-example.tsx

import { useEffect } from 'react';

export default function SseExamplePage() {
  useEffect(() => {
    // โœ… Next.js API Routes๋ฅผ ํ–ฅํ•œ SSE ์—ฐ๊ฒฐ
    const es = new EventSource('/api/sse');

    es.onopen = () => {
      console.log('[SSE] ์—ฐ๊ฒฐ ์„ฑ๊ณต');
    };

    es.onmessage = (event) => {
      console.log('[SSE] ์ˆ˜์‹  ๋ฐ์ดํ„ฐ:', event.data);
    };

    es.onerror = (error) => {
      console.error('[SSE] ์—ฐ๊ฒฐ ์—๋Ÿฌ', error);
    };

    return () => {
      console.log('[SSE] ์—ฐ๊ฒฐ ํ•ด์ œ');
      es.close();
    };
  }, []);

  return <div>SSE Proxy ํ…Œ์ŠคํŠธ ํŽ˜์ด์ง€</div>;
}

 

Next.js API Routes: SSE Proxy ์„œ๋ฒ„

  • ๋ธŒ๋ผ์šฐ์ €์—์„œ /api/sse ์š”์ฒญ ์ˆ˜์‹ 
  • ๋‚ด๋ถ€์ ์œผ๋กœ Spring Boot SSE ์„œ๋ฒ„์— ๋‹ค์‹œ ์—ฐ๊ฒฐ
  • ๋ฐฑ์—”๋“œ์—์„œ ์˜ค๋Š” ์ŠคํŠธ๋ฆผ ๋ฐ์ดํ„ฐ๋ฅผ ๊ทธ๋Œ€๋กœ ๋ธŒ๋ผ์šฐ์ €๋กœ ์ค‘๊ณ„
  • gzip, CORS, ์ธ์ฆ ํ—ค๋” ๋ฌธ์ œ ํ•ด๊ฒฐ์— ๋งค์šฐ ์œ ๋ฆฌ
// pages/api/sse.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import axios from 'axios';


export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  // 1) ๋ธŒ๋ผ์šฐ์ €๋กœ ๋ณด๋‚ผ SSE ํ—ค๋” ์„ธํŒ…
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
  });

  // flush ๊ฐ€๋Šฅํ•œ ํ™˜๊ฒฝ์ด๋ฉด ๋ฐ”๋กœ ํ—ค๋” ํ”Œ๋Ÿฌ์‹œ
  // (์••์ถ• ๋ฏธ๋“ค์›จ์–ด ๋ผ์–ด์žˆ์„ ๋•Œ ๋„์›€๋จ)
  // @ts-ignore
  res.flushHeaders?.();

  // 2) ์‹ค์ œ ๋ฐฑ์—”๋“œ SSE ์„œ๋ฒ„ ์ฃผ์†Œ
  const backendSseUrl = process.env.BACKEND_SSE_URL || 'http://localhost:8080/sse';

  let backendResponse;
  try {
    backendResponse = await axios.get(backendSseUrl, {
      responseType: 'stream', // ๐Ÿ”ด ์—ฌ๊ธฐ ์ค‘์š”: ์ŠคํŠธ๋ฆผ์œผ๋กœ ๋ฐ›๊ธฐ
      headers: {
        Accept: 'text/event-stream',
        // ํ•„์š”ํ•˜๋ฉด ์ธ์ฆ ํ—ค๋”๋„ ์—ฌ๊ธฐ์„œ
        // Authorization: `Bearer ${token}`,
      },
    });
  } catch (error) {
    console.error('[SSE Proxy] ๋ฐฑ์—”๋“œ ์—ฐ๊ฒฐ ์‹คํŒจ', error);
    res.write(`event: error\ndata: backend connection error\n\n`);
    res.end();
    return;
  }

  const backendStream = backendResponse.data; // ReadableStream

  // 3) ๋ฐฑ์—”๋“œ → Next API → ๋ธŒ๋ผ์šฐ์ €๋กœ ๊ทธ๋Œ€๋กœ ์ค‘๊ณ„
  backendStream.on('data', (chunk: Buffer) => {
    res.write(chunk);
  });

  backendStream.on('end', () => {
    console.log('[SSE Proxy] ๋ฐฑ์—”๋“œ ์ŠคํŠธ๋ฆผ ์ข…๋ฃŒ');
    res.end();
  });

  backendStream.on('error', (err: Error) => {
    console.error('[SSE Proxy] ๋ฐฑ์—”๋“œ ์ŠคํŠธ๋ฆผ ์—๋Ÿฌ', err);
    res.end();
  });

  // 4) ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ์—ฐ๊ฒฐ์„ ๋Š์—ˆ์„ ๋•Œ ๋ฐฑ์—”๋“œ ์ŠคํŠธ๋ฆผ๋„ ์ •๋ฆฌ
  req.on('close', () => {
    console.log('[SSE Proxy] ํด๋ผ์ด์–ธํŠธ ์—ฐ๊ฒฐ ์ข…๋ฃŒ');
    backendStream.destroy();
  });
}

 

 

๋Œ“๊ธ€