<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발일기</title>
    <link>https://phsun102.tistory.com/</link>
    <description>https://www.linkedin.com/in/hyeongseon-park-21299a237/</description>
    <language>ko</language>
    <pubDate>Sun, 21 Jun 2026 09:31:43 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Flashback</managingEditor>
    <image>
      <title>개발일기</title>
      <url>https://tistory1.daumcdn.net/tistory/3831788/attach/40f318ac412e475388a5d33616e7f6e5</url>
      <link>https://phsun102.tistory.com</link>
    </image>
    <item>
      <title>React - hls로 스트리밍되는 영상 재생하기</title>
      <link>https://phsun102.tistory.com/214</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;630&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ca0vA9/btsPJTQUFDp/LkLZWAKINThZkQv8wBiQHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ca0vA9/btsPJTQUFDp/LkLZWAKINThZkQv8wBiQHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ca0vA9/btsPJTQUFDp/LkLZWAKINThZkQv8wBiQHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fca0vA9%2FbtsPJTQUFDp%2FLkLZWAKINThZkQv8wBiQHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1200&quot; height=&quot;630&quot; data-origin-width=&quot;1200&quot; data-origin-height=&quot;630&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hls형식의 미디어를 재생하려면 브라우저 호환성으로 인해 hls.js를 import하여 가져오는 것이 추천된다.&lt;/p&gt;
&lt;pre id=&quot;code_1754479656429&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;yarn add hls.js&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;react에서 미디어를 재생하기 위해 react-player를 추가로 설치한다.&lt;/p&gt;
&lt;pre id=&quot;code_1754479740979&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;yarn add react-player&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;렌더링하는 부분에 RectPlayer를 통해 master.m3u8이 재생될 수 있도록 코드를 입력한다.&lt;/p&gt;
&lt;pre id=&quot;code_1754479805135&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;return (
    &amp;lt;&amp;gt;
        &amp;lt;ReactPlayer src='./cat/master.m3u8' controls={ true } /&amp;gt;
    &amp;lt;/&amp;gt;
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 코드는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1754479874686&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import react from 'react'
import ReactPlayer from 'react-player'
import Hls from 'hls.js'

function App() {
    return (
        &amp;lt;&amp;gt;
            &amp;lt;ReactPlayer src='./cat/master.m3u8' controls={ true } /&amp;gt;
        &amp;lt;/&amp;gt;
    )
}

export default App&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;import한 hls를 쓰지않더라도 저렇게 import한 코드를 입력해줘야 오류없이 재생될 수있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;252&quot; data-origin-height=&quot;147&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhEdoM/btsPHXmhnCi/FASWuGeMDVtkzmshv9Kea0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhEdoM/btsPHXmhnCi/FASWuGeMDVtkzmshv9Kea0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhEdoM/btsPHXmhnCi/FASWuGeMDVtkzmshv9Kea0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhEdoM%2FbtsPHXmhnCi%2FFASWuGeMDVtkzmshv9Kea0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;252&quot; height=&quot;147&quot; data-origin-width=&quot;252&quot; data-origin-height=&quot;147&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Network탭을 보면 다음과 같이 ts segment들이 순차적으로 불러와지면서 영상이 재생되는 것을 확인할 수 있다. mp4파일과 다른 점은 mp4파일은 먼저 페이지가 로드되면 미디어를 한 번에 다운받아 재생하는데 hls는 ts segment를 순차적으로 받아와 재생하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 사이트&lt;/b&gt;:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/react-player&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.npmjs.com/package/react-player&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754480113585&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;react-player&quot; data-og-description=&quot;A React component for playing a variety of URLs, including file paths, Mux, YouTube, Vimeo, and Wistia. Latest version: 3.3.1, last published: 21 days ago. Start using react-player in your project by running &amp;#96;npm i react-player&amp;#96;. There are 1328 other proje&quot; data-og-host=&quot;www.npmjs.com&quot; data-og-source-url=&quot;https://www.npmjs.com/package/react-player&quot; data-og-url=&quot;https://www.npmjs.com/package/react-player&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/teSyq/hyZuGUG82W/fht5bbIomt8eeDoTILgHdK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/cG2Ddo/hyZuJqnyRP/iOnGABjcBXcpP9PgPGR05k/img.png?width=384&amp;amp;height=384&amp;amp;face=0_0_384_384&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/react-player&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.npmjs.com/package/react-player&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/teSyq/hyZuGUG82W/fht5bbIomt8eeDoTILgHdK/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/cG2Ddo/hyZuJqnyRP/iOnGABjcBXcpP9PgPGR05k/img.png?width=384&amp;amp;height=384&amp;amp;face=0_0_384_384');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;react-player&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;A React component for playing a variety of URLs, including file paths, Mux, YouTube, Vimeo, and Wistia. Latest version: 3.3.1, last published: 21 days ago. Start using react-player in your project by running `npm i react-player`. There are 1328 other proje&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.npmjs.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/hls.js?activeTab=readme&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.npmjs.com/package/hls.js?activeTab=readme&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754480121982&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;hls.js&quot; data-og-description=&quot;JavaScript HLS client using MediaSourceExtension. Latest version: 1.6.8, last published: a day ago. Start using hls.js in your project by running &amp;#96;npm i hls.js&amp;#96;. There are 957 other projects in the npm registry using hls.js.&quot; data-og-host=&quot;www.npmjs.com&quot; data-og-source-url=&quot;https://www.npmjs.com/package/hls.js?activeTab=readme&quot; data-og-url=&quot;https://www.npmjs.com/package/hls.js&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b6m94f/hyZrAhisn1/8Grv1wwtF7ItR2aCy5WfA0/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/brJ35o/hyZux4xR8l/9RBcVUGj6MygzDF8qnhS20/img.png?width=421&amp;amp;height=316&amp;amp;face=0_0_421_316&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/hls.js?activeTab=readme&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.npmjs.com/package/hls.js?activeTab=readme&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b6m94f/hyZrAhisn1/8Grv1wwtF7ItR2aCy5WfA0/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/brJ35o/hyZux4xR8l/9RBcVUGj6MygzDF8qnhS20/img.png?width=421&amp;amp;height=316&amp;amp;face=0_0_421_316');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;hls.js&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JavaScript HLS client using MediaSourceExtension. Latest version: 1.6.8, last published: a day ago. Start using hls.js in your project by running `npm i hls.js`. There are 957 other projects in the npm registry using hls.js.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.npmjs.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Javascript/React.js</category>
      <category>React</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/214</guid>
      <comments>https://phsun102.tistory.com/214#entry214comment</comments>
      <pubDate>Wed, 6 Aug 2025 20:35:53 +0900</pubDate>
    </item>
    <item>
      <title>Node.js - ffmpeg으로 mp4파일을 hls로 변환하기</title>
      <link>https://phsun102.tistory.com/213</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EhLVq/btsPILeYhmp/9J24OsRR6XHAuLe4Epvuvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EhLVq/btsPILeYhmp/9J24OsRR6XHAuLe4Epvuvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EhLVq/btsPILeYhmp/9J24OsRR6XHAuLe4Epvuvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEhLVq%2FbtsPILeYhmp%2F9J24OsRR6XHAuLe4Epvuvk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;250&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;250&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. HLS란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HLS는 HTTP기반의 적응형 스트리밍 프로토콜이며 미디어 파일을 일정한 세그먼트(segment) 단위로 나눠서 전송한다. 적응형 스트리밍이란 사용자의 네트워크 환경에 맞는 화질을 자동으로 선택하여 전송하는 것을 의미한다. 즉 사용자의 네트워크 속도가 느릴 경우 화질을 낮춰 전송하고 네트워크 속도가 괜찮을 경우 좋은 화질로 미디어 데이터를 전송하는 것을 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 코덱과 비트레이트&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Video Codec&lt;/b&gt;: 영상을 인코딩 하거나 디코딩하는 기술로 영상 데이터를 저장하거나 전송하기에 적합한 형태로 압축을 하고 재생 시 다시 원래 형태로 복원하는 역할을 한다. 비디오 코덱의 종류에는 libx264, libx265, mpeg4 등이 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Audio Codec&lt;/b&gt;: Audio Codec은 Video Codec과 다르게 소리를 인코딩하거나 디코딩한다. 오디오 코덱의 종류에는 MP3, AAC, FLAC 등이 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Bitrate&lt;/b&gt;: 1초당 몇 비트의 데이터를 사용할지를 의미한다. 비트레이트가 높으면 파일 크기가 크며 고화질이다. 반대로 비트레이트가 낮으면 파일 크기가 작으며 저화질이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Constant Bitrate(CBR)&lt;/b&gt;: 고정 비트레이트로 처음부터 끝까지 고정된 비트레이트를 사용하여 압축을 진행한다. 고정된 비트로 인해 전송이 안정적이지만 화면 전환이 많은 구간에는 충분한 비트가 할당되지 않아 해당 부분 한정으로 품질이 낮아질 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Variable Bitrate(VBR)&lt;/b&gt;: 가변 비트레이트로 CBR과 다르게 비트레이트가 매번 변하며 이로 인해 CBR의 단점을 보완할 수 있다. 전체적인 품질은 VBR이 더 우수하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. ffmpeg 옵션&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1754479061606&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const ffmpegOptions = [
    '-loglevel', 'info',
    '-i', path.join(__dirname, `${mp4DirName}/SampleVideo_1280x720_30mb.mp4`),
    '-filter_complex', // 여러 스트림을 동시에 처리하기 위해 사용
    '[0:v]split=3[v1][v2][v3];' + // 비디오 스트림을 3개로 분할
    '[v1]scale=1280:720[vout1];' + // 첫 번째 분할된 스트림을 1280x720 크기로 지정
    '[v2]scale=854:480[vout2];' + // 두 번째 분할된 스트림을 854x480 크기로 지정
    '[v3]scale=640:360[vout3]', // 세 번째 분할된 스트림을 640x360 크기로 지정

    '-map', '[vout1]',     '-map', '0:a', // 매핑
    '-map', '[vout2]',     '-map', '0:a',
    '-map', '[vout3]',     '-map', '0:a',

    '-c:v', 'libx264', // 영상 코덱
    '-g', '24',  // GOP 크기 설정
    '-keyint_min', '24', // 최소 키프레임 간격

    '-b:v:0', '2500k', // 영상 비트레이트
    '-b:v:1', '1500k',
    '-b:v:2', '800k',

    '-c:a', 'aac', // 오디오 코덱
    '-b:a', '128k', // 오디오 비트레이트

    '-hls_time', '5', // segment 간격
    '-hls_list_size', '0', // m3u8 파일에 포함할 ts파일 개수
    '-hls_segment_filename', `${targetDirName}/%v/segments_%03d.ts`, // ts 파일 이름 형식
    '-master_pl_name', 'master.m3u8', // master 파일 이름
    '-var_stream_map', 'v:0,a:0,name:720p v:1,a:1,name:480p v:2,a:2,name:360p', // 스트림 매핑

    `${targetDirName}/%v/playlist.m3u8`
]&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;-filter_complex&lt;/b&gt;: 여러 스트림을 동시에 처리하거나 입력 파일의 미디어 스트림을 여러 개 분할하여 변환할 때 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'[0:v]split=3[v1][v2][v3];&amp;rsquo;&lt;/b&gt;: [0:v]는 입력 파일의 첫 번째 영상 스트림을 의미한다. split으로 입력 파일의 영상을 3개의 스트림으로 분할하여 각각 v1, v2, v3로 이름을 지정한다. 여기서 v는 video를 의미한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;[v1]scale=1280:720[vout1]&amp;hellip;&lt;/b&gt;: 분할된 스트림의 해상도를 지정한다. width, height 순으로 나열하며 720p, 480p, 360p로 지정한다. vout1, vout2, vout3은 filter_complex로 분할되고 해상도가 지정된 최종 영상 스트림의 이름을 의미한다. vout1, vout2, vout3을 -map에 사용하여 매핑을 진행한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-map&lt;/b&gt;: ffmpeg에서 어떤 입력 스트림을 출력해 포함시킬지 정의한다. 0:a는 입력 파일의 첫 번째 오디오 스트림을 의미한다. 0:v와 같이 0은 첫 번째 스트림을 의미하며 a는 audio를 의미한다. 각 영상마다 오디오가 필요하므로 &amp;lsquo;-map&amp;rsquo;: &amp;lsquo;[vout1]&amp;rsquo;, &amp;lsquo;-map&amp;rsquo;, &amp;lsquo;0:a&amp;rsquo;, &amp;lsquo;-map&amp;rsquo;: &amp;lsquo;[vout2&amp;rsquo;], &amp;lsquo;-map&amp;rsquo;], &amp;lsquo;0:a&amp;rsquo; &amp;hellip; 처럼 3개의 영상 스트림과 오디오 스트림을 출력에 포함되도록 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-c:v', 'libx264'&lt;/b&gt;: 비디오의 코덱을 libx264로 설정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-b:v:0', '2500k'&lt;/b&gt;: 첫 번째 영상 스트림의 비트레이트를 2500k로 설정한다. 2번째 세번째 영상 스트림은 해상도에 따라 비트레이트를 조절한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-c:a', 'aac'&lt;/b&gt;: 음성 스트림의 코덱을 aac로 설정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-b:a', '128k'&lt;/b&gt;: 음성 스트림의 비트레이트를 128k로 설정한다. 음성 스트림은 하나이기 때문에 a:0, a:1이 아닌 a로 입력한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-g&lt;/b&gt;: 키프레임부터 다음 키프레임까지의 최대 프레임 수를 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-keyint_min&lt;/b&gt;: 키프레임들 사이의 최소 프레임 수를 지정한다. 보통 keyint_min과 -g는 같은 값으로 지정한다. 단위는 fps로 24면 24프레임을 의미한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-hls_time', '5'&lt;/b&gt;: 생성될 hls segment의 길이를 지정한다. 단위는 초로 5로 지정하면 5초를 의미한다. keyint_min과 g값으로 인해 hls_time은 5로 지정했지만 각 segment의 길이가 달라질 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-hls_list_size', '0&amp;rsquo;&lt;/b&gt;: m3u8플레이 리스트에 포함시킬 hls segment의 개수를 지정한다. 0으로 지정하면 모든 hls segment를 m3u8에 포함하여 저장한다. 5로 지정하면 최근 생성된 5개의 hls segment만 m3u8에 포함하여 저장하게 된다. 0이 아닌 다른 숫자를 지정하는 경우는 보통 라이브 스트리밍을 할 때 다른 숫자로 지정하게 된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-var_stream_map&amp;rsquo;&lt;/b&gt;: -map에서 매핑되어 출력 스트림에 포함된 스트림들을 그룹화한다. 위 코드를 보면 v:0과 a:0을 720p로 v:1과 a:0을 480p로 그룹 지정하였다. 이렇게 지정된 이름은 %v로 사용할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-hls_segment_filename&amp;rsquo;&lt;/b&gt;: 생성되는 hls segment의 파일 명을 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'-master_pl_name&amp;rsquo;&lt;/b&gt;: 마스터 플레이리스트의 파일 명을 지정한다. 지정된 이름으로 m3u8파일이 생성된다.&lt;/li&gt;
&lt;li&gt;${targetDirName}/%v/playlist.m3u8: 분할되어 생성되는 각 해상도별 플레이리스트 파일 명을 지정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. 변환 코드&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1754479176922&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const spawn = require('child_process').spawn;
const path = require('path');
const fs = require('fs');

const mp4DirName = 'original_videos'
const targetDirName = 'streaming_videos/SampleVideo_1280x720_30mb'

if (!fs.existsSync(path.join(__dirname, targetDirName))) {
    fs.mkdirSync(path.join(__dirname, targetDirName), { recursive: true });
}

const ffmpegOptions = [
    '-loglevel', 'info',
    '-i', path.join(__dirname, `${mp4DirName}/SampleVideo_1280x720_30mb.mp4`),
    '-filter_complex', // 여러 스트림을 동시에 처리하기 위해 사용
    '[0:v]split=3[v1][v2][v3];' + // 비디오 스트림을 3개로 분할
    '[v1]scale=1280:720[vout1];' + // 첫 번째 분할된 스트림을 1280x720 크기로 지정
    '[v2]scale=854:480[vout2];' + // 두 번째 분할된 스트림을 854x480 크기로 지정
    '[v3]scale=640:360[vout3]', // 세 번째 분할된 스트림을 640x360 크기로 지정

    '-map', '[vout1]',     '-map', '0:a', // 매핑
    '-map', '[vout2]',     '-map', '0:a',
    '-map', '[vout3]',     '-map', '0:a',

    '-c:v', 'libx264', // 영상 코덱
    '-g', '24',  // GOP 크기 설정
    '-keyint_min', '24', // 최소 키프레임 간격

    '-b:v:0', '2500k', // 영상 비트레이트
    '-b:v:1', '1500k',
    '-b:v:2', '800k',

    '-c:a', 'aac', // 오디오 코덱
    '-b:a', '128k', // 오디오 비트레이트

    '-hls_time', '5', // segment 간격
    '-hls_list_size', '0', // m3u8 파일에 포함할 ts파일 개수
    '-hls_segment_filename', `${targetDirName}/%v/segments_%03d.ts`, // ts 파일 이름 형식
    '-master_pl_name', 'master.m3u8', // master 파일 이름
    '-var_stream_map', 'v:0,a:0,name:720p v:1,a:1,name:480p v:2,a:2,name:360p', // 스트림 매핑

    `${targetDirName}/%v/playlist.m3u8`
]

