<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>taehyuck 님의 블로그</title>
    <link>https://taehyuck.tistory.com/</link>
    <description>taehyuck 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sat, 16 May 2026 17:14:41 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>taehyuck</managingEditor>
    <image>
      <title>taehyuck 님의 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/7765107/attach/17af547038fd40c083858359f7de4638</url>
      <link>https://taehyuck.tistory.com</link>
    </image>
    <item>
      <title>Next.js와 AI 3D 아바타 연동기 렌더링 최적화 - 문제 고민</title>
      <link>https://taehyuck.tistory.com/35</link>
      <description>&lt;h1 data-pm-slice=&quot;1 1 []&quot;&gt;Next.js에서 AI 3D 아바타 렌더링하기: 블렌더 퀄리티를 웹에서 구현할 수 있을까?&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 Next.js를 기반으로 한 프론트엔드 환경에서 서버로부터 gpt-realtime-mini 모델의 음성 응답을 받아, 그에 맞춰 3D 아바타가 **립싱크(Lip-sync), 표정 연기(Facial Expression), 제스처(Gesture)**를 수행하도록 하는 인터랙티브 웹 서비스를 연구하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI가 사람처럼 말하고 행동하는 것을 웹 브라우저 위에서 구현하는 매력적인 작업이지만, 이 과정에서 3D 렌더링이라는 거대한 벽에 부딪혔습니다. 오늘은 3D 그래픽을 전혀 모르는 개발자가 실사풍 3D 아바타를 웹에 올리기 위해 겪은 시행착오와 렌더링 최적화에 대한 고민을 공유하고자 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 문제의 시작: 블렌더(Blender)에서는 예뻤는데, 웹에서는 왜 이럴까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹에서 3D 모델을 띄울 때 가장 널리 사용되는 표준 확장자는 **GLB (glTF Binary)**입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3D 디자이너가 블렌더(Blender)에서 작업한 아바타를 보면 감탄이 절로 나옵니다. 피부의 질감, 머리카락의 찰랑거림, 현실적인 빛 반사까지 완벽하죠. 그런데 이 모델을 .glb로 추출하여 Next.js(Three.js / React Three Fiber) 웹 화면에 렌더링하는 순간, 충격적인 결과가 나타납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입체감은 사라지고, 피부는 플라스틱 인형처럼 밋밋해지며, 퀄리티가 수직 하락하는 현상을 겪게 됩니다. 대체 왜 이런 일이 발생하는 걸까요?&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 엔진의 차이: 사진관(Blender) vs 폴라로이드 카메라(Web)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말씀드리면, &lt;b&gt;블렌더의 렌더링 방식과 웹 브라우저의 렌더링 엔진(WebGL/WebGPU)이 근본적으로 다르기 때문&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;블렌더 (오프라인 렌더링): 최고급 스튜디오 사진관&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;블렌더는 이미지를 그릴 때 컴퓨터의 성능을 영혼까지 끌어모아 수 분, 수 시간에 걸쳐 빛이 반사되고 튕기는 모든 물리적 경로를 계산합니다(Ray Tracing).&lt;/li&gt;
&lt;li&gt;조명, 그림자, 질감을 극한으로 표현할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;웹 (실시간 렌더링): 롤러코스터 위에서 찍는 폴라로이드 카메라&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;웹 브라우저(Three.js 등)는 사용자의 스마트폰이나 노트북 등 한정된 자원 안에서 초당 60번(60FPS) 그림을 빠르게 그려내야 합니다.&lt;/li&gt;
&lt;li&gt;복잡한 빛 계산을 할 시간이 없으므로, 아주 단순화된 방식의 '가짜 조명'과 '가짜 그림자'를 사용합니다.&lt;/li&gt;
&lt;li&gt;블렌더에서 설정한 복잡한 재질(Material Node)은 대부분 GLB로 내보낼 때 웹이 이해할 수 없는 언어라 유실됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때문에 블렌더에서의 화려한 모습이 웹에서는 전부 날아가고 '뼈대와 기본 색칠'만 남게 되는 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 대안 탐색 1: VRM 확장자는 어떨까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관련 도메인을 공부하다 보니 GLB 외에 &lt;b&gt;VRM&lt;/b&gt;이라는 확장자도 알게 되었습니다. 버튜버(VTuber)나 메타버스에서 많이 쓰이는 형식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확실히 VRM 모델은 웹이나 모바일에서 렌더링해도 퀄리티가 크게 떨어지지 않고 매우 훌륭해 보입니다. &lt;b&gt;하지만 치명적인 문제가 있습니다. 바로 '카툰풍(애니메이션 풍)'에 특화되어 있다는 점입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VRM은 그림자를 애니메이션처럼 딱딱 끊어지게 표현하는 툰 셰이더(Toon Shader)를 기본적으로 많이 사용합니다. 제가 구상하는 서비스는 전문적이고 **'실사풍(Photorealistic)'**에 가까운 아바타가 필요한데, VRM의 만화 같은 느낌은 서비스의 기획 의도와 맞지 않아 채택할 수 없었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 대안 탐색 2: 언리얼 엔진 픽셀 스트리밍 (Pixel Streaming)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;웹 브라우저의 한계라면, 사양 좋은 서버에서 언리얼 엔진(Unreal Engine)으로 초고퀄리티 3D를 구동하고, 그 화면만 영상(비디오)으로 웹에 전송하면 되지 않을까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 &lt;b&gt;픽셀 스트리밍(Pixel Streaming)&lt;/b&gt; 기술이라고 합니다. 최근 메타휴먼(MetaHuman) 등을 웹에서 보여줄 때 쓰는 방식이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 스타트업 백엔드/데브옵스 개발자로서 아키텍처를 검토해 본 결과, 이는 현실적으로 불가능에 가까웠습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;높은 러닝 커브:&lt;/b&gt; 제가 언리얼 엔진이나 C++에 대한 지식이 전무합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;치명적인 인프라 비용 (클라우드 리소스):&lt;/b&gt; 일반적인 웹 서비스는 서버 1대가 수만 명을 감당할 수 있지만, 픽셀 스트리밍은 &lt;b&gt;클라이언트 1명당 클라우드 GPU(예: AWS EC2 G4 인스턴스)를 1개씩 할당&lt;/b&gt;해야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결론:&lt;/b&gt; 동시 접속자가 100명만 되어도 서버 비용이 천문학적으로 뛰어오릅니다. 대기업의 단기 프로모션 이벤트가 아닌 이상, 소규모 스타트업이 유지할 수 있는 비즈니스 모델이 절대 아닙니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 최종 결론: WebGL(GLB) 환경에서 '조명과 질감 깎기' 장인이 되자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌고 돌아 결국 &lt;b&gt;Next.js 프론트엔드 환경에서 Three.js(React Three Fiber)를 이용해 GLB 모델을 직접 렌더링&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;앞으로 블로그를 통해 다음과 같은 &lt;b&gt;Web 3D 실사풍 렌더링 최적화 팁&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;환경 맵(HDRI) 적용:&lt;/b&gt; 단순히 빛을 쏘는 게 아니라, 실제 환경(방, 스튜디오 등)의 360도 이미지를 반사체로 사용하여 현실감을 부여합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;텍스처 베이킹(Texture Baking):&lt;/b&gt; 블렌더의 복잡한 조명과 그림자 연산 결과를 아예 '이미지(텍스처)' 자체로 구워내어(Bake) 모델에 입힌 채로 가져옵니다. 웹은 연산 없이 구워진 이미지만 보여주므로 퍼포먼스와 퀄리티를 동시에 잡을 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PBR (Physically Based Rendering) 재질 튜닝:&lt;/b&gt; 프론트엔드 코드 상에서 금속성(metalness), 거칠기(roughness) 수치를 미세하게 조절하여 사람의 피부 질감을 표현합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;포스트 프로세싱(Post-processing):&lt;/b&gt; 렌더링된 화면 위에 빛 번짐(Bloom), 안티앨리어싱(계단현상 제거) 등의 후처리를 코드로 추가하여 영화 같은 느낌을 줍니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비록 지금은 3D 지식이 부족해 &quot;이게 과연 될까?&quot; 하는 막막함도 있지만, 프론트엔드 코드 최적화를 통해 밋밋한 아바타에 생명을 불어넣는 과정을 계속 기록해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GPT 실시간 음성에 맞춰 자연스럽게 숨 쉬고, 말하고, 눈을 맞추는 완벽한 웹 기반 실사 아바타가 완성되는 그날까지 지켜봐 주세요!&lt;/p&gt;</description>
      <category>에러, 문제 해결</category>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/35</guid>
      <comments>https://taehyuck.tistory.com/35#entry35comment</comments>
      <pubDate>Sun, 12 Apr 2026 20:14:03 +0900</pubDate>
    </item>
    <item>
      <title>실시간 AI 아바타 대화 서비스 최적화 - Saas를 적절하게 활용</title>
      <link>https://taehyuck.tistory.com/34</link>
      <description>&lt;h1 data-pm-slice=&quot;1 1 []&quot;&gt;[Next.js + FastAPI] STT/LLM/TTS 로컬 파이프라인에서 OpenAI Realtime API로 전환하여 서버 비용 90% 줄인 후기&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 오늘은 &lt;b&gt;Next.js&lt;/b&gt;와 &lt;b&gt;FastAPI&lt;/b&gt;를 활용하여 &amp;lsquo;실시간 AI 아바타 대화 프로젝트&amp;rsquo;를 진행하면서 겪었던 뼈아픈 시행착오와, 이를 상용화 수준으로 끌어올리기 위해 서버 아키텍처를 전면 개편했던 경험을 공유하고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인공지능 대화 서비스를 기획하고 계시거나, 오픈소스 AI 모델을 서버에 직접 올려서 상용화를 고민 중이신 분들께 이 글이 실질적인 도움이 되기를 바랍니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 첫 번째 설계: 야심 차게 시작한 '로컬 오픈소스' 파이프라인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 초기, 우리의 목표는 명확했습니다. &lt;b&gt;&quot;웹(Next.js)에서 사용자가 말을 하면, 서버(FastAPI)가 이를 알아듣고, 답변을 생성해서, 아바타가 사람처럼 말하게 하자!&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 구현하기 위해 처음 구축한 시스템은 다음과 같은 3단계 릴레이 방식이었습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;STT (Speech-to-Text)&lt;/b&gt;: 사용자의 음성을 텍스트로 변환 (오픈소스 Whisper 등 사용)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LLM (Large Language Model)&lt;/b&gt;: 텍스트를 바탕으로 AI 답변 생성 (Llama 등 로컬 모델 사용)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TTS (Text-to-Speech)&lt;/b&gt;: 생성된 텍스트를 다시 음성으로 변환 (오픈소스 TTS 모델 사용)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;648&quot; data-origin-height=&quot;445&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cn9rFV/dJMcadVTx2L/nYc0uNojIeD0KXjJNg2tfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cn9rFV/dJMcadVTx2L/nYc0uNojIeD0KXjJNg2tfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cn9rFV/dJMcadVTx2L/nYc0uNojIeD0KXjJNg2tfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcn9rFV%2FdJMcadVTx2L%2FnYc0uNojIeD0KXjJNg2tfk%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;648&quot; height=&quot;445&quot; data-origin-width=&quot;648&quot; data-origin-height=&quot;445&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  직면한 문제: &quot;컴퓨팅 자원이라는 이름의 괴물&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 개발 환경(로컬 PC)에서는 그럭저럭 돌아갔습니다. 하지만 **상용화(실제 서비스 배포)**를 앞두고 치명적인 문제들에 부딪혔습니다.&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;감당 안 되는 클라우드 GPU 비용&lt;/b&gt;: STT, LLM, TTS 세 개의 무거운 모델을 동시에 메모리에 올려두어야 했습니다. AWS나 GCP에서 이를 감당할 만한 GPU 인스턴스(예: A100 등)를 대여하려면 한 달에 수백만 원에서 수천만 원이 깨지는 상황이었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;낮은 사업성 (단가 문제)&lt;/b&gt;: 서버 배포 대신 프로그램 자체를 GPU피씨에 담아 '설치형 패키지'로 파는 B2B 모델도 고려했습니다. 하지만 고성능 그래픽카드가 필수적으로 탑재되어야 하니 하드웨어 단가가 너무 높아져 시장 경쟁력이 전혀 없었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  결론:&lt;/b&gt; 로컬 오픈소스를 활용한 파이프라인은 '연구용'으로는 좋지만, 자본력이 부족한 스타트업이나 개인 레벨에서 '상용 서비스'로 운영하기에는 비용적/구조적으로 불가능에 가까웠습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 두 번째 시도: 가성비 API를 찾아라 (Gemini의 한계)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 서버에서 무거운 계산을 하는 것을 포기하고, &lt;b&gt;외부 클라우드 API&lt;/b&gt;를 사용하기로 결정했습니다. (이른바 SaaS 모델로의 전환입니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 눈에 들어온 것은 구글의 **Gemini(제미나이)**였습니다. 가성비가 훌륭했기 때문이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(이미지 설명: FastAPI에서 Gemini API를 호출하여 응답을 받아오는 로그 화면)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만, 여기서도 예상치 못한 암초를 만납니다.&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;API 요청 수 제한 (Rate Limit)&lt;/b&gt;: 실시간 대화 서비스는 사용자가 말을 할 때마다, 혹은 문장이 끝날 때마다 쉴 새 없이 서버와 통신해야 합니다. 하지만 Gemini API는 짧은 시간에 요청이 몰릴 경우 API 호출 제한(Quota)에 걸려 서비스가 먹통이 되는 현상이 발생했습니다.&lt;/li&gt;
&lt;li&gt;끊김 없는 '실시간(Real-time)'을 보장해야 하는 서비스 특성상, 잦은 호출 제한은 사용자 경험(UX)을 완전히 망가뜨렸습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 세 번째 시도이자 해답: OpenAI API, 그리고 'gpt-realtime-mini'&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 가장 안정적이고 성능이 검증된 &lt;b&gt;OpenAI API&lt;/b&gt;로 눈을 돌렸습니다. 여기서 우리는 두 가지 선택지를 두고 깊은 고민에 빠졌습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;딜레마: 개별 조립 vs 일체형 모델&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 비용을 아끼기 위해 OpenAI의 기능을 개별적으로 호출하려 했습니다. (사용자 음성 -&amp;gt; Whisper API(STT) -&amp;gt; GPT-4o-mini API(LLM) -&amp;gt; TTS API)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확실히 개별 API를 각각 호출하는 것이 토큰당 단가는 미세하게 더 저렴했습니다. 하지만 이 방식은 치명적인 단점이 있었습니다. 바로 **지연 시간(Latency)**입니다. 네트워크를 3번이나 왔다 갔다 해야 하니, 사용자가 말을 끝내고 AI가 대답하기까지 2~3초의 정적이 흘렀습니다. (사람과 대화할 때 3초의 침묵은 엄청나게 어색합니다!)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  신의 한 수: OpenAI Realtime API (gpt-realtime-mini) 도입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 우리의 구세주로 등장한 것이 바로 &lt;b&gt;OpenAI의 Realtime API (gpt-4o-mini-realtime)&lt;/b&gt; 모델이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모델은 텍스트를 거치지 않고 **오디오를 입력받아 오디오로 바로 출력(Audio-in, Audio-out)**하는 네이티브 멀티모달 아키텍처입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비교 항목&lt;/b&gt;&lt;b&gt;기존 분리형 API 구조 (STT+LLM+TTS)&lt;/b&gt;&lt;b&gt;OpenAI Realtime API (gpt-realtime-mini)&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;&lt;b&gt;처리 방식&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;텍스트 변환 과정을 거치는 3단계 릴레이&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;오디오 입력을 직접 이해하고 오디오로 즉시 반환&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;&lt;b&gt;응답 속도 (Latency)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;약 2,000ms ~ 3,000ms (느림)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;&lt;b&gt;약 300ms ~ 500ms (사람과 거의 동일)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;&lt;b&gt;네트워크 통신&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;REST API 3회 왕복&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;&lt;b&gt;WebSocket 1회 연결로 양방향 지속 통신&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;&lt;b&gt;가격&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;매우 저렴함&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;&lt;b&gt;개별 API보단 살짝 높지만 상용화에 무리 없는 수준&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;&lt;b&gt;코드 복잡도&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;에러 처리, 타이밍 동기화 등 매우 복잡함&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;WebSocket 파이프만 열어주면 끝 (매우 단순)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로, 개별 API를 쓰는 것보다 약간의 비용이 더 들더라도 &lt;b&gt;압도적인 응답 속도&lt;/b&gt;와 &lt;b&gt;관리의 편의성&lt;/b&gt;을 가져다주는 Realtime API를 선택하는 것이 상용화 관점에서 훨씬 이득이었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 아키텍처의 진화와 경이로운 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI Realtime API를 도입한 후, 우리의 시스템 아키텍처는 놀라울 정도로 가벼워졌습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✨ 결과 1: 서버 자원 사용량 '0'에 수렴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거 로컬에서 STT, LLM, TTS를 돌리며 비명을 지르던 서버(FastAPI)는 이제 단순히 클라이언트(Next.js)와 OpenAI 서버 사이에서 &lt;b&gt;WebSocket 통신을 중계(Proxy)하고 보안 및 세션을 관리하는 가벼운 다리 역할&lt;/b&gt;만 하게 되었습니다. 비싼 GPU 서버는 아예 필요 없어졌고, 가장 저렴한 CPU 인스턴스만으로도 수천 명의 동시 접속을 처리할 수 있게 되었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✨ 결과 2: 프론트엔드 역량 집중 (아바타 렌더링)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 아키텍처와 AI 파이프라인 고민이 사라지면서, 남는 개발 리소스를 &lt;b&gt;웹(Next.js) 상에서의 3D/2D 아바타 렌더링 최적화&lt;/b&gt;에 온전히 집중할 수 있었습니다. OpenAI에서 넘어오는 음성 스트림 데이터에 맞춰 아바타의 입모양(Viseme)을 실시간으로 동기화하고, 표정을 자연스럽게 구현하는 데 시간을 쏟아 서비스의 퀄리티를 비약적으로 높일 수 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✨ 결과 3: 비교 불가능한 시스템 안정성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 직접 STT, TTS 모델을 관리할 때는 알 수 없는 메모리 누수나 워커(Worker) 다운 현상으로 서버가 자주 뻗었습니다. 하지만 OpenAI의 인프라를 활용한 이후로는 시스템 안정성이 99.9%에 달하게 되었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리하며: 실무적 의사결정의 중요성과 한 가지 경고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초보 개발자나 학생 시절에는 &quot;모든 것을 내가 직접 바닥부터(From Scratch) 오픈소스로 구현하는 것&quot;이 미덕이라고 생각하기 쉽습니다. 하지만 **'상용화'**와 **'비즈니스'**의 영역으로 넘어오면 이야기가 다릅니다. 이번 프로젝트를 통해 깨달은 가장 큰 교훈은 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;내부 컴퓨팅 자원 유지비 vs 외부 API 사용료의 정확한 계산이 필요하다.&lt;/b&gt; (대부분의 경우 초기 스타트업은 API를 쓰는 것이 압도적으로 저렴합니다.)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실시간 서비스에서는 '속도(Latency)'가 가장 강력한 사용자 경험(UX)이다.&lt;/b&gt; 3. &lt;b&gt;내가 집중해야 할 코어 밸류가 무엇인지 파악하라.&lt;/b&gt; (우리의 핵심은 'AI 모델 개발'이 아니라, '그것을 활용한 매력적인 아바타 대화 서비스 구현'이었습니다.)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚠️ SaaS 의존의 치명적 단점: &quot;내 프로젝트의 목줄을 남에게?&quot;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 성공적인 결과와는 별개로, &lt;b&gt;SaaS(외부 API) 서비스에 과도하게 의존하는 것은 항상 경계해야 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 OpenAI가 갑자기 정책을 바꿔서 가격을 10배로 올리거나, 최악의 경우 서비스를 종료해 버린다면 어떻게 될까요? 우리 프로젝트 역시 하루아침에 서비스가 중단되는 치명적인 위기를 맞이하게 됩니다. 마치 남의 땅에 예쁜 집을 지어놓은 것과 같죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 개인적으로는 내 서비스의 생명줄(목줄)을 외부 업체에 쥐여주는 이런 상황을 전혀 좋아하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 현재는 초기 서비스의 빠른 런칭과 비용 효율을 위해 SaaS를 적극 활용하고 있지만, 장기적으로는 &lt;b&gt;자본과 데이터가 충분히 모였을 때 다시 가벼운 자체 모델을 학습시켜 우리 서버로 가져오는 '플랜 B(내재화)'를 항상 염두에 두고 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 저희의 실시간 AI 대화 서비스는 가벼운 FastAPI 백엔드와 매끄러운 Next.js 프론트엔드의 조합, 그리고 OpenAI Realtime API의 강력한 성능 덕분에 성공적으로 궤도에 올랐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비슷한 고민을 하고 계신 현업의 실무자분들이나 창업자분들에게 이 글이 명쾌한 해답의 실마리, 그리고 앞으로 대비해야 할 플랜 B에 대한 영감이 되었기를 바랍니다. 궁금한 점이 있으시다면 언제든 댓글로 남겨주세요!&lt;/p&gt;</description>
      <category>에러, 문제 해결</category>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/34</guid>
      <comments>https://taehyuck.tistory.com/34#entry34comment</comments>
      <pubDate>Sun, 12 Apr 2026 19:47:37 +0900</pubDate>
    </item>
    <item>
      <title>SSH 터널링</title>
      <link>https://taehyuck.tistory.com/33</link>
      <description>&lt;h2 data-path-to-node=&quot;0&quot; data-ke-size=&quot;size26&quot;&gt;[삽질 일기] 온프레미스 서버에서 숨겨진 NCP Private DB 뚫기: 위대한 점프서버와 SSH 터널링&lt;/h2&gt;
&lt;p data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;오늘도 평화롭게 코딩을 하고 있었다. 로컬 개발 환경에서는 잘 돌아가던 코드를 **온프레미스 서버(사내 물리 서버)**에 배포하는 순간, 평화는 깨졌다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwjwvaK07ZySAxUAAAAAHQAAAAAQ1AE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;Connection timed out: connect...
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;빨간색 에러 로그가 화면을 가득 채웠다. 아... 서버가 DB를 못 찾는다. 알고 보니 내가 사용하려는 NCP(Naver Cloud Platform)의 데이터베이스가 &lt;b data-index-in-node=&quot;93&quot; data-path-to-node=&quot;3&quot;&gt;Private Subnet&lt;/b&gt;에 숨어 있었던 것이다. 외부(온프레미스)에서는 당연히 접근 불가능한 '성역'에 있었던 셈.&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 이 철벽같은 Private DB에 온프레미스 서버가 접속할 수 있도록 **점프서버(Bastion Host)**를 이용해 길을 뚫어준 과정을 기록으로 남긴다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;5&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size23&quot;&gt;1. 문제 상황: 너는 닿을 수 없는 곳에&lt;/h3&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;상황은 이랬다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;8&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,0,0&quot;&gt;내 서버:&lt;/b&gt; 회사 전산실에 있는 온프레미스 서버 (외부 인터넷망)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,0&quot;&gt;DB:&lt;/b&gt; NCP 클라우드 내부의 &lt;b data-index-in-node=&quot;17&quot; data-path-to-node=&quot;8,1,0&quot;&gt;Private Subnet&lt;/b&gt;에 위치 (공인 IP 없음, 사설 IP만 존재)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;Private DB는 보안상 외부 인터넷과 차단되어 있다. 내 온프레미스 서버가 아무리 문을 두드려도, NCP의 보안 그룹(ACG)과 네트워크 구조상 절대 닿을 수 없는 구조였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uiV3Y/dJMcagqJ5bi/dQK18lkikfDNxdNyNGCuDK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uiV3Y/dJMcagqJ5bi/dQK18lkikfDNxdNyNGCuDK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uiV3Y/dJMcagqJ5bi/dQK18lkikfDNxdNyNGCuDK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuiV3Y%2FdJMcagqJ5bi%2FdQK18lkikfDNxdNyNGCuDK%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;392&quot; height=&quot;214&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;그림처럼 내 요청은 방화벽 앞에서 가로막혀 갈 곳을 잃고 타임아웃만 뱉고 있었다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;12&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;2. 해결책: 점프서버(Jump Server) 소환&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하려면 **&quot;다리&quot;**가 필요하다. NCP 내부의 &lt;b data-index-in-node=&quot;36&quot; data-path-to-node=&quot;14&quot;&gt;Public Subnet&lt;/b&gt;(외부와 통신 가능)에 있는 서버 하나를 섭외했다. 우리는 이걸 &lt;b data-index-in-node=&quot;85&quot; data-path-to-node=&quot;14&quot;&gt;점프서버(Jump Server)&lt;/b&gt; 또는 **배스천 호스트(Bastion Host)**라고 부른다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;15&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;점프서버는 **공인 IP(Public IP)**가 있어서 내 온프레미스 서버가 접속할 수 있다.&lt;/li&gt;
&lt;li&gt;점프서버는 &lt;b data-index-in-node=&quot;6&quot; data-path-to-node=&quot;15,1,0&quot;&gt;같은 VPC 내부&lt;/b&gt;에 있어서 Private DB와 통신할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b data-index-in-node=&quot;3&quot; data-path-to-node=&quot;16&quot;&gt;&quot;온프레미스 -&amp;gt; 점프서버 -&amp;gt; Private DB&quot;&lt;/b&gt; 순서로 데이터를 토스해주면 된다. 이걸 기술적으로 구현하는 방법이 바로 **SSH 터널링(Tunneling)**이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnchFc/dJMcafFnWd4/HPLUjAWkaCYT2CPSKifwIk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnchFc/dJMcafFnWd4/HPLUjAWkaCYT2CPSKifwIk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnchFc/dJMcafFnWd4/HPLUjAWkaCYT2CPSKifwIk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnchFc%2FdJMcafFnWd4%2FHPLUjAWkaCYT2CPSKifwIk%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;559&quot; height=&quot;305&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-path-to-node=&quot;18&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;3. 실전: SSH 터널링 뚫기 (따라해보자)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;이제 온프레미스 서버의 터미널을 열고 터널을 뚫어보자. 원리는 간단하다. &lt;b data-index-in-node=&quot;41&quot; data-path-to-node=&quot;20&quot;&gt;&quot;내 서버의 특정 포트(예: 9999)로 들어오는 신호를 점프서버를 통해 저쪽 DB 포트(1433)로 보내줘!&quot;&lt;/b&gt; 라고 명령하는 것이다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size20&quot;&gt;준비물&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;22&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,0,0&quot;&gt;점프서버 접속 정보:&lt;/b&gt; 공인 IP, 계정 ID, PEM 키 파일 (또는 비밀번호)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,1,0&quot;&gt;타겟 DB 정보:&lt;/b&gt; Private IP (사설 IP), 포트 번호 (MSSQL은 보통 1433)&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-path-to-node=&quot;23&quot; data-ke-size=&quot;size20&quot;&gt;SSH 터널링 명령어 (리눅스/맥 터미널)&lt;/h4&gt;
&lt;p data-path-to-node=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;온프레미스 서버에서 아래 명령어를 입력한다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwjwvaK07ZySAxUAAAAAHQAAAAAQ1QE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;ssh -N -L [로컬포트]:[DB_Private_IP]:[DB_포트] -i [PEM키경로] [점프서버계정]@[점프서버_Public_IP]
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-path-to-node=&quot;26&quot; data-ke-size=&quot;size20&quot;&gt;예시 상황&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;27&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27,0,0&quot;&gt;내 서버에서 사용할 포트:&lt;/b&gt; 9999 (아무거나 빈 거 쓰면 됨)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27,1,0&quot;&gt;DB Private IP:&lt;/b&gt; 192.168.1.50&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27,2,0&quot;&gt;DB 포트:&lt;/b&gt; 1433 (MSSQL)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27,3,0&quot;&gt;점프서버 IP:&lt;/b&gt; 203.0.113.10&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27,4,0&quot;&gt;점프서버 계정:&lt;/b&gt; root&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;27,5,0&quot;&gt;키 파일:&lt;/b&gt; key.pem&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size20&quot;&gt;실제 명령어 입력&lt;/h4&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwjwvaK07ZySAxUAAAAAHQAAAAAQ1gE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Bash&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;ssh -N -L 9999:192.168.1.50:1433 -i ./key.pem root@203.0.113.10
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-path-to-node=&quot;30&quot; data-ke-size=&quot;size20&quot;&gt;옵션 설명 (이게 중요함!)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;31&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,0,0&quot;&gt;-L (Local Port Forwarding):&lt;/b&gt; 가장 중요한 옵션. &quot;로컬 포트를 리모트 호스트로 연결해라&quot;라는 뜻.
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;31,0,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;구조: 내_컴퓨터_포트:최종_목적지_IP:최종_목적지_포트&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,1,0&quot;&gt;-N:&lt;/b&gt; &quot;명령어 실행하지 마&quot;. 터널링만 연결하고 쉘 접속은 안 하겠다는 뜻이다. 이걸 안 쓰면 점프서버 터미널로 들어가져 버린다. (백그라운드에서 조용히 길만 열어둠)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;31,2,0&quot;&gt;-i:&lt;/b&gt; 접속에 필요한 인증 키 파일 경로.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;32&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size23&quot;&gt;4. 코드 수정: 이제 DB는 '로컬'에 있다&lt;/h3&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;터널링이 성공했다면(에러 없이 커서가 깜빡이거나 멈춰있으면 성공), 이제 내 온프레미스 서버 입장에서 &lt;b data-index-in-node=&quot;57&quot; data-path-to-node=&quot;34&quot;&gt;Private DB는 마치 내 컴퓨터의 9999번 포트에 있는 것과 같다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;35&quot; data-ke-size=&quot;size16&quot;&gt;따라서 애플리케이션(Java/Python 등)의 DB 설정 파일(application.yaml 등)을 수정해야 한다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;36&quot;&gt;수정 전 (직접 접속 시도 - 실패)&lt;/b&gt;&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwjwvaK07ZySAxUAAAAAHQAAAAAQ1wE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;YAML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:sqlserver://192.168.1.50:1433;... # Private IP로 직접 가려니 실패
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;38&quot;&gt;수정 후 (터널링 경유 - 성공)&lt;/b&gt;&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwjwvaK07ZySAxUAAAAAHQAAAAAQ2AE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;YAML&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:sqlserver://localhost:9999;... # 내 서버의 9999로 보내면 터널 타고 DB로 감!
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;40&quot;&gt;핵심 포인트:&lt;/b&gt; 호스트는 localhost(또는 127.0.0.1), 포트는 내가 -L 옵션 맨 앞에 적었던 9999를 써야 한다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;41&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;42&quot; data-ke-size=&quot;size23&quot;&gt;5. 오늘의 교훈&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;43&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;43,0,0&quot;&gt;보안은 불편하다, 하지만 이유가 있다:&lt;/b&gt; DB를 Private Subnet에 두는 건 보안의 기본이다. 불편하다고 Public으로 열어버리면 해킹 맛집이 된다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;43,1,0&quot;&gt;SSH는 만능이다:&lt;/b&gt; 단순히 원격 접속 도구인 줄 알았는데, 네트워크 길을 뚫어주는 터널링 기능은 정말 강력하다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;43,2,0&quot;&gt;점프서버 관리를 잘하자:&lt;/b&gt; 점프서버가 털리면 내부망이 다 털리는 거다. 점프서버 보안 그룹(ACG) 설정도 빡빡하게 해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;오늘의 삽질 끝! 다음엔 이 터널링을 매번 명령어로 치기 귀찮으니 AutoSSH로 서비스 등록하는 걸 해봐야겠다.&lt;/p&gt;</description>
      <category>에러, 문제 해결</category>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/33</guid>
      <comments>https://taehyuck.tistory.com/33#entry33comment</comments>
      <pubDate>Mon, 2 Mar 2026 17:29:38 +0900</pubDate>
    </item>
    <item>
      <title>서버 중단 현상 - MSSQL 동시성 락 문제</title>
      <link>https://taehyuck.tistory.com/32</link>
      <description>&lt;h2 data-path-to-node=&quot;0&quot; data-ke-size=&quot;size26&quot;&gt;[삽질 기록] 잘 돌아가던 서버가 멈췄다? MSSQL 동시성 락(Lock) 문제 해결기 (feat. Java Write vs Python Read)&lt;/h2&gt;
&lt;p data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;안녕하세요! 오늘은 Suno AI를 활용한 프로젝트를 진행하던 중 마주친, 아주 골치 아팠던 &lt;b data-index-in-node=&quot;52&quot; data-path-to-node=&quot;1&quot;&gt;데이터베이스 동시성(Concurrency) 이슈&lt;/b&gt;에 대해 이야기해보려 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;멀쩡히 잘 돌아가던 백엔드 서버가 갑자기 데이터 저장 시점에서 아무런 에러 로그 없이 무한 대기(Hang) 상태에 빠지는 현상이었는데요. 범인은 바로 &lt;b data-index-in-node=&quot;84&quot; data-path-to-node=&quot;2&quot;&gt;MSSQL의 락(Lock) 메커니즘&lt;/b&gt;이었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;저와 비슷한 환경(특히 MSSQL을 사용하는 다중 서비스 환경)에서 고통받는 분들에게 도움이 되길 바라며 기록을 남깁니다.&lt;/p&gt;
&lt;hr data-path-to-node=&quot;4&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size23&quot;&gt;1. 문제 상황: 평화로운 아키텍처에 찾아온 위기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;제가 구축한 시스템의 구조는 대략 이렇습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;7&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,0,0&quot;&gt;Java (Spring Boot) 서버&lt;/b&gt;: 사용자의 요청을 받아 Suno API로 노래를 생성하고, 완료되면 DB에 정보를 **저장(INSERT/UPDATE)**합니다. (Writer)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,1,0&quot;&gt;Python (FastAPI) 서버&lt;/b&gt;: DB를 주기적으로 **조회(SELECT)**하여, 생성된 노래를 라디오처럼 스트리밍합니다. (Reader)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,2,0&quot;&gt;MSSQL 데이터베이스&lt;/b&gt;: 두 서버가 공유하는 저장소입니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 테스트 데이터가 적어서 문제가 없었습니다. 그런데 Python 라디오 서버가 3초마다 DB를 맹렬하게 조회하기 시작하고, Java 서버가 동시에 생성을 완료하여 데이터를 넣으려는 순간 문제가 발생했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;9&quot;&gt;Java 서버가 DB에 저장을 시도하다가 그대로 멈춰버린 것입니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;11&quot; data-ke-size=&quot;size23&quot;&gt;2. 원인 분석: 범인은 '조회'하는 녀석이었다&lt;/h3&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;로그를 아무리 봐도 에러는 없고, 그냥 쿼리 실행 직전에서 멈춰있었습니다. 원인은 &lt;b data-index-in-node=&quot;46&quot; data-path-to-node=&quot;12&quot;&gt;MSSQL의 기본 격리 수준(Isolation Level)인 READ COMMITTED의 특성&lt;/b&gt; 때문이었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;13&quot; data-ke-size=&quot;size16&quot;&gt;쉽게 설명하면 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;14&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,0,0&quot;&gt;MSSQL의 특징&lt;/b&gt;: 기본적으로 데이터를 읽을 때(SELECT) 데이터가 변경되지 않도록 **공유 락(Shared Lock)**을 겁니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,1,0&quot;&gt;Python의 폭주&lt;/b&gt;: 라디오 서버가 쉴 새 없이 SELECT를 날리며 테이블에 공유 락을 유지합니다. &quot;나 이거 읽는 중이니까 아무도 건드리지 마!&quot;라고 선언하는 것과 같습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14,2,0&quot;&gt;Java의 고통&lt;/b&gt;: 데이터를 쓰려면(INSERT) **배타적 락(Exclusive Lock)**이 필요합니다. 하지만 Python이 이미 공유 락을 잡고 놔주질 않으니, Java는 락이 풀릴 때까지 하염없이 기다리게 됩니다 (Blocking).&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size20&quot;&gt;문제 상황 시각화&lt;/h4&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;이해를 돕기 위해 당시 상황을 그림으로 표현해보았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;&lt;i data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17&quot;&gt;(아래 이미지는 제가 겪었던 Java Writer와 Python Reader 간의 DB Lock 충돌 상황을 도식화한 것입니다.)&lt;/i&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wq82x/dJMcab32FQJ/DqRgQzpeuSSpMYEwdeKdQ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wq82x/dJMcab32FQJ/DqRgQzpeuSSpMYEwdeKdQ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wq82x/dJMcab32FQJ/DqRgQzpeuSSpMYEwdeKdQ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwq82x%2FdJMcab32FQJ%2FDqRgQzpeuSSpMYEwdeKdQ1%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;1024&quot; height=&quot;559&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;3. 증상 및 로그 (The Symptoms)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;Java (Hibernate) 측 로그를 보면 다음과 같은 상황에서 더 이상 진행되지 않았습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23&quot;&gt;Java 백엔드 로그 (멈춤)&lt;/b&gt;&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwjwvaK07ZySAxUAAAAAHQAAAAAQiQE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;코드 스니펫&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;2026-01-21 18:58:09.226  INFO --- [Thread-34] c.e.s.service.SunoService :   [Suno API] 요청 시작! ...
// ... (API 호출 및 응답 성공 로그) ...
2026-01-21 18:58:30.451  INFO --- [Thread-34] c.e.s.service.SunoService : ✅ 노래 생성 완료, DB 저장 시도...

// Hibernate가 insert 쿼리를 출력했지만, 이 라인 이후로 영원히 다음 로그가 찍히지 않음
Hibernate: insert into music_log (audio_url, created_at, filename, ...) values (?, ?, ?, ...)

// (여기서 무한 대기 발생...)
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;반면, Python 쪽 로그는 아주 평화롭게 계속 조회를 하고 있었습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;26&quot;&gt;Python 백엔드 로그 (원인 제공자)&lt;/b&gt;&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwjwvaK07ZySAxUAAAAAHQAAAAAQigE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;코드 스니펫&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;accesslog&quot;&gt;&lt;code&gt;// 3초마다 무한 반복
INFO: 127.0.0.1:51234 - &quot;GET /radio/next HTTP/1.1&quot; 200 OK
INFO: sqlalchemy.engine.Engine SELECT music_log.id, music_log.title ... FROM music_log ORDER BY music_log.id ASC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY
INFO: 127.0.0.1:51235 - &quot;GET /radio/next HTTP/1.1&quot; 200 OK
...
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;28&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;29&quot; data-ke-size=&quot;size23&quot;&gt;4. 해결 방법: &quot;야, 그냥 대충 읽어!&quot; (READ UNCOMMITTED)&lt;/h3&gt;
&lt;p data-path-to-node=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하는 가장 확실한 방법은 &lt;b data-index-in-node=&quot;22&quot; data-path-to-node=&quot;30&quot;&gt;읽기 작업(Python Reader)이 쓰기 작업(Java Writer)을 방해하지 못하도록 하는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;31&quot; data-ke-size=&quot;size16&quot;&gt;라디오 스트리밍 서비스 특성상, 아주 찰나의 순간에 데이터 정합성이 조금 안 맞더라도(예: 막 insert 된 데이터를 못 읽거나, 커밋 전 데이터를 읽거나) 큰 문제가 되지 않습니다. 락이 걸려서 서비스가 멈추는 것보단 훨씬 낫죠.&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;그래서 Python 서버가 DB에 접속할 때 &lt;b data-index-in-node=&quot;25&quot; data-path-to-node=&quot;32&quot;&gt;트랜잭션 격리 수준을 READ UNCOMMITTED로 낮추기로 결정&lt;/b&gt;했습니다. 이는 MSSQL 쿼리에서 WITH (NOLOCK) 힌트를 사용하는 것과 동일한 효과를 냅니다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;33&quot; data-ke-size=&quot;size20&quot;&gt;해결 코드 (Python - SQLAlchemy)&lt;/h4&gt;
&lt;p data-path-to-node=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;Python의 app/database.py 파일에서 create_engine 부분에 옵션을 딱 한 줄 추가했습니다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwjwvaK07ZySAxUAAAAAHQAAAAAQiwE&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;&lt;span&gt;Python&lt;/span&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# app/database.py 수정 전
# engine = create_engine(SQLALCHEMY_DATABASE_URL)

# app/database.py 수정 후 (해결책 적용)
from sqlalchemy import create_engine

# ... 기존 코드 ...

# 핵심 변경점: isolation_level 옵션 추가
# &quot;READ UNCOMMITTED&quot;를 설정하여 조회 시 공유 락(Shared Lock)을 걸지 않도록 합니다.
engine = create_engine(
    SQLALCHEMY_DATABASE_URL,
    isolation_level=&quot;READ UNCOMMITTED&quot;
)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# ... 이후 코드 동일
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-path-to-node=&quot;36&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;37&quot; data-ke-size=&quot;size23&quot;&gt;5. 마치며: DB의 특성을 이해하자&lt;/h3&gt;
&lt;p data-path-to-node=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;이 설정을 적용하고 Python 서버를 재시작하자마자 거짓말처럼 Java 서버의 DB 저장이 쌩쌩하게 돌아가기 시작했습니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;이번 삽질을 통해 **&quot;여러 서비스가 하나의 DB를 공유할 때는 데이터베이스 엔진의 락(Lock) 특성과 트랜잭션 격리 수준을 반드시 고려해야 한다&quot;**는 값진 교훈을 얻었습니다. 특히 MSSQL은 읽기 락이 꽤 강력하게 동작한다는 점을 다시 한번 깨달았네요.&lt;/p&gt;
&lt;p data-path-to-node=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;혹시 저처럼 이유 없이 DB가 멈추는 현상을 겪고 계시다면, 어딘가에서 무수한 SELECT 요청이 당신의 INSERT를 가로막고 있는 건 아닌지 의심해보시길 바랍니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;41&quot; data-ke-size=&quot;size16&quot;&gt;끝!&lt;/p&gt;</description>
      <category>에러, 문제 해결</category>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/32</guid>
      <comments>https://taehyuck.tistory.com/32#entry32comment</comments>
      <pubDate>Mon, 2 Mar 2026 17:27:54 +0900</pubDate>
    </item>
    <item>
      <title>NCP 공공 클라우드 - On-Premise AI 서버와 Cloud DB 연동</title>
      <link>https://taehyuck.tistory.com/31</link>
      <description>&lt;h1 data-pm-slice=&quot;1 1 []&quot;&gt;[NCP 공공 클라우드] IPsec 장비 호환 문제 해결: 베스천 호스트 터널링으로 On-Premise AI 서버와 Cloud DB 연동하기&lt;/h1&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;IPsec 장비 호환 문제 해결: 베스천 호스트와 SSH 터널링으로 On-Premise AI 서버와 Cloud DB 안전하게 연동하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 오늘은 네이버 클라우드 플랫폼(NCP) 공공기관용(Public/Gov) 환경을 구축하면서 겪었던 **'네트워크 장비 호환성 문제'**와 이를 타개하기 위한 &lt;b&gt;'우회 연결 아키텍처(Bastion Host + SSH Tunneling)'&lt;/b&gt; 구축 경험을 공유하고자 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 AI 프로젝트를 진행하면서, 저희 회사의 온프레미스(사내) GPU 서버와 NCP 클라우드 내부에 격리된(Private) Cloud DB for MySQL을 안전하게 연동해야 하는 미션이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말씀드리면, 예산과 장비 호환성 문제로 즉각적인 VPN 도입이 어려워 **보안을 완벽하게 유지하면서도 외부망에서 내부망으로 다이렉트 터널을 뚫는 임시 우회로(SSH 로컬 포트 포워딩)**를 구축했습니다. 혹시 저와 비슷한 상황에 부닥치신 데브옵스, 백엔드 개발자분들께 이 글이 실질적인 도움이 되길 바랍니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;직면한 문제: IPsec VPN 장비의 호환성 한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 정석적이고 안전한 방법은 저희 회사 사내망과 NCP 클라우드망을 &lt;b&gt;IPsec VPN&lt;/b&gt;으로 연결하는 것입니다. 이를 위해 세팅을 준비하고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여기서 예상치 못한 벽에 부딪혔습니다. &lt;b&gt;NCP 공공기관용 클라우드에서 공식적으로 지원하는 IPsec VPN 장비 기종이 'AXGATE'와 'SECUI'로 제한&lt;/b&gt;되어 있었기 때문입니다. 불행히도 저희 회사의 방화벽/라우터 장비는 'ASUS' 제품이었고, NCP 측과 연동 테스트를 해보았지만 프로토콜 규격 문제로 터널링이 맺어지지 않았습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;대안 모색: 베스천 호스트(Bastion Host)를 활용한 SSH 포트 포워딩&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdCpXK/dJMcacvtEt6/qj2ZXxKCbfLw5LJGzEAN10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdCpXK/dJMcacvtEt6/qj2ZXxKCbfLw5LJGzEAN10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdCpXK/dJMcacvtEt6/qj2ZXxKCbfLw5LJGzEAN10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdCpXK%2FdJMcacvtEt6%2Fqj2ZXxKCbfLw5LJGzEAN10%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;440&quot; height=&quot;240&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;/figure&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;따라서 **&quot;어떻게 하면 외부망(사내 GPU 서버)에서 철저히 격리된 내부망(NCP Cloud DB)에 안전하게 접근할 수 있을까?&quot;**를 고민하다가 다음의 아키텍처를 설계했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;쉽게 이해하기 (초등학생도 이해하는 비유!)&lt;/b&gt; 은행(Cloud DB)에 돈을 넣으러 가야 하는데, 정문(VPN) 규격에 맞는 전용 장갑차(AXGATE 장비)가 없어서 못 들어가는 상황입니다. 그래서 은행 앞마당에 임시 초소(베스천 호스트)를 세웠습니다. 이 초소는 오직 '우리 회사 직원(IP 화이트리스트)'만 들어갈 수 있습니다. 초소에 들어가면 외부 해커가 절대 볼 수 없는 튼튼한 '비밀 지하 터널(SSH 포트 포워딩)'을 통해 내부 금고까지 바로 연결되도록 뚫어 놓은 것입니다!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아키텍처 및 해결 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 전체 아키텍처 구성도&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;사내 AI GPU 서버&lt;/b&gt;: autossh 백그라운드 서비스를 통해 베스천 호스트로 끊임없는 접속을 시도 및 유지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;NCP 베스천 호스트 (Bastion Host)&lt;/b&gt;: 터널의 도착지이자 출입구. 도착한 트래픽을 내부 DB로 전달&lt;/li&gt;
&lt;li&gt;&lt;b&gt;NCP Cloud DB for MySQL&lt;/b&gt;: 외부와 단절된 Private Subnet에 위치 (베스천에서 넘어온 터널링 트래픽 수신)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 베스천 호스트(Bastion Host) 서버 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 NCP 환경에 접속 통로가 될 서버를 한 대 생성했습니다.&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;OS&lt;/b&gt;: ubuntu-24.04-base (가장 안정적이고 최신인 우분투 환경)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버 스펙&lt;/b&gt;: s2-g3a (vCPU 2EA, Memory 8GB) (터널링 트래픽을 충분히 감당할 수 있는 스펙)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;공인 IP&lt;/b&gt;: 할당 완료 (175.45.220.199)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;** (실제 구축된 베스천 호스트의 상세 정보입니다. 공인 IP와 내부 IP가 모두 할당된 것을 볼 수 있습니다.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 강력한 보안 설정: ACG (IP 화이트리스트) ★ (매우 중요)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무리 터널링이 암호화된다 하더라도 출입구 자체의 보안은 타협할 수 없습니다. &lt;b&gt;NCP의 ACG(Access Control Group - 방화벽 기능)를 설정하여 '우리 회사 사내망 IP'만 베스천 호스트에 접근할 수 있도록 화이트리스트(Whitelist) 방식으로 차단&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;접근 소스(Target)&lt;/b&gt;: [회사_사내_공인_IP]/32 (예: 123.45.67.89/32)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;허용 포트&lt;/b&gt;: 22 (SSH 접속 및 터널링용 포트)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 설정하면, 전 세계 어떤 해커가 접근하더라도 우리 회사 IP가 아니면 해당 서버에 핑조차 보낼 수 없습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 핵심 구현: SSH 로컬 포트 포워딩 (Tunneling) 자동화 구성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 개발자 PC 터미널에 명령어 한 줄을 쳐서 터널을 여는 것은 실무 환경(Production)에서 적합하지 않습니다. 서버가 재부팅되거나 네트워크가 잠시 끊겨도 &lt;b&gt;알아서 터널이 복구되도록 autossh 패키지와 Linux systemd 서비스&lt;/b&gt;를 사용하여 프로덕션 레벨로 구성해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 모든 작업은 **사내 AI GPU 서버(온프레미스 우분투 장비)**에서 수행합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단계 4-1: 사내 AI 서버에 autossh 설치&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 끊김 없는 터널링을 유지해 주는 autossh 패키지를 설치합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 사내 AI 서버 터미널에서 실행합니다.
sudo apt-get update
sudo apt-get install autossh -y
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단계 4-2: SSH 키 생성 및 패스워드 없는 자동 접속 설정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동으로 접속이 복구되고 유지되려면 비밀번호를 손으로 입력하는 과정이 없어야 합니다. 이를 위해 SSH 인증키(비대칭 키)를 구성합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 1. 사내 AI 서버에서 SSH 키 생성 (엔터만 쳐서 기본값으로 생성, 비밀번호 설정 X)
ssh-keygen -t rsa -b 4096

# 2. 생성된 공개키를 베스천 호스트로 전송 (이때만 베스천 서버의 비밀번호 1회 입력 필요)
# username: 베스천 호스트 계정 (예: root 또는 ubuntu)
ssh-copy-id -i ~/.ssh/id_rsa.pub root@175.45.220.199
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단계 4-3: 백그라운드 서비스(systemd) 등록 (실무 필수)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 재시작되어도 항상 터널링이 백그라운드에서 유지되도록 Linux의 서비스 관리자에 등록합니다. 지정된 경로에 파일을 생성하고 아래 풀 코드를 작성합니다.&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;생성할 파일 위치&lt;/b&gt;: /etc/systemd/system/db-tunnel.service&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;# /etc/systemd/system/db-tunnel.service 의 전체 코드
[Unit]
Description=AutoSSH tunnel to NCP Bastion Host for Cloud DB
After=network-online.target
Wants=network-online.target

[Service]
# 사내 서버에서 autossh를 실행할 사용자 계정 (root 또는 일반 사용자 계정명)
User=root
# autossh 자체 모니터링 포트 비활성화 (0으로 두면 기본 ssh KeepAlive 활용)
Environment=&quot;AUTOSSH_PORT=0&quot;
Environment=&quot;AUTOSSH_GATETIME=0&quot;

# 터널링 핵심 명령어 설명:
# -M 0: autossh 자체 모니터링 끄기
# -N: 쉘(명령어 창)을 실행하지 않고 포트 포워딩 터널만 유지
# -q: 조용히 실행 (불필요한 로그 생략)
# -o &quot;ServerAliveInterval 30&quot; -o &quot;ServerAliveCountMax 3&quot;: 30초마다 생존 핑을 보내고 3번 실패 시 재연결
# -L 3306:[NCP_DB_내부IP]:3306 -&amp;gt; 내 사내 서버의 3306포트를 베스천을 거쳐 DB의 3306포트로 매핑
ExecStart=/usr/bin/autossh -M 0 -N -q \
    -o &quot;ServerAliveInterval 30&quot; \
    -o &quot;ServerAliveCountMax 3&quot; \
    -o &quot;ExitOnForwardFailure yes&quot; \
    -L 3306:10.0.0.X:3306 root@175.45.220.199
    # ★주의: 10.0.0.X 부분을 실제 NCP Cloud DB의 Private IP로 반드시 변경하세요.
    # ★주의: root@175.45.220.199 부분은 베스천 호스트 접속 계정과 공인 IP입니다.

Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;단계 4-4: 서비스 실행 및 자동 시작 등록&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 작성이 끝났으면 시스템 데몬을 새로고침하고 서비스를 시작합니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# 데몬 재로드 (새로 만든 시스템 파일 인식)
sudo systemctl daemon-reload

# 부팅 시 터널링 자동 시작 등록
sudo systemctl enable db-tunnel

# 서비스 지금 즉시 시작
sudo systemctl start db-tunnel

# 서비스 상태 확인 (초록색으로 active (running)이 뜨면 완벽하게 성공입니다!)
sudo systemctl status db-tunnel
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 완벽한 보안과 연결: Spring Boot 백엔드 서버 설정 적용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSH 터널링의 가장 큰 장점은 &lt;b&gt;사내망(인터넷 구간)을 통과하는 트래픽이 SSH 프로토콜에 의해 강력하게 암호화(철제 금고)&lt;/b&gt; 된다는 점입니다. 해커가 중간에서 패킷을 가로채도 절대 평문 데이터를 볼 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터널링이 사내 서버의 3306 포트에 뚫려있으므로, 사내 서버에서 동작하는 애플리케이션(예: Spring Boot)은 마치 &lt;b&gt;'자기 자신의 PC에 DB가 설치되어 있는 것처럼'&lt;/b&gt; 설정하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 회사 AI 서버에 배포된 스프링 부트 애플리케이션의 설정 파일은 아래와 같이 수정했습니다.&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;수정할 파일 위치&lt;/b&gt;: src/main/resources/application.yml (또는 application.properties)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# src/main/resources/application.yml (전체 풀 코드 예시)
spring:
  datasource:
    # 핵심: Host 주소가 베스천 공인 IP가 아닌 '127.0.0.1 (localhost)' 입니다!
    # 터널이 사내 서버의 로컬 포트로 뚫려 있기 때문입니다.
    url: jdbc:mysql://127.0.0.1:3306/ai_project_db?characterEncoding=UTF-8&amp;amp;serverTimezone=Asia/Seoul
    username: your_db_username
    password: your_db_password
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    properties:
      hibernate:
        format_sql: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DBeaver 같은 DB 클라이언트 툴로 접속하실 때도 Host를 127.0.0.1로, Port를 3306으로 잡으시면 안전하고 빠르게 NCP 내부망 DB와 통신할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;향후 계획 (Next Step)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 이 방식은 당장의 프로젝트 진행을 위한 훌륭한 '우회 기동'입니다. SSH 프로토콜 자체의 강력한 종단 간 암호화(End-to-End Encryption)와 ACG(화이트리스트 IP 차단)라는 이중 보안 체계 덕분에 공공 프로젝트에서도 무리 없이 사용할 수 있는 매우 안전한 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 터널링 유지를 위한 관리 포인트(systemd 등)가 하나 늘어났다는 아쉬움이 있으므로, 근본적인 아키텍처 개선을 위해 향후 다음과 같은 단계를 밟을 예정입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;예산 확보&lt;/b&gt;: 프로젝트 1차 마일스톤 달성 후, 인프라 보안 예산 기안.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;호환 장비 도입&lt;/b&gt;: NCP 공식 지원 기종인 &lt;b&gt;AXGATE&lt;/b&gt; 또는 &lt;b&gt;SECUI&lt;/b&gt; 방화벽 장비 구매 혹은 렌탈.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정식 IPsec VPN 마이그레이션&lt;/b&gt;: 현재의 SSH 터널링 서비스를 내리고, 라우터 단에서 맺어지는 Site-to-Site VPN으로 전환하여 사내망과 클라우드망을 하나의 안전한 사설 네트워크처럼 연동할 계획입니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라우드, 특히 공공(Gov) 클라우드를 다루다 보면 보안 규정과 장비 호환성 때문에 이론대로 되지 않는 경우가 허다합니다. 그럴 때 포기하지 않고 인프라 지식을 총동원하여 'SSH 로컬 포트 포워딩'과 systemd를 결합한 자동화된 실용적 우회로를 만들어내는 것이 데브옵스와 백엔드 개발자의 진정한 묘미가 아닐까 싶습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장비 문제로 고생하시는 분들께 이 글이 한 줄기 빛이 되길 바라며, 인프라 세팅이나 Spring Boot 설정과 관련하여 궁금한 점은 언제든 댓글로 남겨주시면 아는 선에서 최대한 자세히 답변드리겠습니다! 감사합니다.&lt;/p&gt;</description>
      <category>에러, 문제 해결</category>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/31</guid>
      <comments>https://taehyuck.tistory.com/31#entry31comment</comments>
      <pubDate>Mon, 2 Mar 2026 17:25:04 +0900</pubDate>
    </item>
    <item>
      <title>분산된 의료 장비의 다운 원인 찾기 - heartbeat</title>
      <link>https://taehyuck.tistory.com/29</link>
      <description>&lt;h1 data-pm-slice=&quot;1 1 []&quot;&gt;울산에서 서울의 병원 서버를 지키는 법: Heartbeat 프로젝트 회고&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 거리라는 장벽, 그리고 막막함&lt;/h3&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;선생님, 컴퓨터가 꺼졌어요.&quot; &quot;혹시 화면에 뭐 뜨는 거 없었나요?&quot; &quot;그냥 꺼져서 모르겠는데요.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현장에 있는 의료진분들에게 전문적인 트러블 슈팅을 기대할 순 없다. 그분들은 환자를 보는 게 주 업무니까. 결국 우리는 원인을 '추측'만 해야 했다. 전원 코드가 빠졌나? 윈도우 업데이트가 꼬였나? 부품이 노후화됐나?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 답답한 건 **&quot;병원에서 연락이 오기 전까지는 장비가 죽었는지 살았는지조차 모른다&quot;**는 사실이었다. 수동적인 대응. 터지고 나서야 수습하는 상황. 개발자로서, 그리고 서비스를 책임지는 운영자로서 이 상황을 더 이상 방치할 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;! (캡션: 물리적 거리는 멀지만, 관리는 실시간이어야 했다.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 살아있음을 증명하라: Heartbeat 프로젝트의 시작&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 시작했다. 이름하여 &lt;b&gt;Heartbeat(하트비트) 프로젝트&lt;/b&gt;. 말 그대로 심장 박동이다. 장비가 살아있다면, 나에게 주기적으로 &quot;나 살아있어!&quot;라고 신호를 보내게 만드는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트의 핵심 목표는 명확했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;선제적 대응:&lt;/b&gt; 병원보다 내가 먼저 장애를 알아야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;원인 규명:&lt;/b&gt; 왜 죽었는지, 죽기 직전의 상태(로그)를 확보해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자동화:&lt;/b&gt; 내가 자고 있을 때도 시스템은 감시되어야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;Agent (병원 장비):&lt;/b&gt; Python (가볍고 시스템 접근이 용이함)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Server (수집 서버):&lt;/b&gt; Spring Boot (안정적인 데이터 처리)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Monitoring &amp;amp; Alert:&lt;/b&gt; Prometheus &amp;amp; Grafana, Slack&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;! (캡션: 하트비트 프로젝트의 전체적인 데이터 흐름)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 무엇을 감시할 것인가? (디테일의 싸움)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &quot;켜져 있다/꺼져 있다&quot;만으로는 부족했다. 원인을 분석하려면 **맥락(Context)**이 필요했다. 그래서 Python 에이전트에 꽤 많은 기능을 심었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, 죽음의 흔적 찾기 (Event Log &amp;amp; Reboot)&lt;/b&gt; 의도치 않은 다운이 발생했을 때가 가장 문제다. 그래서 특정 시간이 되면 자동으로 부팅되도록 바이오스단/스케줄러를 세팅하고, 부팅 직후 Python 스크립트가 윈도우 이벤트 로그를 긁어오게 만들었다. &quot;Kernel-Power 41&quot; 오류인지, &quot;BugCheck(블루스크린)&quot;인지, 아니면 정상적인 &quot;종료&quot;였는지. 이제는 로그를 통해 원인을 역추적할 수 있게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, 위기 상황 감지 (Resource Monitoring)&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;CPU 온도:&lt;/b&gt; 쿨러가 고장 나서 쓰로틀링이 걸리는 건 아닌지?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;메모리 사용률:&lt;/b&gt; 램 누수(Leak)가 발생하고 있진 않은지?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상위 프로세스:&lt;/b&gt; 어떤 놈이 자원을 다 잡아먹고 있는지?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 데이터들을 실시간으로 긁어서 프로메테우스로 쏘아 올렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;! (캡션: 울산에서도 서울 병원의 장비 온도가 80도를 찍는 걸 볼 수 있다.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 딜레마: 감시자가 무거우면 안 된다 (최적화)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능을 구현하면서 예상치 못한 문제가 터졌다. 모니터링 대상 중에는 성능이 좋지 않은 &lt;b&gt;미니 PC&lt;/b&gt;들도 많았는데, &quot;감시를 철저히 하겠다&quot;는 욕심에 기능을 이것저것 넣다 보니 정작 감시 프로그램(Agent) 자체가 무거워지기 시작한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 &lt;b&gt;'상위 프로세스(Top Process) 수집'&lt;/b&gt; 기능이 문제였다. 어떤 프로세스가 자원을 많이 쓰는지 알기 위해 수시로 모든 프로세스 목록을 스캔하고 정렬하다 보니, 평소에도 Agent가 미니 PC의 순간 CPU 점유율을 **10%**나 잡아먹는 기현상이 발생했다. 장비의 상태를 지키려다 장비에 부하를 주는 꼴이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;항상 감시할 필요가 있을까?&quot;&lt;/b&gt; 생각을 바꿨다. 평소에는 가볍게 전체 사용량(Total Usage)만 체크하고, &lt;b&gt;자원 사용량이 특정 임계치를 넘었을 때만&lt;/b&gt; 무거운 '상위 프로세스 스캔' 로직이 돌도록 조건부(Conditional)로 코드를 수정했다.&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;Before:&lt;/b&gt; 무조건 주기적으로 전체 프로세스 스캔 &amp;amp; 정렬 -&amp;gt; CPU 10% 점유&lt;/li&gt;
&lt;li&gt;&lt;b&gt;After:&lt;/b&gt; 평소엔 단순 사용량 체크 -&amp;gt; CPU **2~3%**대로 안정화 (임계치 초과 시에만 정밀 분석 수행)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 최적화를 통해 리소스가 부족한 미니 PC에서도 부담 없이 24시간 돌아가는 가벼운 Agent를 완성할 수 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5. 그라파나와 슬랙, 나만의 관제 센터&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터가 모이니 비로소 **'관제'**가 가능해졌다. 그라파나 대시보드에 띄워진 수많은 그래프들은 단순한 그림이 아니다. 각 병원의 맥박이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 하루 종일 모니터만 보고 있을 순 없다. 그래서 &lt;b&gt;Slack 알림&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;&quot;A병원 장비 CPU 온도 85도 초과! 확인 요망&quot;&lt;/li&gt;
&lt;li&gt;&quot;B병원 장비 Heartbeat 끊김 (5분 경과). 다운 의심&quot;&lt;/li&gt;
&lt;li&gt;&quot;C병원 장비 재부팅 감지. 이벤트 로그 분석 결과: 업데이트로 인한 재부팅&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 슬랙 알림이 뜨면, 나는 병원에서 전화가 오기 전에 먼저 전화를 건다. &quot;선생님, 지금 장비 온도가 좀 높은데 혹시 본체 통풍구 쪽에 물건이 막혀 있나요?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한마디가 주는 신뢰감은 엄청나다. 우리가 계속 신경 쓰고 있다는 느낌을 주니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;! (캡션: 내 스마트폰으로 날아오는 골든타임 알람들)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6. 회고: 개발자가 편하려고 만든 게 아니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;내가 편하려고&quot;, &quot;주말에 전화를 덜 받고 싶어서&quot; 시작한 프로젝트였다. 하지만 구축하고 나니 생각이 바뀌었다. 이건 &lt;b&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;울산에 있는 개발팀이 전국에 흩어진 병원 장비를 관리하는 법. 거창한 AI 예측 모델이 아니더라도, 기본에 충실한 모니터링 시스템 하나가 우리 팀의 워라밸과 클라이언트의 신뢰를 동시에 지켜주고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;b&gt;[To-Do]&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;/li&gt;
&lt;li&gt;에이전트 업데이트 자동화 기능 추가하기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 과거의 치열했던 고민과 개발 과정을 복습하며 기록한 회고록입니다.&lt;/p&gt;</description>
      <category>에러, 문제 해결</category>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/29</guid>
      <comments>https://taehyuck.tistory.com/29#entry29comment</comments>
      <pubDate>Sat, 17 Jan 2026 18:07:45 +0900</pubDate>
    </item>
    <item>
      <title>MSSQL 마이그레이션 문제 - 병원 대용량 데이터</title>
      <link>https://taehyuck.tistory.com/28</link>
      <description>&lt;h1 data-pm-slice=&quot;0 0 []&quot;&gt;식은땀 흘리며 완수한 병원 데이터 대이동 (MSSQL 마이그레이션 회고)&lt;/h1&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;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. &quot;데이터는 건드리면 안 된다&quot;는 압박감&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 팀장님께 이 업무를 받았을 때가 생각난다. &lt;b&gt;&quot;운영 중인 병원 데이터를 NCP(Naver Cloud Platform)로 옮겨야 해. 근데 서비스는 멈추면 안 돼.&quot;&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;가장 먼저 한 일은 코드를 짜는 게 아니었다. &lt;b&gt;백업 데이터베이스&lt;/b&gt;부터 만들었다. &quot;실수해도 돌아갈 곳이 있다&quot;는 심리적 안전장치가 없으면 손이 떨려서 아무것도 못 할 것 같았기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 현실의 벽: 교과서대로 되는 게 하나도 없다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 DB 이관이라고 하면 떠오르는 정석적인 방법들이 있다. 나도 처음엔 당연히 그걸 쓰려고 했다. 하지만 레거시 시스템의 현실은 냉혹했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시도했던 방법들이 실패한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 미러링(Mirroring)?&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;실패. 원본 DB 버전이 대상(NCP)보다 높았다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;[공식 제약 사항]&lt;/b&gt; Microsoft 공식 문서 및 기술 자료에 따르면, &lt;b&gt;상위 버전 SQL Server에서 생성된 데이터베이스 백업 파일은 하위 버전으로 복원(Restore)할 수 없다.&lt;/b&gt; (내부 데이터베이스 버전 호환성 문제)&lt;/li&gt;
&lt;li&gt;미러링을 구성하려면 먼저 대상 서버에 원본 DB를 NORECOVERY 모드로 복원해야 하는데, 버전 차이로 인해 이 &lt;b&gt;복원 자체가 불가능&lt;/b&gt;했기 때문에 미러링 시도조차 할 수 없었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. CDC(Change Data Capture)?&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;실패. 이게 제일 뼈아팠다. CDC를 쓰려면 테이블에 **PK(Primary Key)**가 있어야 하는데, 옛날에 설계된 테이블이라 PK 없는 테이블이 수두룩했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 단순 덤프 앤 리스토어?&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;불가능. 데이터 양이 수백만 건이다. 이걸 옮기는 동안 병원 업무를 멈출 수는 없었다. (Zero Downtime 필수)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 돌파구: &quot;없으면 만들어서 옮긴다&quot;&lt;/h2&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 아이디어는 두 가지였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;조합키(Composite Key):&lt;/b&gt; PK가 없다면, 유니크함을 보장할 수 있는 컬럼 몇 개를 묶어서 가상의 키로 쓰자.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파이썬 배치(Python Batch):&lt;/b&gt; 원본과 대상을 비교해서 &lt;b&gt;'없는 것만 쏙쏙'&lt;/b&gt; 집어넣자.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;조합키 전략 (예시)&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;※ 실제 데이터 구조는 훨씬 복잡하고 민감하므로, 이해를 돕기 위해 단순화한 예시입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환자ID, 진료일자, 진료코드, 순번. 이 4개 컬럼을 합치면 중복되지 않는 하나의 행(Row)을 특정할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 중요한 건 &lt;b&gt;성능&lt;/b&gt;이었다. 수백만 건을 그냥 비교하면 DB가 뻗어버린다. 원본 DB의 해당 컬럼들에 **인덱스(Index)**를 걸어줬다. 이거 안 했으면 아마 마이그레이션이 일주일은 걸렸을 거다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 코드 구현: 핵심 로직 (예시 코드)&lt;/h2&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;다시 봐도 exists == 0 체크하는 부분이 이 로직의 심장이다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;# ... DB 연결 설정 생략 ...

def migration_logic():
    # 1. 원본에서 데이터 가져오기 (배치 단위로 가져오는 게 좋음)
    # (예시 쿼리) 실제로는 수십 개의 컬럼이 존재함
    sql = &quot;SELECT pid, date, code, type, value, comment FROM PatientTable&quot;
    source_cursor.execute(sql)
    
    row = source_cursor.fetchone()
    while row:
        # 2. [핵심] 대상 DB에 이미 있는지 확인 (조합키 활용)
        # PK가 없으므로 4개의 컬럼(예시)을 AND 조건으로 묶어서 확인한다.
        check_sql = &quot;&quot;&quot;
            SELECT count(*) FROM PatientTable 
            WHERE pid=%s AND date=%s AND code=%s AND type=%s
        &quot;&quot;&quot;
        target_cursor.execute(check_sql, (row['pid'], row['date'], row['code'], row['type']))
        
        # 결과가 0이면 대상 DB에 없다는 뜻
        exists = target_cursor.fetchone()[0]

        if exists == 0:
            # 3. 데이터 삽입 (INSERT)
            # 대상 DB에 없는 데이터만 선별적으로 이관
            insert_sql = &quot;&quot;&quot;
                INSERT INTO PatientTable (pid, date, code, type, value, comment)
                VALUES (%s, %s, %s, %s, %s, %s)
            &quot;&quot;&quot;
            target_cursor.execute(insert_sql, (
                row['pid'], row['date'], row['code'], row['type'], 
                row['value'], row['comment']
            ))
            
            # 로그 남기기
            logging.info(f&quot;데이터 이관 성공: {row['pid']} - {row['date']}&quot;)
        
        row = source_cursor.fetchone()

    target_conn.commit()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 파이썬 배치로 돌려놓고, 모니터링 화면을 뚫어져라 쳐다봤던 기억이 난다. 로그에 데이터 이관 성공이 줄줄이 찍힐 때의 그 쾌감이란.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 회고: 무엇을 배웠는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 마이그레이션은 성공했다. 운영 중인 서비스는 단 한 번의 끊김도 없었고, 데이터 누락도 발생하지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 일을 통해 배운 점을 정리해 본다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;안 되는 환경 탓을 하지 말자.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;버전이 안 맞고 PK가 없어도, 데이터를 식별할 수 있는 논리적인 방법(조합키)은 반드시 존재한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기본기가 중요하다.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인덱스(Index)를 적절히 걸어주지 않았다면 이 방식은 불가능했을 것이다. 쿼리 튜닝의 중요성을 다시 한번 느꼈다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;백업은 개발자의 수명 연장 수단이다.&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;백업본이 있었기에 과감하게 인덱스도 걸고 테스트도 할 수 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때는 정말 힘들었지만, 덕분에 레거시 데이터를 다루는 데 있어서 큰 자신감을 얻었다. 다음에 또 이런 말도 안 되는(?) 데이터를 만나더라도, 당황하지 않고 &quot;조합키부터 찾아보자&quot;라고 말할 수 있을 것 같다.&lt;/p&gt;</description>
      <category>에러, 문제 해결</category>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/28</guid>
      <comments>https://taehyuck.tistory.com/28#entry28comment</comments>
      <pubDate>Sat, 17 Jan 2026 17:42:25 +0900</pubDate>
    </item>
    <item>
      <title>도커란? - 이미지, 컨테이너, 도커 컴포즈까지</title>
      <link>https://taehyuck.tistory.com/27</link>
      <description>&lt;h1&gt;도커(Docker)란 무엇인가&lt;/h1&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;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 컴퓨터에서는 잘 돌아가는데,&lt;br /&gt;다른 사람 컴퓨터나 서버에서는 에러가 난다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 간단합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운영체제(OS)가 다르고&lt;/li&gt;
&lt;li&gt;라이브러리 버전이 다르고&lt;/li&gt;
&lt;li&gt;실행 환경 설정이 다르기 때문입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도커는 이런 문제를 해결하기 위해 등장했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행 환경 자체를 하나로 묶어서 어디서든 동일하게 실행할 수 있게 해줍니다.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;도커의 핵심 개념&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도커를 이해하려면 아래 세 가지만 알면 됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;이미지(Image)&lt;/li&gt;
&lt;li&gt;컨테이너(Container)&lt;/li&gt;
&lt;li&gt;Docker Compose&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 이미지(Image)란?&lt;/h2&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운영체제 환경&lt;/li&gt;
&lt;li&gt;필요한 라이브러리&lt;/li&gt;
&lt;li&gt;프로그램 코드&lt;/li&gt;
&lt;li&gt;실행 명령어&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지는 &lt;b&gt;실행되지 않습니다&lt;/b&gt;.&lt;br /&gt;실행되기 위한 모든 준비가 되어 있는 상태일 뿐입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유하자면,&lt;br /&gt;이미지는 예전 &lt;b&gt;닌텐도 게임 칩&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;/li&gt;
&lt;li&gt;기기에 꽂아야 게임이 실행됨&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 컨테이너(Container)란?&lt;/h2&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;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하나의 컴퓨터 안에서 동작하는 독립된 실행 공간&lt;/li&gt;
&lt;li&gt;여러 개의 컨테이너를 동시에 실행 가능&lt;/li&gt;
&lt;li&gt;각 컨테이너는 서로 영향을 주지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 컨테이너 하나를&lt;br /&gt;&lt;b&gt;하나의 프로젝트 또는 하나의 서비스 전용 공간&lt;/b&gt;으로 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이미지를 실행한 결과가 컨테이너다&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라고 이해하면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 프로젝트 예시&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 프로젝트에 다음 기술들이 있다고 가정해봅시다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Next.js (프론트엔드)&lt;/li&gt;
&lt;li&gt;Spring Boot (백엔드)&lt;/li&gt;
&lt;li&gt;Redis (캐시, 세션 저장소)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 자주 하는 오해가 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이걸 하나의 이미지로 묶어야 하나?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답은 &lt;b&gt;아닙니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;❌ 하나의 이미지로 묶지 않는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 Next.js, Spring Boot, Redis를&lt;br /&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;/li&gt;
&lt;li&gt;하나가 죽으면 전부 같이 영향 받음&lt;/li&gt;
&lt;li&gt;서비스 확장(Scale)이 어려움&lt;/li&gt;
&lt;li&gt;실무 구조와 완전히 다름&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 실무에서는 절대 이렇게 구성하지 않습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 올바른 도커 구성 방식&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서비스 하나당 이미지 하나&lt;/b&gt;를 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;mel&quot;&gt;&lt;code&gt;Next.js      &amp;rarr; nextjs 이미지  &amp;rarr; nextjs 컨테이너
Spring Boot  &amp;rarr; spring 이미지  &amp;rarr; spring 컨테이너
Redis        &amp;rarr; redis 이미지   &amp;rarr; redis 컨테이너
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 서비스는 &lt;b&gt;완전히 독립된 컨테이너&lt;/b&gt;로 실행됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Docker Compose란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Compose는&lt;br /&gt;&lt;b&gt;여러 컨테이너를 하나의 프로젝트처럼 묶어서 관리하는 도구&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Compose가 하는 일은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 컨테이너를 한 번에 실행&lt;/li&gt;
&lt;li&gt;컨테이너 간 네트워크 연결&lt;/li&gt;
&lt;li&gt;실행 순서 관리&lt;/li&gt;
&lt;li&gt;환경 변수, 포트 설정 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;각각의 컨테이너를 연결하고 동시에 관리해주는 도구&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라고 이해하면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개념을 한 번에 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;/li&gt;
&lt;li&gt;컨테이너: 설명서대로 만든 레고 작품&lt;/li&gt;
&lt;li&gt;Docker Compose: 여러 레고를 한 판 위에 올려서 연결해주는 판&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한 줄 요약&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이미지: 실행 환경이 모두 들어 있는 설계도&lt;/li&gt;
&lt;li&gt;컨테이너: 이미지를 실행한 독립된 공간&lt;/li&gt;
&lt;li&gt;Docker Compose: 여러 컨테이너를 하나의 프로젝트처럼 관리하는 도구&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도커는&lt;br /&gt;&quot;내 컴퓨터에서는 됐는데요?&quot;라는 말을&lt;br /&gt;없애기 위해 만들어진 기술입니다.&lt;/p&gt;</description>
      <category>도커</category>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/27</guid>
      <comments>https://taehyuck.tistory.com/27#entry27comment</comments>
      <pubDate>Sat, 3 Jan 2026 19:34:27 +0900</pubDate>
    </item>
    <item>
      <title>신입 개발자의 첫 실무 도전기: 얼굴인식 + 예약관리 서비스 구축</title>
      <link>https://taehyuck.tistory.com/26</link>
      <description>&lt;h1 data-end=&quot;196&quot; data-start=&quot;169&quot;&gt;코쿤부스 예약 관리 프로젝트 개발기&lt;/h1&gt;
&lt;p data-end=&quot;223&quot; data-start=&quot;197&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;입사 일주일 만에 맡은 첫 실무 프로젝트&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;373&quot; data-start=&quot;225&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트 시작한 지 이제 2주 정도. 입사하자마자 &amp;ldquo;한 번 맡아보라&amp;rdquo;는 말을 들었을 때, 기대와 걱정이 동시에 밀려왔다.&lt;br /&gt;특히 나는 &lt;b&gt;백엔드 중심 개발자&lt;/b&gt;라 프론트엔드 디자인 감각이 좋지 않다는 피드백을 자주 들어서, 실제 화면을 만들 생각에 가장 긴장됐다.&lt;/p&gt;
&lt;hr data-end=&quot;378&quot; data-start=&quot;375&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;394&quot; data-start=&quot;380&quot; data-ke-size=&quot;size26&quot;&gt;1. 프로젝트 개요&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;563&quot; data-start=&quot;396&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;416&quot; data-start=&quot;396&quot;&gt;&lt;b&gt;프론트엔드:&lt;/b&gt; React&lt;/li&gt;
&lt;li data-end=&quot;453&quot; data-start=&quot;417&quot;&gt;&lt;b&gt;Web Backend API:&lt;/b&gt; Spring Boot&lt;/li&gt;
&lt;li data-end=&quot;482&quot; data-start=&quot;454&quot;&gt;&lt;b&gt;AI&amp;middot;얼굴인식 API:&lt;/b&gt; FastAPI&lt;/li&gt;
&lt;li data-end=&quot;516&quot; data-start=&quot;483&quot;&gt;&lt;b&gt;DB &amp;amp; 배포:&lt;/b&gt; 추후 클라우드 환경 포함 예정&lt;/li&gt;
&lt;li data-end=&quot;563&quot; data-start=&quot;517&quot;&gt;&lt;b&gt;기능:&lt;/b&gt; 회원가입 + 얼굴 인증, 예약&amp;middot;취소, 관리자 승인, 로그 저장 등&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;652&quot; data-start=&quot;565&quot; data-ke-size=&quot;size16&quot;&gt;FastAPI 하나로 웹 API까지 모두 처리해도 됐지만,&lt;br /&gt;&lt;b&gt;장기적인 고도화(확장성)를 고려해서 웹 API는 Spring Boot로 분리&lt;/b&gt;했다.&lt;/p&gt;
&lt;hr data-end=&quot;796&quot; data-start=&quot;793&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;830&quot; data-start=&quot;798&quot; data-ke-size=&quot;size26&quot;&gt;2.&amp;nbsp; 프로젝트 시작: 가장 먼저 API부터 만들었다&lt;/h2&gt;
&lt;p data-end=&quot;927&quot; data-start=&quot;832&quot; data-ke-size=&quot;size16&quot;&gt;백엔드는 익숙하기 때문에 &lt;b&gt;Spring Boot 기반 API는 몇 시간 만에 프로토타입 완성&lt;/b&gt;했다.&lt;br /&gt;로그인, 회원가입, 예약 조회, 예약 생성 정도는 금방 뽑았다.&lt;/p&gt;
&lt;p data-end=&quot;939&quot; data-start=&quot;929&quot; data-ke-size=&quot;size16&quot;&gt;문제는 프론트였다.&lt;/p&gt;
&lt;hr data-end=&quot;944&quot; data-start=&quot;941&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;976&quot; data-start=&quot;946&quot; data-ke-size=&quot;size26&quot;&gt;3.&amp;nbsp; 프론트엔드 디자인, 하루가 사라지는 경험&amp;hellip;&lt;/h2&gt;
&lt;p data-end=&quot;1038&quot; data-start=&quot;978&quot; data-ke-size=&quot;size16&quot;&gt;React로 구현하는 것 자체는 어렵지 않았는데,&lt;br /&gt;&quot;디자인을 어떻게 할 것인가&quot;가 진짜 문제였다.&lt;/p&gt;
&lt;p data-end=&quot;1114&quot; data-start=&quot;1040&quot; data-ke-size=&quot;size16&quot;&gt;원하는 UI/UX를 만들기 위해 계속 수정하고,&lt;br /&gt;CSS를 고치고, margin 하나에 수십 번 새로고침하면서 하루가 다 지나갔다.&lt;/p&gt;
&lt;blockquote data-end=&quot;1172&quot; data-start=&quot;1116&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1172&quot; data-start=&quot;1118&quot; data-ke-size=&quot;size16&quot;&gt;이때 깨달음&lt;br /&gt;&lt;b&gt;프론트는 단순히 코드가 아니라 '감각'과 '구현력'이 동시에 필요하다.&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;1228&quot; data-start=&quot;1174&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1228&quot; data-start=&quot;1174&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1228&quot; data-start=&quot;1174&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1228&quot; data-start=&quot;1174&quot; data-ke-size=&quot;size16&quot;&gt;하지만 확실히 하루 동안 엄청 배웠다.&lt;br /&gt;오히려 지금은 더 빠르게 화면을 뽑아낼 자신도 생겼다.&lt;/p&gt;
&lt;p data-end=&quot;1228&quot; data-start=&quot;1174&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1228&quot; data-start=&quot;1174&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-end=&quot;1330&quot; data-start=&quot;1327&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1361&quot; data-start=&quot;1332&quot; data-ke-size=&quot;size26&quot;&gt;4. 회원가입: 얼굴을 이용한 중복 가입 방지&lt;/h2&gt;
&lt;p data-end=&quot;1415&quot; data-start=&quot;1363&quot; data-ke-size=&quot;size16&quot;&gt;가장 특징적인 기능은 &lt;b&gt;OpenCV로 얼굴을 촬영해 회원가입 시 얼굴 정보를 저장&lt;/b&gt;한 것.&lt;/p&gt;
&lt;p data-end=&quot;1415&quot; data-start=&quot;1363&quot; data-ke-size=&quot;size16&quot;&gt;AI 센터에 둘거라서 오는 사람들로 하여금 미래 지향적인 느낌을 주고 싶었다.&lt;/p&gt;
&lt;p data-end=&quot;1489&quot; data-start=&quot;1417&quot; data-ke-size=&quot;size16&quot;&gt;FastAPI에서 OpenCV를 이용해 사용자의 얼굴을 분석하고,&lt;br /&gt;이미 등록된 얼굴과 유사도가 높으면 가입을 막도록 구현했다.&lt;/p&gt;
&lt;h3 data-end=&quot;1516&quot; data-start=&quot;1491&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;FastAPI 얼굴 분석 예시 코드&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1764509973966&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# /app/face_check.py
import cv2
import numpy as np

def is_duplicate_face(new_face_img, saved_face_img):
    new = cv2.imread(new_face_img, cv2.IMREAD_GRAYSCALE)
    saved = cv2.imread(saved_face_img, cv2.IMREAD_GRAYSCALE)

    orb = cv2.ORB_create()
    kp1, des1 = orb.detectAndCompute(new, None)
    kp2, des2 = orb.detectAndCompute(saved, None)

    bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
    matches = bf.match(des1, des2)

    score = sum([m.distance for m in matches]) / len(matches)
    return score &amp;lt; 50  # 임계값&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2106&quot; data-start=&quot;2068&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2106&quot; data-start=&quot;2068&quot; data-ke-size=&quot;size16&quot;&gt;이 기능은 실무에서 꽤 신선해했다.&lt;br /&gt;다만 고민되는 부분이 있었다.&lt;/p&gt;
&lt;hr data-end=&quot;2111&quot; data-start=&quot;2108&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2147&quot; data-start=&quot;2113&quot; data-ke-size=&quot;size26&quot;&gt;5. 고민 : 쌍둥이처럼 닮은 사람은 어떻게 해야 할까?&lt;/h2&gt;
&lt;p data-end=&quot;2222&quot; data-start=&quot;2149&quot; data-ke-size=&quot;size16&quot;&gt;얼굴 인식 기반 중복 가입 방지가 좋긴 하지만,&lt;br /&gt;&lt;b&gt;쌍둥이나 아주 비슷하게 생긴 사람은 가입이 막힐 수 있다&lt;/b&gt;는 문제가 있다.&lt;/p&gt;
&lt;p data-end=&quot;2271&quot; data-start=&quot;2224&quot; data-ke-size=&quot;size16&quot;&gt;민원 가능성도 있고, 너무 강한 인증은 &quot;가벼운 프로젝트&quot;에 맞지 않을 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2339&quot; data-start=&quot;2273&quot; data-ke-size=&quot;size16&quot;&gt;PASS 같은 본인인증 연동도 생각했지만&lt;br /&gt;- 신입에, 작은 프로젝트에, 회사에서도 그렇게까지는 원하지 않는 상황.&lt;/p&gt;
&lt;p data-end=&quot;2388&quot; data-start=&quot;2341&quot; data-ke-size=&quot;size16&quot;&gt;그래서 이 문제는&lt;br /&gt;&quot;고도화 2차 단계에서 해결할 과제로 보류&quot;하기로 했다.&lt;/p&gt;
&lt;hr data-end=&quot;2393&quot; data-start=&quot;2390&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2429&quot; data-start=&quot;2395&quot; data-ke-size=&quot;size26&quot;&gt;6. 보안 강화 : 세션 로그인이었지만 JWT를 추가했다&lt;/h2&gt;
&lt;p data-end=&quot;2505&quot; data-start=&quot;2431&quot; data-ke-size=&quot;size16&quot;&gt;원래는 쿠키 + 세션 기반 로그인만 구현했었다.&lt;br /&gt;하지만 예약 시스템은 &lt;b&gt;다른 사용자 예약을 취소하면 큰 사고&lt;/b&gt;가 될 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;2527&quot; data-start=&quot;2507&quot; data-ke-size=&quot;size16&quot;&gt;그래서 다음과 같이 설계를 변경했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2585&quot; data-start=&quot;2529&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2550&quot; data-start=&quot;2529&quot;&gt;쿠키 + 세션 &amp;rarr; 기본 로그인 유지&lt;/li&gt;
&lt;li data-end=&quot;2585&quot; data-start=&quot;2551&quot;&gt;JWT &amp;rarr; 예약&amp;middot;취소&amp;middot;승인 API 접근 시 필요하도록 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2609&quot; data-start=&quot;2587&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;이중 보안 구조&lt;/b&gt;를 사용했다.&lt;/p&gt;
&lt;h3 data-end=&quot;2638&quot; data-start=&quot;2611&quot; data-ke-size=&quot;size23&quot;&gt;Spring Boot JWT 필터 예시&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1764510046868&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// src/main/java/com/project/security/JwtFilter.java
public class JwtFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader(&quot;Authorization&quot;);

        if (token != null &amp;amp;&amp;amp; JwtUtils.validateToken(token)) {
            Authentication auth = JwtUtils.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;3340&quot; data-start=&quot;3337&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;3381&quot; data-start=&quot;3342&quot; data-ke-size=&quot;size26&quot;&gt;7. 운영을 고려한 개발: N+1 해결, 로그 저장, 성능 고민&lt;/h2&gt;
&lt;p data-end=&quot;3431&quot; data-start=&quot;3383&quot; data-ke-size=&quot;size16&quot;&gt;팀에서 &quot;실제로 상용화될 가능성이 있다&quot;고 하셔서&lt;br /&gt;운영단계를 염두에 두고 개발했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3580&quot; data-start=&quot;3433&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3494&quot; data-start=&quot;3433&quot;&gt;&lt;b&gt;Spring Data JPA의 N+1 문제 해결 (fetch join, EntityGraph 적용)&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;3521&quot; data-start=&quot;3495&quot;&gt;&lt;b&gt;요청 로그&amp;middot;AI 로그 저장하도록 구성&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;3561&quot; data-start=&quot;3522&quot;&gt;&lt;b&gt;fastapi와 spring 각각의 성능 모니터링 구조 구성&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;3580&quot; data-start=&quot;3562&quot;&gt;&lt;b&gt;예약 API 성능 튜닝&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3630&quot; data-start=&quot;3582&quot; data-ke-size=&quot;size16&quot;&gt;아직 실제 대규모 트래픽은 받아보지 않았지만&lt;br /&gt;최소한 구조적으로는 준비해 둔 셈이다.&lt;/p&gt;
&lt;p data-end=&quot;3630&quot; data-start=&quot;3582&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;3630&quot; data-start=&quot;3582&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-end=&quot;3725&quot; data-start=&quot;3722&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;3757&quot; data-start=&quot;3727&quot; data-ke-size=&quot;size26&quot;&gt;8. 배포 고민: 클라우드에서 어떻게 구성할까?&lt;/h2&gt;
&lt;p data-end=&quot;3780&quot; data-start=&quot;3759&quot; data-ke-size=&quot;size16&quot;&gt;지금 가장 고민되는 점은 배포 구조다.&lt;/p&gt;
&lt;p data-end=&quot;3797&quot; data-start=&quot;3782&quot; data-ke-size=&quot;size16&quot;&gt;가능한 옵션은 다음 3가지:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;3950&quot; data-start=&quot;3799&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;3846&quot; data-start=&quot;3799&quot;&gt;&lt;b&gt;서버 한 대에 React + Spring + FastAPI 모두 넣기 (가격과 가장 편리함, 하지만 장애에 취약)&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;3905&quot; data-start=&quot;3847&quot;&gt;&lt;b&gt;Spring / FastAPI 분리, React는 S3 또는 Storage에 정적 호스팅&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;3950&quot; data-start=&quot;3906&quot;&gt;&lt;b&gt;서버 2대 구성 후, API Load Balancer까지 확장 고려&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;4002&quot; data-start=&quot;3952&quot; data-ke-size=&quot;size16&quot;&gt;회사에서 어떤 선택을 할지는 아직 모르지만,&lt;br /&gt;나는 2번 또는 3번을 추천드릴 생각이다.&lt;/p&gt;
&lt;p data-end=&quot;4002&quot; data-start=&quot;3952&quot; data-ke-size=&quot;size16&quot;&gt;로드밸런서가 엄청나게 큰 규모의 프로젝트에서도 달에 10만원 정도만 나온다고 하고&lt;/p&gt;
&lt;p data-end=&quot;4002&quot; data-start=&quot;3952&quot; data-ke-size=&quot;size16&quot;&gt;서버는 내부망에서만 접근할 수 있어 보안 수준이 향상된다.&lt;/p&gt;
&lt;hr data-end=&quot;4007&quot; data-start=&quot;4004&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;4030&quot; data-start=&quot;4009&quot; data-ke-size=&quot;size26&quot;&gt;9. 회고 : 한 주 동안의 성장&lt;/h2&gt;
&lt;p data-end=&quot;4112&quot; data-start=&quot;4032&quot; data-ke-size=&quot;size16&quot;&gt;이 프로젝트는 단순한 CRUD 시스템이 아니다.&lt;br /&gt;&lt;b&gt;AI + 인증 + 예약 로직 + 프론트 + 배포까지 혼자 맡은 첫 실무 프로젝트&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-end=&quot;4130&quot; data-start=&quot;4114&quot; data-ke-size=&quot;size16&quot;&gt;가장 큰 배움은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4316&quot; data-start=&quot;4132&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4173&quot; data-start=&quot;4132&quot;&gt;백엔드만 하던 내가 &lt;b&gt;프론트&amp;middot;디자인의 난이도&lt;/b&gt;를 직접 체감했다.&lt;/li&gt;
&lt;li data-end=&quot;4241&quot; data-start=&quot;4174&quot;&gt;빠르게 기능을 개발하는 것보다,&lt;br /&gt;&lt;b&gt;사용자가 편하게 느끼는 화면을 만드는 것이 훨씬 어렵다&lt;/b&gt;는 것을 알았다.&lt;/li&gt;
&lt;li data-end=&quot;4276&quot; data-start=&quot;4242&quot;&gt;실제 운영을 고려한 성능&amp;middot;예외 처리&amp;middot;보안 구조를 경험했다.&lt;/li&gt;
&lt;li data-end=&quot;4316&quot; data-start=&quot;4277&quot;&gt;기술 선택은 결국 &amp;ldquo;현재와 미래&amp;rdquo; 둘 다를 봐야 한다는 걸 배웠다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;4377&quot; data-start=&quot;4318&quot; data-ke-size=&quot;size16&quot;&gt;앞으로는 얼굴인식 정교화, PASS 본인인증 연동,&lt;br /&gt;운영 환경에서의 부하테스트 등 해야 할 것이 많다.&lt;/p&gt;
&lt;p data-end=&quot;4444&quot; data-start=&quot;4379&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 프로젝트를 통해 확실히 한 단계 성장한 느낌이다.&lt;br /&gt;첫 실무 프로젝트 치고는 꽤 좋은 경험이라 생각한다.&lt;/p&gt;
&lt;hr data-end=&quot;4449&quot; data-start=&quot;4446&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;4460&quot; data-start=&quot;4451&quot; data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-end=&quot;4521&quot; data-start=&quot;4462&quot; data-ke-size=&quot;size16&quot;&gt;앞으로도 기능 추가와 고도화를 계속하면서&lt;br /&gt;내가 만든 시스템이 실제 운영되는 경험을 반드시 해보고 싶다.&lt;/p&gt;
&lt;p data-end=&quot;4586&quot; data-start=&quot;4523&quot; data-ke-size=&quot;size16&quot;&gt;혹시 구현 과정이나 소스 구조에 대해 궁금한 점이 있다면&lt;br /&gt;댓글로 남겨주시면 최대한 상세하게 기록해볼 생각이다.&lt;/p&gt;</description>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/26</guid>
      <comments>https://taehyuck.tistory.com/26#entry26comment</comments>
      <pubDate>Sun, 30 Nov 2025 22:58:40 +0900</pubDate>
    </item>
    <item>
      <title>온프레미스 서버 띄우기</title>
      <link>https://taehyuck.tistory.com/25</link>
      <description>&lt;h1 data-end=&quot;207&quot; data-start=&quot;153&quot;&gt;온프레미스 서버 구축 도전기: 새 컴퓨터에 Ubuntu 설치부터 FastAPI 배포까지&lt;/h1&gt;
&lt;p data-end=&quot;356&quot; data-start=&quot;209&quot; data-ke-size=&quot;size16&quot;&gt;직장에 입사한 이후, 처음으로 &amp;ldquo;온프레미스 서버&amp;rdquo;라는 걸 직접 구축하게 되었다.&lt;br /&gt;클라우드(AWS EC2)만 경험해 본 나에게 이 작업은 낯설고 조금은 두려운 일이었다.&lt;br /&gt;하지만 실제로 서버를 직접 띄워보고 운영하는 경험은 생각보다 흥미롭고 배울 점이 많았다.&lt;/p&gt;
&lt;p data-end=&quot;490&quot; data-start=&quot;358&quot; data-ke-size=&quot;size16&quot;&gt;이 글에서는 &lt;b&gt;Ubuntu 설치 &amp;rarr; NVIDIA 드라이버 구성 &amp;rarr; SSH 포트 오픈 &amp;rarr; 포트포워딩 &amp;rarr; FastAPI 배포&lt;/b&gt;까지의 모든 과정을 기록한다.&lt;br /&gt;누군가 온프레미스 서버를 처음 세팅하려 한다면 좋은 레퍼런스가 되었으면 한다.&lt;/p&gt;
&lt;hr data-end=&quot;495&quot; data-start=&quot;492&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;536&quot; data-start=&quot;497&quot;&gt;1. 회사에서 받은 첫 과제: &amp;ldquo;5090 컴퓨터 두 대를 세팅해라&amp;rdquo;&lt;/h1&gt;
&lt;p data-end=&quot;610&quot; data-start=&quot;538&quot; data-ke-size=&quot;size16&quot;&gt;입사한 지 얼마 되지 않은 시점에 사수님께서 컴퓨터 두 대(GeForce RTX 5090 장착)를 가져다 놓고 이렇게 말씀하셨다.&lt;/p&gt;
&lt;blockquote data-end=&quot;662&quot; data-start=&quot;612&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;662&quot; data-start=&quot;614&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;OS를 Ubuntu로 설치하고, 각각 컴퓨터 사양부터 드라이버까지 완벽히 세팅해봐.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;733&quot; data-start=&quot;664&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 당혹스러웠다.&lt;br /&gt;윈도우는 수도 없이 깔아봤지만, 리눅스(Ubuntu)는 설치만 몇 번 다뤄본 수준이었기 때문이다.&lt;/p&gt;
&lt;hr data-end=&quot;858&quot; data-start=&quot;855&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;900&quot; data-start=&quot;860&quot;&gt;2. Ubuntu 설치 &amp;mdash; 다행히 Windows와 크게 다르지 않았다&lt;/h1&gt;
&lt;p data-end=&quot;986&quot; data-start=&quot;902&quot; data-ke-size=&quot;size16&quot;&gt;Ubuntu 설치는 생각보다 어렵지 않았다.&lt;br /&gt;윈도우 설치할 때처럼 USB에 이미지를 굽고, BIOS에서 부팅 순서만 변경하면 금방 설치가 완료된다.&lt;/p&gt;
&lt;h3 data-end=&quot;1005&quot; data-start=&quot;988&quot; data-ke-size=&quot;size23&quot;&gt;설치 시 주의한 점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1084&quot; data-start=&quot;1006&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1034&quot; data-start=&quot;1006&quot;&gt;파티션 자동 설정 사용 &amp;rarr; 초보자에겐 가장 안전&lt;/li&gt;
&lt;li data-end=&quot;1052&quot; data-start=&quot;1035&quot;&gt;부트로더는 기본 설정 그대로&lt;/li&gt;
&lt;li data-end=&quot;1084&quot; data-start=&quot;1053&quot;&gt;인터넷 연결은 유선 LAN으로 먼저 잡는 것이 안정적&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1125&quot; data-start=&quot;1086&quot; data-ke-size=&quot;size16&quot;&gt;처음이라 긴장했지만, 예상보다 매끄럽게 Ubuntu 설치가 완료되었다.&lt;/p&gt;
&lt;hr data-end=&quot;1130&quot; data-start=&quot;1127&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;1186&quot; data-start=&quot;1132&quot;&gt;3. 그래픽카드(NVIDIA) 드라이버 문제: &amp;ldquo;설치했는데 nvidia-smi가 안 먹힌다?&amp;rdquo;&lt;/h1&gt;
&lt;p data-end=&quot;1273&quot; data-start=&quot;1188&quot; data-ke-size=&quot;size16&quot;&gt;Ubuntu 설치 후 가장 중요한 작업은 GPU 드라이버 설정이었다.&lt;br /&gt;특히 5090처럼 최신 그래픽카드는 드라이버 버전 호환 문제가 발생하기 쉬웠다.&lt;/p&gt;
&lt;p data-end=&quot;1319&quot; data-start=&quot;1275&quot; data-ke-size=&quot;size16&quot;&gt;나는 처음에 &amp;lsquo;최신 버전 드라이버&amp;rsquo;를 그대로 설치했지만,&lt;br /&gt;막상 실행해 보니&amp;hellip;&lt;/p&gt;
&lt;p data-end=&quot;1319&quot; data-start=&quot;1275&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1763217142532&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;nvidia-smi
command not found 또는 드라이버 불일치 오류&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;1399&quot; data-start=&quot;1388&quot; data-ke-size=&quot;size16&quot;&gt;이런 문제가 발생했다.&lt;/p&gt;
&lt;h3 data-end=&quot;1399&quot; data-start=&quot;1388&quot; data-ke-size=&quot;size23&quot;&gt;✔ 해결 방법&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1597&quot; data-start=&quot;1400&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1472&quot; data-start=&quot;1400&quot;&gt;현재 Ubuntu에서 권장하는 드라이버 목록 확인
&lt;pre id=&quot;code_1763217289860&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ubuntu-drivers devices&lt;/code&gt;&lt;/pre&gt;
&lt;br /&gt;&lt;br /&gt;&lt;/li&gt;
&lt;li data-end=&quot;1472&quot; data-start=&quot;1436&quot;&gt;&amp;ldquo;recommended&amp;rdquo; 버전을 설치
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1763217326590&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;sudo apt install nvidia-driver-###&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1552&quot; data-start=&quot;1473&quot;&gt;재부팅 후 다시 확인&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1763217375347&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;nvidia-smi&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-end=&quot;1657&quot; data-start=&quot;1637&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확한 버전을 맞추니 정상적으로 GPU 정보를 확인할 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;643&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lbotv/dJMcain9HK3/STnNljgpFxhBsvt5JGTXrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lbotv/dJMcain9HK3/STnNljgpFxhBsvt5JGTXrk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lbotv/dJMcain9HK3/STnNljgpFxhBsvt5JGTXrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flbotv%2FdJMcain9HK3%2FSTnNljgpFxhBsvt5JGTXrk%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;937&quot; height=&quot;643&quot; data-origin-width=&quot;937&quot; data-origin-height=&quot;643&quot;/&gt;&lt;/span&gt;&lt;/figure&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;&amp;nbsp;&lt;/p&gt;
&lt;hr data-end=&quot;1714&quot; data-start=&quot;1711&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;1765&quot; data-start=&quot;1716&quot;&gt;4. 본격적인 온프레미스 서버 만들기: SSH 포트 오픈 &amp;rarr; 포트포워딩 &amp;rarr; 고정 IP&lt;/h1&gt;
&lt;p data-end=&quot;1831&quot; data-start=&quot;1767&quot; data-ke-size=&quot;size16&quot;&gt;그래픽카드 세팅을 끝내고 &amp;ldquo;이제 끝났다!&amp;rdquo;라고 생각했지만,&lt;br /&gt;사실 그건 &lt;b&gt;온프레미스 서버 구축의 시작&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;1855&quot; data-start=&quot;1833&quot; data-ke-size=&quot;size16&quot;&gt;사수님께서 주신 키워드는 다음 세 가지.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1900&quot; data-start=&quot;1857&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1871&quot; data-start=&quot;1857&quot;&gt;SSH 포트 열기&lt;/li&gt;
&lt;li data-end=&quot;1882&quot; data-start=&quot;1872&quot;&gt;포트포워딩&lt;/li&gt;
&lt;li data-end=&quot;1900&quot; data-start=&quot;1883&quot;&gt;고정 IP(DHCP 예약)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;1959&quot; data-start=&quot;1902&quot; data-ke-size=&quot;size16&quot;&gt;이 세 단계를 듣는 순간,&lt;br /&gt;문득 AWS EC2 인스턴스를 만들 때 보았던 보안그룹 설정이 떠올랐다.&lt;/p&gt;
&lt;h3 data-end=&quot;1988&quot; data-start=&quot;1961&quot; data-ke-size=&quot;size23&quot;&gt;&amp;ldquo;아&amp;hellip; 이거 EC2랑 비슷한 작업이구나!&amp;rdquo;&lt;/h3&gt;
&lt;h4 data-end=&quot;2009&quot; data-start=&quot;1990&quot; data-ke-size=&quot;size20&quot;&gt;✔ 1) SSH 포트 열기&lt;/h4&gt;
&lt;p data-end=&quot;2031&quot; data-start=&quot;2011&quot; data-ke-size=&quot;size16&quot;&gt;Ubuntu에서 SSH를 활성화한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;pre id=&quot;code_1763218020027&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt install openssh-server
sudo systemctl enable ssh
sudo systemctl start ssh&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2131&quot; data-start=&quot;2125&quot; data-ke-size=&quot;size16&quot;&gt;포트 확인:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1763218037541&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo ufw allow 22/tcp 
sudo ufw enable&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2200&quot; data-start=&quot;2180&quot; data-ke-size=&quot;size16&quot;&gt;이제 외부에서 접속할 준비가 끝난다.&lt;/p&gt;
&lt;h4 data-end=&quot;2230&quot; data-start=&quot;2202&quot; data-ke-size=&quot;size20&quot;&gt;✔ 2) 포트포워딩 (ipTIME 공유기)&lt;/h4&gt;
&lt;p data-end=&quot;2275&quot; data-start=&quot;2232&quot; data-ke-size=&quot;size16&quot;&gt;ipTIME 공유기 환경은 많은 한국 회사에서 사용하는 구조라 낯설지 않았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2380&quot; data-start=&quot;2277&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2297&quot; data-start=&quot;2277&quot;&gt;192.168.0.1 접속&lt;/li&gt;
&lt;li data-end=&quot;2328&quot; data-start=&quot;2298&quot;&gt;고급 설정 &amp;rarr; NAT/라우터 관리 &amp;rarr; 포트포워딩&lt;/li&gt;
&lt;li data-end=&quot;2371&quot; data-start=&quot;2329&quot;&gt;외부포트 22 &amp;rarr; 내부IP 192.168.0.xxx &amp;rarr; 내부포트 22&lt;/li&gt;
&lt;li data-end=&quot;2380&quot; data-start=&quot;2372&quot;&gt;TCP 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2404&quot; data-start=&quot;2382&quot; data-ke-size=&quot;size16&quot;&gt;설정 후 외부망에서 SSH 접속 테스트:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&lt;span&gt;&lt;span&gt;ssh &lt;/span&gt;&lt;span&gt;&lt;span&gt;user@&lt;/span&gt;&lt;/span&gt;&lt;span&gt;공인IP주소 또는 DDNS주소 &lt;/span&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-end=&quot;2459&quot; data-start=&quot;2441&quot; data-ke-size=&quot;size20&quot;&gt;✔ 3) 고정 IP 설정&lt;/h4&gt;
&lt;p data-end=&quot;2485&quot; data-start=&quot;2461&quot; data-ke-size=&quot;size16&quot;&gt;내부망 IP가 바뀌면 포트포워딩도 망가진다.&lt;/p&gt;
&lt;p data-end=&quot;2524&quot; data-start=&quot;2487&quot; data-ke-size=&quot;size16&quot;&gt;그래서 MAC 주소 기반으로 DHCP 예약을 걸어 IP를 고정했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2594&quot; data-start=&quot;2545&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2594&quot; data-start=&quot;2568&quot;&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;231&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cCjEjB/dJMb995OVKR/bJl7y3z37n9M7psl3Wn5Mk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cCjEjB/dJMb995OVKR/bJl7y3z37n9M7psl3Wn5Mk/img.jpg&quot; data-alt=&quot;예시 이미지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cCjEjB/dJMb995OVKR/bJl7y3z37n9M7psl3Wn5Mk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcCjEjB%2FdJMb995OVKR%2FbJl7y3z37n9M7psl3Wn5Mk%2Fimg.jpg&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;480&quot; height=&quot;231&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;231&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;예시 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;2599&quot; data-start=&quot;2596&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;2628&quot; data-start=&quot;2601&quot;&gt;5. 드디어 내 손으로 온프레미스 서버 개통!&lt;/h1&gt;
&lt;p data-end=&quot;2667&quot; data-start=&quot;2630&quot; data-ke-size=&quot;size16&quot;&gt;SSH 접속이 성공하는 순간 느껴지던 그 짜릿함은 아직도 생생하다.&lt;/p&gt;
&lt;blockquote data-end=&quot;2694&quot; data-start=&quot;2669&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2694&quot; data-start=&quot;2671&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;이제 이 컴퓨터는 진짜 나만의 서버다.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2787&quot; data-start=&quot;2696&quot; data-ke-size=&quot;size16&quot;&gt;이전까지는 AWS나 GCP 같은 &lt;b&gt;클라우드 기반 서버&lt;/b&gt;만 사용했는데,&lt;br /&gt;이번엔 처음으로 &lt;b&gt;물리적인 서버&lt;/b&gt;를 직접 준비해서 운영하는 경험을 하게 된 것이다.&lt;/p&gt;
&lt;hr data-end=&quot;2792&quot; data-start=&quot;2789&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;2834&quot; data-start=&quot;2794&quot;&gt;6. 욕심이 생겼다. 그래서 개인 FastAPI 프로젝트까지 올려봤다&lt;/h1&gt;
&lt;p data-end=&quot;2896&quot; data-start=&quot;2836&quot; data-ke-size=&quot;size16&quot;&gt;서버가 성공적으로 뜬 순간,&lt;br /&gt;&amp;ldquo;여기까지 온 김에 내가 만든 프로젝트도 배포해볼까?&amp;rdquo; 라는 생각이 들었다.&lt;/p&gt;
&lt;p data-end=&quot;2947&quot; data-start=&quot;2898&quot; data-ke-size=&quot;size16&quot;&gt;그래서 로컬에서 개발 중이던 &lt;b&gt;FastAPI 프로젝트&lt;/b&gt;를 온프레미스 서버에 배포했다.&lt;/p&gt;
&lt;h3 data-end=&quot;2963&quot; data-start=&quot;2949&quot; data-ke-size=&quot;size23&quot;&gt;✔ 배포 과정 요약&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;3066&quot; data-start=&quot;2965&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;2993&quot; data-start=&quot;2965&quot;&gt;서버에 Python &amp;amp; uvicorn 설치&lt;/li&gt;
&lt;li data-end=&quot;3009&quot; data-start=&quot;2994&quot;&gt;프로젝트 clone&lt;/li&gt;
&lt;li data-end=&quot;3022&quot; data-start=&quot;3010&quot;&gt;venv 세팅&lt;/li&gt;
&lt;li data-end=&quot;3050&quot; data-start=&quot;3023&quot;&gt;pm2 또는 systemd로 서비스 등록&lt;/li&gt;
&lt;li data-end=&quot;3066&quot; data-start=&quot;3051&quot;&gt;외부에서 API 테스트&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;3112&quot; data-start=&quot;3068&quot; data-ke-size=&quot;size16&quot;&gt;기존에 EC2에서 배포해 본 경험 덕분에 어려움 없이 빠르게 배포할 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;3142&quot; data-start=&quot;3114&quot; data-ke-size=&quot;size16&quot;&gt;API endpoint에 접속해 응답을 받는 순간,&lt;/p&gt;
&lt;blockquote data-end=&quot;3177&quot; data-start=&quot;3144&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;3177&quot; data-start=&quot;3146&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;와&amp;hellip; 내가 직접 서버를 띄우고, 직접 배포까지 했네?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;3199&quot; data-start=&quot;3179&quot; data-ke-size=&quot;size16&quot;&gt;이 감정이 진짜 성취감으로 다가왔다.&lt;/p&gt;
&lt;hr data-end=&quot;3274&quot; data-start=&quot;3271&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;3313&quot; data-start=&quot;3276&quot;&gt;7. 느낀 점: 내 손으로 서버를 만든다는 건 생각보다 멋진 일&lt;/h1&gt;
&lt;p data-end=&quot;3337&quot; data-start=&quot;3315&quot; data-ke-size=&quot;size16&quot;&gt;이 경험을 통해 느낀 점은 다음과 같다.&lt;/p&gt;
&lt;h3 data-end=&quot;3383&quot; data-start=&quot;3339&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;1) 클라우드는 편하지만, 온프레미스는 직접적으로 배울 수 있다&lt;/h3&gt;
&lt;p data-end=&quot;3473&quot; data-start=&quot;3384&quot; data-ke-size=&quot;size16&quot;&gt;클라우드는 쉽게 할 수 있지만,&lt;br /&gt;온프레미스는 네트워크, 장비, 드라이버 등 기초가 전부 필요하다.&lt;br /&gt;그래서 난이도는 있지만 &lt;b&gt;더 많이 배울 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-end=&quot;3506&quot; data-start=&quot;3475&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;2) 서버의 &amp;ldquo;전 과정&amp;rdquo;을 이해하게 된다&lt;/h3&gt;
&lt;p data-end=&quot;3578&quot; data-start=&quot;3507&quot; data-ke-size=&quot;size16&quot;&gt;OS 설치 &amp;rarr; 드라이버 &amp;rarr; 네트워크 &amp;rarr; 방화벽 &amp;rarr; 포트포워딩 &amp;rarr; 외부 접속&lt;br /&gt;이 흐름이 모두 연결된다는 것을 몸으로 체감했다.&lt;/p&gt;
&lt;h3 data-end=&quot;3613&quot; data-start=&quot;3580&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;3) 잠깐의 시행착오가 큰 성장으로 이어졌다&lt;/h3&gt;
&lt;p data-end=&quot;3662&quot; data-start=&quot;3614&quot; data-ke-size=&quot;size16&quot;&gt;드라이버 충돌, SSH 오류, 공유기 설정 등&lt;br /&gt;하나하나 해결해가며 자신감이 붙었다.&lt;/p&gt;
&lt;h3 data-end=&quot;3709&quot; data-start=&quot;3664&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;4) 직접 구축한 서버에서 API가 돌아가는 모습이 너무 뿌듯했다&lt;/h3&gt;
&lt;p data-end=&quot;3746&quot; data-start=&quot;3710&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;내 손으로 만들었다&amp;rdquo;는 건 개발자로서 정말 큰 동기부여가 된다.&lt;/p&gt;
&lt;hr data-end=&quot;3751&quot; data-start=&quot;3748&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;3758&quot; data-start=&quot;3753&quot;&gt;마무리&lt;/h1&gt;
&lt;p data-end=&quot;3830&quot; data-start=&quot;3760&quot; data-ke-size=&quot;size16&quot;&gt;이번 온프레미스 서버 구축 경험은 단순한 장비 세팅이 아니라,&lt;br /&gt;&lt;b&gt;네트워크・리눅스・서버 운영의 본질을 배운 시간&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-end=&quot;3906&quot; data-start=&quot;3832&quot; data-ke-size=&quot;size16&quot;&gt;앞으로도 이 서버 위에서 더 많은 실험과 프로젝트를 해볼 계획이다.&lt;br /&gt;클라우드에서는 느끼기 어려운 재미를 온프레미스는 확실히 준다.&lt;/p&gt;</description>
      <author>taehyuck</author>
      <guid isPermaLink="true">https://taehyuck.tistory.com/25</guid>
      <comments>https://taehyuck.tistory.com/25#entry25comment</comments>
      <pubDate>Sat, 15 Nov 2025 23:51:29 +0900</pubDate>
    </item>
  </channel>
</rss>