const converter = spawn('ffmpeg', ffmpegOptions)

converter.stderr.on('data', (data) =&amp;gt; {
    console.log(`stderr: ${data}`);
})
converter.stderr.on('end', () =&amp;gt; {
    console.log('End converting video')
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 코드를 실행하면 streaming_videos라는 폴더가 생성되고 그 하위 폴더에 360p, 480p과 720p 해상도별 폴더가 생성된다. 적응형 스트리밍을 위해 master.m3u8은 각 플레이리스트의 정보를 담고 있으며 각각의 폴더는 ts segment과 playlist.m3u8을 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;288&quot; data-origin-height=&quot;102&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZJyr0/btsPHLzCTy8/BsiK5Spm70pzKs13UWlydK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZJyr0/btsPHLzCTy8/BsiK5Spm70pzKs13UWlydK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZJyr0/btsPHLzCTy8/BsiK5Spm70pzKs13UWlydK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZJyr0%2FbtsPHLzCTy8%2FBsiK5Spm70pzKs13UWlydK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;288&quot; height=&quot;102&quot; data-origin-width=&quot;288&quot; data-origin-height=&quot;102&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 사이트&lt;/b&gt;:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://frontdev.tistory.com/entry/ffmpeg%EB%A1%9C-hls-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%98%B5%EC%85%98%EC%A0%95%EB%A6%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://frontdev.tistory.com/entry/ffmpeg%EB%A1%9C-hls-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%98%B5%EC%85%98%EC%A0%95%EB%A6%AC&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754479463979&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;ffmpeg로 hls 만들기, 옵션정리&quot; data-og-description=&quot;ffmpeg 프로그램을 다운받고 영상을 hls 전용 스트리밍 영상 파일로 변환 및 ts (각각의 세그먼트 파일) 들을 추출 한 뒤에 웹에 적용해볼 수 있습니다. ie 11 이상으로 지원하고, 각각 영상 조각 파일&quot; data-og-host=&quot;frontdev.tistory.com&quot; data-og-source-url=&quot;https://frontdev.tistory.com/entry/ffmpeg%EB%A1%9C-hls-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%98%B5%EC%85%98%EC%A0%95%EB%A6%AC&quot; data-og-url=&quot;https://frontdev.tistory.com/entry/ffmpeg%EB%A1%9C-hls-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%98%B5%EC%85%98%EC%A0%95%EB%A6%AC&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/buUuF9/hyZvxDbUZs/QEFpaIDKXXZkeobDHPI8OK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bjyC1T/hyZvm9wBwi/cpAPUkk7IKjFqwfKinW941/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://frontdev.tistory.com/entry/ffmpeg%EB%A1%9C-hls-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%98%B5%EC%85%98%EC%A0%95%EB%A6%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://frontdev.tistory.com/entry/ffmpeg%EB%A1%9C-hls-%EB%A7%8C%EB%93%A4%EA%B8%B0-%EC%98%B5%EC%85%98%EC%A0%95%EB%A6%AC&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/buUuF9/hyZvxDbUZs/QEFpaIDKXXZkeobDHPI8OK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bjyC1T/hyZvm9wBwi/cpAPUkk7IKjFqwfKinW941/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;ffmpeg로 hls 만들기, 옵션정리&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;ffmpeg 프로그램을 다운받고 영상을 hls 전용 스트리밍 영상 파일로 변환 및 ts (각각의 세그먼트 파일) 들을 추출 한 뒤에 웹에 적용해볼 수 있습니다. ie 11 이상으로 지원하고, 각각 영상 조각 파일&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;frontdev.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://video.stackexchange.com/questions/37648/ffmpeg-hls-time-and-hls-init-time-not-working&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://video.stackexchange.com/questions/37648/ffmpeg-hls-time-and-hls-init-time-not-working&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754479472155&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;ffmpeg: -hls_time and -hls_init_time not working&quot; data-og-description=&quot;Source video: h264 mp4, 45 seconds, 30 fps, keyint 30, min keyint 16 I am trying to create an m3u8 playlist where the first chunk is smaller (2 seconds) and the rest are 10 seconds. when using this&quot; data-og-host=&quot;video.stackexchange.com&quot; data-og-source-url=&quot;https://video.stackexchange.com/questions/37648/ffmpeg-hls-time-and-hls-init-time-not-working&quot; data-og-url=&quot;https://video.stackexchange.com/questions/37648/ffmpeg-hls-time-and-hls-init-time-not-working&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bLQJIU/hyZrpGNNK4/hNh1RKhNr2HAa0jIpXXZ4K/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316&quot;&gt;&lt;a href=&quot;https://video.stackexchange.com/questions/37648/ffmpeg-hls-time-and-hls-init-time-not-working&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://video.stackexchange.com/questions/37648/ffmpeg-hls-time-and-hls-init-time-not-working&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bLQJIU/hyZrpGNNK4/hNh1RKhNr2HAa0jIpXXZ4K/img.png?width=316&amp;amp;height=316&amp;amp;face=0_0_316_316');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;ffmpeg: -hls_time and -hls_init_time not working&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Source video: h264 mp4, 45 seconds, 30 fps, keyint 30, min keyint 16 I am trying to create an m3u8 playlist where the first chunk is smaller (2 seconds) and the rest are 10 seconds. when using this&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;video.stackexchange.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/bramp/ffmpeg-cli-wrapper/issues/347&quot;&gt;hls_time: Invalid chars on ffmpeg version 3 &amp;middot; Issue #347 &amp;middot; bramp/ffmpeg-cli-wrapper&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754479478122&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;hls_time: Invalid chars on ffmpeg version 3 &amp;middot; Issue #347 &amp;middot; bramp/ffmpeg-cli-wrapper&quot; data-og-description=&quot;Describe the bug HlsOutputBuilder is incompatible with the hls format used in ffmpeg version 3. FFmpeg switched from second based value (eg. 120) to the format we use (2:00, hh:mm:ss) in version 4....&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/bramp/ffmpeg-cli-wrapper/issues/347&quot; data-og-url=&quot;https://github.com/bramp/ffmpeg-cli-wrapper/issues/347&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cqOaQC/hyZuB6VTmU/5wmn21Q6muAXDXy4PYMz01/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/MyauZ/hyZuw5D4nh/uFKkK9hiovroILJBkINuY0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/bramp/ffmpeg-cli-wrapper/issues/347&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/bramp/ffmpeg-cli-wrapper/issues/347&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cqOaQC/hyZuB6VTmU/5wmn21Q6muAXDXy4PYMz01/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/MyauZ/hyZuw5D4nh/uFKkK9hiovroILJBkINuY0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;hls_time: Invalid chars on ffmpeg version 3 &amp;middot; Issue #347 &amp;middot; bramp/ffmpeg-cli-wrapper&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Describe the bug HlsOutputBuilder is incompatible with the hls format used in ffmpeg version 3. FFmpeg switched from second based value (eg. 120) to the format we use (2:00, hh:mm:ss) in version 4....&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ffmpeg.org/documentation.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ffmpeg.org/documentation.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754479533683&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Documentation&quot; data-og-description=&quot;The following documentation is regenerated nightly, and corresponds to the newest FFmpeg revision. Consult your locally installed documentation for older versions. API Documentation Doxygen documentation for current trunk (regenerated nightly); documentati&quot; data-og-host=&quot;ffmpeg.org&quot; data-og-source-url=&quot;https://ffmpeg.org/documentation.html&quot; data-og-url=&quot;https://ffmpeg.org/documentation.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://ffmpeg.org/documentation.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ffmpeg.org/documentation.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The following documentation is regenerated nightly, and corresponds to the newest FFmpeg revision. Consult your locally installed documentation for older versions. API Documentation Doxygen documentation for current trunk (regenerated nightly); documentati&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ffmpeg.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Javascript/Node.js</category>
      <category>node.js</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/213</guid>
      <comments>https://phsun102.tistory.com/213#entry213comment</comments>
      <pubDate>Wed, 6 Aug 2025 20:26:04 +0900</pubDate>
    </item>
    <item>
      <title>Node.js - ffprobe로 미디어 파일 분석하기</title>
      <link>https://phsun102.tistory.com/212</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4cUYV/btsPJknR74X/OoVYdRtYMPLZSPKQM9ujU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4cUYV/btsPJknR74X/OoVYdRtYMPLZSPKQM9ujU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4cUYV/btsPJknR74X/OoVYdRtYMPLZSPKQM9ujU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4cUYV%2FbtsPJknR74X%2FOoVYdRtYMPLZSPKQM9ujU0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;250&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;250&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. ffprobe란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ffprobe로 미디어 파일이 담고 있는 영상, 음성, 자막 등의 정보를 확인할 수 있으며 해당 파일의 크기 등을 확인할 수 있다. 이렇게 얻은 정보를 바탕을 통해 ffmpeg으로 영상을 다른 확장자로 변환하거나 스트리밍 형식으로 변환할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Node.js에서 ffprobe를 사용하려면 ffmpeg을 다운로드 해야한다. &lt;a href=&quot;https://ffmpeg.org/&quot;&gt;https://ffmpeg.org/&lt;/a&gt; 이 사이트에서 자신의 OS에 맞게 설치를 하거나 npm에 올라가 있는 @ffprobe-installer/ffprobe, @ffmpeg-installer/ffmpeg패키지를 설치하여 사용할 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;npm ffmpeg&lt;/b&gt;: &lt;a href=&quot;https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg&quot;&gt;https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;npm ffprobe&lt;/b&gt;: &lt;a href=&quot;https://www.npmjs.com/package/@ffprobe-installer/ffprobe&quot;&gt;https://www.npmjs.com/package/@ffprobe-installer/ffprobe&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. ffprobe 옵션&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const ffprobeOptions = [
    '-loglevel', 'info',
    '-print_format', 'json',
    '-show_streams',
    '-show_format',
    `${path.join(__dirname, `${mp4DirName}/SampleVideo_1280x720_30mb.mp4`)}`
];&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;-loglevel&lt;/b&gt;: ffprobe를 실행했을 때, 나타날 로그의 로그 레벨을 설정한다. error, quiet, info 등이 있는데 error는 에러가 발생했을 때 로그 출력, quiet는 로그를 출력하지 않으며 info는 현재 진행 상황 등을 출력한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-print_format&lt;/b&gt;: 결과 값을 출력한 형태를 지정한다. 여기선 json으로 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-show_streams&lt;/b&gt;: 입력 파일이 가지고 있는 영상, 음성, 자막 등의 미디어 스트림 정보를 출력하게 하는 옵션이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;-show_format&lt;/b&gt;: 해당 미디어 파일의 파일 크기, 비트레이트와 재생 시간 등을 출력하게 하는 옵션이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;path.join~&lt;/b&gt;: 분석할 미디어 파일의 경로를 지정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const spawn = require('child_process').spawn;
const path = require('path');
const fs = require('fs');

const mp4DirName = 'original_videos'
const ffprobeOptions = [
    '-loglevel', 'error',
    '-print_format', 'json',
    '-show_streams',
    '-show_format',
    `${path.join(__dirname, `${mp4DirName}/SampleVideo_1280x720_30mb.mp4`)}`
];
const process = spawn('ffprobe', ffprobeOptions)
let output = '';

process.stdout.on('data', (data) =&amp;gt; {
    output += data.toString();
});
process.stderr.on('data', (data) =&amp;gt; {
    console.log('stderr: ', data.toString())
})
process.on('close', (code) =&amp;gt; {
    if (code === 0) {
        output = JSON.parse(output)
        let videoStream = {}
        let audioStream = {}
        let mediaFormat = {}
        if(typeof output.streams !== 'undefined') {
            videoStream = output.streams.find(stream =&amp;gt; stream.codec_type === 'video')
            audioStream = output.streams.find(stream =&amp;gt; stream.codec_type === 'audio')
        }
        if(typeof output.format !== 'undefined') {
            mediaFormat = output.format
        }
        console.log('videoStream: ', videoStream)
        console.log('audioStream: ', audioStream)
        console.log('media format: ', mediaFormat)
    } else {
        console.log('error code: ', code)
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;nodejs에서 ffmpeg, ffprobe를 사용하려면 spawn을 사용해야 한다. ffprobe는 Node.js가 아닌 외부 프로그램이기 때문에 spawn으로 외부 프로세스를 실행할 수 있게 해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;stdout.on(&amp;rsquo;data&amp;rsquo;)&lt;/b&gt;: 표준 출력으로 현재 출력되는 데이터를 확인할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;stderr.on(&amp;rsquo;data&amp;rsquo;)&lt;/b&gt;: 표준 에러로 오류 또는 ffmpeg의 진행 상황을 확인할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;process.on(&amp;rsquo;close&amp;rsquo;)&lt;/b&gt;: spawn으로 실행한 프로세스가 종료되면 실행된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;output의 streams는 -show_streams 옵션이 추가된 상태에서 확인할 수 있다. 이와 마찬가지로 output.format은 -show_format 옵션이 추가된 상태에서 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. video, audio, format 출력 값&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// video stream
videoStream:  {
  index: 0,
  codec_name: 'h264',
  codec_long_name: 'H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10',
  profile: 'Main',
  codec_type: 'video',
  codec_tag_string: 'avc1',
  codec_tag: '0x31637661',
  width: 1280,
  height: 720,
  coded_width: 1280,
  coded_height: 720,
  has_b_frames: 0,
  sample_aspect_ratio: '1:1',
  display_aspect_ratio: '16:9',
  pix_fmt: 'yuv420p',
  level: 31,
  chroma_location: 'left',
  field_order: 'progressive',
  refs: 1,
  is_avc: 'true',
  nal_length_size: '4',
  id: '0x1',
  r_frame_rate: '25/1',
  avg_frame_rate: '25/1',
  time_base: '1/12800',
  start_pts: 0,
  start_time: '0.000000',
  duration_ts: 2186752,
  duration: '170.840000',
  bit_rate: '1086104',
  bits_per_raw_sample: '8',
  nb_frames: '4271',
  extradata_size: 38,
  disposition: {
    default: 1,
    dub: 0,
    original: 0,
    comment: 0,
    lyrics: 0,
    karaoke: 0,
    forced: 0,
    hearing_impaired: 0,
    visual_impaired: 0,
    clean_effects: 0,
    attached_pic: 0,
    timed_thumbnails: 0,
    non_diegetic: 0,
    captions: 0,
    descriptions: 0,
    metadata: 0,
    dependent: 0,
    still_image: 0,
    multilayer: 0
  },
  tags: {
    creation_time: '1970-01-01T00:00:00.000000Z',
    language: 'und',
    handler_name: 'VideoHandler',
    vendor_id: '[0][0][0][0]'
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;// audio stream
audioStream:  {
  index: 1,
  codec_name: 'aac',
  codec_long_name: 'AAC (Advanced Audio Coding)',
  profile: 'LC',
  codec_type: 'audio',
  codec_tag_string: 'mp4a',
  codec_tag: '0x6134706d',
  sample_fmt: 'fltp',
  sample_rate: '48000',
  channels: 6,
  channel_layout: '5.1',
  bits_per_sample: 0,
  initial_padding: 0,
  id: '0x2',
  r_frame_rate: '0/0',
  avg_frame_rate: '0/0',
  time_base: '1/48000',
  start_pts: 0,
  start_time: '0.000000',
  duration_ts: 8201216,
  duration: '170.858667',
  bit_rate: '383933',
  nb_frames: '8009',
  extradata_size: 2,
  disposition: {
    default: 1,
    dub: 0,
    original: 0,
    comment: 0,
    lyrics: 0,
    karaoke: 0,
    forced: 0,
    hearing_impaired: 0,
    visual_impaired: 0,
    clean_effects: 0,
    attached_pic: 0,
    timed_thumbnails: 0,
    non_diegetic: 0,
    captions: 0,
    descriptions: 0,
    metadata: 0,
    dependent: 0,
    still_image: 0,
    multilayer: 0
  },
  tags: {
    creation_time: '1970-01-01T00:00:00.000000Z',
    language: 'und',
    handler_name: 'SoundHandler',
    vendor_id: '[0][0][0][0]'
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;video와 audio정보는 위와 같이 출력된다. 핵심적인 내용으로 해당 미디어 스트림의 코덱명, 미디어 타입, 비트레이트, 재생시간과 video면 width, height 해상도, 프레임, audio면 샘플레이트 등을 확인할 수 있다. 이 정보를 바탕으로 여러 해상도로 다운 스케일링하여 스트리밍 형식으로 변환할 때 사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// format
media format:  {
  filename: '~\\\\original_videos\\\\SampleVideo_1280x720_30mb.mp4', // filename은 앞에 폴더 경로가 있는데 로컬 컴퓨터라 ~로 대체합니다.
  nb_streams: 2,
  nb_programs: 0,
  nb_stream_groups: 0,
  format_name: 'mov,mp4,m4a,3gp,3g2,mj2',
  format_long_name: 'QuickTime / MOV',
  start_time: '0.000000',
  duration: '170.858667',
  size: '31491130',
  bit_rate: '1474487',
  probe_score: 100,
  tags: {
    major_brand: 'isom',
    minor_version: '512',
    compatible_brands: 'isomiso2avc1mp41',
    creation_time: '1970-01-01T00:00:00.000000Z',
    encoder: 'Lavf53.24.2'
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;format은 파일 크기, 재생 시간, 비트레이트 등을 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참조 사이트:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ffmpeg.org/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ffmpeg.org/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754474640912&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;FFmpeg&quot; data-og-description=&quot;Converting video and audio has never been so easy. $ ffmpeg -i input.mp4 output.avi &amp;nbsp; &amp;nbsp; News September 30th, 2024, FFmpeg 7.1 &amp;quot;P&amp;eacute;ter&amp;quot; FFmpeg 7.1 &amp;quot;P&amp;eacute;ter&amp;quot;, a new major release, is now available! A full list of changes can be found in the release changelo&quot; data-og-host=&quot;ffmpeg.org&quot; data-og-source-url=&quot;https://ffmpeg.org/&quot; data-og-url=&quot;https://ffmpeg.org/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://ffmpeg.org/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ffmpeg.org/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;FFmpeg&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Converting video and audio has never been so easy. $ ffmpeg -i input.mp4 output.avi &amp;nbsp; &amp;nbsp; News September 30th, 2024, FFmpeg 7.1 &quot;P&amp;eacute;ter&quot; FFmpeg 7.1 &quot;P&amp;eacute;ter&quot;, a new major release, is now available! A full list of changes can be found in the release changelo&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ffmpeg.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/@ffprobe-installer/ffprobe&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.npmjs.com/package/@ffprobe-installer/ffprobe&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754474641130&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;@ffprobe-installer/ffprobe&quot; data-og-description=&quot;Platform independent binary installer of FFprobe for node projects. Latest version: 2.1.2, last published: 2 years ago. Start using @ffprobe-installer/ffprobe in your project by running &amp;#96;npm i @ffprobe-installer/ffprobe&amp;#96;. There are 104 other projects in th&quot; data-og-host=&quot;www.npmjs.com&quot; data-og-source-url=&quot;https://www.npmjs.com/package/@ffprobe-installer/ffprobe&quot; data-og-url=&quot;https://www.npmjs.com/package/@ffprobe-installer/ffprobe&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/USa7u/hyZuxXMD3G/tGG8iS1yZSJkQE2TnTKEI0/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/FKxIj/hyZuAGWOpj/NWfd2B1jxZ48ZshySHXD8K/img.jpg?width=460&amp;amp;height=460&amp;amp;face=174_95_348_285,https://scrap.kakaocdn.net/dn/LrZrt/hyZuG8eJnd/PgziwiqzMT9ERAXwiQXeo0/img.png?width=460&amp;amp;height=460&amp;amp;face=0_0_460_460&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/@ffprobe-installer/ffprobe&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.npmjs.com/package/@ffprobe-installer/ffprobe&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/USa7u/hyZuxXMD3G/tGG8iS1yZSJkQE2TnTKEI0/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/FKxIj/hyZuAGWOpj/NWfd2B1jxZ48ZshySHXD8K/img.jpg?width=460&amp;amp;height=460&amp;amp;face=174_95_348_285,https://scrap.kakaocdn.net/dn/LrZrt/hyZuG8eJnd/PgziwiqzMT9ERAXwiQXeo0/img.png?width=460&amp;amp;height=460&amp;amp;face=0_0_460_460');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;@ffprobe-installer/ffprobe&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Platform independent binary installer of FFprobe for node projects. Latest version: 2.1.2, last published: 2 years ago. Start using @ffprobe-installer/ffprobe in your project by running `npm i @ffprobe-installer/ffprobe`. There are 104 other projects in th&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.npmjs.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg&quot;&gt;@ffmpeg-installer/ffmpeg - npm&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754474647846&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;@ffmpeg-installer/ffmpeg&quot; data-og-description=&quot;Platform independent binary installer of FFmpeg for node projects. Latest version: 1.1.0, last published: 4 years ago. Start using @ffmpeg-installer/ffmpeg in your project by running &amp;#96;npm i @ffmpeg-installer/ffmpeg&amp;#96;. There are 331 other projects in the npm&quot; data-og-host=&quot;www.npmjs.com&quot; data-og-source-url=&quot;https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg&quot; data-og-url=&quot;https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/JjAKz/hyZuwxOavU/A1JBL3ASuazKZfvgxKkD91/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/JjAKz/hyZuwxOavU/A1JBL3ASuazKZfvgxKkD91/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;@ffmpeg-installer/ffmpeg&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Platform independent binary installer of FFmpeg for node projects. Latest version: 1.1.0, last published: 4 years ago. Start using @ffmpeg-installer/ffmpeg in your project by running `npm i @ffmpeg-installer/ffmpeg`. There are 331 other projects in the npm&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.npmjs.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ffmpeg.org/ffprobe.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ffmpeg.org/ffprobe.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1754474653792&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;ffprobe Documentation&quot; data-og-description=&quot;Table of Contents ffprobe [options] input_url ffprobe gathers information from multimedia streams and prints it in human- and machine-readable fashion. For example it can be used to check the format of the container used by a multimedia stream and the form&quot; data-og-host=&quot;ffmpeg.org&quot; data-og-source-url=&quot;https://ffmpeg.org/ffprobe.html&quot; data-og-url=&quot;https://ffmpeg.org/ffprobe.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://ffmpeg.org/ffprobe.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ffmpeg.org/ffprobe.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;ffprobe Documentation&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Table of Contents ffprobe [options] input_url ffprobe gathers information from multimedia streams and prints it in human- and machine-readable fashion. For example it can be used to check the format of the container used by a multimedia stream and the form&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ffmpeg.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>Javascript/Node.js</category>
      <category>node.js</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/212</guid>
      <comments>https://phsun102.tistory.com/212#entry212comment</comments>
      <pubDate>Wed, 6 Aug 2025 19:04:48 +0900</pubDate>
    </item>
    <item>
      <title>06/12 - 박달 과학화 기본훈련 후기</title>
      <link>https://phsun102.tistory.com/211</link>
      <description>&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;박달과학화예비군훈련장에서 2훈련장에 배정받아 훈련을 진행하였다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;~9:00 - 문진표 작성 후 명찰과 시계를 받고 조원들을 따라 안보교육관으로 이동. 가방을 들고 들어갈 수 없으며 물품보관함에 넣고 가야함. 입소를 한 이후에 위병소 밖에 있는 제 2물품보관함을 이용이 불가능하다.&lt;br /&gt;9:25 - 안보교육관에서 소개교육들은 후 훈련장으로 이동. 1차 소개교육은 8:50에 있는데 더 일찍오면 이에 참여할 수 있다. 안보교육은 오늘 일정에 대해 간단한 설명을 들은 후에 본격적으로 교육을 시작하게 된다. 첫 교육은 임의로 지정되며 그 이후부터는 동선을 고려하여 자율적으로 임하게 된다.&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;훈련 내용&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;훈련 일정은 자율로 진행한 것이기 때문에 내용만 주로 확인하면 된다.&lt;br /&gt;&lt;b&gt;9:50 ~ 10:30 - 개인화기사격.&lt;/b&gt; 총 5발을 쏘는데 탄착군이 형성되면 합격한다. 조원중에서 70%?(확실하지않음)이 합격하면 해당 조는 최종 합격이다. 오조준만 안하면 대부분은 합격이다. 개인화기사격은 제 2안보관과 거리가 가장 먼 곳이기 때문에 이점 유의할 것.&lt;br /&gt;&lt;b&gt;10:30 ~ 10:50 - 영상모의사격.&lt;/b&gt; 개인화기사격장 건물 1층에서 영상모의사격을 진행한다. 센서가 달려있는 옷을 입고 모의사격장에 입장하는데 분대장은 명령하달하고 3번 8번?은 그 명령에 따라 크레모아 격발과 수류탄을 투척하면 된다. 나머지 조원들은 화면에 나온 적군을 맞추기만 하면 된다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;11:20 ~ 11:40 - 핵 및 화생방.&lt;/b&gt; 영상모의사격이 끝나고 바로 교육을 진행하려고 했는데 교육 쉬는 시간에 걸려버려서 10시 50분부터 11시 20분까지 기다렸다 교육을 진행하였다. 이렇게 한 번 시간이 꼬이게되면 오전에 교육을 5개 듣지 못하고 4개만 듣게될 수 있으니 처음 동선을 짤 대 유의할 것. 핵 및 화생방 교육은 먼저 영상 시청을 한 후 방독면 주머니 메는법, 착용법과 방독면 주머니 닫는 법을 배운다. 방독면을 직접 쓰지는 않고 쓰는 모션을 취하고 방독면을 주머니에 넣고 닫는 것 까지 실습을 진행한다. 실습을 그렇게 어려운 편이 아니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;11:40 ~ 12:10 - 시가지훈련.&lt;/b&gt; 개인적으로 기대가 됬던 훈련이다. 먼저 영상 시청을 간단하게 한 후 청군 황군으로 나눠서 쌍방교전을 진행한다. 근데 같은 조원들 전부가 대충하려고 해서 많이 실망스러웠다. 전략을 짜는 분대장을 만나면 그렇게 행동하면되고 아니면 그냥 각자 개인플레이를 하면 된다. 마일즈 장비가 이상한지 상대방을 맞췄는데도 상대방이 계속 살아있는 신기한 경험을 할 수 있다. 어쨌든 그냥 서로 교전하다가 시간지나면 점수체크하고 다음 교장으로 이동하면 된다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;12:10 ~ 14:00 - 점심 시간.&lt;/b&gt; 점심 배식은 12시 30분부터인데 남은 교육이 야지전술과 제2 안보교육이라 식당 앞에서 도시락을 기다림. 좌측이 A, C형 도시락이고 우측이 B형 도시락이다. 식당 입장을 기다릴 때 A, C형이면 그늘도 없는 곳에서 줄 서있어야 한다. B형쪽은 그늘진 곳이 있어서 어느정도 시원하게 기다릴 수 있다(본인은 A형이였다. 한식이 좋아서) 점심을 빠르게 먹으면 PX를 이용할 수 있는데 꼭 점심 시간이 아니라 3시 30분까지 PX 입장이 가능함으로 점심 시간에 무리해서 기다릴 필요는 없다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;1600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dGwoEr/btsOyr3iclp/vD6J1akKIkTAfwjf65d8tk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dGwoEr/btsOyr3iclp/vD6J1akKIkTAfwjf65d8tk/img.png&quot; data-alt=&quot;한식 맛있습니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dGwoEr/btsOyr3iclp/vD6J1akKIkTAfwjf65d8tk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdGwoEr%2FbtsOyr3iclp%2FvD6J1akKIkTAfwjf65d8tk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;739&quot; height=&quot;1600&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;1600&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;한식 맛있습니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;**중요**&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;* 점심 시간에 PX 줄 기다리다가 2시 정각에 시작하는 제2안보교육을 못 듣게 될 수도 있다. 혼자 듣는 거라면 상관없는데 본인이 빠지면 조원 전체가 해당 교육에 참여할 수 없기 때문에 시간은 꼭 엄수할 것. 실제로 본인 조원 중에서 한 명이 20초전에 안보관으로 등장해서 심장이 쫄깃했다. 50분에 모이기로 했는데 시간이 지남에도 안 나타나고 57분이 넘어서자 욕이 나올 지경이였다... 앞 자리 조와 뒷 자리 조에도 한 명이 안 나타났는데 해당 조들은 실제로 교육장 밖으로 퇴장당했다. + 해당 조원들이 욕하는 건 덤.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;14:00 ~ 14:30 - 제2 안보교육.&lt;/b&gt; 영상 시청을 두 개 하고 문제를 푼다. 첫 번째 영상은 예비군의 중요함을 알려주는 영상이고 제 두 번째 영상은 연예인들이 나와서 예비군에 대한 이런저런 얘기를 하는 영상이다. 영상 시청이 끝나고 간단한 퀴즈를 10문제 풀면 교육 이수가 완료되는데 미이수 훈련이 남은 조는 해당 교육장으로 이동하고 모두 이수한 조는 안보관에서 휴식을 취하거나 이 시간에 PX를 이용할 수 있다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;14:30 이후에 이용하는 PX는 줄이 생각보다 널널하니 웬만하면 PX이용은 이 시간대를 이용하자.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;14:30 ~ 14:50 - 야지전술훈련.&lt;/b&gt; 이름은 야지전술훈련인데 구급법교육으로 대체되었으며 점심 식사를 했던 식당에서 진행한다. 지혈대 사용법에 대한 영상을 시청한 후에 식당 뒷편에서 실습을 진행한다. 실습 내용은 지혈대를 사용하는 방법인데 영상을 잘 시청했다면 쉽게 통과가 가능하다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;14:50 ~ 15:40 - 자유시간.&lt;/b&gt; 처음에 예측하고 희망했던 바는 제2 안보교육이 끝나고 바로 휴식을 취하는 것인데 오전에 4개 교육밖에 하지 못해 자유시간이 좀 줄어들었다. 분대장을 잘 만나야 한다는 것을 이때 깨달았다(그냥 귀찮다고 대충하는 분대장을 만나면 오전에 4개를 하거나 심지어 3개만 이수하게 될 수도 있음. - 본인 조 분대장은 시가지는 오후에 하자고 하긴했었음. 근데 조원들의 반대?로 오전에 진행함) 자유시간 때 PX를 이용할 수 있는데 14:30부터 줄 서있던 사람도 있기 때문에 어느정도 줄은 서야한다. 근데 점심 시간과 비교하면 줄은 2/3가량 짧다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;15:40 ~ 16:00 - 퇴소.&lt;/b&gt; 퇴소는 조 순서대로 퇴소를 진행한다. 즉, 1조부터 먼저 나가고 2조가 나가는 식이다. 가장 빨리 나가면 버스에서 가장 좋은 자리에 앉을 수 있는 장점이 있다. 물론 집을 빨리 간다는 장점또한 존재하기에 조기입소를 강력 추천한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;1600&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/v3y9y/btsOzPIDh9Y/FLPE58OuG32EP5O6fQ2jX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/v3y9y/btsOzPIDh9Y/FLPE58OuG32EP5O6fQ2jX0/img.png&quot; data-alt=&quot;구로, 고척은 2훈련장&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/v3y9y/btsOzPIDh9Y/FLPE58OuG32EP5O6fQ2jX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fv3y9y%2FbtsOzPIDh9Y%2FFLPE58OuG32EP5O6fQ2jX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;739&quot; height=&quot;1600&quot; data-origin-width=&quot;739&quot; data-origin-height=&quot;1600&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;구로, 고척은 2훈련장&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JtoAp/btsOzcxQh87/9XPgiKWkK5vOO9K80Kpc50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JtoAp/btsOzcxQh87/9XPgiKWkK5vOO9K80Kpc50/img.png&quot; data-alt=&quot;퇴소 시간에 맞춰 기다리고 있다!!!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JtoAp/btsOzcxQh87/9XPgiKWkK5vOO9K80Kpc50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJtoAp%2FbtsOzcxQh87%2F9XPgiKWkK5vOO9K80Kpc50%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;739&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;퇴소 시간에 맞춰 기다리고 있다!!!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;훈련 팁&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입소는 빠르게 하는게 좋다. 입소를 먼저 한 순서대로 제1 안보교육을 듣고 조 순서에 따라 다른 강의장으로 이동한다. 오전에 빠르게 교육을 받으면 오후에는 쉴 수 있다. (8시 30분에 도착했을 때 16조에 배정받음)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://yeyak.seoul.go.kr/web/main.do&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://yeyak.seoul.go.kr/web/main.do&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1749737894864&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;서울특별시 공공서비스예약&quot; data-og-description=&quot;한번에 쉽게 간편하게 서울특별시 공공서비스예약&quot; data-og-host=&quot;yeyak.seoul.go.kr&quot; data-og-source-url=&quot;https://yeyak.seoul.go.kr/web/main.do&quot; data-og-url=&quot;https://yeyak.seoul.go.kr/web/main.do&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bmkQEJ/hyY8QbSDed/2Gu2ZXEAKKnAyGMlYtww81/img.jpg?width=423&amp;amp;height=317&amp;amp;face=0_0_423_317,https://scrap.kakaocdn.net/dn/uKEtN/hyY8MN5QDz/AsGpsqwnWefX0QNSTPwuHk/img.jpg?width=423&amp;amp;height=317&amp;amp;face=0_0_423_317,https://scrap.kakaocdn.net/dn/bmAXMJ/hyY8TGsL4v/YcAY0kHDTcT6XXQgouoVK0/img.jpg?width=423&amp;amp;height=299&amp;amp;face=0_0_423_299&quot;&gt;&lt;a href=&quot;https://yeyak.seoul.go.kr/web/main.do&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://yeyak.seoul.go.kr/web/main.do&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bmkQEJ/hyY8QbSDed/2Gu2ZXEAKKnAyGMlYtww81/img.jpg?width=423&amp;amp;height=317&amp;amp;face=0_0_423_317,https://scrap.kakaocdn.net/dn/uKEtN/hyY8MN5QDz/AsGpsqwnWefX0QNSTPwuHk/img.jpg?width=423&amp;amp;height=317&amp;amp;face=0_0_423_317,https://scrap.kakaocdn.net/dn/bmAXMJ/hyY8TGsL4v/YcAY0kHDTcT6XXQgouoVK0/img.jpg?width=423&amp;amp;height=299&amp;amp;face=0_0_423_299');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;서울특별시 공공서비스예약&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;한번에 쉽게 간편하게 서울특별시 공공서비스예약&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;yeyak.seoul.go.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공공서비스예약을 통해서 해당 날짜에 운용되는 셔틀버스가 있으면 신청할 수 있는데 집 주변 및 훈련장 근처로 바로 이동이 가능한 장점이 있다. 훈련장에서 퇴소하면 안양 1번 버스가 4대정도 기다리고 있는데 이에 탑승 못 하면 다음 버스를 기다려야 하고 타더라도 서서 가야할 가능성이 높다. 하지만 셔틀버스를 타면 편하게 앉아서 집 주변까지 갈 수 있으니 별 일이 없다면 셔틀버스를 이용하자.&lt;/li&gt;
&lt;li&gt;14:00 제2 안보교육 시간은 꼭 지키자. 이 시간을 못 지켜서 다른 조원들이 교육을 못 받아 퇴소 시간이 늦어지는 일이 생기지 않도록 하자.&lt;/li&gt;
&lt;li&gt;박달 기준으로 구로, 고척은 2훈련장이다. 신도림 등 다른 지역은 1훈련장이다. 확실하지는 않지만 보통 이렇게 배정되는 것 같다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;PX이용 팁&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;점심 시간말고 제 2안보교육이 끝나는 시간을 이용하자. 제 2안보교육이 끝나는 14:30은 PX에 줄이 없는 상태기 때문에 교육이 끝나고 빠르게 뛰어가면 빠르게 입장이 가능하다.&lt;/li&gt;
&lt;li&gt;PX입구에서 한쪽면은 화장품 진열대가 있고 반대편은 로카티나 아이스크림을 보관 냉장고가 있다. PX중앙이랑 다른 용품 산 후에 마지막으로 계산줄 기다리면서 화장품 사는 것도 나쁘지 않은 선택이다.(계산 기다리는 시간을 줄일 수 있음) + 마스크팩이 엄청 인기가 많다. 마스크팩 사려는 사람은 유의할 것&lt;/li&gt;
&lt;li&gt;15:30이 지나면 더이상 PX에 입장할 수 없다. 이미 바구니를 받아서 물건을 고르고 있는 상태면 괜찮은데 그게 아니면 입장을 할 수 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이외에 훈련에 대해 궁금한 점을 댓글로 남기면 바로 답변해 드리겠습니다.&lt;/p&gt;</description>
      <category>일상</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/211</guid>
      <comments>https://phsun102.tistory.com/211#entry211comment</comments>
      <pubDate>Thu, 12 Jun 2025 23:45:19 +0900</pubDate>
    </item>
    <item>
      <title>Javascript - WebRTC 연결 예제</title>
      <link>https://phsun102.tistory.com/210</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/FlashBack102/webrtc-boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1749359629303&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - FlashBack102/webrtc-boilerplate&quot; data-og-description=&quot;Contribute to FlashBack102/webrtc-boilerplate development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot; data-og-url=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bh6YD2/hyY37yZ53S/EOeKu4HOZeinKr9Y24kyHk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bTqyy1/hyY5huuaY5/oMqezowkE7dL5KJFmE0NHK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bh6YD2/hyY37yZ53S/EOeKu4HOZeinKr9Y24kyHk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bTqyy1/hyY5huuaY5/oMqezowkE7dL5KJFmE0NHK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - FlashBack102/webrtc-boilerplate&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to FlashBack102/webrtc-boilerplate development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Javascript</category>
      <category>javascript</category>
      <category>node.js</category>
      <category>React</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/210</guid>
      <comments>https://phsun102.tistory.com/210#entry210comment</comments>
      <pubDate>Sun, 8 Jun 2025 14:14:18 +0900</pubDate>
    </item>
    <item>
      <title>Javascript - WebRTC로 실시간 미디어 스트리밍 구축</title>
      <link>https://phsun102.tistory.com/209</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;256&quot; data-origin-height=&quot;256&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lkRrQ/btsOslBAx4W/p7TMoPRwX30hQ1Z4ZE7bA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lkRrQ/btsOslBAx4W/p7TMoPRwX30hQ1Z4ZE7bA1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lkRrQ/btsOslBAx4W/p7TMoPRwX30hQ1Z4ZE7bA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlkRrQ%2FbtsOslBAx4W%2Fp7TMoPRwX30hQ1Z4ZE7bA1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;256&quot; height=&quot;256&quot; data-origin-width=&quot;256&quot; data-origin-height=&quot;256&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. WebRTC의 기본 구성 요소&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;WebRTC&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 브라우저 간에 실시간 통신을 할 수 있게 해주는 오픈 소스 기술이다. 별도의 플러그인 없이 브라우저만으로 미디어 데이터 스트리밍을 주고받을 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Stun Server&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 자신의 공인 IP주소, 포트와 NAT종류를 파악할 수 있도록 해주는 서버로 주로 공유기나 NAT 환경에 이는 사용자가 외부에서 볼 수 있는 정보를 확인하게 해주는 서버이다. NAT 뒤에 있는 기기는 보통 사설 IP를 사용하여 외부 피어가 직접 연결을 하기 힘들다. STUN은 NAT으로 인해 발생하는 연결 문제를 우회하는데 중요한 역할을 한다. 즉 Stun 서버를 통해 자신의 공인 IP와 포트 번호를 파악한 후 그 정보를 다른 피어에게 전달해주는 역할을 하는게 Stun서버이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Turn Server&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stun서버로 P2P연결이 불가능한 경우가 있다. 네트워크 설정에서 특정 규칙이 적용되어 네트워크 통신이 제한된 NAT이나 방화벽 제한이 있을 경우가 이에 속한다. Stun서버의 단점을 보완하기 위해 사용하는게 바로 Turn서버이다. Turn서버는 직접 연결이 불가능한 장치들 사이에서 중계자 역할을 하며 양방향으로 전달되는 트래픽을 우회하여 전달한다. 즉, Stun서버는 직접 연결을 하게 해주지만 직접 연결이 어려울 경우 Turn을 통해 데이터를 중간에서 전달해준다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;ICE&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ICE는 Stun과 Turn처럼 서버를 나타내는게 아니라 연결 방법을 의미한다. Stun과 Turn 등 다양한 연결 경로(candidate)중에서 가장 좋은 연결을 선택하는 방식으로 최적의 연결을 도와주는 도구이다. 최적의 연결이라는 것은 두 브라우저가 서로 통신하기 위해 사용할 수 있는 가장 빠르고 안정적인 네트워크 경로를 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ICE Candidate는 브라우저가 연결을 시도할 수 있는 네트워크 주소들의 후보들을 의미한다. 이 후보들을 바탕으로 연결이 가능한지 확인한 후 가장 적합한 경로를 찾아 통신을 시작하게 한다. 후보 타입에는 Local Address, Server Reflexive Address, Relayed Address가 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Local Address&lt;/b&gt;: 자신의 로컬 IP를 의미한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Server Reflexive Address&lt;/b&gt;: STUN서버를 통해 확인한 공인 IP를 의미한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Relayed Address&lt;/b&gt;: TURN서버를 경유한 중계용 IP를 의미한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;NAT&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공유기, 방화벽, 라우터 같은 네트워크 장비가 사용하는 기능으로 내부 사설 IP와 외부 공인 IP를 연결해주는 역할을 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;new RTCPeerConnection()&lt;/b&gt;: 피어 간의 연결을 설정하기 위한 객체를 생성하는데 사용하는 함수이다. 여기서 각 피어는 주로 로컬 피터와 원격 피어 간의 RTC연결을 의미한다. 또한 RTCPeerConnection()은 영상과 음성 미디어 데이터를 주고받을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const peerConnection = new RTCPeerConnection({
    {urls: [
        &quot;stun:stun1.l.google.com:19302&quot;,
        &quot;stun:stun2.l.google.com:19305&quot;
    ]},
    {urls:[&quot;turn server url&quot;], username: &quot;fruit&quot;, credential: &quot;mango&quot;} 
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SDP&lt;/b&gt;: 피어 간에 미디어 연결 설정에 필요한 정보인 코덱, IP, 포트, 암호화 등을 정의하는 문자열이다. WebRTC에서 서로 연결하려면 이 SDP 정보를 주고받아야 한다. offer, answer를 통해 각 peer가 sdp를 주고받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebRTC API에서 제공하는 createOffer()와 createAnswer()로 SDP를 생성하고 상대방에게 전달한다. 받는 상대방은 setRemoteDescription()으로 정보를 받는다. 피어간 정보를 교환할 때 브라우저 간 직접적으로 주고받을 수 없으며 WebSocket, socket.io와 같은 Signaling Server를 사용하여 별도의 경로를 통해 정보를 주고받아야 한다. 즉, WebSocket과 socket.io는 단순히 SDP 문자열을 전달하기 위한 용도로만 사용된다. 이 방법 외에 다른 방법을 사용하여 SDP 문자열만 전달하면 두 피어간의 RTC 연결이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. WebRTC의 연결 과정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PeerA&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;PeerA가 P2P연결을 실행하기 위해 new RTCPeerConnection()로 객체를 생성한다.&lt;/li&gt;
&lt;li&gt;PeerA가 createOffer()로 sdp를 생성한다.&lt;/li&gt;
&lt;li&gt;setLocalDescription()으로 생성된 sdp를 자신의 peerConnection에 추가한다.&lt;/li&gt;
&lt;li&gt;sdp데이터를 담아 시그널링 서버를 통해 PeerB에 데이터를 전달한다. 이때 전달되는 sdp type은 offer이다.&lt;/li&gt;
&lt;li&gt;시그널링 서버에서 PeerA에서 온 데이터를 PeerB로 전달한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PeerB&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;PeerB는 new RTCPeerConnection()으로 객체를 생성 한 후, 전달받은 sdp데이터를 setRemoteDescription()로 자신의 peerConnection에 추가한다.&lt;/li&gt;
&lt;li&gt;PeerB가 createAnswer()로 sdp를 생성한다.&lt;/li&gt;
&lt;li&gt;setLocalDescription()으로 생성된 sdp를 자신의 peerConnection에 추가한다.&lt;/li&gt;
&lt;li&gt;sdp데이터를 담아 시그널링 서버를 통해 PeerA에 데이터를 전달한다. 이때 전달되는 sdp type은 answer이다.&lt;/li&gt;
&lt;li&gt;시그널링 서버에서 PeerB에서 온 데이터를 PeerA로 전달한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PeerA&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;PeerB로부터 전달받은 sdp데이터를 setRemoteDescription()로 자신의 peerConnection에 추가한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebRTC는 위의 과정을 거쳐 연결이 이뤄진다. 복잡하긴 하지만 각각의 peer가 데이터를 주고받아 연결이 이뤄지게 된다. 연결을 하면서 시그널링 서버라는 것이 등장한다. 시그널링 서버는 각 Peer가 연결되기 전에 필요한 정보를 주고 받을 수 있게 해야 하는데 이를 가능하게 해주는 중계 서버를 의미한다. 시그널링 서버를 통해 sdp, ICE Candidate와 상대 PeerId 등을 교환한다. 주로 시그널링 서버로 소켓 서버를 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. WebRTC Connection&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;peerA가 peerB와 통신하는 1:1 통신이라는 것을 가정하고 코드를 확인해보자. peerA는 먼저 peerB에 offer를 보내고 peerB는 peerA에 answer를 보내는 역할이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-1. socket connection&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Client
import { io } from &quot;socket.io-client&quot;

const peerListRef = useRef({}) // socket connection list
const dataChannelRef = useRef({}) // data channel list
const mediaRef = useRef({}) // media stream
const [userSocket, setUserSocket] = useState(false)
const [peerList, setPeerList] = useState({})
const [messageText, setMessageText] = useState(&quot;&quot;)

useEffect(() =&amp;gt; {
		const myPeerId = 'client-' + crypto.randomUUID()
		console.log('My PeerId: ', myPeerId)
		let socket = io('&amp;lt;http://localhost:3000&amp;gt;', {
			query: {
				peerId: myPeerId
			},
			transports: ['websocket']
		}) // Connect socket.io
		console.log('Connect socket')
		setUserSocket(socket)

		return () =&amp;gt; {
			  console.log('Socket disconnected!!')
        socket.disconnect()
        socket.removeAllListeners()
    }
	}, [])
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useEffect(() &amp;rArr; {&amp;hellip;}, [] ) 부분에 소켓 연결 부분을 넣어 페이지에 접속하는 클라이언트들은 소켓 서버에 연결되게 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-2. connection server code&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// Server
const peerList = {}
io.on('connection', (socket) =&amp;gt; {
    console.log('Socket Connected: ', socket.id)

    const peerId = socket.handshake.query.peerId // connected client id
    console.log('My peerId: ', peerId)
    
    const socketKeyList = Array.from(io.sockets.sockets.keys())
    socketKeyList.map((key) =&amp;gt; {
        const connectedSocket = io.sockets.sockets.get(key)
        if(connectedSocket) {
            const queryId = connectedSocket.handshake.query.peerId
            if(typeof peerList[queryId] === 'undefined') {
                peerList[queryId] = connectedSocket
            }
        }
    })
    console.log('socket list: ', socketKeyList)

    socket.emit('peer-connected', {
        peerList: Object.keys(peerList),
        peerId: peerId
    })
})
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;peerId&lt;/b&gt;: 클라이언트의 쿼리에 담겨 넘어온 peerId는 서버에 연결된 소켓을 구분하는 용도로 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;socketKeyList&lt;/b&gt;: 현재 소켓에 연결된 소켓들을 배열 형식으로 저장한다. socketKeyList 변수에 자신을 제외한 연결된 peer들을 저장하여 관리하기 위해 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;peer-connected&lt;/b&gt;: 연결이 되면 클라이언트에 연결된 peer목록과 자신의 peerId를 전달한다. 이 이벤트를 기점으로 RTC연결이 시작된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-3. new RTCPeerConnection&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// Client - new RTCPeerConnection
useEffect(() =&amp;gt; {
	if(userSocket !== false) {
		const peerConnectionConfig = { 
			'iceServers': [
				{ urls: [ &quot;stun:stun1.l.google.com:19302&quot;, &quot;stun:stun2.l.google.com:19305&quot; ] },
			]
		}; // RTC peer conneciton info

		userSocket.on('peer-connected', (data) =&amp;gt; { // other peer connected
			const peerId = data.peerId // connected peer id
			const connectedPeerList = data.peerList // connected peer list

			console.log('Connected peerId: ', peerId)
			console.log('peerList: ', connectedPeerList)

			connectedPeerList.forEach((peer) =&amp;gt; {
				if(typeof peerListRef.current[peer] === 'undefined') {
					if(peer !== peerId) { // Except my peer
						console.log('Start peer connection!')
						const peerConnection = new RTCPeerConnection(peerConnectionConfig)
						peerListRef.current[peer] = peerConnection
						setPeerList((prev) =&amp;gt; ({
							...prev,
							[peer]: peerConnection
						}))
					} else {	
						console.log('current peer connection except')
					}
				}
			})

			console.log('peerListRef:', peerListRef.current)
		})
	}
}, [userSocket])
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 소켓에 접속하면 서버에서 emit을 통해 peer-connected 이벤트가 클라이언트에 전달된다. 클라이언트는 연결된 peer목록과 자신의 peerId를 받아 RTC연결을 진행한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;peer !== peerId&lt;/b&gt;: 자신의 peerId는 RTC연결을 진행하지 않기 위해 추가한다. 다른 peer와 연결을 진행해야 하기 때문에 예외처리를 추가한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;new RTCPeerConnection()&lt;/b&gt;: 선언한 peerConnectionConfig데이터를 바탕으로 객체를 생성한다. peerConnectionConfig에 기술된 내용을 stun, turn중에서 연결을 시도한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-4. createDataChannel&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Client - createDataChannel
const dataChannel = peerConnection.createDataChannel('dataChannel') // Create data channel
dataChannelRef.current[peer] = dataChannel
console.log('Create DataChannel: ', dataChannel)

dataChannel.onopen = () =&amp;gt; { // Data channel open success
	console.log('dataChannel open!')
	dataChannel.send('hello')
}
dataChannel.onmessage =(event) =&amp;gt; { // Received data channel message
	console.log('dataChannel onmessage: ', event)
}
dataChannel.onerror = (error) =&amp;gt; {
	console.error('dataChannel error: ', error)
}
dataChannel.onclose = () =&amp;gt; {
	console.log('dataChannel close')
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebRTC는 기본적으로 영상, 음성을 스트림을 전송하는데 사용하지만 문자열이나 일반 데이터도 P2P로 주고받을 수 있다. 이때 사용하는 기능이 바로 createDataChannel이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;createDataChannel()&lt;/b&gt;: 데이터 채널을 생성하며 지정한 채널 명에 따라 데이터가 전달되는 채널이 달라진다. 데이터 채널이 A와 B가 있다면 A채널에 전달되는 데이터는 B채널에서 확인할 수 없다. 데이터 채널을 통해 전달되는 데이터를 확인하려면 서로 같은 데이터 채널에 연결되어야 하낟.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onopen()&lt;/b&gt;: 데이터 채널이 연결되어 메시지를 전달이 가능할 때 실행된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onmessage&lt;/b&gt;: 데이터 채널에 메시지가 전달되면 실행된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onerror&lt;/b&gt;: 채널에서 오류가 발생하면 실행된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onclose&lt;/b&gt;: 채널이 닫히면 실행된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-5. onicecandidate&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;mel&quot;&gt;&lt;code&gt;// Client - opicecandidate
peerConnection.onicecandidate = event =&amp;gt; { // Get IceCandidate
	console.log('Start onicecandidate')
	if(event.candidate) {
		console.log('Candidate: ', event.candidate)
		userSocket.emit('ice-candidate', {
			toId: peer,
			fromId: peerId,
			candidate: event.candidate
		})
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;onicecandidate는 WebRTC연결에 필수적인 단계이며 이를 통해 상대방에게 내 ICE Candidate를 전달한다. setLocalDescription()이 호출되면 ICE Candidate를 자동으로 수집을 시작한다. ICE Candidate가 발견되면 ICE Candidate를 시그널링 서버를 통해 상대방에게 전달한다. 상대방은 addIceCandidate()로 추가하여 연결 테스트를 진행한다. 전달된 ICE Candidate중에서 하나로 ICE 연결이 성공하면 P2P연결이 최종 성공하게 된다. 이 코드가 없으면 ip와 port를 교환하지 못해 P2P연결이 성립되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-6. signaling offer&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Client - signaling offer
const startSiganlingOffer = async () =&amp;gt; {
	try {
		const offer = await peerConnection.createOffer()
		await peerConnection.setLocalDescription(offer)
		console.log('Signal offer')
		userSocket.emit('signal', {
			toId: peer, // Receiver peer id
			fromId: peerId, // Sender peer id (me)
			sdp: offer // SDP offer data
		})
	} catch (error) {
		console.error('startSignalingOffer error: ', error)
	} finally {}
}
startSiganlingOffer()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sdp데이터를 주고받는 부분의 시작점이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;createOffer()&lt;/b&gt;: WebRTC 연결을 시작하기 위해 필요한 sdp를 생성하는 함수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setLocalDescription()&lt;/b&gt;: 생성된 내 sdp데이터를 peerConnection에 등록하는 함수&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 시그널링 서버를 통해 상대방에게 sdp데이터를 전달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-7. signaling server code&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// Server - signaling
socket.on('signal', (data) =&amp;gt; {
    const toId = data.toId // Receiver
    const fromId = data.fromId // Sender
    const sdp = data.sdp // SDP data: offer, answer

    console.log('signal type: ', sdp.type)

    peerList[toId].emit('signal', data)
})

socket.on('ice-candidate', (data) =&amp;gt; {
    const toId = data.toId
    const fromId = data.fromId
    const candidate = data.candidate

    peerList[toId].emit('ice-candidate', data)
    console.log('ice candidate on!')
})
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;signal&lt;/b&gt;: signal이벤트는 클라이언트에서 넘어온 sdp 데이터를 상대방에게 전달한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ice-candidate&lt;/b&gt;: ice-candidate이벤트는 클라이언트에서 넘어온 ICE Candidate를 상대방에게 전달한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-8. client ice-candidate event&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// Client
userSocket.on('ice-candidate', (data) =&amp;gt; {
	const fromId = data.fromId
	const candidateData = data.candidate
	console.log('candidate data: ', data)

	if(typeof peerListRef.current[fromId] != 'undefined') {
		peerListRef.current[fromId].addIceCandidate(candidateData)
		console.log('success icenCandidate')
	}
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;socket.emit을 통해 peerA에서 peerB에게 전달된 ice-candidate이벤트다. 전달된 ICE Candidate를 바탕으로 연결을 시도한다. 연결이 성공하면 WebRTC 연결을 할 port와 ip를 찾은 단계가 되고 sdp 시그널링도 마무리해야 WebRTC 연결이 마무리된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-9. offer and answer&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;userSocket.on('signal', (data) =&amp;gt; {
	console.log('Signal Event On', data)

	let toId = data.toId // Receiver (me)
	let fromId = data.fromId // Sender
	let sdp = data.sdp // SDP offer data

	if(sdp.type === 'offer') {
		console.log('SDP type: offer')

		const peerConnection = new RTCPeerConnection(peerConnectionConfig)
		peerListRef.current[fromId] = peerConnection
		setPeerList((prev) =&amp;gt; ({
			...prev,
			[fromId]: peerConnection
		}))

		peerConnection.ondatachannel = (event) =&amp;gt; { // Get data channel
			const channel = event.channel
			dataChannelRef.current[fromId] = channel
			console.log('ondatachannel', fromId)
			console.log('channel: ', channel)

			channel.onopen = () =&amp;gt; { // Data channel open success
				console.log('data channel open!')
			}
			channel.onmessage = (event) =&amp;gt; { // Receive message data
				console.log('Receive data channel onmessage: ', event)
			}
		}
		
		navigator.mediaDevices.getUserMedia({
			video: true,
			audio: true
		})
		.then((stream) =&amp;gt; {
			console.log('answer getUserMedia: ', stream)
			stream.getTracks().forEach((track) =&amp;gt; {
				peerConnection.addTrack(track, stream)
			})
		})
		.catch((error) =&amp;gt; {
			console.error('getUserMedia error: ', error)
		})
		.finally(() =&amp;gt; {})

		peerConnection.setRemoteDescription(new RTCSessionDescription(sdp))
		.then(() =&amp;gt; {
			peerConnection.createAnswer()
			.then((answer) =&amp;gt; {
				peerConnection.setLocalDescription(answer)
				.then(() =&amp;gt; {
					console.log('Signal answer')
					
					userSocket.emit('signal', {
						toId: data.fromId, // Sender peer id (me)
						fromId: data.toId, // Receiver peer id
						sdp: answer
					}) // Reverse toId, fromId
				})
				.catch((error) =&amp;gt; {
					console.error('setLocaleDescription error: ', error)
				})
				.finally(() =&amp;gt; {}) // End setLocaleDescription
			})
			.catch((error) =&amp;gt; {
				console.error('createAnswer error: ', error)
			})
			.finally(() =&amp;gt; {}) // End createAnswer
		})
		.catch((error) =&amp;gt; {
			console.error('setRemoteDescription error: ', error)
		})
		.finally(() =&amp;gt; {}) // End setRemoteDescription

		peerConnection.oniceconnectionstatechange = () =&amp;gt; {
			console.log(peerConnection.iceConnectionState, 'connected state')
		}

		peerConnection.ontrack = (event) =&amp;gt; {
			console.log('Offer ontrack: ', event)
			const [remoteStream] = event.streams
			const videoElement = mediaRef.current[fromId]

			if(videoElement) {
				videoElement.srcObject = remoteStream
			} else {
				console.warn(`video element not found for peer ${fromId}`)
			}
		}

	} else if(sdp.type === 'answer') {
		console.log('SDP type: answer')

		peerListRef.current[fromId].setRemoteDescription(new RTCSessionDescription(sdp))
		.then(() =&amp;gt; {
			console.log('RTC Connection Success')

			peerListRef.current[fromId].ontrack = (event) =&amp;gt; {
				console.log('Answer ontrack: ', event)
				const [remoteStream] = event.streams
				const videoElement = mediaRef.current[fromId]

				if(videoElement) {
					videoElement.srcObject = remoteStream
				} else {
					console.warn(`video element not found for peer ${fromId}`)
				}
			}
		})
		.catch((error) =&amp;gt; {
			console.error('setRemoteDescription error: ', error)
		})
		.finally(() =&amp;gt; {})
		
	}
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;offer는 peerB에서 실행되고 answer는 peerA에서 실행된다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;setRemoteDescription&lt;/b&gt;: peerA가 보낸 sdp offer는 peerB에 등록한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;createAnswer&lt;/b&gt;: 등록된 offer에 대한 answer를 생성한다. 이 answer는 시그널링 서버를 통해 peerA에 전달된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;setLocalDescription&lt;/b&gt;: peerB가 생성한 answer를 peerB의 peerConnection에 등록한다. 이를 통해 ICE Candidate를 수집하여 네트워크 연결을 진행할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 peerA가 데이터 채널을 생성했기 대문에 해당 채널에 접근하려면 ondatachannel이 필요하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ondatachannel&lt;/b&gt;: 상대방이 만든 데이터 채널을 수신할 때 호출되는 이벤트 핸들러다. 이를 통해 상대가 생성한 데이터 채널을 받을 수 있으며 이를 통해 메시지를 수신하거나 전달할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;offer를 받은 후 signal이벤트를 호출하여 시그널링 서버를 거쳐 다시 peerA에게 sdp answer를 보내게 된다. answer부분을 실행하는 peerA가 setRemoteDescription로 peerB가 보낸 sdp answer를 peerA에 정상적으로 등록하게 되면 두 peer는 WebRTC연결에 성공하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미디어 스트림도 주고받으려면 추가적으로 navigator.mediaDevices.getUserMedia, addtrack과 ontrack을 통해 미디어 데이터도 주고받아야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3-10. media stream&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;// Client - addTrack offer
navigator.mediaDevices.getUserMedia({
	video: true,
	audio: true
})
.then((stream) =&amp;gt; {
	console.log('answer getUserMedia: ', stream)
	stream.getTracks().forEach((track) =&amp;gt; {
		peerConnection.addTrack(track, stream)
	})
})
.catch((error) =&amp;gt; {
	console.error('getUserMedia error: ', error)
})
.finally(() =&amp;gt; {})
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;addTrack&lt;/b&gt;: navigator.mediaDevices.getUserMedia를 통해 얻은 오디오, 비디오 트랙을 peerConnection에 추가하여 상대방에게 전송할 수 있게 하는 함수다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Client - ontrack answer
peerListRef.current[fromId].ontrack = (event) =&amp;gt; {
	console.log('Answer ontrack: ', event)
	const [remoteStream] = event.streams
	const videoElement = mediaRef.current[fromId]

	if(videoElement) {
		videoElement.srcObject = remoteStream
	} else {
		console.warn(`video element not found for peer ${fromId}`)
	}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ontrack&lt;/b&gt;: 상대방이 addTrack()으로 보낸 트랙을 수신하면 ontrack 이벤트가 발생한다. useRef로 선언한 mediaRef에 스트림 데이터를 담아 video태그에서 상대방의 실시간 영상을 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. 전체 코드&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;typescript&quot;&gt;&lt;code&gt;// Server
const http = require('http');
const { Server } = require('socket.io');
const port = 3000
const server = http.createServer();
const io = new Server(server, {
    cors: {
        origin: ['&amp;lt;http://localhost:4000&amp;gt;', '&amp;lt;http://localhost:3001&amp;gt;'],
        methods: ['GET', 'POST']
    }
});
const peerList = {}

io.on('connection', (socket) =&amp;gt; {
    console.log('Socket Connected: ', socket.id)

    const peerId = socket.handshake.query.peerId // connected client id
    console.log('My peerId: ', peerId)
    
    const socketKeyList = Array.from(io.sockets.sockets.keys())
    socketKeyList.map((key) =&amp;gt; {
        const connectedSocket = io.sockets.sockets.get(key)
        if(connectedSocket) {
            const queryId = connectedSocket.handshake.query.peerId
            if(typeof peerList[queryId] === 'undefined') {
                peerList[queryId] = connectedSocket
            }
        }
    })
    console.log('socket list: ', socketKeyList)

    socket.emit('peer-connected', {
        peerList: Object.keys(peerList),
        peerId: peerId
    })
    
    socket.on('signal', (data) =&amp;gt; {
        const toId = data.toId // Receiver
        const fromId = data.fromId // Sender
        const sdp = data.sdp // SDP data: offer, answer

        console.log('signal type: ', sdp.type)

        peerList[toId].emit('signal', data)
    })

    socket.on('ice-candidate', (data) =&amp;gt; {
        const toId = data.toId
        const fromId = data.fromId
        const candidate = data.candidate

        peerList[toId].emit('ice-candidate', data)
        console.log('ice candidate on!')
    })

    socket.on('disconnect', (reason) =&amp;gt; {
        console.log('disconnect', reason)
        delete peerList[peerId]
        socket.broadcast.emit('peer-disconnected', peerId)
    })
})

server.listen(port, () =&amp;gt; {
    console.log('Server listening on port ' + port);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// Client
import { useEffect, useRef, useState } from 'react';
import { io } from &quot;socket.io-client&quot;

function App() {

	const peerListRef = useRef({}) // socket connection list
	const dataChannelRef = useRef({}) // data channel list
	const mediaRef = useRef({}) // media stream
	const [userSocket, setUserSocket] = useState(false)
	const [peerList, setPeerList] = useState({})
	const [messageText, setMessageText] = useState(&quot;&quot;)

	useEffect(() =&amp;gt; {
		const myPeerId = 'peer-' + crypto.randomUUID()
		console.log('My PeerId: ', myPeerId)
		let socket = io('&amp;lt;http://localhost:3000&amp;gt;', {
			query: {
				peerId: myPeerId
			},
			transports: ['websocket']
		}) // Connect socket.io
		console.log('Connect socket')
		setUserSocket(socket)

		return () =&amp;gt; {
			console.log('Socket disconnected!!')
			peerListRef.current = {}
			dataChannelRef.current = {}
			mediaRef.current = {}
			setPeerList({})
            socket.disconnect()
            socket.removeAllListeners()
        }
	}, [])

	useEffect(() =&amp;gt; {
		if(userSocket !== false) {
			const peerConnectionConfig = { 
				'iceServers': [
					{ urls: [ &quot;stun:stun1.l.google.com:19302&quot;, &quot;stun:stun2.l.google.com:19305&quot; ] },
				]
			}; // RTC peer conneciton info

			userSocket.on('peer-connected', (data) =&amp;gt; { // other peer connected
				const peerId = data.peerId // connected peer id
				const connectedPeerList = data.peerList // connected peer list

				console.log('Connected peerId: ', peerId)
				console.log('peerList: ', connectedPeerList)

				connectedPeerList.forEach((peer) =&amp;gt; {
					if(typeof peerListRef.current[peer] === 'undefined') {
						if(peer !== peerId) { // Except my peer
							console.log('Start peer connection!')
							const peerConnection = new RTCPeerConnection(peerConnectionConfig)
							peerListRef.current[peer] = peerConnection
							setPeerList((prev) =&amp;gt; ({
								...prev,
								[peer]: peerConnection
							}))

							const dataChannel = peerConnection.createDataChannel('dataChannel') // Create data channel
							dataChannelRef.current[peer] = dataChannel
							console.log('Create DataChannel: ', dataChannel)

							dataChannel.onopen = () =&amp;gt; { // Data channel open success
								console.log('dataChannel open!')
								dataChannel.send('hello')
							}
							dataChannel.onmessage =(event) =&amp;gt; { // Received data channel message
								console.log('dataChannel onmessage: ', event)
							}
							dataChannel.onerror = (error) =&amp;gt; {
								console.error('dataChannel error: ', error)
							}
							dataChannel.onclose = () =&amp;gt; {
								console.log('dataChannel close')
							}

							peerConnection.onicecandidate = event =&amp;gt; { // Get IceCandidate
								console.log('Start onicecandidate')
								if(event.candidate) {
									console.log('Candidate: ', event.candidate)
									userSocket.emit('ice-candidate', {
										toId: peer,
										fromId: peerId,
										candidate: event.candidate
									})
								}
							}

							const startSiganlingOffer = async () =&amp;gt; {
								try {
									const offer = await peerConnection.createOffer()
									await peerConnection.setLocalDescription(offer)
									console.log('Signal offer')
									userSocket.emit('signal', {
										toId: peer, // Receiver peer id
										fromId: peerId, // Sender peer id (me)
										sdp: offer // SDP offer data
									})
								} catch (error) {
									console.error('startSignalingOffer error: ', error)
								} finally {}
							}

							navigator.mediaDevices.getUserMedia({
								video: true,
								audio: true
							})
							.then((stream) =&amp;gt; {
								console.log('offer getUserMedia: ', stream)
								// mediaRef.current.srcObject = stream
								stream.getTracks().forEach((track) =&amp;gt; {
									peerConnection.addTrack(track, stream)
								})

								startSiganlingOffer()
							})
							.catch((error) =&amp;gt; {
								console.error('error: ', error)
								startSiganlingOffer()
							})
							.finally(() =&amp;gt; {})

							peerConnection.oniceconnectionstatechange = () =&amp;gt; {
								console.log(peerConnection.iceConnectionState, 'connected state')
							}

							console.log('user media device: ', mediaRef)

						} else {	
							console.log('current peer connection except')
						}
					}
				})

				console.log('peerListRef:', peerListRef.current)
			})

			/* Get signal data */
			userSocket.on('signal', (data) =&amp;gt; {
				console.log('Signal Event On', data)

				let toId = data.toId // Receiver (me)
				let fromId = data.fromId // Sender
				let sdp = data.sdp // SDP offer data

				if(sdp.type === 'offer') {
					console.log('SDP type: offer')

					const peerConnection = new RTCPeerConnection(peerConnectionConfig)
					peerListRef.current[fromId] = peerConnection
					setPeerList((prev) =&amp;gt; ({
						...prev,
						[fromId]: peerConnection
					}))

					peerConnection.ondatachannel = (event) =&amp;gt; { // Get data channel
						const channel = event.channel
						dataChannelRef.current[fromId] = channel
						console.log('ondatachannel', fromId)
						console.log('channel: ', channel)

						channel.onopen = () =&amp;gt; { // Data channel open success
							console.log('data channel open!')
						}
						channel.onmessage = (event) =&amp;gt; { // Receive message data
							console.log('Receive data channel onmessage: ', event)
						}
					}
					
					navigator.mediaDevices.getUserMedia({
						video: true,
						audio: true
					})
					.then((stream) =&amp;gt; {
						console.log('answer getUserMedia: ', stream)
						stream.getTracks().forEach((track) =&amp;gt; {
							peerConnection.addTrack(track, stream)
						})
					})
					.catch((error) =&amp;gt; {
						console.error('getUserMedia error: ', error)
					})
					.finally(() =&amp;gt; {})

					peerConnection.setRemoteDescription(new RTCSessionDescription(sdp))
					.then(() =&amp;gt; {
						peerConnection.createAnswer()
						.then((answer) =&amp;gt; {
							peerConnection.setLocalDescription(answer)
							.then(() =&amp;gt; {
								console.log('Signal answer')
								
								userSocket.emit('signal', {
									toId: data.fromId, // Sender peer id (me)
									fromId: data.toId, // Receiver peer id
									sdp: answer
								}) // Reverse toId, fromId
							})
							.catch((error) =&amp;gt; {
								console.error('setLocaleDescription error: ', error)
							})
							.finally(() =&amp;gt; {}) // End setLocaleDescription
						})
						.catch((error) =&amp;gt; {
							console.error('createAnswer error: ', error)
						})
						.finally(() =&amp;gt; {}) // End createAnswer
					})
					.catch((error) =&amp;gt; {
						console.error('setRemoteDescription error: ', error)
					})
					.finally(() =&amp;gt; {}) // End setRemoteDescription

					peerConnection.oniceconnectionstatechange = () =&amp;gt; {
						console.log(peerConnection.iceConnectionState, 'connected state')
					}

					peerConnection.ontrack = (event) =&amp;gt; {
						console.log('Offer ontrack: ', event)
						const [remoteStream] = event.streams
						const videoElement = mediaRef.current[fromId]

						if(videoElement) {
							videoElement.srcObject = remoteStream
						} else {
							console.warn(`video element not found for peer ${fromId}`)
						}
					}

				} else if(sdp.type === 'answer') {
					console.log('SDP type: answer')

					peerListRef.current[fromId].setRemoteDescription(new RTCSessionDescription(sdp))
					.then(() =&amp;gt; {
						console.log('RTC Connection Success')

						peerListRef.current[fromId].ontrack = (event) =&amp;gt; {
							console.log('Answer ontrack: ', event)
							const [remoteStream] = event.streams
							const videoElement = mediaRef.current[fromId]

							if(videoElement) {
								videoElement.srcObject = remoteStream
							} else {
								console.warn(`video element not found for peer ${fromId}`)
							}
						}
					})
					.catch((error) =&amp;gt; {
						console.error('setRemoteDescription error: ', error)
					})
					.finally(() =&amp;gt; {})
					
				}
			})

			userSocket.on('ice-candidate', (data) =&amp;gt; {
				const fromId = data.fromId
				const candidateData = data.candidate
				console.log('candidate data: ', data)

				if(typeof peerListRef.current[fromId] != 'undefined') {
					peerListRef.current[fromId].addIceCandidate(candidateData)
					console.log('success icenCandidate')
				}
			})

			userSocket.on('peer-disconnected', (disconnectedClientId) =&amp;gt; { // CleanUp disconnected client info
				console.log('peer-disconnected', disconnectedClientId)
				if(typeof peerListRef.current[disconnectedClientId] != 'undefined') {
					peerListRef.current[disconnectedClientId].close()
				}
				delete peerListRef.current[disconnectedClientId]
				delete dataChannelRef.current[disconnectedClientId]
				setPeerList((prev) =&amp;gt; {
					const updated = { ...prev }
					delete updated[disconnectedClientId]
					return updated
				})
			})

		}
	}, [userSocket])

	const changeMessageText = (e) =&amp;gt; {
		setMessageText(e.target.value)
	}

	const sendMessage = (peer) =&amp;gt; {
		console.log('send success')
		console.log('dataChannelRef: ', dataChannelRef)
		if(typeof dataChannelRef.current[peer] != 'undefined') {
			dataChannelRef.current[peer].send(messageText)
		}
	}

	return (
		&amp;lt;&amp;gt;
			{ Object.keys(peerList).map((peer) =&amp;gt; {
				return (
					&amp;lt;div key={ peer }&amp;gt;
						&amp;lt;video
							ref={el =&amp;gt; {
								if (el) mediaRef.current[peer] = el
							}}
							autoPlay
							playsInline
							style={{ width: '300px', border: '1px solid black' }}
						/&amp;gt;
						&amp;lt;input type='text' onChange={(e) =&amp;gt; changeMessageText(e)} value={ messageText } /&amp;gt;
						&amp;lt;button onClick={() =&amp;gt; sendMessage(peer)}&amp;gt;send to { peer }&amp;lt;/button&amp;gt;
					&amp;lt;/div&amp;gt;
				)
				
			}) }
		&amp;lt;/&amp;gt;
	);
}

export default App;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Client의 렌더링 부분을 보면 video, input태그가 있다. 상대방이 연결되면 해당 태그를 통해 상대방의 실시간 영상을 확인할 수 있고 상대방에게 메시지를 보낼 수 있다. 상대방이 비디오나 오디오에 연결되지 않아도 WebRTC연결을 성공하게 구조를 만들었고 데이터 채널을 통해 메시지 수신, 전달은 가능하다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 사이트&lt;/b&gt;:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://getstream.io/resources/projects/webrtc/basics/rtcpeerconnection/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://getstream.io/resources/projects/webrtc/basics/rtcpeerconnection/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1749351519020&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;RTCPeerConnection WebRTC Tutorial&quot; data-og-description=&quot;Did you know? All Video &amp;amp; Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans -&amp;gt;&quot; data-og-host=&quot;getstream.io&quot; data-og-source-url=&quot;https://getstream.io/resources/projects/webrtc/basics/rtcpeerconnection/&quot; data-og-url=&quot;https://getstream.io/resources/projects/webrtc/basics/rtcpeerconnection/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://getstream.io/resources/projects/webrtc/basics/rtcpeerconnection/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://getstream.io/resources/projects/webrtc/basics/rtcpeerconnection/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;RTCPeerConnection WebRTC Tutorial&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Did you know? All Video &amp;amp; Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans -&amp;gt;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;getstream.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://getstream.io/resources/projects/webrtc/advanced/stun-turn/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://getstream.io/resources/projects/webrtc/advanced/stun-turn/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1749351535937&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;WebRTC Stun vs Turn Servers&quot; data-og-description=&quot;Did you know? All Video &amp;amp; Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans -&amp;gt;&quot; data-og-host=&quot;getstream.io&quot; data-og-source-url=&quot;https://getstream.io/resources/projects/webrtc/advanced/stun-turn/&quot; data-og-url=&quot;https://getstream.io/resources/projects/webrtc/advanced/stun-turn/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cTCnii/hyY44PqZqz/FibUKA7Mls5mDtdDjkpm6K/img.jpg?width=768&amp;amp;height=390&amp;amp;face=0_0_768_390&quot;&gt;&lt;a href=&quot;https://getstream.io/resources/projects/webrtc/advanced/stun-turn/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://getstream.io/resources/projects/webrtc/advanced/stun-turn/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cTCnii/hyY44PqZqz/FibUKA7Mls5mDtdDjkpm6K/img.jpg?width=768&amp;amp;height=390&amp;amp;face=0_0_768_390');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;WebRTC Stun vs Turn Servers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Did you know? All Video &amp;amp; Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans -&amp;gt;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;getstream.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/FlashBack102/webrtc-boilerplate&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1749359759395&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - FlashBack102/webrtc-boilerplate&quot; data-og-description=&quot;Contribute to FlashBack102/webrtc-boilerplate development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot; data-og-url=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bh6YD2/hyY37yZ53S/EOeKu4HOZeinKr9Y24kyHk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bTqyy1/hyY5huuaY5/oMqezowkE7dL5KJFmE0NHK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/FlashBack102/webrtc-boilerplate&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bh6YD2/hyY37yZ53S/EOeKu4HOZeinKr9Y24kyHk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bTqyy1/hyY5huuaY5/oMqezowkE7dL5KJFmE0NHK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - FlashBack102/webrtc-boilerplate&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to FlashBack102/webrtc-boilerplate development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Javascript</category>
      <category>javascript</category>
      <category>node.js</category>
      <category>React</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/209</guid>
      <comments>https://phsun102.tistory.com/209#entry209comment</comments>
      <pubDate>Sun, 8 Jun 2025 14:13:07 +0900</pubDate>
    </item>
    <item>
      <title>Javascript - navigator 객체로 미디어 제어하기</title>
      <link>https://phsun102.tistory.com/208</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;256&quot; data-origin-height=&quot;256&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uJPh4/btsOkQCa2KD/ve60kPRtqusDRRu6uNFBZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uJPh4/btsOkQCa2KD/ve60kPRtqusDRRu6uNFBZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uJPh4/btsOkQCa2KD/ve60kPRtqusDRRu6uNFBZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuJPh4%2FbtsOkQCa2KD%2Fve60kPRtqusDRRu6uNFBZk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;256&quot; height=&quot;256&quot; data-origin-width=&quot;256&quot; data-origin-height=&quot;256&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. enumerateDevices()&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;enumerateDevices()는 연결된 장치 목록을 배열 형식으로 반환한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;navigator.mediaDevices.enumerateDevices().then((devices) =&amp;gt; {
    console.log('devices: ', devices)
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배열 요소에는 deviceId, groupId, kind, label이 포함된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;deviceId&lt;/b&gt;: 장치 하나를 고유하게 식별하는 id다. deviceId를 통해 특정 장치를 선택하여 getUserMedia() 메서드를 사용할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;groupId&lt;/b&gt;: 카메라와 마이크가 함께 내장되어 있는 모니터와 같은 물리적 장치를 그룹으로 묶는 id다. 단순히 같은 그룹에 속해있다는 것을 알려주는 값이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;kind&lt;/b&gt;: 해당 장치가 어떤 장치인지 알려준다. audioinput(마이크), videoinput(카메라), audiooutput(스피커)로 구분된다.&lt;/li&gt;
&lt;li&gt;label: 장치의 이름을 나타낸다. MacBook Pro 스피커 (Built-in), MacBook Pro 마이크 (Built-in)처럼 해당 장치의 이름으로 표시된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1-1. getUserMedia()&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;getUserMedia()는 사용자의 audio나 video 장치에 권한을 요청하여 해당 장치에 접근한 후 미디어 스트림 객체를 반환받아 사용할 때 사용하는 메서드다. 반환된 스트림을 녹화, 분석, 전송 등을 할 때 사용한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;navigator.mediaDevices.getUserMedia({
    video: {
        deviceId: 'e62af3b11b6f3249ae286f271b4e2e5536c771d75bd145b0ae4c540d049508a2',
        width: 640,
        height: 480,
        frameRate: 30,
    },
    audio: {
        deviceId: '539d4ee3bc49ae530097e0f7e02406f4243c01be93b9082e0c986c17a82d9672',
        sampleRate: 44100,
        sampleSize: 16,
    }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1-2. video&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;video 객체 안에 사용할 video장치의 제약 조건을 지정한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;deviceId&lt;/b&gt;: 권한을 요청할 deviceId를 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;width&lt;/b&gt;: width와 height는 영상 해상도를 지정한다. video의 가로 길이를 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;height&lt;/b&gt;: video의 세로 길이를 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;frameRate&lt;/b&gt;: 영상에서 1초에 몇 개의 프레임을 보여줄 지 지정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1-3. audio&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;audio 객체 안에 사용할 audio장치의 제약 조건을 지정한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;deviceId&lt;/b&gt;: 권한을 요청할 deviceId를 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;sampleRate&lt;/b&gt;: 1초에 몇 번 샘플링하는지 비율을 나타낸다. Hz단위로 보통 44100으로 지정한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;sampleSize&lt;/b&gt;: 각 샘플을 몇 비트로 표현할지 지정한다. bit단위로 보통 16으로 지정한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 기본값으로 설정&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;true로 설정하면 기본값으로 지정된 장치를 바탕으로 MediaStream을 생성한다. 특정 장치로 설정하려면 위의 예시처럼 deviceId로 제약 조건을 지정해야 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 사이트:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1748676501373&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;MediaDevices: getUserMedia() method - Web APIs | MDN&quot; data-og-description=&quot;The getUserMedia() method of the MediaDevices interface prompts the user for permission to use a media input which produces a MediaStream with tracks containing the requested types of media.&quot; data-og-host=&quot;developer.mozilla.org&quot; data-og-source-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia&quot; data-og-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bqnHk6/hyY1dFFy9o/xrekjjjFJKDNA4tEwQZCsK/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bqnHk6/hyY1dFFy9o/xrekjjjFJKDNA4tEwQZCsK/img.png?width=1920&amp;amp;height=1080&amp;amp;face=0_0_1920_1080');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;MediaDevices: getUserMedia() method - Web APIs | MDN&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The getUserMedia() method of the MediaDevices interface prompts the user for permission to use a media input which produces a MediaStream with tracks containing the requested types of media.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.mozilla.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Javascript</category>
      <category>javascript</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/208</guid>
      <comments>https://phsun102.tistory.com/208#entry208comment</comments>
      <pubDate>Sat, 31 May 2025 16:28:26 +0900</pubDate>
    </item>
    <item>
      <title>Javascript - btoa()와 atob()로 문자열을 Base64로 인코딩, 디코딩하기</title>
      <link>https://phsun102.tistory.com/207</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;256&quot; data-origin-height=&quot;256&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mMXgz/btsOlZSduEY/kX0ROOXtGJSrbYkSzskaZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mMXgz/btsOlZSduEY/kX0ROOXtGJSrbYkSzskaZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mMXgz/btsOlZSduEY/kX0ROOXtGJSrbYkSzskaZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmMXgz%2FbtsOlZSduEY%2FkX0ROOXtGJSrbYkSzskaZ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;256&quot; height=&quot;256&quot; data-origin-width=&quot;256&quot; data-origin-height=&quot;256&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;btoa()와 atob()는 문자열을 각각 Base64로 인코딩하거나 디코딩할 때 사용하는 javascript의 내장 함수다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;btoa()&lt;/b&gt;: 문자열을 Base64 인코딩된 문자열로 변환하는 함수이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;let data = {
    name: 'mango',
    price: 1000
}
data = JSON.stringify(data) // object to string

const encodedData = btoa(data) // encode data
console.log('encodedData: ', encodedData)

// encodedData:  eyJuYW1lIjoibWFuZ28iLCJwcmljZSI6MTAwMH0=&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;btoa()로 Base64 형태로 인코딩하려면 먼저 데이터를 문자열로 변경해야 한다. JSON.stringify로 object타입의 변수를 문자열로 변경한다. 인코딩 후 데이터를 출력하면 문자열이 위와같이 뭉개져서 나오는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;atob()&lt;/b&gt;: btoa()와 반대로 Base64로 인코딩된 문자열을 디코딩하여 원래의 문자열로 표시하는 함수이다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const decodedData = atob(encodedData) // decode data
console.log('decodedData: ', decodedData)
console.log('parsed decodedData: ', JSON.parse(decodedData))

// decodedData:  {&quot;name&quot;:&quot;mango&quot;,&quot;price&quot;:1000}
// parsed decodedData:  { name: 'mango', price: 1000 }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인코딩된 문자열을 atob()로 디코딩하면 원래의 문자열로 변경된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 사이트:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/33854103/why-were-javascript-atob-and-btoa-named-like-that&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/33854103/why-were-javascript-atob-and-btoa-named-like-that&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/3538021/why-do-we-use-base64&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/3538021/why-do-we-use-base64&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.freecodecamp.org/news/what-is-base64-encoding/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.freecodecamp.org/news/what-is-base64-encoding/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1748673069250&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;What is base64 Encoding and Why is it Necessary?&quot; data-og-description=&quot;By Sergei Bachinin In this article, we'll thoroughly explore base64 encoding. You'll learn how it came into being and why it's still so prevalent in modern systems. Here's what we'll cover: What is base64? Why use base64? When to use base64 A Case o...&quot; data-og-host=&quot;www.freecodecamp.org&quot; data-og-source-url=&quot;https://www.freecodecamp.org/news/what-is-base64-encoding/&quot; data-og-url=&quot;https://www.freecodecamp.org/news/what-is-base64-encoding/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bsKA05/hyY1hnKu6j/OYkZspE32iPKYKFcL6LLC1/img.jpg?width=1910&amp;amp;height=1000&amp;amp;face=0_0_1910_1000,https://scrap.kakaocdn.net/dn/rukY3/hyY1fjbqzK/JeGyLyIsw5N5lTDXS0uAsK/img.jpg?width=1910&amp;amp;height=1000&amp;amp;face=0_0_1910_1000,https://scrap.kakaocdn.net/dn/QFxhr/hyY1cfEjtT/HVnCaYbCRlRq1HsyBkPpmK/img.jpg?width=1910&amp;amp;height=1000&amp;amp;face=0_0_1910_1000&quot;&gt;&lt;a href=&quot;https://www.freecodecamp.org/news/what-is-base64-encoding/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.freecodecamp.org/news/what-is-base64-encoding/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bsKA05/hyY1hnKu6j/OYkZspE32iPKYKFcL6LLC1/img.jpg?width=1910&amp;amp;height=1000&amp;amp;face=0_0_1910_1000,https://scrap.kakaocdn.net/dn/rukY3/hyY1fjbqzK/JeGyLyIsw5N5lTDXS0uAsK/img.jpg?width=1910&amp;amp;height=1000&amp;amp;face=0_0_1910_1000,https://scrap.kakaocdn.net/dn/QFxhr/hyY1cfEjtT/HVnCaYbCRlRq1HsyBkPpmK/img.jpg?width=1910&amp;amp;height=1000&amp;amp;face=0_0_1910_1000');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;What is base64 Encoding and Why is it Necessary?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;By Sergei Bachinin In this article, we'll thoroughly explore base64 encoding. You'll learn how it came into being and why it's still so prevalent in modern systems. Here's what we'll cover: What is base64? Why use base64? When to use base64 A Case o...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.freecodecamp.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Javascript</category>
      <category>javascript</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/207</guid>
      <comments>https://phsun102.tistory.com/207#entry207comment</comments>
      <pubDate>Sat, 31 May 2025 15:31:37 +0900</pubDate>
    </item>
    <item>
      <title>AWS - AWS Summit Seoul 2025 방문 후기</title>
      <link>https://phsun102.tistory.com/206</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqzQj9/btsNYwqOWBb/WkV6tYKKg2NZCVi9qzNBR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqzQj9/btsNYwqOWBb/WkV6tYKKg2NZCVi9qzNBR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqzQj9/btsNYwqOWBb/WkV6tYKKg2NZCVi9qzNBR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqzQj9%2FbtsNYwqOWBb%2FWkV6tYKKg2NZCVi9qzNBR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;739&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 기조 연설&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기조&lt;span&gt; &lt;/span&gt;연설에서&lt;span&gt; &lt;/span&gt;생성형&lt;span&gt; AI&lt;/span&gt;와&lt;span&gt; &lt;/span&gt;해당&lt;span&gt; &lt;/span&gt;사업의&lt;span&gt; &lt;/span&gt;현대화를&lt;span&gt; &lt;/span&gt;주력으로&lt;span&gt; &lt;/span&gt;삼고&lt;span&gt; &lt;/span&gt;있다는&lt;span&gt; &lt;/span&gt;현재&lt;span&gt; AWS&lt;/span&gt;의&lt;span&gt; &lt;/span&gt;전략에&lt;span&gt; &lt;/span&gt;대해&lt;span&gt; &lt;/span&gt;간단히&lt;span&gt; &lt;/span&gt;설명하고&lt;span&gt; &lt;/span&gt;카카오페이&lt;span&gt;, &lt;/span&gt;현대카드와&lt;span&gt; &lt;/span&gt;트레블랩스&lt;span&gt; &lt;/span&gt;등&lt;span&gt; &lt;/span&gt;생성형&lt;span&gt; AI &lt;/span&gt;서비스를&lt;span&gt; &lt;/span&gt;도입하여&lt;span&gt; &lt;/span&gt;사용하고&lt;span&gt; &lt;/span&gt;있는&lt;span&gt; &lt;/span&gt;기업들에&lt;span&gt; &lt;/span&gt;대해&lt;span&gt; &lt;/span&gt;간략한&lt;span&gt; &lt;/span&gt;설명을&lt;span&gt; &lt;/span&gt;진행하였다&lt;span&gt;. &lt;/span&gt;또한&lt;span&gt; AWS Nova&lt;/span&gt;를&lt;span&gt; &lt;/span&gt;소개했는데&lt;span&gt; &lt;/span&gt;차세대&lt;span&gt; &lt;/span&gt;생성형&lt;span&gt; AI &lt;/span&gt;파운데이션&lt;span&gt; &lt;/span&gt;모델로&lt;span&gt; &lt;/span&gt;검색&lt;span&gt; &lt;/span&gt;증강&lt;span&gt; &lt;/span&gt;생성&lt;span&gt;, &lt;/span&gt;프롬프트&lt;span&gt; &lt;/span&gt;엔지니어링&lt;span&gt;, &lt;/span&gt;미세&lt;span&gt; &lt;/span&gt;조정&lt;span&gt;, &lt;/span&gt;사전&lt;span&gt; &lt;/span&gt;학습&lt;span&gt; &lt;/span&gt;등의&lt;span&gt; &lt;/span&gt;기능을&lt;span&gt; &lt;/span&gt;포함하고&lt;span&gt; &lt;/span&gt;있다&lt;span&gt;. AWS Bedrock Guardrails&lt;/span&gt;도&lt;span&gt; &lt;/span&gt;언급하여&lt;span&gt; &lt;/span&gt;단어&lt;span&gt; &lt;/span&gt;필터&lt;span&gt; &lt;/span&gt;토픽&lt;span&gt; &lt;/span&gt;필터&lt;span&gt; &lt;/span&gt;유해&lt;span&gt; &lt;/span&gt;콘텐츠와&lt;span&gt; &lt;/span&gt;개인정보&lt;span&gt; &lt;/span&gt;보호&lt;span&gt; &lt;/span&gt;등의&lt;span&gt; &lt;/span&gt;기능을&lt;span&gt; &lt;/span&gt;통하여&lt;span&gt; LLM &lt;/span&gt;이용자의&lt;span&gt; &lt;/span&gt;데이터를&lt;span&gt; &lt;/span&gt;보호하는&lt;span&gt; &lt;/span&gt;기능을&lt;span&gt; &lt;/span&gt;제공한다는&lt;span&gt; &lt;/span&gt;등의&lt;span&gt; LLM&lt;/span&gt;보완점을&lt;span&gt; &lt;/span&gt;추가로&lt;span&gt; &lt;/span&gt;언급하였다&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 강연&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OwwgF/btsNZxoF8Zr/pQiLlY28PPhqaxnVtQfjfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OwwgF/btsNZxoF8Zr/pQiLlY28PPhqaxnVtQfjfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OwwgF/btsNZxoF8Zr/pQiLlY28PPhqaxnVtQfjfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOwwgF%2FbtsNZxoF8Zr%2FpQiLlY28PPhqaxnVtQfjfK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;739&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span&gt;2-1. 지구상의 연결의 한계를 뛰어넘기 위한 우주에서의 혁신 - Amazon Project Kuiper&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재&lt;span&gt; &lt;/span&gt;지구에는&lt;span&gt; &lt;/span&gt;아직&lt;span&gt; &lt;/span&gt;인터넷&lt;span&gt; &lt;/span&gt;접근성조차&lt;span&gt; &lt;/span&gt;확보하지&lt;span&gt; &lt;/span&gt;못한&lt;span&gt; &lt;/span&gt;채&lt;span&gt; &lt;/span&gt;살아가고&lt;span&gt; &lt;/span&gt;있는&lt;span&gt; &lt;/span&gt;수십억의&lt;span&gt; &lt;/span&gt;인구가&lt;span&gt; &lt;/span&gt;존재한다&lt;span&gt;. &lt;/span&gt;지리적&lt;span&gt;, &lt;/span&gt;인프라적&lt;span&gt; &lt;/span&gt;한계로&lt;span&gt; &lt;/span&gt;인해&lt;span&gt; &lt;/span&gt;발생하는&lt;span&gt; &lt;/span&gt;문제인데&lt;span&gt; &lt;/span&gt;이러한&lt;span&gt; &lt;/span&gt;한계를&lt;span&gt; &lt;/span&gt;극복하고&lt;span&gt; &lt;/span&gt;모든&lt;span&gt; &lt;/span&gt;인류가&lt;span&gt; &lt;/span&gt;디지털&lt;span&gt; &lt;/span&gt;문명의&lt;span&gt; &lt;/span&gt;혜택을&lt;span&gt; &lt;/span&gt;누릴&lt;span&gt; &lt;/span&gt;수&lt;span&gt; &lt;/span&gt;있도록&lt;span&gt; &lt;/span&gt;하기&lt;span&gt; &lt;/span&gt;위한&lt;span&gt; &lt;/span&gt;프로젝트가&lt;span&gt; &lt;/span&gt;바로&lt;span&gt; Amazon Project Kuiper&lt;/span&gt;다&lt;span&gt;. LEO &lt;/span&gt;기반&lt;span&gt; &lt;/span&gt;통신망을&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;기존&lt;span&gt; &lt;/span&gt;정지궤도&lt;span&gt; &lt;/span&gt;위성&lt;span&gt; &lt;/span&gt;대비&lt;span&gt; &lt;/span&gt;지연시간이&lt;span&gt; &lt;/span&gt;낮고&lt;span&gt; &lt;/span&gt;속도가&lt;span&gt; &lt;/span&gt;빠르며&lt;span&gt; AWS &lt;/span&gt;인프라와&lt;span&gt; &lt;/span&gt;통합하여&lt;span&gt; &lt;/span&gt;데이터&lt;span&gt; &lt;/span&gt;통합과&lt;span&gt; &lt;/span&gt;원활한&lt;span&gt; &lt;/span&gt;데이터&lt;span&gt; &lt;/span&gt;흐름을&lt;span&gt; &lt;/span&gt;보장한다&lt;span&gt;. &lt;/span&gt;향후&lt;span&gt; 10&lt;/span&gt;년&lt;span&gt; &lt;/span&gt;이내에&lt;span&gt; &lt;/span&gt;지구&lt;span&gt; &lt;/span&gt;저궤도에&lt;span&gt; 3236&lt;/span&gt;개의&lt;span&gt; &lt;/span&gt;위성을&lt;span&gt; &lt;/span&gt;배치하여&lt;span&gt; &lt;/span&gt;전&lt;span&gt; &lt;/span&gt;세계에&lt;span&gt; &lt;/span&gt;광대역&lt;span&gt; &lt;/span&gt;인터넷&lt;span&gt; &lt;/span&gt;서비스를&lt;span&gt; &lt;/span&gt;제공하겠다는&lt;span&gt; &lt;/span&gt;계획을&lt;span&gt; &lt;/span&gt;가지고&lt;span&gt; &lt;/span&gt;있다&lt;span&gt;. &lt;/span&gt;이&lt;span&gt; &lt;/span&gt;프로젝트를&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;개발도상국의&lt;span&gt; &lt;/span&gt;디지털&lt;span&gt; &lt;/span&gt;전환&lt;span&gt; &lt;/span&gt;촉진과&lt;span&gt; &lt;/span&gt;일관된&lt;span&gt; &lt;/span&gt;통신&lt;span&gt; &lt;/span&gt;품질&lt;span&gt; &lt;/span&gt;제공과&lt;span&gt; &lt;/span&gt;교육&lt;span&gt;, &lt;/span&gt;의료&lt;span&gt;, &lt;/span&gt;공공&lt;span&gt; &lt;/span&gt;서비스&lt;span&gt; &lt;/span&gt;접근성&lt;span&gt; &lt;/span&gt;확대&lt;span&gt; &lt;/span&gt;등의&lt;span&gt; &lt;/span&gt;이점을&lt;span&gt; &lt;/span&gt;기대할&lt;span&gt; &lt;/span&gt;수&lt;span&gt; &lt;/span&gt;있다&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wRyzg/btsNYvMfdSH/9WSc1vVCgHdEzdm3QlJY4K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wRyzg/btsNYvMfdSH/9WSc1vVCgHdEzdm3QlJY4K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wRyzg/btsNYvMfdSH/9WSc1vVCgHdEzdm3QlJY4K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwRyzg%2FbtsNYvMfdSH%2F9WSc1vVCgHdEzdm3QlJY4K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;739&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span&gt;2-2. 데이터로 날개를 달다. 대한항공의 여객분석 플랫폼 2.0과 전사 BI Amazon QuickSight 전환 여정&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여객&lt;span&gt; &lt;/span&gt;분석&lt;span&gt; &lt;/span&gt;플랫폼&lt;span&gt; 2.0&lt;/span&gt;으로&lt;span&gt; &lt;/span&gt;개편을&lt;span&gt; &lt;/span&gt;진행할&lt;span&gt; &lt;/span&gt;대&lt;span&gt; &lt;/span&gt;높은&lt;span&gt; &lt;/span&gt;안정성을&lt;span&gt; &lt;/span&gt;위한&lt;span&gt; &lt;/span&gt;원천&lt;span&gt; &lt;/span&gt;시스템과&lt;span&gt; &lt;/span&gt;의존성을&lt;span&gt; &lt;/span&gt;분리하여&lt;span&gt; &lt;/span&gt;서비스&lt;span&gt; &lt;/span&gt;무중단&lt;span&gt; &lt;/span&gt;운영&lt;span&gt; &lt;/span&gt;설계를&lt;span&gt; &lt;/span&gt;목표로&lt;span&gt; &lt;/span&gt;하였다&lt;span&gt;. &lt;/span&gt;또한&lt;span&gt; &lt;/span&gt;낮은&lt;span&gt; &lt;/span&gt;복잡도를&lt;span&gt; &lt;/span&gt;위한&lt;span&gt; &lt;/span&gt;파이프라인&lt;span&gt; &lt;/span&gt;간소화와&lt;span&gt; &lt;/span&gt;간편한&lt;span&gt; &lt;/span&gt;유지보수&lt;span&gt;, &lt;/span&gt;높은&lt;span&gt; &lt;/span&gt;유연성을&lt;span&gt; &lt;/span&gt;위해&lt;span&gt; Apache Iceberg&lt;/span&gt;를&lt;span&gt; &lt;/span&gt;도입으로&lt;span&gt; &lt;/span&gt;중복&lt;span&gt; &lt;/span&gt;데이터를&lt;span&gt; &lt;/span&gt;제거하여&lt;span&gt; &lt;/span&gt;비용&lt;span&gt; &lt;/span&gt;절감을&lt;span&gt; &lt;/span&gt;핵심&lt;span&gt; &lt;/span&gt;목표로&lt;span&gt; &lt;/span&gt;삼았다&lt;span&gt;. &lt;/span&gt;이를&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;의존성&lt;span&gt; &lt;/span&gt;감소&lt;span&gt;, DataLake &lt;/span&gt;환경&lt;span&gt; &lt;/span&gt;개선&lt;span&gt;, &lt;/span&gt;성능&lt;span&gt; &lt;/span&gt;향상&lt;span&gt;, &lt;/span&gt;데이터&lt;span&gt; &lt;/span&gt;처리&lt;span&gt; &lt;/span&gt;유연성&lt;span&gt; &lt;/span&gt;향상&lt;span&gt;, &lt;/span&gt;유지보수&lt;span&gt; &lt;/span&gt;편의성&lt;span&gt; &lt;/span&gt;증가와&lt;span&gt; &lt;/span&gt;전체적인&lt;span&gt; &lt;/span&gt;비용&lt;span&gt; &lt;/span&gt;절감의&lt;span&gt; &lt;/span&gt;효과를&lt;span&gt; &lt;/span&gt;얻게&lt;span&gt; &lt;/span&gt;되었다&lt;span&gt;. 1.0&lt;/span&gt;에서&lt;span&gt; 2.0&lt;/span&gt;으로&lt;span&gt; &lt;/span&gt;성공적으로&lt;span&gt; &lt;/span&gt;개편을&lt;span&gt; &lt;/span&gt;진행한&lt;span&gt; &lt;/span&gt;이후&lt;span&gt; &lt;/span&gt;향후&lt;span&gt; &lt;/span&gt;개선&lt;span&gt; &lt;/span&gt;방안으로&lt;span&gt; &lt;/span&gt;아시아나와&lt;span&gt; &lt;/span&gt;통합을&lt;span&gt; &lt;/span&gt;위해&lt;span&gt; &lt;/span&gt;데이터&lt;span&gt; &lt;/span&gt;라이프&lt;span&gt; &lt;/span&gt;사이클을&lt;span&gt; &lt;/span&gt;관리&lt;span&gt;, CI/CD &lt;/span&gt;관리와&lt;span&gt; &lt;/span&gt;데이터&lt;span&gt; &lt;/span&gt;모델&lt;span&gt; &lt;/span&gt;최적화&lt;span&gt; &lt;/span&gt;등을&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;현&lt;span&gt; 2.0&lt;/span&gt;플랫폼을&lt;span&gt; &lt;/span&gt;더욱&lt;span&gt; &lt;/span&gt;개선해&lt;span&gt; &lt;/span&gt;나가려&lt;span&gt; &lt;/span&gt;하고&lt;span&gt; &lt;/span&gt;있다&lt;span&gt;.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GybtO/btsN0w3wOyx/YFOUZVVvhT1TwedifTmTXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GybtO/btsN0w3wOyx/YFOUZVVvhT1TwedifTmTXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GybtO/btsN0w3wOyx/YFOUZVVvhT1TwedifTmTXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGybtO%2FbtsN0w3wOyx%2FYFOUZVVvhT1TwedifTmTXk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;739&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span&gt;2-3. LLM Observability: LLM의 거짓말을 잡아내는 법&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM에는 아직 환각 현상(Hallucination)이라 불리는 허위 정보 생성 문제가 남아있다. 이는 사실과 다른 내용을 매우 설득력있게 출력한다. datadog에서는 이러한 환각 증상을 최소화하고 모델별 성능 비교, 최적화와 프롬프트 개발과 개선을 진행할 수 있는 하나의 플랫폼을 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Experiments라는 플랫폼에서는 여러 LLM모델 간 성능 비교 및 최적화가 가능하고 Playground는 프롬프트 디버깅과 성능 분석을 할 수 있다. 즉 이 플랫폼을 통해 메시지를 바꾸거나 모델 등의 설정을 변경하여 여러 과정을 실험할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;즉&lt;/span&gt;, datdog llm observability&lt;span&gt;를&lt;/span&gt; &lt;span&gt;통해&lt;/span&gt; LLM&lt;span&gt;을&lt;/span&gt; &lt;span&gt;모니터링&lt;/span&gt;, &lt;span&gt;트러블&lt;/span&gt; &lt;span&gt;슈팅&lt;/span&gt; &lt;span&gt;등을&lt;/span&gt; &lt;span&gt;하나의&lt;/span&gt; &lt;span&gt;플랫폼에서&lt;/span&gt; &lt;span&gt;관리할&lt;/span&gt; &lt;span&gt;수&lt;/span&gt; &lt;span&gt;있다&lt;/span&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kHW6A/btsNZXN6zIa/3DwkcHFCd4YCs2KGxnGIEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kHW6A/btsNZXN6zIa/3DwkcHFCd4YCs2KGxnGIEK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kHW6A/btsNZXN6zIa/3DwkcHFCd4YCs2KGxnGIEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkHW6A%2FbtsNZXN6zIa%2F3DwkcHFCd4YCs2KGxnGIEK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;739&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2-4. 개발을 덜 하고 가치는 더 크게: 타이드스퀘어의 NDC Aggregator SaaS여정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타이드스퀘어는&lt;span&gt; AWS &lt;/span&gt;클라우드&lt;span&gt; &lt;/span&gt;인프라를&lt;span&gt; &lt;/span&gt;활요해&lt;span&gt; NDC&lt;/span&gt;라는&lt;span&gt; &lt;/span&gt;항공사와&lt;span&gt; &lt;/span&gt;직접&lt;span&gt; &lt;/span&gt;연동해&lt;span&gt; &lt;/span&gt;항공권과&lt;span&gt; &lt;/span&gt;부가&lt;span&gt; &lt;/span&gt;서비스를&lt;span&gt; &lt;/span&gt;단일&lt;span&gt; &lt;/span&gt;인터페이스에서&lt;span&gt; &lt;/span&gt;제공할&lt;span&gt; &lt;/span&gt;수&lt;span&gt; &lt;/span&gt;있는&lt;span&gt; &lt;/span&gt;항공&lt;span&gt; &lt;/span&gt;유통&lt;span&gt; &lt;/span&gt;표준을&lt;span&gt; &lt;/span&gt;기반으로&lt;span&gt; Aggregator SaaS &lt;/span&gt;솔루션을&lt;span&gt; &lt;/span&gt;개발하였다&lt;span&gt;. &lt;/span&gt;개발을&lt;span&gt; &lt;/span&gt;하면서&lt;span&gt; &lt;/span&gt;개발&lt;span&gt; &lt;/span&gt;기간을&lt;span&gt; &lt;/span&gt;대략&lt;span&gt; 6&lt;/span&gt;주가량&lt;span&gt; &lt;/span&gt;단축시켰는데&lt;span&gt; upstream kanban&lt;/span&gt;을&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;일하는&lt;span&gt; &lt;/span&gt;방식과&lt;span&gt; Design Thinking&lt;/span&gt;면에&lt;span&gt; &lt;/span&gt;도움이&lt;span&gt; &lt;/span&gt;되었다고&lt;span&gt; &lt;/span&gt;한다&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RXUnl/btsNZaN8BJd/SIvDcsYUzkt4eSxmfCg6EK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RXUnl/btsNZaN8BJd/SIvDcsYUzkt4eSxmfCg6EK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RXUnl/btsNZaN8BJd/SIvDcsYUzkt4eSxmfCg6EK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRXUnl%2FbtsNZaN8BJd%2FSIvDcsYUzkt4eSxmfCg6EK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;739&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span&gt;&lt;b&gt;2-5. Amazon Bedrock을 이용한 이미지 검색 서비스의 혁신&lt;/b&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게티이미지코리아는 수억 개의 사진과 동영상을 제공하는 글로벌 콘텐츠 기업이다. 이처럼 방대한 양의 이미지에서 사용자가 원하는 이미지를 찾아내어 보여주는 것에는 한계가 있다. 기존에는 키워드 위주의 검색을 통해 이미지를 찾아냈지만 문맥 이해 부족과 반복적인 검색이 필요하다는 단점이 존재하였다. 이를 극복하기 위해 Amazon Bedrock과 OpenSearch기반의 자연어 검색 기능을 도입하여 키워드 중심 검색에서 자연어 언어 기반의 의미 검색으로 전환을 이루어냈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Amazon Bedrock&lt;/span&gt;은&lt;span&gt; &lt;/span&gt;생성형&lt;span&gt; AI API&lt;/span&gt;를&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;입력된&lt;span&gt; &lt;/span&gt;자연어&lt;span&gt; &lt;/span&gt;문장을&lt;span&gt; &lt;/span&gt;벡터&lt;span&gt; &lt;/span&gt;임베딩으로&lt;span&gt; &lt;/span&gt;전환하고&lt;span&gt; OpenSearch&lt;/span&gt;와&lt;span&gt; &lt;/span&gt;벡터&lt;span&gt; DB&lt;/span&gt;를&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;사전&lt;span&gt; &lt;/span&gt;임베딩된&lt;span&gt; &lt;/span&gt;이미지&lt;span&gt; &lt;/span&gt;메타데이터와&lt;span&gt; &lt;/span&gt;벡터&lt;span&gt; &lt;/span&gt;유사도&lt;span&gt; &lt;/span&gt;검색을&lt;span&gt; &lt;/span&gt;수행하여&lt;span&gt; &lt;/span&gt;사용자가&lt;span&gt; &lt;/span&gt;자연스럽게&lt;span&gt; &lt;/span&gt;질의를&lt;span&gt; &lt;/span&gt;하여&lt;span&gt; &lt;/span&gt;검색&lt;span&gt; &lt;/span&gt;결과를&lt;span&gt; &lt;/span&gt;도출해&lt;span&gt; &lt;/span&gt;내도록&lt;span&gt; &lt;/span&gt;전환되었다&lt;span&gt;. &lt;/span&gt;이를&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;사용자의&lt;span&gt; &lt;/span&gt;만족도가&lt;span&gt; &lt;/span&gt;향상되었고&lt;span&gt; &lt;/span&gt;탐색&lt;span&gt; &lt;/span&gt;효율이&lt;span&gt; &lt;/span&gt;증가하여&lt;span&gt; &lt;/span&gt;검색&lt;span&gt; &lt;/span&gt;자연어&lt;span&gt; &lt;/span&gt;검색&lt;span&gt; &lt;/span&gt;도입이&lt;span&gt; &lt;/span&gt;성공적으로&lt;span&gt; &lt;/span&gt;이루어졌다&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/esJOEZ/btsNZhNbbqI/kkPfwkaxjuWIqj0jpQVkV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/esJOEZ/btsNZhNbbqI/kkPfwkaxjuWIqj0jpQVkV1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/esJOEZ/btsNZhNbbqI/kkPfwkaxjuWIqj0jpQVkV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FesJOEZ%2FbtsNZhNbbqI%2FkkPfwkaxjuWIqj0jpQVkV1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1600&quot; height=&quot;739&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span&gt;2-6. 계산과학의 혁명적 전환점: 양자 컴퓨팅 기술과 Amazon Braket이 여는 미래&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;양자 컴퓨팅은 고전적인 컴퓨팅 방식의 한계를 뛰어넘는 차세대 기술로 주목받고 있다. 하지만 아직은 완전히 상용화되기까지 넘어야 할 기술적, 경제적 장벽이 존재한다. 특히 개발은 극도로 복잡하고 고비용이 수반되며, 수천만 달러에 이르는 초기 투자비용이 필요하다. 현재 대부분의 연구는 논문과 특허에 기반하고 있으며, 실제 활용 사례는 아직 초기 단계에 머물러 있습니다. 하지만 이 기술이 계산 과학 및 산업 전반에 영향을 끼칠 것으로 기대되고 있기 때문에 가능성은 열려있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS에서도 이러한 양자 컴퓨팅을 할 수 있게 하는 서비스를 제공하고 있다. 바로 AWS Bracket인데 사용자가 고전적인 컴퓨팅 자원과 양자 하드웨어를 하나의 통합된 클라우드 환경에서 접근하고 실험할 수 있게 하는 플랫폼으로 AWS Console에서 쉽게 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Advanced Solutions Labs&lt;/span&gt;을&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;고객의&lt;span&gt; &lt;/span&gt;유즈케이스를&lt;span&gt; &lt;/span&gt;확인하고&lt;span&gt; &lt;/span&gt;고객과&lt;span&gt; &lt;/span&gt;함께&lt;span&gt; &lt;/span&gt;공동&lt;span&gt; &lt;/span&gt;연구&lt;span&gt; &lt;/span&gt;프로그램을&lt;span&gt; &lt;/span&gt;진행할&lt;span&gt; &lt;/span&gt;수&lt;span&gt; &lt;/span&gt;있는&lt;span&gt; &lt;/span&gt;실행조직을&lt;span&gt; &lt;/span&gt;적극&lt;span&gt; &lt;/span&gt;운영하고&lt;span&gt; &lt;/span&gt;있다&lt;span&gt;. &lt;/span&gt;이를&lt;span&gt; &lt;/span&gt;통해&lt;span&gt; &lt;/span&gt;기업&lt;span&gt; &lt;/span&gt;고객은&lt;span&gt; &lt;/span&gt;자신들의&lt;span&gt; &lt;/span&gt;문제를&lt;span&gt; AWS &lt;/span&gt;전문가들과&lt;span&gt; &lt;/span&gt;알고리즘에&lt;span&gt; &lt;/span&gt;대한&lt;span&gt; &lt;/span&gt;내용을&lt;span&gt; &lt;/span&gt;개선하거나&lt;span&gt; &lt;/span&gt;맞춤형&lt;span&gt; &lt;/span&gt;양자&lt;span&gt; &lt;/span&gt;워크플로우를&lt;span&gt; &lt;/span&gt;설계하거나&lt;span&gt; &lt;/span&gt;실험을&lt;span&gt; &lt;/span&gt;하는&lt;span&gt; &lt;/span&gt;등의&lt;span&gt; &lt;/span&gt;작업을&lt;span&gt; &lt;/span&gt;할&lt;span&gt; &lt;/span&gt;수&lt;span&gt; &lt;/span&gt;있다&lt;span&gt;.&lt;/span&gt;&lt;/p&gt;</description>
      <category>일상</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/206</guid>
      <comments>https://phsun102.tistory.com/206#entry206comment</comments>
      <pubDate>Fri, 16 May 2025 11:09:11 +0900</pubDate>
    </item>
    <item>
      <title>Windows Powershell - Supplying an input file via '@' gives an error: The splatting operator '@' cannot be used to reference variables in an expression</title>
      <link>https://phsun102.tistory.com/205</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;224&quot; data-origin-height=&quot;168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbM5kz/btsNL82JsGV/0ewVePPosxmqPstYCUb5nk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbM5kz/btsNL82JsGV/0ewVePPosxmqPstYCUb5nk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbM5kz/btsNL82JsGV/0ewVePPosxmqPstYCUb5nk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbM5kz%2FbtsNL82JsGV%2F0ewVePPosxmqPstYCUb5nk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;204&quot; height=&quot;153&quot; data-origin-width=&quot;224&quot; data-origin-height=&quot;168&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;윈도우 파워쉘에서 yarn add @socket.io/redis-adapter를 할 때 이와 같은 에러가 발생했다. 파워쉘이 @ 기호를 배열, 해시테이블, splatting 연산자 용도로 인식했기 때문에 발생하는 에러로 @ 기호가 위치하는 npm 모듈명을 &quot;&quot;로 감싸주면 해결된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1746345729309&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;yarn add &quot;@socket.io/redis-adapter&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 사이트:&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/59805670/supplying-an-input-file-via-gives-an-error-the-splatting-operator-canno&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/59805670/supplying-an-input-file-via-gives-an-error-the-splatting-operator-canno&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>cmd, terminal</category>
      <category>cmd</category>
      <category>Terminal</category>
      <author>Flashback</author>
      <guid isPermaLink="true">https://phsun102.tistory.com/205</guid>
      <comments>https://phsun102.tistory.com/205#entry205comment</comments>
      <pubDate>Sun, 4 May 2025 17:03:38 +0900</pubDate>
    </item>
  </channel>
</rss>