<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>System Consultant Group(SCG) 개발 블로그</title>
    <link>https://scg-skku.tistory.com/</link>
    <description>성균관대학교 최고의 소프트웨어 개발 단체</description>
    <language>ko</language>
    <pubDate>Wed, 15 Jul 2026 16:01:07 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>scg-skku</managingEditor>
    <image>
      <title>System Consultant Group(SCG) 개발 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/8561459/attach/a8fc524c8be54b7baf96228269507788</url>
      <link>https://scg-skku.tistory.com</link>
    </image>
    <item>
      <title>사라진 12월의 fix: eeegrd 사이트 회귀 버그 추적기</title>
      <link>https://scg-skku.tistory.com/8</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 회귀는 발견되는 데 1달이 걸렸고, 발견된 뒤에도 진짜 원인을 찾는 데 한 시간이 더 걸렸다. 그 회귀를 만든 사람은 나였다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요, SCG 장재원입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 작업 중 코드가 이전 형상 (버전) 으로 돌아가게 된 계기와, 그에 대한 회고 및 반성을 주제로 글을 써볼까 합니다. 회고록 형식으로 쓴 글이어서 &amp;lsquo;~다&amp;rsquo; 로 문장을 마무리한 점 양해 부탁드립니다!&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 학기에 나는 종합설계프로젝트(전전 졸업평가 강좌)를 듣고 있었다. 어느 날 같이 팀 프로젝트를 하는 멤버들이 작년 작품을 참고하려고 전자전기공학부 졸업작품 전시회 페이지인 &lt;a href=&quot;https://eeegrd.skku.edu/&quot;&gt;eeegrd.skku.edu&lt;/a&gt;에 접속했는데, &lt;b&gt;설문조사 페이지에서 이메일 인증이 안 되어 사이트에 들어가지 못한다&lt;/b&gt;고 알려왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;01-chat-team-member 1.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mOJr6/dJMcaak0Q5k/3sJtRFtkWbMK9yh7umxAL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mOJr6/dJMcaak0Q5k/3sJtRFtkWbMK9yh7umxAL0/img.png&quot; data-alt=&quot;종합설계프로젝트 팀원의 제보&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mOJr6/dJMcaak0Q5k/3sJtRFtkWbMK9yh7umxAL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmOJr6%2FdJMcaak0Q5k%2F3sJtRFtkWbMK9yh7umxAL0%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;800&quot; height=&quot;560&quot; data-filename=&quot;01-chat-team-member 1.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;560&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;종합설계프로젝트 팀원의 제보&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이상한 일이었다. 분명 작년 12월에 전전 행정실에서 같은 문제를 신고하셨고, 우리 쪽에서 처리 완료라는 회신까지 드렸기 때문이다. 메일 박스를 뒤져 그때의 흔적을 찾아봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;02-email-admin-report 1.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wGYyP/dJMcaaFmW93/jxKkcuYVUWPo2XUJi1EBG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wGYyP/dJMcaaFmW93/jxKkcuYVUWPo2XUJi1EBG1/img.png&quot; data-alt=&quot;행정실 담당자분께서 12월 17일에 이메일 인증 불가하다는 내용으로 SCG 에 확인 요청 보낸 메일&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wGYyP/dJMcaaFmW93/jxKkcuYVUWPo2XUJi1EBG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwGYyP%2FdJMcaaFmW93%2FjxKkcuYVUWPo2XUJi1EBG1%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;800&quot; height=&quot;520&quot; data-filename=&quot;02-email-admin-report 1.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;520&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;행정실 담당자분께서 12월 17일에 이메일 인증 불가하다는 내용으로 SCG 에 확인 요청 보낸 메일&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;03-email-scg-reply 1.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bM8iJr/dJMcafs5Tvv/SfxKBOaw05dCjJI1MVepoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bM8iJr/dJMcafs5Tvv/SfxKBOaw05dCjJI1MVepoK/img.png&quot; data-alt=&quot;당시 긴급한 문제여서 이메일 발송 지연 원인을 파악하기보다는 이메일 발송 기능 자체를 비활성화했던 것으로 보인다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bM8iJr/dJMcafs5Tvv/SfxKBOaw05dCjJI1MVepoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbM8iJr%2FdJMcafs5Tvv%2FSfxKBOaw05dCjJI1MVepoK%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;800&quot; height=&quot;520&quot; data-filename=&quot;03-email-scg-reply 1.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;520&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;당시 긴급한 문제여서 이메일 발송 지연 원인을 파악하기보다는 이메일 발송 기능 자체를 비활성화했던 것으로 보인다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명히 처리됐다는 메일이 남아 있다. 그런데 3개월이 지난 지금, 똑같은 증상이 나타나고 있다. 이게 어떻게 가능한 걸까.&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;이 4개월짜리 회귀를 만든 사람은 다름아닌 나였다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히는 2월 23일에 디스크 풀 장애를 대응하던 중에 만든 자해성 회귀였다.&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;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1차 진단: &quot;그때 fix가 배포가 안 됐던 거구나&quot;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub 히스토리를 켰다. 12월 17일자로 master에 들어간 커밋 3개가 있었다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;4ab95c4  fix: 설문조사 패스
df7e8ac  fix: initial login false 반환
ff6605a  fix: initial login만 false 반환
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 읽어보니 설문조사 페이지를 우회시키는 로직이 분명히 들어 있다. 그런데 운영 환경에서는 그 로직이 적용되지 않은 동작을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;아, 머지는 됐는데 배포가 제대로 안 됐던 거구나.&quot;&lt;/b&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;/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;현재 master를 master-backup-2026-03-21 으로 백업&lt;/li&gt;
&lt;li&gt;위 3개 커밋은 의도가 불분명하다고 판단해서 &lt;b&gt;건너뛰고&lt;/b&gt;, 그 이후에 들어간 네이버 메일서버 인증 비밀 키 변경 커밋만 cherry-pick (인증메일 발송이 안 되던 것이 기존에 발송을 위한 메일서버 인증 비밀 키가 만료되서였다)&lt;/li&gt;
&lt;li&gt;새로 정리된 master로 이미지를 빌드 (eee_gp:4) 후 재배포&lt;/li&gt;
&lt;/ol&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;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 저 3개 커밋은 좀 미스테리네요 설문조사를 없애달라는 요청이 있었던건지&amp;hellip;&lt;/p&gt;
&lt;/blockquote&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결정적 단서: 사라진 35회 배너&lt;/h2&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;전&lt;/b&gt;에는 메인 화면에 &quot;&lt;b&gt;제34회 졸업작품 전시회 &amp;mdash; 2025학년도 1학기&lt;/b&gt;&quot; 배너가 떠 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 재배포 &lt;b&gt;후&lt;/b&gt;에는 &quot;&lt;b&gt;제35회 &amp;mdash; 2025학년도 2학기&lt;/b&gt;&quot;로 자동으로 바뀌었다.&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;이 작업을 하던 시점은 2026학년도 1학기 중이었으므로 35회 (2025학년도 2학기)는 가장 최근 회차가 맞았다. 12월 5일에 35회 메뉴를 추가한 커밋이 master에 이미 들어가 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;7ab43c4  [FIX] 제35회 졸업작품발표회 메뉴 추가 (Dec 5, 2025)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니까, 12월 5일 이후로는 35회 배너가 떠 있어야 정상인데, 어제까지의 운영 환경에서는 &lt;b&gt;34회 배너가 떠 있었다는 뜻&lt;/b&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;12월 17일에 들어간 fix만 누락된 게 아니다. 12월 5일에 들어간 메뉴 업데이트도 누락되어 있었다. 두 개의 정상 머지가 한꺼번에 사라진 셈이다.&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;i&gt;&lt;b&gt;&quot;배포가 안 된 게 아니라, 어디선가 누가 이미지를 갈아끼웠다.&quot;&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시간을 거슬러: 12월에 실제로 있었던 일&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;12월 17일, 전전 행정실 담당자분이 인증 오류를 메일로 신고하셨고, 메일과 별도로 &lt;b&gt;개인 휴대전화로 직접 연락&lt;/b&gt;을 주셨다.&lt;/li&gt;
&lt;li&gt;원래 설문조사는 추첨용 데이터 수집 목적이었는데 더 이상 활용 목적이 없으니, &lt;b&gt;그냥 패스만 시켜달라&lt;/b&gt;는 요청이었다.&lt;/li&gt;
&lt;li&gt;이용욱 님이 3개 커밋을 작성해서 patch를 만들었고, 손형준 님이 빌드/배포 후 본인 계정을 지웠다 재가입하는 테스트까지 마쳤다.&lt;/li&gt;
&lt;/ul&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;애초에 설문조사 및 이메일 인증을 만든 이유는 2024년 9월경 &lt;b&gt;타 학교 학생의 표절 신고&lt;/b&gt;가 들어와서, 전자전기공학부 교수님들께서 졸업작품 전시회 페이지의 외부인 접근을 막아달라고 요청하셨기 때문.&lt;/li&gt;
&lt;li&gt;이메일 인증에 SCG 자체 메일 서버 대신 scgskku@naver.com을 쓴 이유는, 당시 SCG 메일 서버에서 발송한 인증 메일이 inbox에 10&amp;ndash;20분씩 늦게 도착하는 문제가 있어서 외부 메일 서버를 선호했던 것으로 추정.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자, 그러면 12월 17일에 분명히 배포까지 완료되었던 fix는, &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;진짜 원인: 2월 23일의 디스크 풀 사건&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Portainer를 열어보니, 운영 중이던 컨테이너의 이미지는 eee_gp:2였다. 생성일은 &lt;b&gt;2026년 2월 23일 14:12&lt;/b&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;04-portainer-image-list (1) 1.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;520&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baH6js/dJMcadvdsC4/kmGbFjz7g0rStiK7s59gZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baH6js/dJMcadvdsC4/kmGbFjz7g0rStiK7s59gZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baH6js/dJMcadvdsC4/kmGbFjz7g0rStiK7s59gZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaH6js%2FdJMcadvdsC4%2FkmGbFjz7g0rStiK7s59gZk%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;800&quot; height=&quot;520&quot; data-filename=&quot;04-portainer-image-list (1) 1.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;520&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 날, 그 시각. 정확히 기억난다. 내가 한 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 날 시스템 헬스체크 봇이 eeegrd.skku.edu가 죽었다고 알람을 보냈다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1090&quot; data-origin-height=&quot;392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsIQvB/dJMcaciNHsF/gAhWnAehJsdIc6xbfXu9Rk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsIQvB/dJMcaciNHsF/gAhWnAehJsdIc6xbfXu9Rk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsIQvB/dJMcaciNHsF/gAhWnAehJsdIc6xbfXu9Rk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsIQvB%2FdJMcaciNHsF%2FgAhWnAehJsdIc6xbfXu9Rk%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;1090&quot; height=&quot;392&quot; data-origin-width=&quot;1090&quot; data-origin-height=&quot;392&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;들어가 보니 /dev/sda1이 100%로 차 있었고, 원인은 물리 서버인 PM2에 로그 데이터만 21GB까지 적재된 거였다.&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;scg@scg:~$ du -sh ~/.pm2/logs
21G	/home/scg/.pm2/logs
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그를 비우고 컨테이너를 재가동해야 하는데, 그날따라 Portainer 자체가 정상 작동을 안 했다(디스크 용량 문제와 연관된 것으로 추정된다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그래서 서버에 SSH로 직접 접속해서 처리했다.&lt;/b&gt; 프로젝트 디렉토리로 가서 한 줄을 쳤다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;sudo docker-compose -f docker-compose.prod.yaml up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지는 장애 대응으로서 합리적이었다. 문제는 이 한 줄 안에 &lt;b&gt;두 개의 함정&lt;/b&gt;이 숨어 있었다는 점이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;함정 1. 서버 로컬 소스 &amp;ne; 최신 master&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에 체크아웃되어 있던 소스코드는 자동 git pull이 도는 환경이 아니다. 지금 다시 확인해보니, 그 디렉토리의 가장 최근 커밋은 d1b4ff &amp;mdash; &lt;b&gt;2025년 11월 18일&lt;/b&gt;자다. 12월 5일의 35회 메뉴 추가도, 12월 17일의 설문조사 패스도, 그 디렉토리에는 &lt;b&gt;없었다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;서버 로컬 master:    d1b4ff (2025-11-18) &amp;larr; 내가 모르고 빌드한 코드
실제 GitHub master:  ff6605a (2025-12-17) &amp;larr; 운영에 떠 있어야 할 코드
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;함정 2. docker-compose.prod.yaml의 고정 이미지 태그&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 파일은 이렇게 생겼다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;version: '3'
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.prod
    image: eee_gp:2          # &amp;larr; 여기
    container_name: eee_gp_prod
    ports:
      - '8080:3000'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;image: eee_gp:2. 이게 핵심이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;docker-compose up -d를 호출하면 build 컨텍스트에서 이미지를 빌드하고, 그 결과물을 &lt;b&gt;eee_gp:2라는 이름으로 태그한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&lt;b&gt;즉, 11월 18일자 로컬 소스로 빌드된 이미지가 eee_gp:2라는 이름을 강제로 덮어쓴 것이다.&lt;/b&gt;&lt;/i&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;그 전에 12월 17일자 fix가 반영되어 잘 돌고 있던 진짜 eee_gp:2 이미지는 그 순간 사라졌다. 마침 컨테이너도 재기동되니, 새 (잘못된) eee_gp:2가 그대로 운영에 올라갔다.&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;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;2024-09&lt;/td&gt;
&lt;td&gt;김현석 님, 이메일 인증/설문조사 기능 구현 (표절 신고 대응)&lt;/td&gt;
&lt;td&gt;정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-11-18&lt;/td&gt;
&lt;td&gt;서버 로컬 디렉토리의 마지막 git pull (추정) &amp;mdash; d1b4ff&lt;/td&gt;
&lt;td&gt;&amp;mdash;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-12-05&lt;/td&gt;
&lt;td&gt;35회 메뉴 추가 머지 + 배포&lt;/td&gt;
&lt;td&gt;정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-12-17&lt;/td&gt;
&lt;td&gt;행정실 인증 오류 신고 &amp;rarr; 손형준 님 통화 &amp;rarr; 이용욱 님 fix 3건 머지/배포/테스트&lt;/td&gt;
&lt;td&gt;정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;2026-02-23&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;디스크 풀로 다운 &amp;rarr; 내가 11월 18일자 로컬 코드로 eee_gp:2 재빌드&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;회귀 발생&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-03-21&lt;/td&gt;
&lt;td&gt;종설프 팀원 신고 &amp;rarr; 1차 오진 &amp;rarr; 결국 회귀 시점 발견 후 정상 복구&lt;/td&gt;
&lt;td&gt;복구&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 만든 회귀를, 1개월 동안 아무도 모르고 있었다.&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;2월 23일 재배포 직후, 사이트가 다시 떠 있는 걸 확인하고 슬랙에 &quot;소생 완료&quot;라고 적었다. 화면 캡쳐도 같이 첨부했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 캡쳐에는 너무도 당당하게 &lt;b&gt;&quot;제34회 졸업작품 전시회 &amp;mdash; 2025학년도 1학기&quot;&lt;/b&gt; 배너가 떠 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 시점은 이미 12월이 두 달이나 지난 2월 말이었다.&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;내가 전전 졸업작품 전시회의 운영 사이클을 잘 몰랐다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회차가 학기 단위로 올라간다는 것, 35회가 그 시점 최신이라는 것, 12월에 이미 35회로 갱신되어 있어야 한다는 것을 모르고 있었다.&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;&quot;보이는 게 정상으로 보인다&quot;는 기준으로 배포를 끝내면 안 됐다. 그 사이트의 도메인을 아는 사람이 한 명이라도 거쳤다면 바로 알아챘을 것이다.&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;기술적 원인은 위에서 다 풀었고, 사실 더 중요한 건 이번 사건이 드러낸 SCG의 운영상 구조 문제다. 4가지 정도로 정리해 보았다.&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;scgskku@naver.com. 이 계정으로 졸업작품 전시회의 인증 메일이 발송된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 계정에 등록된 &lt;b&gt;관리자 전화번호는 21기 선배의 개인 휴대폰&lt;/b&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;조직 차원에서 쓸 계정은 단체 계정으로 만들어야 한다. 당장은 귀찮더라도 3년 후 5년 후의 우리를 위해서다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가능하면 scg@scg.skku.ac.kr 같은 통합 도메인 안에서 관리하는 게 좋다.&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;추가 조치로&lt;/b&gt; SCG 네이버 계정으로 인증 메일을 발송하는 다른 서비스가 있는지 전수조사를 했고, 현재까지 확인된 건은 eeegrd 한 건 뿐이었다.&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;15개가 넘는 서비스를 운영하면서, 지금까지는 회장이 모든 프로젝트의 모든 내용을 관여해왔다.&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;TPM(Technical Project Manager)&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;차기 TPM에게 반드시 인수인계&lt;/li&gt;
&lt;li&gt;응급 상황에 대응할 최소한의 인프라 지식 보유 (TPM이 이 역할을 겸하면 이상적)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 전화/대면 업무 결과를 Slack에 남기지 않는다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;12월 17일의 처리가 거의 다 통화로 이루어졌다는 점이 이번 사건의 추적을 어렵게 만들었다. 손형준 님이 행정실과 결정한 내용(&quot;설문조사 더 이상 필요 없음&quot;), 빌드와 테스트 결과 &amp;mdash; 이게 슬랙에 한 줄이라도 남아 있었다면 2월에 내가 디스크 풀 대응할 때 훨씬 더 신중하게 했을 것이다.&lt;/p&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 슬랙을 도입한 지 3년 정도밖에 안 됐지만, 그 사이에 슬랙 히스토리 덕분에 살아난 건들이 정말 많다. 인수인계를 진행을 하지만 실제로 작업 디테일 하나하나 까지 인수인계 하기는 쉽지 않은 일이다. 또한 선배들과 접점이 있다 하더라도 1~3년 전 일을 선배님들도 하나하나 기억을 해서 해결을 하기에는 어려운 일이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 프로덕션 배포가 너무 조용하다 (silent deployment)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2월 23일 내가 한 재배포에 대해 슬랙에 짧게 한 줄 남기긴 했지만, 그건 장애 대응 스레드 안이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 그 사이트를 평소 사용하는 다른 회원이 그 캡쳐를 봤다면, &quot;어 근데 35회 배너가 왜 없지?&quot;라고 말해줬을지도 모른다.&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;#org-상용배포 같은 채널을 신설해서, &lt;b&gt;프로젝트 무관, 프로덕션에 올라가는 모든 배포는 전체 활동회원을 @ 태그하여 알리는&lt;/b&gt; 정책을 도입했다. 일종의 릴리즈 노트 개념이다. 실무에서 silent deployment는 있을 수 없는 일이다. 중요한 배포는 비개발 직군까지 대기를 시키는 경우도 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1814&quot; data-origin-height=&quot;1098&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfnOET/dJMcagS51C4/ZfKzRlsIsi7iI43uLIqYI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfnOET/dJMcagS51C4/ZfKzRlsIsi7iI43uLIqYI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfnOET/dJMcagS51C4/ZfKzRlsIsi7iI43uLIqYI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfnOET%2FdJMcagS51C4%2FZfKzRlsIsi7iI43uLIqYI0%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;827&quot; height=&quot;501&quot; data-origin-width=&quot;1814&quot; data-origin-height=&quot;1098&quot;/&gt;&lt;/span&gt;&lt;/figure&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;3월 21일에 처음 그 메시지를 받았을 때, 나는 너무 빠르게 답을 만들고 싶었던 것 같다. &quot;아, 이 커밋들이 배포가 안 됐구나&quot; 하고 단정 짓고는 백업 브랜치까지 만들어서 master를 갈아엎었다. 정작 진짜 원인은 4개월 전 내가 한 디스크 풀 대응에 있었는데도.&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;회귀를 만들고, 그 사실을 1달 동안 모르고, 발견했을 때는 이전 담당자 탓을 먼저 했다.&lt;/b&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;/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;하나는 위의 4가지 구조적인 문제를 실제로 SCG의 운영 방식에 반영하기 위해서. 그냥 이렇게 끝내면 다음 사람이 똑같은 함정에 빠진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 하나는 일종의 자가 처벌이다. 다음에 비슷한 상황에서 다른 사람을 의심하기 전에, 이 글을 다시 읽으면서 한 번 멈춰 서기 위해.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다음 사람을 위한 짧은 메모&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;docker-compose.prod.yaml에서 image: name:tag가 고정으로 박혀 있고 변동이 없는 형태는 &lt;b&gt;회귀 위험이 크다.&lt;/b&gt; 운영 환경에서는 name:${GIT_SHA} 처럼 매 배포마다 unique한 태그를 쓰는 게 안전하다.&lt;/li&gt;
&lt;li&gt;어떤 이미지가 어떤 커밋으로 빌드됐는지 추적할 수 있도록, Dockerfile에 build arg로 git commit hash를 넣어두는 패턴이 유용하다.&lt;/li&gt;
&lt;li&gt;서버 로컬 디렉토리에 체크아웃된 코드는 &lt;b&gt;항상 최신이 아니다.&lt;/b&gt; 배포 전에 반드시 git fetch &amp;amp;&amp;amp; git status로 확인하자&lt;/li&gt;
&lt;li&gt;그리고 배포 후에는, 도메인 지식을 가진 사람이 한 번 더 확인해 줄 수 있게 알려라.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이건 여담이지만 긴급한 이슈를 임시방편으로 해결한 후에는 이후에 잊지 않고 시간을 들여서라도 근본 원인 (root cause) 를 찾는 것이 필요하다.&amp;nbsp;&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;이 일에서도 결국 네이버 이메일 발송 서버의 인증이 만료된 것으로 드러난 것은, 12월 17일이 아니라, 이 문제를 다시 보게 된 3월 21일의 일이었다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Troubleshooting</category>
      <category>Docker</category>
      <category>SCG</category>
      <category>배포관리</category>
      <category>시스템컨설턴트그룹</category>
      <category>운영문화</category>
      <author>장재원</author>
      <guid isPermaLink="true">https://scg-skku.tistory.com/8</guid>
      <comments>https://scg-skku.tistory.com/8#entry8comment</comments>
      <pubDate>Mon, 18 May 2026 11:02:34 +0900</pubDate>
    </item>
    <item>
      <title>DNS 관리 도구를 만들다가, 결국 DNS 서버를 만든 이야기 (1)</title>
      <link>https://scg-skku.tistory.com/7</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;0. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요~ &lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;시스템컨설턴트그룹 25기 인프라 담당 권민성입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;오늘은 DNS 관리 도구를 만들다가, 결국 유사 DNS 서버를 만들게 된 이야기를 해보려고 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;DNS란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선&lt;b&gt; DNS(Domain Name System)&lt;/b&gt;은 우리가 사용하는 &lt;b&gt;도메인 이름을 실제 서버의 IP 주소로 변환해 주는&lt;/b&gt; 시스템입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 우리가 브라우저에 google.com을 입력하면, 컴퓨터는 해당 도메인의 IP 주소를 알아내기 위해 DNS 서버에 질의를 보내고, 그 결과로 얻은 IP로 실제 서버와 통신을 하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 도메인에 대한 실제 정보를 가지고 있고, 요청에 대해 응답을 해주는 서버를 &lt;b&gt;DNS 서버&lt;/b&gt;라고 합니다. 대표적으로 많이 사용되는 DNS 서버로는 ISC에서 개발한 &lt;b&gt;BIND9&lt;/b&gt;이 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;370&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TYJbd/dJMcajnZesF/6twznTxK1jJR5bdk2O4ZRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TYJbd/dJMcajnZesF/6twznTxK1jJR5bdk2O4ZRk/img.png&quot; data-alt=&quot;DNS 서버의 국룰 격인 BIND9&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TYJbd/dJMcajnZesF/6twznTxK1jJR5bdk2O4ZRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTYJbd%2FdJMcajnZesF%2F6twznTxK1jJR5bdk2O4ZRk%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;331&quot; height=&quot;96&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;370&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DNS 서버의 국룰 격인 BIND9&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로&lt;b&gt; nslookup&lt;/b&gt;이나 &lt;b&gt;dig&lt;/b&gt; 명령어를 통해 DNS 서버에 IP 주소를 묻는 질문을 보낼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(dig가 nslookup 보다 최신 도구이고 더 많은 정보를 제공해 줍니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;412&quot; data-origin-height=&quot;118&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8W1SU/dJMcaiJpKmY/3wZKXpIJLdPdzdCB0VXbSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8W1SU/dJMcaiJpKmY/3wZKXpIJLdPdzdCB0VXbSK/img.png&quot; data-alt=&quot;nslookup 명령을 통한 DNS 질의&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8W1SU/dJMcaiJpKmY/3wZKXpIJLdPdzdCB0VXbSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8W1SU%2FdJMcaiJpKmY%2F3wZKXpIJLdPdzdCB0VXbSK%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;381&quot; height=&quot;109&quot; data-origin-width=&quot;412&quot; data-origin-height=&quot;118&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;nslookup 명령을 통한 DNS 질의&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;510&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/s0ygU/dJMcah4RioR/ySveWpOK6jQ1KmVmGYUyI1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/s0ygU/dJMcah4RioR/ySveWpOK6jQ1KmVmGYUyI1/img.png&quot; data-alt=&quot;dig 명령을 통한 DNS 질의&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/s0ygU/dJMcah4RioR/ySveWpOK6jQ1KmVmGYUyI1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fs0ygU%2FdJMcah4RioR%2FySveWpOK6jQ1KmVmGYUyI1%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;308&quot; data-origin-width=&quot;510&quot; data-origin-height=&quot;308&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;dig 명령을 통한 DNS 질의&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;1. 배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템컨설턴트그룹은 그동안 학교로부터 &lt;b&gt;skku.ac.kr&lt;/b&gt;의 서브 도메인인 &lt;b&gt;scg.skku.ac.kr이라는&lt;/b&gt; 도메인을 위임받아 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;BIND9 기반의 자체 DNS 서버를 운영해 왔습니다. 하지만 오랜 기간 운영을 해오면서 아래와 같은 문제점들을 마주하게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;BIND9이 &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;li&gt;파일 기반의 저장 방식으로 인해 이중화에 제약이 많다(쿠버네티스에 여러 레플리카로 띄우기 어려움).&lt;/li&gt;
&lt;li&gt;레코드가 많아지면 파일의 가독성이 떨어지고, 유지보수가 매우 어려워진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;DNS 레코드가 너무 많아짐에 따라 관리가 잘 되지 않고, DNS 레코드 관련 API가 없다.&lt;/li&gt;
&lt;li&gt;학교에서 80번 포트의 사용을 제한함에 따라 기존의 80번 포트를 사용하는 인증서 갱신 방식을 사용할 수 없게 되었다(따로 다룰 예정)&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;위의 문제점들을 살펴봅시다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;파일 기반의 저장 방식?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BIND9은 사실 굉장히 오래된 프로그램입니다. 오랜 기간 동안 DNS 서버의 표준으로 사용되어 왔고, 지금도 많은 환경에서 안정적으로 사용되고 있습니다. 하지만 그만큼 설계 자체도 매우 전통적인 방식에 기반하고 있습니다. 그 대표적인 예가 DNS 레코드 같은 중요한 데이터를 데이터베이스에 보관하는 게 아니라&lt;b&gt; zone 파일이라는 텍스트 파일의 형태로 저장&lt;/b&gt;한다는 점입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1684&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/boHX5b/dJMcadafzgD/U8SKwkuP8tfOoCsUmn0L8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/boHX5b/dJMcadafzgD/U8SKwkuP8tfOoCsUmn0L8K/img.png&quot; data-alt=&quot;파일 형태로 저장된 DNS 레코드들&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/boHX5b/dJMcadafzgD/U8SKwkuP8tfOoCsUmn0L8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FboHX5b%2FdJMcadafzgD%2FU8SKwkuP8tfOoCsUmn0L8K%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;512&quot; height=&quot;1080&quot; data-origin-width=&quot;1684&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;파일 형태로 저장된 DNS 레코드들&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 DNS 서버는 시작 시 모든 zone 파일을 메모리에 로드한 뒤, 들어오는 DNS 질의에 응답합니다. 사진에 보이는 &lt;b&gt;A 레코드는 도메인 이름을 실제 서버의 IP 주소로 매핑해 주는&lt;/b&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;사실 BIND9에는 &lt;a title=&quot;Dynamic Loadable Zones&quot; href=&quot;https://bind9.readthedocs.io/en/latest/chapter6.html#dynamically-loadable-zones-dlz&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Dynamic Loadable Zones(DLZ)&lt;/a&gt;라고 하여 직접 데이터베이스와 연동하여 레코드를 관리할 수 있는 방법이 존재합니다. BIND9이 오픈소스이기 때문에 관련 라이브러리를 함께 빌드하여 사용하는 방식입니다. 하지만 이 방법 또한 아래와 같은 한계가 존재했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제대로 된 BIND9 전용 데이터베이스 라이브러리 코드가 없다(C로 된 BIND9과 연동되도록 데이터베이스 드라이버를 직접 작성해야 함...).&lt;/li&gt;
&lt;li&gt;BIND9의 소스 코드에 의존적이므로 버전업에 따라 스펙이 바뀌면 새로 작성해야 한다.&lt;/li&gt;
&lt;li&gt;결국 구현한다고 하더라도 API의 형태로 DNS를 조회하려면 별도의 로직을 구현해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이중화의 관점에서 파일 기반의 저장 방식은 제약이 많아, 쿠버네티스 환경에서 여러 레플리카로 구성하기 어렵다는 문제가 있습니다. 모든 컨테이너가 zone 파일을 공유하려면 결국 volume 형태로 구성해야 하는데, 이 경우 파일 동기화와 일관성 문제로 인해 운영이 복잡해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;목표&lt;/h3&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;단일 원천(Source of Truth)으로 사용하여 DNS 레코드를 관리&lt;/b&gt;할 수 있을 것&lt;/li&gt;
&lt;li&gt;BIND9은 그대로 사용하되,&lt;b&gt; 소스 코드를 직접 수정하지 않을 것 &lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;REST API 형태로 DNS 레코드를 조회하고 관리&lt;/b&gt;할 수 있을 것&lt;/li&gt;
&lt;li&gt;BIND9를 &lt;b&gt;여러 레플리카로 구성하여 이중화&lt;/b&gt;를 손쉽게 할 수 있을 것&lt;/li&gt;
&lt;li&gt;DNS 기반 인증서 갱신 방식(DNS-01)에 대응할 수 있을 것&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. 첫 번째 해결 과정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이에 대한 첫 번째 해결 과정으로 아래와 같은 구조를 생각해 내었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;데이터베이스(단일 원천)를 두고 &lt;b&gt;각 DNS 서버의 zone 파일을 직접 수정&lt;/b&gt;하는 방식은 어떨까?&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1241&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dnhGIS/dJMcafTvvLg/2sqI7CeUyeXuGkYEjRqZhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dnhGIS/dJMcafTvvLg/2sqI7CeUyeXuGkYEjRqZhK/img.png&quot; data-alt=&quot;같은 서버에 있는 BIND9의 설정 파일을 수정하고 RNDC를 통해 적용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dnhGIS/dJMcafTvvLg/2sqI7CeUyeXuGkYEjRqZhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdnhGIS%2FdJMcafTvvLg%2F2sqI7CeUyeXuGkYEjRqZhK%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;514&quot; height=&quot;227&quot; data-origin-width=&quot;1241&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;같은 서버에 있는 BIND9의 설정 파일을 수정하고 RNDC를 통해 적용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 말해서 BIND9과 같은 서버에 플러그인의 형태로 프로그램을 설치하고, 해당 프로그램이 BIND9의 zone 파일들을 직접 수정하고 반영하는 구조를 생각했습니다. 따라서 사용자의 레코드 변경 및 생성 요청에 대한 구체적인 흐름은 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;REST API를 통해 DNS 레코드 변경 및 생성 요청을 받습니다.&lt;/li&gt;
&lt;li&gt;요청된 내용을 데이터베이스에 반영합니다.&lt;/li&gt;
&lt;li&gt;데이터베이스 상태를 기준으로 BIND9의 zone 파일을 갱신(덮어쓰기)합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt; RNDC 프로토콜&lt;/b&gt;을 통해 BIND9에 reload를 요청하여 변경 사항을 반영합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;BIND9은 단순히 zone 파일을 수정한다고 해서 바로 변경 사항이 반영되지 않습니다. 따라서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;BIND9 서비스를 재시작하거나, RNDC 명령어를 통해 zone 파일을 reload&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;하도록 해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 과정을 천천히 살펴봅시다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;API 서버 구현: Axum 기반 라우팅 구조&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/tokio-rs/axum&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Axum&lt;/a&gt;은 Rust 기반의 웹 프레임워크로, 익숙한 Node.js 계열의 Express.js와 유사하게 라우팅, 요청 추출, 미들웨어 등의 기능을 제공합니다. Bindizr에서는 기존 웹 생태계에서 널리 사용되는 MVC(Model-View-Controller) 구조를 반영하여 zone과 record를 관리하는 API 서버를 구성하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;11496&quot; data-origin-height=&quot;3440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdF1cO/dJMcajaFC1y/0rM8JuUkpAA3MLBesh2dok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdF1cO/dJMcajaFC1y/0rM8JuUkpAA3MLBesh2dok/img.png&quot; data-alt=&quot;(왼쪽) Axum Router 구성 및 인증&amp;amp;middot;CORS 미들웨어 적용 / (오른쪽) Zone 리소스에 대한 REST API 라우팅&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdF1cO/dJMcajaFC1y/0rM8JuUkpAA3MLBesh2dok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdF1cO%2FdJMcajaFC1y%2F0rM8JuUkpAA3MLBesh2dok%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;796&quot; height=&quot;238&quot; data-origin-width=&quot;11496&quot; data-origin-height=&quot;3440&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;(왼쪽) Axum Router 구성 및 인증&amp;middot;CORS 미들웨어 적용 / (오른쪽) Zone 리소스에 대한 REST API 라우팅&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;왼쪽 코드는 전체 API 라우터를 구성하는 부분입니다. ZoneApi, RecordApi처럼 도메인별 라우터를 merge()를 통해 하나의 메인 라우터로 합치고 CorsLayer::permissive()를 통해 CORS 설정한 코드가 있습니다.&lt;/span&gt;&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오른쪽 코드는 실제 Zone API의 실제 구현입니다. /zones, /zones/{name}와 같은 REST API 경로를 정의하고, 각 HTTP Method를 서비스 계층의 로직과 연결합니다. get_zones 핸들러는 ZoneService::get_zones()와 같은 Service 계층을 호출하고 GetZoneResponse와 같은 DTO로 직렬화하여 StatusCode::OK 같은 상태 코드와 함께 응답을 마무리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SQLx 기반 멀티 데이터베이스 지원&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;a href=&quot;https://github.com/launchbadge/sqlx&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;SQLx&lt;/a&gt;는 Rust에서 많이 사용되는 비동기 SQL 라이브러리로, ORM보다는 직접 SQL을 작성하는 방식에 가깝습니다. 또한 컴파일 타임 쿼리 검증 기능을 제공하고, 다양한 데이터베이스를 지원합니다.&lt;br /&gt;&lt;br /&gt;사실 Rust에는 Diesel과 같은 ORM 라이브러리도 존재하지만, Bindizr에서는 복잡한 ORM 기능이 크게 필요하지 않았습니다. 대부분 zone과 record에 대한 단순한 CRUD 작업이 중심이었고, 동시에 여러 데이터베이스를 지원해야 했기 때문에 직접 SQL을 작성하는 방식이 더 단순하다고 판단했습니다.&lt;br /&gt;&lt;/span&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;span&gt;기본적인 사용 예시는 아래와 같습니다. JDBC를 사용해 보신 분들이라면 SQL 문을 작성하고, ? 또는 placeholder 위치에 들어갈 값을 직접 바인딩하는 방식이 익숙하실 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;461&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1uvSw/dJMcacXpoQg/i7LsIawfyOEJ8q0bth1451/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1uvSw/dJMcacXpoQg/i7LsIawfyOEJ8q0bth1451/img.png&quot; data-alt=&quot;JDBC와 유사한 방식의 SQLx의 SQL 실행 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1uvSw/dJMcacXpoQg/i7LsIawfyOEJ8q0bth1451/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1uvSw%2FdJMcacXpoQg%2Fi7LsIawfyOEJ8q0bth1451%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;381&quot; height=&quot;272&quot; data-origin-width=&quot;645&quot; data-origin-height=&quot;461&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;JDBC와 유사한 방식의 SQLx의 SQL 실행 코드&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bindizr에서는 &lt;b&gt;데이터베이스 접근 로직과 도메인 비즈니스 로직을 분리하기 위해 Repository 패턴을 사용&lt;/b&gt;했습니다. Repository 계층은 데이터베이스 연결 관리와 DBMS별 SQL 실행을 담당하고, Service 계층은 zone 생성, record 수정, 검증과 같은 도메인 로직을 담당합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉, API나 NSUPDATE 모듈은 직접 SQL을 실행하지 않고 ZoneService::get_zone(name)과 같은 서비스 메서드를 호출합니다. Service 계층 역시 특정 데이터베이스의 connection pool이나 SQL 문을 직접 다루지 않고, RepositoryService::get_zone_by_name(name)과 같은 Repository 메서드를 통해 데이터에 접근합니다. &lt;br /&gt;&lt;br /&gt;이렇게&amp;nbsp;계층을&amp;nbsp;분리하면&amp;nbsp;MySQL,&amp;nbsp;PostgreSQL,&amp;nbsp;SQLite처럼&amp;nbsp;데이터베이스별&amp;nbsp;구현이&amp;nbsp;달라지더라도,&amp;nbsp;상위의&amp;nbsp;Service/API/NSUPDATE&amp;nbsp;로직은&amp;nbsp;크게&amp;nbsp;변경하지&amp;nbsp;않고&amp;nbsp;유지할&amp;nbsp;수&amp;nbsp;있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2344&quot; data-origin-height=&quot;1182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/duXZzT/dJMcadopINv/ftKTL63lmwVLTtAcop7Gy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/duXZzT/dJMcadopINv/ftKTL63lmwVLTtAcop7Gy1/img.png&quot; data-alt=&quot;Bindizr의 데이터 접근 및 서비스 계층 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/duXZzT/dJMcadopINv/ftKTL63lmwVLTtAcop7Gy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FduXZzT%2FdJMcadopINv%2FftKTL63lmwVLTtAcop7Gy1%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;647&quot; height=&quot;326&quot; data-origin-width=&quot;2344&quot; data-origin-height=&quot;1182&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Bindizr의 데이터 접근 및 서비스 계층 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SQLx는 트랜잭션 기능 또한 지원합니다. 트랜잭션 또한 &lt;span&gt;여러 데이터베이스에서 동일한 방식으로 사용하기 위해, 데이터베이스별 트랜잭션 생성 로직을 분리하고 이를 감싸는 래퍼 함수를 만들어 사용했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;891&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4piiE/dJMcacwm4Hq/Kl5p04ikZRhbkaKgO17pf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4piiE/dJMcacwm4Hq/Kl5p04ikZRhbkaKgO17pf1/img.png&quot; data-alt=&quot;멀티 데이터베이스 환경에서의 트랜잭션 추상화&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4piiE/dJMcacwm4Hq/Kl5p04ikZRhbkaKgO17pf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4piiE%2FdJMcacwm4Hq%2FKl5p04ikZRhbkaKgO17pf1%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;541&quot; height=&quot;333&quot; data-origin-width=&quot;891&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;멀티 데이터베이스 환경에서의 트랜잭션 추상화&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 데이터베이스 타입에 따라 서로 다른 SQLx transaction 객체를 생성한 뒤, 이를 RepositoryTx라는 공통 타입으로 감싸는 과정입니다.&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;MySQL, PostgreSQL, SQLite는 각각 반환하는 transaction 타입이 다르기 때문에 서비스 계층에서 이를 직접 처리하면 데이터베이스별 분기 로직이 계속 등장하게 됩니다. 이를 방지하기 위해 Repository 계층에서 begin_transaction() 함수를 통해 DB별 transaction 생성 로직을 숨기고, Service 계층에서는 begin_tx() 래퍼 함수만 호출하도록 구성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;덕분에&amp;nbsp;&lt;b&gt;Service&amp;nbsp;계층에서는&amp;nbsp;현재&amp;nbsp;어떤&amp;nbsp;데이터베이스를&amp;nbsp;사용하는지&amp;nbsp;신경&amp;nbsp;쓰지&amp;nbsp;않고,&amp;nbsp;동일한&amp;nbsp;방식으로&amp;nbsp;트랜잭션을&amp;nbsp;시작하고&amp;nbsp;사용&lt;/b&gt;할&amp;nbsp;수&amp;nbsp;있게&amp;nbsp;되었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;RNDC 프로토콜을 이용한 변경 사항 반영&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.redhat.com/ko/documentation/red_hat_enterprise_linux/7/html/networking_guide/sec-bind-rndc&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RNDC(Remote Name Daemon Control) 명령어&lt;/a&gt;는 원래 &lt;b&gt;BIND9을 원격에서 제어하기 위한 유틸리티 프로그램&lt;/b&gt;입니다. RNDC 명령어를 통해 BIND9 서버를 끄거나 상태를 조회할 수 있고, zone 관련 작업 등을 할 수 있습니다. 저희는 &lt;b&gt;RNDC를 사용하여 zone 파일을 reload 하는 데 사용&lt;/b&gt;하였습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778784037355&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;rndc reload  # BIND 설정과 zone 파일을 다시 로드&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RNDC는 기본적으로 BIND9을 설치하면 함께 설치되는 프로그램이지만, 궁극적으로는 본 프로그램이 BIND9이 설치되지 않는 독립적인 환경에서도 동작하는 것을 목표로 하였습니다. 따라서 본 프로그램에서는 RNDC 명령어를 CLI를 통해 날리는 게 아니라, RNDC 프로그램이 BIND9에 직접 보내는 프로토콜을 프로그램에 녹여야 했습니다.&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;하지만 RNDC 프로토콜의 Rust 구현체가 없어 고민하던 와중, 고맙게도 ISC에서 &lt;a href=&quot;https://github.com/isc-projects/bind9-rndc-node&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RNDC 프로토콜을 Node.js로 포팅해둔 레포지토리&lt;/a&gt;를 발견하였고, 이를 그대로 &lt;a href=&quot;https://crates.io/crates/rndc&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Rust로 포팅하여 사용&lt;/a&gt;하였습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;864&quot; data-origin-height=&quot;658&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4Yppz/dJMcabji6ET/nVGoZJP0ktj0897nFEETMK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4Yppz/dJMcabji6ET/nVGoZJP0ktj0897nFEETMK/img.png&quot; data-alt=&quot;ISC의 RNDC 프로토콜의 Node.js 구현체&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4Yppz/dJMcabji6ET/nVGoZJP0ktj0897nFEETMK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4Yppz%2FdJMcabji6ET%2FnVGoZJP0ktj0897nFEETMK%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;454&quot; height=&quot;346&quot; data-origin-width=&quot;864&quot; data-origin-height=&quot;658&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ISC의 RNDC 프로토콜의 Node.js 구현체&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이외에도 본 프로그램을 만들면서 고민했던 부분들을 하나씩 소개해보려 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CLI 구현과 UNIX 소켓&lt;/h3&gt;
&lt;p data-end=&quot;183&quot; data-start=&quot;37&quot; data-ke-size=&quot;size16&quot;&gt;CLI를 구현하면서 최대한 MySQL이나 Docker와 같은 널리 사용되는 CLI 도구들의 동작 방식을 참고하고자 하였습니다. CLI를 구현하면서 고민했던 부분은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;183&quot; data-start=&quot;37&quot;&gt;CLI가 네트워크 통신(TCP/UDP 소켓)을 사용하지 않아야 한다.&lt;/li&gt;
&lt;li data-end=&quot;183&quot; data-start=&quot;37&quot;&gt;CLI를 통해 실행 중인 Bindizr 프로그램의 상태를 조회할 수 있어야 한다.&lt;/li&gt;
&lt;li data-end=&quot;183&quot; data-start=&quot;37&quot;&gt;Bindizr를 foreground로 실행할 수 있어야 하며, 다른 터미널에서도 동일한 바이너리를 통해 실행 중인 프로세스에 요청을 보낼 수 있어야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UNIX 소켓을 통한 CLI 구현은 이러한 조건들을 만족함과 동시에 실제로 많은 CLI 도구들에서 사용되는 방식이었습니다. UNIX 소켓은 &lt;b&gt;TCP/UDP 소켓 통신과 유사하게 소켓이라는 파일을 프로세스 간 쓰고 읽으면서 통신하는 로컬 통신 방식&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;482&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XDDMx/dJMcafFZyj0/rMTegoRKWYTBOrABNVxB30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XDDMx/dJMcafFZyj0/rMTegoRKWYTBOrABNVxB30/img.png&quot; data-alt=&quot;UNIX 소켓을 통한 프로세스 간 통신&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XDDMx/dJMcafFZyj0/rMTegoRKWYTBOrABNVxB30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXDDMx%2FdJMcafFZyj0%2FrMTegoRKWYTBOrABNVxB30%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;479&quot; height=&quot;261&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;482&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UNIX 소켓을 통한 프로세스 간 통신&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 UNIX 소켓 서버가 실행 중이어야만 응답을 받을 수 있다는 단점이 있습니다. 하지만 zone 정보를 조회하는 CLI가 직접 DB에 연결하고 데이터를 파싱해 출력하는 구조보다는, 실행 중인 Bindizr 프로세스에 요청을 보내고 결과만 받아오는 방식이 역할 분리 측면에서 더 적절하다고 판단했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;721&quot; data-origin-height=&quot;125&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7vbOU/dJMcag5Yakt/55UznbLyX2sXjGw9GkgWMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7vbOU/dJMcag5Yakt/55UznbLyX2sXjGw9GkgWMk/img.png&quot; data-alt=&quot;Docker가 실행 중이 아닐 때 CLI 요청을 보낼 경우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7vbOU/dJMcag5Yakt/55UznbLyX2sXjGw9GkgWMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7vbOU%2FdJMcag5Yakt%2F55UznbLyX2sXjGw9GkgWMk%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;570&quot; height=&quot;99&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;721&quot; data-origin-height=&quot;125&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Docker가 실행 중이 아닐 때 CLI 요청을 보낼 경우&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1330&quot; data-origin-height=&quot;588&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cSUS16/dJMcaaSexAX/ZbBrpilzWSKnUmCufW5s7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cSUS16/dJMcaaSexAX/ZbBrpilzWSKnUmCufW5s7K/img.png&quot; data-alt=&quot;UNIX 소켓 클라이언트/서버 폴더 구조와 요청을 위한 Enum 정의&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cSUS16/dJMcaaSexAX/ZbBrpilzWSKnUmCufW5s7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcSUS16%2FdJMcaaSexAX%2FZbBrpilzWSKnUmCufW5s7K%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;584&quot; height=&quot;258&quot; data-origin-width=&quot;1330&quot; data-origin-height=&quot;588&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;UNIX 소켓 클라이언트/서버 폴더 구조와 요청을 위한 Enum 정의&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데몬화와 systemd 기반 서비스 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데몬(Daemon)&lt;/b&gt;이란 메모장이나 CLI 프로그램과 같이 사용자가 직접 실행해서 조작하는 프로그램이 아니라, &lt;b&gt;백그라운드에서 계속 실행되면서 특정 작업(task)을 수행하는 프로그램&lt;/b&gt;을 의미합니다. 대표적으로 HTTP 요청을 수신하고 설정한 경로로 요청을 넘겨 (프록시)주는 NGINX, 사용자의 ssh 요청을 기다리고 모든 ssh 연결을 관리하는 sshd, MySQL 쿼리 요청을 수신하고 처리하는 mysqld 모두 데몬 프로그램입니다. HTTP 요청을 수신하고 들어온 요청에 따라 DNS를 제어하는 Bindizr의 경우 또한 데몬 프로그램이라고 할 수 있습니다.&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;span&gt;학교에서 OS 수업을 들으신 분들이라면, 프로세스를 실행할 때 &lt;b&gt;fork를 통해 현재 프로세스와 동일한 자식 프로세스를 생성&lt;/b&gt;하고, &lt;b&gt;exec를 통해 실행하려는 프로그램을 해당 자식 프로세스에 덮어씌우는&lt;/b&gt; 흐름을 접해보셨을 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;646&quot; data-origin-height=&quot;375&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/S1Sp3/dJMcadhGmEl/v8w0KdqzlL1xdVxMY3Z2b0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/S1Sp3/dJMcadhGmEl/v8w0KdqzlL1xdVxMY3Z2b0/img.png&quot; data-alt=&quot;커널 수준에서 이루어지는 프로세스 생성 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/S1Sp3/dJMcadhGmEl/v8w0KdqzlL1xdVxMY3Z2b0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FS1Sp3%2FdJMcadhGmEl%2Fv8w0KdqzlL1xdVxMY3Z2b0%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;424&quot; height=&quot;246&quot; data-origin-width=&quot;646&quot; data-origin-height=&quot;375&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;커널 수준에서 이루어지는 프로세스 생성 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bindizr의&amp;nbsp;초기&amp;nbsp;구현에서는&amp;nbsp;데몬&amp;nbsp;프로그램처럼&amp;nbsp;동작하도록&amp;nbsp;하기&amp;nbsp;위해,&amp;nbsp;&lt;b&gt;Bindizr의&amp;nbsp;자식&amp;nbsp;프로세스를&amp;nbsp;생성한&amp;nbsp;뒤&amp;nbsp;부모&amp;nbsp;프로세스를&amp;nbsp;종료시키는&amp;nbsp;방식을&amp;nbsp;사용&lt;/b&gt;했습니다.&amp;nbsp;이를&amp;nbsp;통해&amp;nbsp;터미널에서&amp;nbsp;실행한&amp;nbsp;부모&amp;nbsp;프로세스가&amp;nbsp;종료되더라도,&amp;nbsp;자식&amp;nbsp;프로세스는&amp;nbsp;백그라운드에서&amp;nbsp;계속&amp;nbsp;실행되도록&amp;nbsp;만들었습니다.&lt;span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;855&quot; data-origin-height=&quot;599&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bU8kqc/dJMcaarJDaO/fMaKtYF8bMGPKPjzCn5WwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bU8kqc/dJMcaarJDaO/fMaKtYF8bMGPKPjzCn5WwK/img.png&quot; data-alt=&quot;Rust에서의 데몬 프로그램 생성 로직&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bU8kqc/dJMcaarJDaO/fMaKtYF8bMGPKPjzCn5WwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbU8kqc%2FdJMcaarJDaO%2FfMaKtYF8bMGPKPjzCn5WwK%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;508&quot; height=&quot;356&quot; data-origin-width=&quot;855&quot; data-origin-height=&quot;599&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Rust에서의 데몬 프로그램 생성 로직&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 버전에서는 Windows 구현체도 함께 지원하려고 했기 때문에, Rust의 조건부 컴파일 매크로를 활용하여 플랫폼별로 다른 코드가 빌드되도록 구성했습니다. 현재는 Windows 지원은 종료되었지만, 당시에는 Linux와 Windows에서 각각 다른 방식으로 데몬화 로직을 처리해야 했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1216&quot; data-origin-height=&quot;458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nX46K/dJMcadaP92w/hewc3uJLfgrhu4yx70b851/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nX46K/dJMcadaP92w/hewc3uJLfgrhu4yx70b851/img.png&quot; data-alt=&quot;다양한 플랫폼을 지원하기 위한 컴파일 매크로, 윈도우 시스템 콜을 통한 데몬 프로그램 생성 로직&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nX46K/dJMcadaP92w/hewc3uJLfgrhu4yx70b851/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnX46K%2FdJMcadaP92w%2Fhewc3uJLfgrhu4yx70b851%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;727&quot; height=&quot;274&quot; data-origin-width=&quot;1216&quot; data-origin-height=&quot;458&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;다양한 플랫폼을 지원하기 위한 컴파일 매크로, 윈도우 시스템 콜을 통한 데몬 프로그램 생성 로직&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 전통적인 프로세스 생성 방식으로 직접 데몬 프로그램을 구현하는 데에는 여러 가지 문제가 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Rust에서 fork 시스템 콜을 사용하려면 unsafe 구문이 필요합니다.&lt;/li&gt;
&lt;li&gt;부모 프로세스와 자식 프로세스의 생명주기를 직접 관리해야 하므로 좀비 프로세스 문제가 발생할 수 있습니다.&lt;/li&gt;
&lt;li&gt;데몬 프로세스의 PID를 관리하기 위한 PID 파일 문제도 있었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;fork와 같은 시스템 콜은 현재 프로세스의 실행 상태를 그대로 복제하는 저수준의 시스템 콜이기 때문에, &lt;b&gt;Rust의 안전성 보장을 벗어난 unsafe 구문을 사용&lt;/b&gt;해야 했습니다. 단순히 백그라운드 실행을 위해 unsafe 코드를 포함해야 한다는 점은 안정성을 중요하게 생각하는 Rust 프로젝트 입장에서는 부담이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로세스&amp;nbsp;생명주기&amp;nbsp;관리도&amp;nbsp;직접&amp;nbsp;처리해야&amp;nbsp;했습니다.&amp;nbsp;자식&amp;nbsp;프로세스를&amp;nbsp;생성한&amp;nbsp;뒤&amp;nbsp;부모&amp;nbsp;프로세스가&amp;nbsp;종료되더라도,&amp;nbsp;&lt;b&gt;자식&amp;nbsp;프로세스의&amp;nbsp;종료&amp;nbsp;상태가&amp;nbsp;제대로&amp;nbsp;회수되지&amp;nbsp;않으면&amp;nbsp;시스템에&amp;nbsp;좀비&amp;nbsp;프로세스로&amp;nbsp;남을&amp;nbsp;수&amp;nbsp;있습니다&lt;/b&gt;.&amp;nbsp;즉,&amp;nbsp;단순히&amp;nbsp;프로세스를&amp;nbsp;하나&amp;nbsp;더&amp;nbsp;만드는&amp;nbsp;것으로&amp;nbsp;끝나는&amp;nbsp;것이&amp;nbsp;아니라,&amp;nbsp;생성된&amp;nbsp;프로세스가&amp;nbsp;언제&amp;nbsp;종료되는지,&amp;nbsp;종료&amp;nbsp;상태는&amp;nbsp;어떻게&amp;nbsp;처리할지까지&amp;nbsp;함께&amp;nbsp;고려해야&amp;nbsp;했습니다. &lt;br /&gt;&lt;br /&gt;마지막으로 PID 파일 관리 문제도 있었습니다. 일반적으로 데몬은 /var/run 또는 /run 아래에 PID 파일을 생성해 현재 실행 중인 프로세스의 PID를 기록합니다. 하지만 PID 파일을 언제 생성하고 삭제할지, 비정상 종료로 인해 남아 있는 PID 파일을 어떻게 처리할지, 이미 실행 중인 프로세스를 어떻게 판별할지 등을 모두 직접 구현해야 했습니다. &lt;br /&gt;&lt;br /&gt;결과적으로&amp;nbsp;전통적인&amp;nbsp;방식으로&amp;nbsp;데몬화를&amp;nbsp;직접&amp;nbsp;구현하면,&amp;nbsp;Bindizr의&amp;nbsp;핵심&amp;nbsp;기능인&amp;nbsp;DNS&amp;nbsp;관리와는&amp;nbsp;거리가&amp;nbsp;있는&amp;nbsp;프로세스&amp;nbsp;관리&amp;nbsp;로직이&amp;nbsp;늘어나게&amp;nbsp;됩니다.&amp;nbsp;이는&amp;nbsp;코드&amp;nbsp;복잡도를&amp;nbsp;높이고,&amp;nbsp;유지보수성과&amp;nbsp;안정성&amp;nbsp;측면에서도&amp;nbsp;부담이&amp;nbsp;되었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 문제를 해결하기 위해 자체적으로 데몬화를 수행하는 방식을 버리고, &lt;b&gt;프로그램은 포그라운드에서 실행하고 프로세스 관리는 Linux의 systemd에 맡기는 방식으로 전환&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ko.wikipedia.org/wiki/Systemd&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;systemd&lt;/b&gt;&lt;/a&gt;&lt;b&gt;는 Linux에서 서비스의 실행과 생명주기를 관리하는 표준적인 서비스 관리자&lt;/b&gt;입니다. 서비스의 시작, 중지, 재시작뿐만 아니라 부팅 시 자동 실행, 비정상 종료 시 자동 복구, 로그 수집, 실행 사용자 및 작업 디렉터리 설정 등을 담당합니다. &lt;br /&gt;&lt;br /&gt;따라서 프로그램이 직접 fork를 사용해 백그라운드 프로세스로 전환하거나, PID 파일을 관리할 필요가 없습니다. 프로그램은 일반적인 서버 프로그램처럼 포그라운드에서 실행되고, 이를 백그라운드 서비스처럼 관리하는 역할은 systemd가 맡습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;systemd 기반 서비스 관리 방식으로 전환하면서 얻을 수 있는 장점은 다음과 같습니다.&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;fork를 사용한 데몬화 로직을 직접 작성하지 않아도&lt;/b&gt; 됩니다.&lt;/li&gt;
&lt;li&gt;PID&amp;nbsp;파일을&amp;nbsp;직접&amp;nbsp;생성,&amp;nbsp;삭제,&amp;nbsp;검증하는&amp;nbsp;로직이&amp;nbsp;필요하지&amp;nbsp;않습니다.&lt;/li&gt;
&lt;li&gt;systemd가 표준 출력과 표준 에러를 수집하므로, &lt;b&gt;journalctl을 통해 로그를 확인할 수 있어, 로그를 저장하는 로직을 직접 작성하지 않아도&lt;/b&gt; 됩니다.&lt;/li&gt;
&lt;li&gt;기본&amp;nbsp;실행&amp;nbsp;방식이&amp;nbsp;포그라운드이기&amp;nbsp;때문에&amp;nbsp;&lt;b&gt;Docker와&amp;nbsp;같은&amp;nbsp;컨테이너&amp;nbsp;환경과도&amp;nbsp;잘&amp;nbsp;어울립&lt;/b&gt;니다.&lt;/li&gt;
&lt;li&gt;서비스 시작, 중지, 재시작, 자동 복구와 같은 운영 기능을 &lt;b&gt;표준적인 방식(systemctl, service)으로 처리&lt;/b&gt;할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bindizr도 이 방식을 사용하여 자체 데몬화 로직을 제거하고, 서비스 실행과 프로세스 관리는 systemd에 위임했습니다. 이를 통해 코드 복잡도를 줄이고, Linux 운영 환경에서 더 표준적인 방식으로 서비스를 관리할 수 있게 되었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포그라운드로 동작하는 Bindizr 프로그램을 systemd 서비스로 등록하려면 아래와 같은 &lt;b&gt;.service 파일을 작성&lt;/b&gt;해야 합니다. 문법은 ini 형식을 따릅니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;559&quot; data-origin-height=&quot;454&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b1UybP/dJMb997pG7v/nokzNOgFIfdvkET8HAKL5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b1UybP/dJMb997pG7v/nokzNOgFIfdvkET8HAKL5k/img.png&quot; data-alt=&quot;systemd 서비스 등록을 위한 .service 파일&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b1UybP/dJMb997pG7v/nokzNOgFIfdvkET8HAKL5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb1UybP%2FdJMb997pG7v%2FnokzNOgFIfdvkET8HAKL5k%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;339&quot; height=&quot;275&quot; data-origin-width=&quot;559&quot; data-origin-height=&quot;454&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;systemd 서비스 등록을 위한 .service 파일&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파일은 해당 서비스를 어떤 방식으로 실행하고, 언제 시작하며, 종료되었을 때 어떻게 처리할지를 systemd에 알려주는 설정 파일입니다. &lt;span&gt;여기서 사용된 주요 옵션을 간단히 살펴보면 다음과 같습니다.&lt;/span&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;Description&lt;/b&gt;: 서비스에 대한 설명, &lt;b&gt;'systemctl status bindizr'&lt;/b&gt; 명령어를 실행했을 때 표시되는 이름&lt;/li&gt;
&lt;li&gt;&lt;b&gt;After&lt;/b&gt;: 서비스의 시작 순서를 지정. 여기서는 network.target(네트워크 연결 보장)과 named.service(BIND 서비스 시작 보장) 이후에 Bindizr가 실행되도록 설정했습니다.&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;ExecStart&lt;/b&gt;: 실제로 서비스를 시작할 때 실행할 명령어. '/usr/bin/bindizr start' 명령을 통해 Bindizr를 실행합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;span&gt;&lt;b&gt;WorkingDirectory&lt;/b&gt;: 서비스가 실행될 작업 디렉터리. '/etc/bindizr'를 기준으로 Bindizr의 설정 파일 등을 다룹니다.&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;StandardOutput / StandardError&lt;/b&gt;: 표준 출력과 표준 에러를 전달할 경로. &lt;b&gt;journalctl로 전송하여&lt;/b&gt;&lt;/span&gt;&lt;span&gt;&lt;b&gt;&amp;nbsp;&lt;/b&gt;'&lt;b&gt;journalctl -u bindizr' &lt;/b&gt;명령어로 로그를 확인할 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로&lt;b&gt; /etc/systemd/system 경로에 배치한 뒤, systemctl 명령어를 통해 서비스를 시작하거나 중지&lt;/b&gt;할 수 있습니다. 또한 등록된 서비스는 OS 부팅 시 systemd가 /etc/systemd/system 경로의 서비스 파일을 확인한 뒤, 설정에 따라 자동으로 실행합니다.&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;이제 Bindizr는 아래와 같은 표준 명령어들로 간편하게 제어, 로그 확인이 가능합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778485619307&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo systemctl start bindizr    # bindizr systemd 서비스 시작
sudo systemctl stop bindizr     # bindizr systemd 서비스 중지
sudo systemctl restart bindizr  # bindizr systemd 서비스 재시작
sudo systemctl status bindizr   # bindizr systemd 서비스 상태 확인

journalctl -u bindizr           # bindizr systemd 서비스 로그 확인&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&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;사용자가 쉽게 설치할 수 있는 형태로 패키징&lt;/b&gt;해야 합니다. 단순히 실행 파일 하나만 제공할 수도 있지만, 시스템 서비스로 동작하는 프로그램이라면 실행 파일뿐만 아니라 설정 파일, systemd 서비스 파일, 기본 디렉터리 구조까지 함께 배포하는 것이 좋습니다.&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;Linux 환경에서는 배포판 계열에 따라 주로 사용하는 패키지 형식이 다릅니다. &lt;b&gt;Debian 계열에서는 deb 패키지&lt;/b&gt;를 사용하며, 이를 &lt;b&gt;APT(Advanced Package Tool)를 통해 설치하고 관리&lt;/b&gt;합니다. &lt;b&gt;Fedora 계열에서는 rpm 패키지&lt;/b&gt;를, &lt;b&gt;경량 리눅스로 유명한 Alpine Linux는 apk 패키지&lt;/b&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;각 패키지마다 빌드 방법이 다르지만 본 글에서는 Ubuntu(Debian 계열)를 기준으로 deb 패키지를 만드는 방법에 대해 알아보겠습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 Debian 패키징에 필요한 도구를 설치해야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778737852733&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt update
# build-essential: 컴파일에 필요한 기본 빌드 도구 모음(gcc, make 등 기본적인 빌드 도구 포함)
# devscripts: Debian 패키징 작업에 필요한 여러 보조 명령어를 제공
# debhelper: Debian 패키지를 만들 때 반복적으로 필요한 작업을 자동화해주는 도구
# dh-make: 기존 프로젝트에 Debian 패키징용 debian/ 디렉터리 구조를 생성해주는 도구
sudo apt install build-essential devscripts debhelper dh-make&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;716&quot; data-start=&quot;654&quot; data-ke-size=&quot;size16&quot;&gt;도구 설치가 끝나면 프로젝트 루트에서 dh_make 명령어를 실행해 기본 패키징 구조를 생성할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;dh_make --single --createorig&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;835&quot; data-start=&quot;761&quot; data-ke-size=&quot;size16&quot;&gt;이 명령어를 실행하면 프로젝트 내부에 debian/ 디렉터리가 생성되고, Debian 패키징에 필요한 기본 파일들이 만들어집니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;debian/
├── changelog
├── control
├── copyright
└── rules&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1045&quot; data-start=&quot;916&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1045&quot; data-start=&quot;916&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;debian/control&lt;/b&gt;에는 패키지 이름, 설명, 의존성 등의 메타데이터를 정의합니다. 아래는 Bindizr의 debian/controls 파일입니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778768141317&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Source: bindizr
Section: utility
Priority: optional
Maintainer: Minsung Kweon &amp;lt;kevin136583@gmail.com&amp;gt;
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.5.0
Homepage: https://github.com/kweonminsung/bindizr

Package: bindizr
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
Description: DNS Synchronization Service for BIND9
 DNS Synchronization Service for BIND9.
 This service allows you to manage BIND9 DNS zones and records through a RESTful API.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;debian/rules&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;에는 프로젝트를 어떻게 빌드하고, 빌드 결과물을 패키지 내부의 어떤 경로에 배치할지 등을 작성합니다.&lt;span&gt; 기본적으로 Makefile의 문법을 따르고, &lt;b&gt;override_dh_auto_build에는 패키지 빌드 시 실행할 명령어&lt;/b&gt;를, &lt;b&gt;override_dh_auto_install에는 빌드된 결과물과 실행에 필요한 설정 파일, 각종 문서를 패키지 내부에 복사하는 명령어&lt;/b&gt;를 작성합니다. override_dh_auto_install에 정의된 파일들은 실제 deb 설치 시 지정된 경로에 복사됩니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;#!/usr/bin/make -f

export DH_VERBOSE=1
export RUST_BACKTRACE=1

%:
	dh $@

override_dh_auto_build:
	cargo build --release --locked --target x86_64-unknown-linux-musl

override_dh_auto_install:
	install -d debian/bindizr/usr/bin
	install -m 755 target/x86_64-unknown-linux-musl/release/bindizr debian/bindizr/usr/bin/
	install -d debian/bindizr/etc/bindizr
	install -m 644 bindizr.conf.toml debian/bindizr/etc/bindizr/bindizr.conf.toml
	install -d debian/bindizr/usr/share/doc/bindizr
	install -m 644 packaging/debian/README.md debian/bindizr/usr/share/doc/bindizr/README.md
	install -d debian/bindizr/usr/share/licenses/bindizr
	install -m 644 LICENSE debian/bindizr/usr/share/licenses/bindizr/LICENSE
	install -d debian/bindizr/usr/lib/systemd/system
	install -m 644 packaging/debian/bindizr.service debian/bindizr/usr/lib/systemd/system/

override_dh_auto_clean:
	cargo clean
	dh_auto_clean&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1218&quot; data-start=&quot;1179&quot; data-ke-size=&quot;size16&quot;&gt;설정이 끝나면 아래 명령어로 deb 패키지를 빌드할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;dpkg-buildpackage -us -uc # bindizr_0.1.0_amd64.deb 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;빌드된 Debian 패키지는 아래와 같이 &lt;b&gt;apt&lt;/b&gt;나 &lt;b&gt;dpkg&lt;/b&gt;를 사용하여 설치할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778743036013&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo apt install ./bindizr_0.1.0_amd64.deb # 필요한 의존성까지 알아서 설치&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;148&quot; data-start=&quot;118&quot; data-ke-size=&quot;size16&quot;&gt;또는 dpkg를 사용해 직접 설치할 수도 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;sudo dpkg -i bindizr_0.1.0_amd64.deb # 해당 패키지만 설치&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;FPM을 사용한 패키지 빌드 간편화&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 Debian 패키지를 빌드하는 정석적인 방식에 대해 알아보았습니다. 하지만 느끼셨다시피 굉장히 번거롭고, 다른 Linux 배포판은 또 다른 방식을 사용해야 하므로 상당히 귀찮은 작업입니다.&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;span&gt;&lt;b&gt;&lt;a href=&quot;https://github.com/jordansissel/fpm&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;fpm&lt;/a&gt;은 여러 패키지 형식을 간단한 명령어로 생성할 수 있게 해주는 패키징 도구&lt;/b&gt;입니다. 복잡한 debian/ 디렉터리 구조를 직접 작성하지 않아도, 실행 파일과 설정 파일, 서비스 파일의 설치 경로를 지정하는 방식으로 deb 또는 rpm 패키지를 만들 수 있습니다. &lt;/span&gt;&lt;span&gt;아래는 Bindizr의 Debian 패키지를 생성하는 fpm 명령어입니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778770802687&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;fpm -s dir -t deb -n bindizr -v &quot;$VERSION&quot; --iteration &quot;$RELEASE&quot; \
    -a x86_64 -m &quot;Minsung Kweon &amp;lt;kevin136583@gmail.com&amp;gt;&quot; \
    --url &quot;https://github.com/kweonminsung/bindizr&quot; \
    --license &quot;Apache-2.0&quot; \
    --description &quot;DNS Synchronization Service for BIND9&quot; \
    --config-files /etc/bindizr/bindizr.conf.toml \
    --after-install scripts/postinstall.sh \
    --after-remove scripts/postremove.sh \
    -C &quot;$TMP_DIR&quot; \
    usr/bin/bindizr usr/lib/systemd/system/bindizr.service etc/bindizr/bindizr.conf.toml usr/share/doc/bindizr/README.md usr/share/licenses/bindizr/LICENSE&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;위 명령어에서 &lt;/span&gt;rpm 패키지를 생성하고 싶으면 -t rpm 옵션을 주면 됩니다.&lt;/span&gt; -s는 패키징 할 폴더를 지정하는 옵션입니다. 패키징 전에 임시 폴더를 만들어 패키지에 넣을 파일들을 미리 넣어두어야 합니다. --after-install은 설치 이후에 실행할 스크립트를, --after-remove는 제거 이후에 실행할 스크립트를 지정하는 옵션입니다.&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;fpm을 활용하면 복잡한 패키징 설정을 단순화할 수 있을 뿐만 아니라, &lt;b&gt;CI/CD 환경에서 패키지를 자동으로 빌드하고 릴리스하기도 훨씬 수월&lt;/b&gt;해집니다. GitHub Actions와 같은 CI 환경에서 바이너리 빌드 이후 동일한 명령어만 실행하면 deb와 rpm 패키지를 일관된 방식으로 생성할 수 있어, 릴리스 과정 전체를 자동화하기 매우 편리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 RNDC 방식의 문제점과 이를 해결하기 위해 전체 아키텍처를 갈아엎은 이야기, 그리고 이 과정에서 공부했던 DNS의 더 깊숙한 이야기를 해보도록 하겠습니다. 긴 글 읽어 주셔서 감사합니다!!&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;P.S. Rust를 사용한 첫 프로젝트이다 보니 중간에 삽질이 정말 많았습니다.(강제로 DI 패턴을 적용하려 했던 등의...)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;92&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bXfF1v/dJMcaflFoLz/18XKDYB6fpSIz5azXPW7T1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bXfF1v/dJMcaflFoLz/18XKDYB6fpSIz5azXPW7T1/img.png&quot; data-alt=&quot;수많은 삽질의 흔적들&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bXfF1v/dJMcaflFoLz/18XKDYB6fpSIz5azXPW7T1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbXfF1v%2FdJMcaflFoLz%2F18XKDYB6fpSIz5azXPW7T1%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;614&quot; height=&quot;92&quot; data-origin-width=&quot;614&quot; data-origin-height=&quot;92&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;수많은 삽질의 흔적들&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Infra</category>
      <category>bind9</category>
      <category>DNS</category>
      <category>SCG</category>
      <category>시스템컨설턴트그룹</category>
      <author>minsung</author>
      <guid isPermaLink="true">https://scg-skku.tistory.com/7</guid>
      <comments>https://scg-skku.tistory.com/7#entry7comment</comments>
      <pubDate>Fri, 15 May 2026 03:44:49 +0900</pubDate>
    </item>
    <item>
      <title>Postfix에서 메일을 Slack으로 포워딩하는 방법</title>
      <link>https://scg-skku.tistory.com/6</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요, 시스템컨설턴트그룹28기 김준호입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;시스템컨설턴트그룹에서는 Postfix 기반의 메일 서버를 운영하며 부원들에게 각자의 이메일 계정을 발급하고 있습니다. 외부와의 소통이나 여러 요청 사항은 주로 회장메일을 통해 주고받습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;기존에는 회장메일로 도착한 메일을 담당자가 직접 확인한 뒤 필요한 내용을 부원들에게 공유했지만, 직접 메일함에 접속해야 하는 번거로움과 부원들에 대한 접근성이 낮다는 문제가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이를 개선하기 위해 회장메일로 들어온 이메일을 Slack 채널로 자동 전달하는 작업을 진행했는데, 그 과정에서 있었던 일들을 공유하려 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Postfix란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Postfix는 리눅스 환경에서 널리 사용되는 메일 전송 에이전트입니다. 메일을 수신하거나 다른 메일 서버로 전달하는 역할을 하며, 설정에 따라 특정 주소로 들어온 메일을 다른 주소로 복사하거나 외부 프로그램으로 전달하는 등의 처리를 할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;회장메일에서 Slack으로 포워딩하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회장메일을 Slack 채널에 전달하기 전에 한 가지 문제가 있었습니다. 예전부터 회장메일 주소로 스팸 메일이 주기적으로 들어오고 있었다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;메일 서버에는 이미 rspamd 등의 기본적인 스팸 필터링이 적용되어 있지만, 일부 스팸 메일은 여전히 필터를 통과하고 있습니다. 기존에는 회장메일 계정에서 직접 확인하고 무시했지만, Slack으로 자동 전달되면 부원 전체가 해당 스팸을 보게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;따라서 단순히 모든 메일을 Slack으로 전달하는 것이 아니라, 필요한 메일만 전달하도록 중간에 한 번 더 필터링하는 구조가 필요했습니다. 즉, 목표는 다음과 같았습니다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Slack 전송용 복사본을 따로 만들고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 복사본만 Python 스크립트로 전달하여&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터링 후 Slack으로 전송&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;다행히 Postfix는 여기서 필요한 모든 기능을 제공합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. recipient_bcc 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 회장메일로 들어온 메일을 복사하도록 설정합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778321647775&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo vi /etc/postfix/recipient_bcc&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일에는 다음 내용을 추가합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778321677721&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;scg@scg.skku.ac.kr  slack@localhost&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정은 scg@scg.skku.ac.kr로 들어오는 메일에 대해 slack@localhost를 BCC 수신자로 추가한다는 의미입니다. 즉, 기존 회장메일 수신은 그대로 유지하면서 Slack 전송을 위한 복사본이 하나 더 만들어집니다.&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;여기서 BCC는 숨은 참조를 의미합니다. 원래 수신자인 회장메일에는 정상적으로 메일이 전달되고, Postfix 내부에서만 slack@localhost로 복사본이 추가됩니다.&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;설정 파일을 작성한 뒤에는 postmap 명령어를 실행합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778322017852&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo postmap /etc/postfix/recipient_bcc&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;postmap 명령을 실행하면 /etc/postfix/recipient_bcc.db 파일이 생성됩니다. 아래에서 recipient_bcc_maps를 hash 타입으로 등록할 것이므로, Postfix가 조회할 hash DB 파일을 생성해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. transport 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 slack@localhost에 들어온 메일을 일반적인 로컬 메일함에 저장하지 않고, 별도의 transport로 넘기도록 설정합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778322127491&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo vi /etc/postfix/transport&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일에는 다음 내용을 추가합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778322136175&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;slack@localhost  slack-service:&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이&amp;nbsp;설정은&amp;nbsp;slack@localhost로&amp;nbsp;들어온&amp;nbsp;메일을&amp;nbsp;slack-service라는&amp;nbsp;transport로&amp;nbsp;처리하겠다는&amp;nbsp;의미입니다.&lt;br /&gt;&lt;br /&gt;즉,&amp;nbsp;slack@localhost는&amp;nbsp;실제&amp;nbsp;사람이&amp;nbsp;확인하는&amp;nbsp;메일함이&amp;nbsp;아니라,&amp;nbsp;Slack&amp;nbsp;전송을&amp;nbsp;위한&amp;nbsp;중간&amp;nbsp;주소로&amp;nbsp;사용됩니다.&amp;nbsp;이&amp;nbsp;주소로&amp;nbsp;들어온&amp;nbsp;메일은&amp;nbsp;뒤에서&amp;nbsp;설정할&amp;nbsp;slack-service를&amp;nbsp;통해&amp;nbsp;Python&amp;nbsp;스크립트로&amp;nbsp;전달됩니다.&lt;br /&gt;&lt;br /&gt;마찬가지로 postmap 명령어를 실행해 hash DB 파일을 생성합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778322327987&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo postmap /etc/postfix/transport&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. main.cf 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 만든 recipient_bcc와 transport 설정 파일을 Postfix가 실제로 사용하도록 main.cf에 등록합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778322349601&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo vi /etc/postfix/main.cf&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 내용을 추가합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778322361986&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;recipient_bcc_maps = hash:/etc/postfix/recipient_bcc
transport_maps = hash:/etc/postfix/transport&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 회장메일로 들어온 메일은 기존 수신자에게 정상적으로 전달되면서, 동시에 slack@localhost로 복사됩니다. 그리고 slack@localhost로 복사된 메일은 transport_maps 설정에 의해 slack-service로 전달됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서&amp;nbsp;hash:&amp;nbsp;접두사는&amp;nbsp;해당&amp;nbsp;map을&amp;nbsp;hash&amp;nbsp;DB&amp;nbsp;파일로&amp;nbsp;조회하겠다는&amp;nbsp;의미입니다.&amp;nbsp;앞에서&amp;nbsp;postmap&amp;nbsp;명령을&amp;nbsp;실행해&amp;nbsp;생성한&amp;nbsp;.db&amp;nbsp;파일들이&amp;nbsp;이때&amp;nbsp;사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;4. master.cf 설정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 slack-service transport가 실제로 어떤 명령을 실행할지 정의해야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778322416103&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo vi /etc/postfix/master.cf&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 내용을 추가합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778322436115&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;slack-service   unix  -       n       n       -       -       pipe
  flags=R user=scg argv=/usr/bin/python3 /path/to/python/script&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정은 slack-service라는 transport가 호출되었을 때, scg 사용자 권한으로 Python 스크립트를 실행하라는 의미입니다. 메일 내용은 pipe를 통해 스크립트의 표준 입력으로 전달됩니다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scg@scg.skku.ac.kr로&amp;nbsp;메일&amp;nbsp;수신&lt;br /&gt;&amp;rarr;&amp;nbsp;recipient_bcc_maps에&amp;nbsp;의해&amp;nbsp;slack@localhost로&amp;nbsp;복사&lt;br /&gt;&amp;rarr;&amp;nbsp;transport_maps에&amp;nbsp;의해&amp;nbsp;slack-service로&amp;nbsp;전달&lt;br /&gt;&amp;rarr; master.cf의 pipe 설정에 따라 Python 스크립트 실행&lt;br /&gt;&amp;rarr; Python 스크립트에서 필터링 후 Slack으로 메일 전송&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;5. Python 스크립트 작성&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서&amp;nbsp;설정한&amp;nbsp;pipe&amp;nbsp;transport에&amp;nbsp;의해&amp;nbsp;Python&amp;nbsp;스크립트가&amp;nbsp;실행되면,&amp;nbsp;Postfix는&amp;nbsp;메일&amp;nbsp;원문을&amp;nbsp;스크립트의&amp;nbsp;표준&amp;nbsp;입력으로&amp;nbsp;전달합니다.&amp;nbsp;따라서&amp;nbsp;스크립트에서는&amp;nbsp;표준&amp;nbsp;입력을&amp;nbsp;읽어&amp;nbsp;메일&amp;nbsp;데이터를&amp;nbsp;가져올&amp;nbsp;수&amp;nbsp;있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778334622640&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import sys
import email
from email import policy

raw_data = sys.stdin.buffer.read()
msg = email.message_from_bytes(raw_data, policy=policy.default)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 해당 메일 데이터를 Slack으로 전송하기만 하면 됩니다. Slack에서는 채널용 이메일 주소를 생성할 수 있는데, 이렇게 생성된 이메일로 데이터를 전송시키는 코드를 작성하면 됩니다. 자세한 내용은 &lt;a href=&quot;https://slack.com/intl/ko-kr/help/articles/206819278-Slack%EC%97%90-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EB%B3%B4%EB%82%B4%EA%B8%B0&quot;&gt;Slack 공식 문서&lt;/a&gt;에서 확인하실 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;6. 설정 적용&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778322518111&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;sudo postfix check
sudo postfix reload&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;스팸 필터링&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회장메일을 Slack으로 전달하는 것은 Postfix에서 간단히 설정할 수 있지만, 필터링 로직은 직접 추가해야 했습니다.&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;먼저 추가한 것은 기존 메일 서버에 적용되어 있던 rspamd의 검사 결과였습니다. rspamd가 스팸으로 판정한 이메일은 스팸함에 저장되는데, 이는 메일의 헤더에서 확인할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778323774761&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def is_spam(msg):
    spamd_result = str(msg.get(&quot;X-Spamd-Result&quot;, &quot;&quot;)).lower()
    return &quot;true&quot; in spamd_result&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이것만으로는 충분하지 않았습니다. rspamd에서 걸러지지 않는 스팸 메일들이 있었기 때문입니다. 그래서 메일함에 저장되어 있던 스팸 메일의 원문을 확인해보았습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778341038280&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;cd /var/vmail/scg.skku.ac.kr/scg/Maildir/cur/
cat &quot;$(ls -t | head -n 1)&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 해당 메일 원문 중 일부를 발췌한 내용입니다. *는 임의로 마스킹한 값입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778343997943&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Delivered-To: scg@scg.skku.ac.kr
Return-Path: &amp;lt;uh*****@w***-b*****.******&amp;gt;
From: &quot;N********&quot; &amp;lt;uh*****@w***-b*****.******&amp;gt;
To: &amp;lt;t**@s********.**.**&amp;gt;

X-Spamd-Result: default: False [1.70 / 15.00];
        ...
        FORGED_RECIPIENTS(2.00)[m:t**@s********.**.**,s:scg@scg.skku.ac.kr]

Content-Type: text/plain;
        charset=&quot;windows-1251&quot;

Content-Type: text/html;
        charset=&quot;windows-1251&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 메일 헤더를 보면, 실제로는 scg@scg.skku.ac.kr로 전달되었지만 To 헤더에는 다른 주소가 적혀 있습니다. 이메일에서는 사용자에게 보이는 To 헤더와 실제 배달 대상이 항상 일치하지는 않습니다. BCC, 포워딩, 메일링 리스트 같은 정상적인 상황에서도 이런 불일치가 발생할 수 있기 때문에, 이 사실만으로 스팸이라고 단정할 수는 없습니다. 다만 이 메일에서는 다른 의심 요소들과 함께 나타났기 때문에 참고할 만한 신호로 볼 수 있습니다.&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;rspamd도 이를 FORGED_RECIPIENTS 항목으로 감지하고 있었습니다. FORGED_RECIPIENTS는 메일 헤더에 표시된 수신자와 실제로 메일이 전달된 수신자가 다를 때 추가되는 rspamd 심볼입니다. 즉, 이 메일은 겉으로 보이는 수신자와 실제 배달 대상이 일치하지 않았고, rspamd는 이를 의심 요소로 판단해 2점을 추가했습니다. 하지만 최종 점수는 1.7점으로 임계값 15점보다 낮아 결국 스팸으로 판정되지 않았습니다.&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;그런데 메일 원문을 더 확인해보니 중요한 특징이 있었습니다. plain text 본문과 HTML 본문 모두 Content-Type 헤더에 charset=&quot;windows-1251&quot;이 설정되어 있었습니다.&lt;br /&gt;&lt;br /&gt;windows-1251은 주로 키릴 문자권에서 사용되는 문자 인코딩입니다. 이 메일의 제목과 본문은 한국어로 된 광고성 내용이었지만, 본문 MIME 파트의 charset은 windows-1251로 지정되어 있었습니다.&lt;br /&gt;&lt;br /&gt;이후&amp;nbsp;회장메일로&amp;nbsp;수신된&amp;nbsp;메일들을&amp;nbsp;추가로&amp;nbsp;확인해보니,&amp;nbsp;확인한&amp;nbsp;범위&amp;nbsp;내에서는&amp;nbsp;스팸&amp;nbsp;메일들이&amp;nbsp;모두&amp;nbsp;windows-1251&amp;nbsp;인코딩을&amp;nbsp;사용하고&amp;nbsp;있었습니다.&amp;nbsp;반면&amp;nbsp;정상&amp;nbsp;메일&amp;nbsp;중에서는&amp;nbsp;windows-1251&amp;nbsp;인코딩을&amp;nbsp;사용하는&amp;nbsp;경우가&amp;nbsp;없었습니다.&lt;br /&gt;&lt;br /&gt;물론 windows-1251을 사용하는 모든 메일이 일반적으로 스팸이라는 뜻은 아닙니다. 하지만 저희 회장메일의 수신 사례에서는 스팸 메일과 정상 메일을 구분하는 데 매우 뚜렷한 기준으로 작동했습니다. 회장메일로 들어오는 정상적인 메일은 대부분 한국어나 영어 기반이었기 때문에, 현재 운영 환경에서는 windows-1251 인코딩을 사용하는 메일을 Slack으로 전달하지 않아도 정상 메일이 잘못 차단될 가능성이 낮다고 판단했습니다.&lt;br /&gt;&lt;br /&gt;따라서&amp;nbsp;rspamd&amp;nbsp;검사&amp;nbsp;결과와&amp;nbsp;별개로,&amp;nbsp;메일의&amp;nbsp;문자&amp;nbsp;인코딩이&amp;nbsp;windows-1251인&amp;nbsp;경우&amp;nbsp;Slack으로&amp;nbsp;전달하지&amp;nbsp;않도록&amp;nbsp;추가&amp;nbsp;필터링을&amp;nbsp;적용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1778333816213&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def is_spam(msg):
    spamd_result = str(msg.get(&quot;X-Spamd-Result&quot;, &quot;&quot;)).lower()
    if &quot;true&quot; in spamd_result:
        return True

    for part in msg.walk():
        charset = part.get_content_charset()
        if charset and charset.lower() == &quot;windows-1251&quot;:
            return True

    return False&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업을 통해 회장메일로 들어오는 메일을 Slack 채널로 자동 전달하고, 불필요한 스팸 메일들을 걸러낼 수 있게 되었습니다.&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;처음에는 rspamd를 통과한 스팸 메일들을 어떤 기준으로 필터링해야 할지 막막했습니다. 스팸의 제목이나 본문을 하드코딩하는 방식도 사용해봤고, LLM을 이용해 메일 내용을 분류해야 하나 고민하기도 했습니다.&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;하지만 실제로 수신된 메일 원문을 하나씩 확인해보면서, 회장메일로 들어오던 스팸 메일들이 windows-1251 인코딩을 사용하고 있다는 공통점을 찾을 수 있었습니다. 덕분에 복잡한 분류 로직 없이도 현재 운영 환경에 맞는 간단하고 효과적인 필터링을 적용할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SCG에서는 웹사이트 개발과 관리뿐만 아니라, 인프라를 운영하며 다양한 문제를 직접 마주하고 해결해나가고 있습니다. 저 역시 SCG 덕분에 메일 서버를 다뤄보고, 실제 운영 환경에서 문제를 해결해보는 경험을 할 수 있었습니다.&lt;br /&gt;&lt;br /&gt;SCG에&amp;nbsp;많은&amp;nbsp;관심&amp;nbsp;가져주시고,&amp;nbsp;지원도&amp;nbsp;많이&amp;nbsp;해주시면&amp;nbsp;좋겠습니다.&lt;br /&gt;&lt;br /&gt;읽어주셔서&amp;nbsp;감사합니다!&lt;/p&gt;</description>
      <category>Infra</category>
      <category>SCG</category>
      <category>메일서버</category>
      <category>스팸필터링</category>
      <category>시스템컨설턴트그룹</category>
      <author>simtal</author>
      <guid isPermaLink="true">https://scg-skku.tistory.com/6</guid>
      <comments>https://scg-skku.tistory.com/6#entry6comment</comments>
      <pubDate>Sun, 10 May 2026 02:26:34 +0900</pubDate>
    </item>
    <item>
      <title>웍실 네트워크 개편</title>
      <link>https://scg-skku.tistory.com/5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 시스템컨설턴트그룹 30기 인프라 담당 신동현입니다.&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;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;제목 없음(2).png&quot; data-origin-width=&quot;4104&quot; data-origin-height=&quot;3080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FKUZM/dJMcaaSmAvJ/KEzC2Jw6nXE3HQJRkkAzeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FKUZM/dJMcaaSmAvJ/KEzC2Jw6nXE3HQJRkkAzeK/img.png&quot; data-alt=&quot;네트워크 구조도 (초록색: 추가, 빨간색: 제거)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FKUZM/dJMcaaSmAvJ/KEzC2Jw6nXE3HQJRkkAzeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFKUZM%2FdJMcaaSmAvJ%2FKEzC2Jw6nXE3HQJRkkAzeK%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;4104&quot; height=&quot;3080&quot; data-filename=&quot;제목 없음(2).png&quot; data-origin-width=&quot;4104&quot; data-origin-height=&quot;3080&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;네트워크 구조도 (초록색: 추가, 빨간색: 제거)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;2&quot; data-ke-size=&quot;size23&quot;&gt;'고성능'&amp;nbsp; 라우터 투입&lt;/h3&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;이번 개편의 핵심 중 하나는 라우터 분리였습니다. 기존 무선 AP가 기기 50여 대를 감당하다 보니 자꾸 뻗어버리더라고요. 그래서 웍실 구석에 굴러다니던 데스크톱을 라우터로 개조해 투입했습니다. 무려 '브로드웰' 기반의 PC인데, 지금이 2026년이니 무려 10년도 더 된 할아버지급 CPU죠. 그래도 단순 가정용 AP 보다는 훨씬 듬직하게 트래픽을 처리해 줍니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size23&quot;&gt;&quot;랜선에 불은 들어오는데 왜 안 되지?&quot;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;구조를 바꾸면서 골칫덩어리였던 빨간색 공유기(의문의 스위치)를 떼어내고, 책상 밑 라인들을 정보검색실 스위치로 직결하는 작업을 했습니다. 실제 서버를 건드리기 전에 혹시 몰라서 남는 PC에 새 케이블을 꽂고 테스트를 해봤는데... 분명 랜 포트에 불은 잘 들어오는데 서버와 통신이 아예 안 되는 겁니다. 처음엔 케이블 단선을 의심했는데, 가만히 생각해보니 작년 '네트워크시스템개론' 수업에서 들었던 내용이 머리를 스쳐지나갔습니다. 결론부터 말하자면 이건 &lt;b data-index-in-node=&quot;119&quot; data-path-to-node=&quot;7&quot;&gt;MAC 주소 테이블과 ARP 캐시&lt;/b&gt;가 만들어낸 환장의 콜라보였습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size23&quot;&gt;조금 깊게 파보는 MAC과 ARP의 세계&lt;/h3&gt;
&lt;p data-path-to-node=&quot;9&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;10&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;10,0,0&quot;&gt;스위치의 MAC 테이블:&lt;/b&gt; 스위치는 자신의 몇 번 포트에 어떤 기기(MAC 주소)가 연결되어 있는지 기억해 둡니다. 패킷이 들어오면 이 테이블을 보고 목적지가 있는 포트로만 쏙 보내줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;라우터의 ARP 캐시:&lt;/b&gt; 라우터는 특정 IP 주소가 어떤 MAC 주소를 가지고 있는지 매칭해 둡니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;자, 여기서 서버에 꽂혀있던 랜선을 뽑아서 다른 경로(다른 스위치나 포트)로 옮겨 꽂으면 어떻게 될까요? 물리적인 선은 옮겨졌지만, 상위 스위치와 라우터의 '기억'은 아직 과거에 머물러 있습니다. 상위 장비들은 여전히 &lt;i data-index-in-node=&quot;122&quot; data-path-to-node=&quot;11&quot;&gt;&quot;아, 그 서버는 예전 길로 가면 있지?&quot;&lt;/i&gt; 하고 엉뚱한 옛날 포트로 데이터를 던져버립니다. 이 상황에서 서버가 가만히 숨만 쉬고 있으면(외부로 데이터를 먼저 보내지 않으면), 스위치 입장에서는 이 녀석이 다른 포트로 이사 갔다는 사실을 알아챌 방법이 없습니다. 기존 장비들의 캐시가 만료될 때까지 통신이 완전히 먹통이 되는 겁니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;13&quot; data-ke-size=&quot;size23&quot;&gt;Ping 한 줄로 해결한 무중단 핫스왑&lt;/h3&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;이 가설이 맞는지 확인하려고 먹통이 된 서버에 키보드랑 모니터를 직접 연결해서 터미널을 열었습니다. 그리고 딱 한 줄을 쳤죠. &quot;ping 1.1.1.1&quot;. 엔터를 치는 순간 핑이 나가면서 즉시 통신이 복구됐습니다! 서버가 외부로 핑을 쏘면서 자신의 MAC 주소를 담은 패킷을 '새로운 경로'로 내보냈고, 그 패킷을 받은 스위치들이 &lt;i data-index-in-node=&quot;98&quot; data-path-to-node=&quot;16&quot;&gt;&quot;어? 이 MAC 주소가 이제 이쪽 포트에서 들어오네?&quot;&lt;/i&gt; 하고 즉각 자신의 테이블을 갱신해 버린 겁니다. 이 원리를 바탕으로, 실제 서비스 중인 서버들의 랜선을 끊김 없이 교체할 꼼수가 생겼습니다. 서버들의 케이블을 뽑기 전에, 미리 각 서버에 SSH로 접속해서 &quot;ping 1.1.1.1 &amp;amp; disown&quot;을 실행했습니다. 서버가 쉴 새 없이 자기 위치를 동네방네 떠들게 만든 상태에서 랜선을 새 경로로 확 바꿔치기했죠. 선이 연결되는 그 0.1초의 순간에 핑 패킷이 새 경로를 타고 올라가서 스위치들의 경로를 강제로 업데이트시켜 버렸습니다. 결과적으로 단 몇 초의 끊김도 없는 완벽한 핫스왑에 성공했습니다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;앞으로의 계획&lt;/h3&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;이제 당면한 급한 불은 껐고, 다음 목표는 라우터를 중앙 시스코 스위치와 메인 업스트림 포트 사이의 최상단으로 옮기는 겁니다. 현재 웍실은 수동으로 적어둔 IP 관리 대장이 업데이트가 안 돼서, 무슨 IP가 빈자리인지도 모르는 막장 상태거든요. 라우터를 최상단으로 올리면 전체 IP 사용 현황을 한눈에 모니터링할 수 있습니다. 겸사겸사 좌/우측 PC들도 전부 DHCP와 NAT 기반으로 돌릴 예정입니다. 예전엔 학교에 포트 개방을 요청할 수 있어서 PC마다 공인 IP를 직접 먹여놨었는데, 이제 정책상 더 이상 불가능해졌습니다. PC 초기화할 때마다 IP, 서브넷 마스크, 게이트웨이, DNS를 수동으로 잡아주는 노가다도 이젠 끝내보려고 합니다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Infra</category>
      <category>ARP</category>
      <category>MAC</category>
      <category>ping</category>
      <category>SCG</category>
      <category>시스템컨설턴트그룹</category>
      <author>aperso</author>
      <guid isPermaLink="true">https://scg-skku.tistory.com/5</guid>
      <comments>https://scg-skku.tistory.com/5#entry5comment</comments>
      <pubDate>Fri, 3 Apr 2026 15:16:48 +0900</pubDate>
    </item>
    <item>
      <title>8년 된 레거시 시스템의 에러를 추적한 과정 - 로깅 한 줄의 힘</title>
      <link>https://scg-skku.tistory.com/4</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;0. 들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요 :) 시스템컨설턴트그룹 27기 백엔드 개발을 하고 있는 노주희입니다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 성균관대학교 &lt;a href=&quot;https://cssys.cs.skku.ac.kr/cssys/login&quot;&gt;소프트웨어융합대학의 졸업평가 시스템&lt;/a&gt;(연구논문작품시스템, 이하 cssys)에서 발생한 파일 업로드 500 에러를 추적하고 해결한 과정을 정리한 것입니다. 로깅이 부재한 레거시 시스템에서 어떻게 원인을 좁혀나갔는지, 그리고 그 과정에서 느낀 점을 공유합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 배경&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서비스 소개&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cssys는 소프트웨어학과 졸업평가 연구논문작품의 서류 제출 및 심사 과정이 이루어지는 시스템입니다. 학생들은 서약서, 제안서, 중간보고서, 최종보고서 등을 이 시스템을 통해 제출하고, 행정실에서는 관리자 페이지를 통해 서류를 관리합니다.&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;이 서비스는 SCG에서 운영하고 있으며, 첫 커밋이 2018년 1월로 기록되어 있는, 8년 이상의 역사를 가진 레거시 시스템입니다. 정보통신대학의 졸업평가 시스템(iccsys)이 2016년에 만들어졌고 해당 프로젝트를 fork 해서 만든 서비스로 파악됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;867&quot; data-origin-height=&quot;807&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RCQt9/dJMcahcGIoh/xWs9QC803wdusHqrdJkRaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RCQt9/dJMcahcGIoh/xWs9QC803wdusHqrdJkRaK/img.png&quot; data-alt=&quot;GitLab에 보관된 원본 소스 코드&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RCQt9/dJMcahcGIoh/xWs9QC803wdusHqrdJkRaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRCQt9%2FdJMcahcGIoh%2FxWs9QC803wdusHqrdJkRaK%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;693&quot; height=&quot;645&quot; data-origin-width=&quot;867&quot; data-origin-height=&quot;807&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;GitLab에 보관된 원본 소스 코드&lt;/figcaption&gt;
&lt;/figure&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;: Node.js + Express 4&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ORM&lt;/b&gt;: Sequelize&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB&lt;/b&gt;: MySQL&lt;/li&gt;
&lt;li&gt;&lt;b&gt;템플릿 엔진&lt;/b&gt;: Swig&lt;/li&gt;
&lt;li&gt;&lt;b&gt;배포&lt;/b&gt;: 물리 서버의 VM 위에서 pm2로 수동 배포&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파일 저장&lt;/b&gt;: 서버 로컬 파일시스템&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마이그레이션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 물리 서버의 디스크 용량 부족 문제로 인해, 2026년 3월 초에 28기 양현준 님과 27기 송영욱 님이 함께 다음과 같은 마이그레이션을 진행했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 83.9535%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15%;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 27.3256%;&quot;&gt;Before&lt;/td&gt;
&lt;td style=&quot;width: 41.6279%;&quot;&gt;After&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15%;&quot;&gt;배포 환경&lt;/td&gt;
&lt;td style=&quot;width: 27.3256%;&quot;&gt;VM 위 수동 실행&lt;/td&gt;
&lt;td style=&quot;width: 41.6279%;&quot;&gt;Kubernetes + ArgoCD + GitHub Actions CI/CD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15%;&quot;&gt;파일 저장&lt;/td&gt;
&lt;td style=&quot;width: 27.3256%;&quot;&gt;서버 로컬 폴더&lt;/td&gt;
&lt;td style=&quot;width: 41.6279%;&quot;&gt;MinIO Object Storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15%;&quot;&gt;라이브러리&lt;/td&gt;
&lt;td style=&quot;width: 27.3256%;&quot;&gt;mysql / Sequelize v2&lt;/td&gt;
&lt;td style=&quot;width: 41.6279%;&quot;&gt;mysql2 / Sequelize v6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15%;&quot;&gt;템플릿 엔진&lt;/td&gt;
&lt;td style=&quot;width: 27.3256%;&quot;&gt;Swig&lt;/td&gt;
&lt;td style=&quot;width: 41.6279%;&quot;&gt;EJS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션 후 행정실에 테스트를 요청했고, 특별한 이상이 없다는 회신을 받았습니다. 3월 6일~7일에 서버 이전 작업이 완료되었고, 서비스가 정상적으로 운영되는 것으로 판단했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 문제 발생&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;에러 보고 (3월 11일)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 이전이 완료된 지 며칠 후, 행정실로부터 메일이 도착했습니다. 관리자 계정으로 학생의 신청서를 업로드하려 했으나, &quot;에러가 발생하였습니다. 시스템 관리자에게 문의해주세요.&quot;라는 팝업이 표시된다는 내용이었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;455&quot; data-origin-height=&quot;154&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdfmYM/dJMcagkxt0M/VevNvBguKi46AikDHqXyT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdfmYM/dJMcagkxt0M/VevNvBguKi46AikDHqXyT1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdfmYM/dJMcagkxt0M/VevNvBguKi46AikDHqXyT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdfmYM%2FdJMcagkxt0M%2FVevNvBguKi46AikDHqXyT1%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;455&quot; height=&quot;154&quot; data-origin-width=&quot;455&quot; data-origin-height=&quot;154&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;/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;의미 있는 로그가 없었습니다.&lt;/b&gt; ArgoCD를 통해 로그를 확인할 수는 있었지만, 에러 핸들러가 URL만 기록하고 에러 내용 자체는 출력하지 않는 구조였습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// 기존 에러 핸들러 (app.js)
console.error(`[${now}] [ERROR] ${req.method} ${req.originalUrl}`);
// err 객체는 출력하지 않음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, &lt;b&gt;관리자 페이지에 직접 접근할 수 없었습니다.&lt;/b&gt; 관리자 계정으로 접속하면 IP가 로그에 남기 때문에, 보안상의 이유로 개발자의 직접 접근을 지양하고 있었습니다.&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;개발 서버가 존재하지 않았습니다.&lt;/b&gt; 운영 환경만 존재하는 상황에서, 변경사항을 테스트하려면 매번 행정실에 재시도를 요청해야 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 해결 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 1: 초기 가설 수립&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그에서 확인할 수 있었던 것은 다음 정보뿐이었습니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;발생 시간: 2026-03-11 13:06~13:09
클라이언트 IP: 115.145.180.***
HTTP 메서드: POST
대상 엔드포인트: /cssys/work/admin/student/8805
상태 코드: 500
에러 메시지: Response timeout&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POST 요청에서 500 에러가 발생했고, 행정실에서 전달받은 파일은 139kB에 불과했으므로 파일 크기 문제는 아니었습니다. 마이그레이션 과정에서 MinIO 연결 설정에 문제가 있을 수 있다는 가설을 세웠지만, 로그 정보만으로는 원인을 특정할 수 없었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 2: 로깅 추가 및 배포&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인 추적을 위해 에러 핸들러에 스택 트레이스 출력을 추가하는 것을 최우선 조치로 결정했습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// 변경 후
console.error(`[${now}] [ERROR] ${req.method} ${req.originalUrl}\\n${err.stack}`);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단 한 줄의 수정&lt;/b&gt;이었지만, 이것이 이후 문제 해결의 결정적인 전환점이 되었습니다. &lt;a href=&quot;https://github.com/SystemConsultantGroup/CSE-undergrad-gp-manage-v2/pull/3&quot;&gt;PR&lt;/a&gt;을 생성하고 머지한 뒤, CI/CD 파이프라인을 통해 자동 배포되었습니다. 이후 행정실에 동일한 동작의 재시도를 요청하는 메일을 발송했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 3: 1차 원인 특정 &amp;mdash; multer 필드명 불일치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;행정실에서 재시도한 후 로그를 확인하니, 드디어 스택 트레이스가 남아 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;MulterError: Unexpected field
    at wrappedFileFilter (multer/index.js:40:19)
    ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MulterError: Unexpected field. multer가 예상하지 못한 필드명으로 파일이 전송되었다는 의미입니다. 코드를 확인한 결과, 원인은 &lt;b&gt;어드민 뷰의 폼 필드명과 multer 설정의 불일치&lt;/b&gt;였습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// 라우터 설정 (admin.js)
router.post('/student/:id', upload.single('upload'), ...)
// multer는 'upload'라는 이름의 파일 필드만 허용&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;html xml&quot; data-ke-language=&quot;html&quot;&gt;&lt;code&gt;&amp;lt;!-- 어드민 뷰 (student_view.ejs) --&amp;gt;
&amp;lt;input type=&quot;file&quot; name=&quot;oath&quot;&amp;gt;           &amp;lt;!-- 실제 파일 필드: 'oath' --&amp;gt;
&amp;lt;input type=&quot;hidden&quot; name=&quot;upload&quot; value=&quot;oath&quot;&amp;gt;  &amp;lt;!-- 파일 타입 정보: 텍스트 --&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;multer는 upload라는 이름의 파일 필드를 기다리고 있었지만, 실제 폼에서는 oath라는 이름으로 파일이 전송되고 있었습니다. name=&quot;upload&quot;인 필드는 파일이 아닌 hidden 텍스트 필드로, 어떤 종류의 파일인지를 서버에 알려주는 역할이었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 4: 수정 및 2차 에러 발견&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 방향은 어드민 뷰의 7개 파일 input 필드명을 upload으로 통일하는 것으로 결정했습니다. &lt;a href=&quot;https://github.com/SystemConsultantGroup/CSE-undergrad-gp-manage-v2/pull/4&quot;&gt;(수정 PR)&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!-- 수정 전 --&amp;gt;
&amp;lt;input type=&quot;file&quot; name=&quot;oath&quot;&amp;gt;

&amp;lt;!-- 수정 후 --&amp;gt;
&amp;lt;input type=&quot;file&quot; name=&quot;upload&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 후 배포하고 행정실에 다시 테스트를 요청했지만, 여전히 에러가 발생한다는 회신이 돌아왔습니다. 로그를 확인하니 이번에는 다른 에러가 기록되어 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;SequelizeValidationError: notNull Violation: StudentFile.time cannot be null,
notNull Violation: StudentFile.ip cannot be null
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 업로드 기록을 DB에 저장할 때 time과 ip 값이 null로 들어가 유효성 검사에 실패한 것이었습니다. 마이그레이션 과정에서 기존 time/ip 파서의 호환성이 깨진 문제로, 마이그레이션 담당자인 현준 님이 이미 인지하고 있던 이슈였기에 곧바로 수정해 주셨습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스택 트레이스 로깅이 남아 있었기 때문에, 2차 에러의 원인 파악에는 1분도 걸리지 않았습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 5: 해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2차 에러까지 수정하고 배포를 완료했습니다. 행정실 담당자분의 퇴근 시간 이후였기 때문에 다음 날 최종 확인을 받을 수 있었습니다. 에러 보고를 받은 오후 1시부터 수정 배포 완료까지 약 5시간이 소요되었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Claude Code 활용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 트러블슈팅 과정에서 Claude Code(Anthropic의 CLI 기반 AI 코딩 도구)를 적극적으로 활용했습니다. 다만, AI에 완전히 의존하지 않고 &lt;b&gt;판단의 주체는 사람이 가져가는 방식&lt;/b&gt;으로 사용했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;활용 방식&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;가설 수립 단계&lt;/b&gt;: 에러 현상, 환경 변경사항, 제약조건 등의 컨텍스트를 제공하고 원인 가설을 요청했습니다. AI는 MinIO SDK의 HTTPS 설정 불일치, K8s 네트워크 문제, 에러 핸들링 누락 등 여러 가설을 제시했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;가설 검증 단계&lt;/b&gt;: AI의 가설에 대해 &quot;다른 서비스에서는 MinIO 연결에 문제가 없다&quot;는 사실을 제공하여 반박하고, AI가 재분석하도록 유도했습니다. AI는 응답 시간 4.514ms라는 단서에 주목하여 MinIO hang이 아닌 즉시 실패라는 결론을 도출했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;수정 방향 결정 단계&lt;/b&gt;: AI는 upload.any()로 서버를 변경하는 방안을 제시했지만, 폼 필드명을 일치시키는 방향이 더 적절하다고 판단하여 직접 방향을 수정했습니다. 학생/어드민 구조 차이를 AI에게 분석시킨 뒤, 최종 수정 범위를 확정했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실행 단계&lt;/b&gt;: PR 생성, 커밋 메시지 작성, 코드 수정 등의 실행은 AI에 위임했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;패턴 정리&lt;/h4&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;AI 가설 제시 &amp;rarr; 사람이 검증/반박 &amp;rarr; AI 재분석 &amp;rarr; 사람이 방향 결정 &amp;rarr; AI 실행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI를 &quot;만능 해결사&quot;가 아닌 &quot;빠른 분석 도구&quot;로 활용한 것이 효과적이었습니다. 특히 익숙하지 않은 코드베이스(Node.js/Express)를 빠르게 파악하는 데 큰 도움이 되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/G8xc0/dJMcacWJ1BW/IerSNhEfXKE8KfGDLj8yDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/G8xc0/dJMcacWJ1BW/IerSNhEfXKE8KfGDLj8yDk/img.png&quot; data-origin-width=&quot;669&quot; data-origin-height=&quot;753&quot; data-is-animation=&quot;false&quot; style=&quot;width: 47.3123%; margin-right: 10px;&quot; data-widthpercent=&quot;47.87&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/G8xc0/dJMcacWJ1BW/IerSNhEfXKE8KfGDLj8yDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FG8xc0%2FdJMcacWJ1BW%2FIerSNhEfXKE8KfGDLj8yDk%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;669&quot; height=&quot;753&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bax3P5/dJMcaiQacir/0LdWiBZTx9cEicYMoP6ka0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bax3P5/dJMcaiQacir/0LdWiBZTx9cEicYMoP6ka0/img.png&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;678&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;52.13&quot; style=&quot;width: 51.5249%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bax3P5/dJMcaiQacir/0LdWiBZTx9cEicYMoP6ka0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbax3P5%2FdJMcaiQacir%2F0LdWiBZTx9cEicYMoP6ka0%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;656&quot; height=&quot;678&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;원인 파악, 수정 방향 결정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 도구를 운영 서비스에 적용하면서 느낀 점은 개인 블로그의&amp;nbsp;&lt;a href=&quot;https://yesjuhee.tistory.com/44&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;별도 글&lt;/a&gt;로 정리했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 느낀 점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로깅의 중요성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 경험에서 가장 크게 느낀 것은 로깅의 힘입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 핸들러에 err.stack 한 줄을 추가한 것이 문제 해결의 전환점이었습니다. 실제 슬랙 타임스탬프를 기반으로 &lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;MTTR&lt;/b&gt;&lt;/span&gt;(Mean Time To Repair, 장애 복구 평균 시간)을 비교해 보면 그 차이가 극명합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1차 에러 (로깅 없는 상태) &amp;mdash; MTTR: 약 3시간 32분&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 보고(13:11) &amp;rarr; 로깅 추가 배포(14:48) &amp;rarr; 행정실 재시도(15:54) &amp;rarr; 원인 특정(16:03).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그에 URL만 남아 있었기 때문에 원인을 추정할 수 없었고, 로깅 코드를 추가하고 배포한 뒤 행정실에 재시도를 요청하는 과정을 거쳐야 했습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2차 에러 (로깅 있는 상태) &amp;mdash; MTTR: 약 0분&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 로그 확인(17:22) &amp;rarr; 원인 특정(17:22).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스택 트레이스에 &lt;i&gt;&lt;b&gt;SequelizeValidationError: notNull Violation: StudentFile.time cannot be null&lt;/b&gt;&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;626&quot; data-origin-height=&quot;667&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnQEhg/dJMcadaeGBo/MH0vKClw2NLbGeVAp1NpX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnQEhg/dJMcadaeGBo/MH0vKClw2NLbGeVAp1NpX1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnQEhg/dJMcadaeGBo/MH0vKClw2NLbGeVAp1NpX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnQEhg%2FdJMcadaeGBo%2FMH0vKClw2NLbGeVAp1NpX1%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;626&quot; height=&quot;667&quot; data-origin-width=&quot;626&quot; data-origin-height=&quot;667&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시 슬랙 대화를 보면 이 속도감이 잘 드러납니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;행정실&lt;/b&gt; [오후 5:21] 에러 메일 발신&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;노주희&lt;/b&gt; [오후 5:22] 에러 로그 확인&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;양현준&lt;/b&gt; [오후 5:22] 원인 파악 완료&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 시스템, 같은 팀, 같은 날에 발생한 에러인데, 로그 한 줄의 유무가 원인 파악 시간을 &lt;b&gt;3시간에서 0분으로&lt;/b&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;console.log&lt;/b&gt;를 찍어가며 하나씩 배포했어야 했을 것입니다. 로그가 얼마나 많은 시간과 노력을 절약해 주는지 체감한 경험이었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 환경의 부재가 만드는 비효율&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cssys는 운영 환경만 존재합니다. 개발 서버가 따로 없고, 어드민 페이지는 보안상 개발자가 직접 접근하기 어렵습니다.&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;/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;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SCG가 나아갔으면 하는 방향&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 에러 대응을 거치면서, 올해 맡게 된 31기 신입 백엔드 스터디의 방향에 대한 확신이 생겼습니다.&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;SCG는 기술적으로 계속 성장하고 있고, 훌륭한 회원들이 많은 단체입니다. 다만 학생 단체의 특성상, 개발 당시에는 기능을 빠르게 완성하는 데 집중하게 되고, 그것을 만든 사람들은 얼마 지나지 않아 졸업합니다. 과거의 코드를 보면, 미래에 유지보수를 담당할 사람에 대한 고려가 부족한 경우가 많습니다.&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;이 말을 하고 있는 저 역시 다르지 않았습니다. 아는 것이 없었으니 아는 만큼 구현하고 끝냈습니다. 그런데 지난 1년간 우아한테크코스를 거치면서 좋은 코드에 대한 이야기를 반복적으로 나누었고, 그 경험이 시야를 넓혀주었습니다. 이렇게 얻은 것을 다시 SCG에 전파하고 싶습니다.&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;한 번에 모든 것을 바꾸기는 어렵습니다. 그래서 31기 신입 스터디부터 시작해보려 합니다.&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;이번 경험을 거치고 나서, 다음과 같은 문화가 SCG에 정착되었으면 합니다.&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;li&gt;내일의 나와 미래의 후배를 위해 문서로 기록한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8년 된 코드 위에 새로운 인프라를 올리고, 로그 한 줄 없는 상태에서 에러를 추적하고, 행정실과 메일을 주고받으며 문제를 좁혀나간 하루였습니다. 돌이켜보면 기술적으로 어려운 문제는 아니었습니다. multer 필드명 불일치라는, 코드를 보면 바로 알 수 있는 버그였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &quot;코드를 볼 수 있는 상태&quot;에 도달하기까지의 과정이었습니다. 로그가 없었고, 테스트 환경이 없었고, 관리자 페이지에 접근할 수 없었습니다. 결국 err.stack 한 줄을 추가하는 것이 이 모든 장벽을 뚫는 열쇠가 되었습니다.&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;코드는 작성하는 시간보다 유지보수하는 시간이 훨씬 깁니다. 오늘 한 줄의 로그가, 내일 누군가의 5시간을 30분으로 줄여줄 수 있습니다.&lt;/p&gt;</description>
      <category>Troubleshooting</category>
      <category>logging</category>
      <category>MTTR</category>
      <category>SCG</category>
      <category>시스템컨설턴트그룹</category>
      <author>yesjuhee</author>
      <guid isPermaLink="true">https://scg-skku.tistory.com/4</guid>
      <comments>https://scg-skku.tistory.com/4#entry4comment</comments>
      <pubDate>Sun, 22 Mar 2026 21:49:25 +0900</pubDate>
    </item>
    <item>
      <title>MinIO에 다량의 파일을 빠르게 업로드하는 방법</title>
      <link>https://scg-skku.tistory.com/3</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8h1NB/dJMcacChKP7/V9f3Un7eWxW1tmYBFYDD9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8h1NB/dJMcacChKP7/V9f3Un7eWxW1tmYBFYDD9K/img.png&quot; data-alt=&quot;MinIO 로고&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8h1NB/dJMcacChKP7/V9f3Un7eWxW1tmYBFYDD9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8h1NB%2FdJMcacChKP7%2FV9f3Un7eWxW1tmYBFYDD9K%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;300&quot; height=&quot;300&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;MinIO 로고&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 시스템컨설턴트그룹 28기 인프라 담당 양현준입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 MinIO를 다루면서 겪은 일과 다량의 파일을 효율적으로 MinIO에 업로드하는 방법에 대해 정리했습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MinIO란?&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;MinIO는 고성능 오픈소스 오브젝트 스토리지로 Amazon S3와 호환되는 API를 제공하는 오브젝트 스토리지입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;소스코드가 AGPLv3 라이선스 하에 공개되어 있는 오픈소스 소프트웨어입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;MinIO Object Browser&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;MinIO는 관리자 홈페이지(Object Browser)를 제공합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이를 통해 손쉽게 MinIO 클러스터를 관리하고, 오브젝트 브라우저 기능을 통해 클러스터에 파일을 업로드하고 다운로드 받을 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;파일 마이그레이션을 하는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희 단체에서 기존에 운영하던 애플리케이션이 있습니다. 이는 로컬 파일시스템을 이용해서 사용자로부터 받은 파일을 로컬에 저장하고 이를 서빙하도록 코드가 작성되어 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 점차 컨테이너 기반으로 인프라 및 모든 애플리케이션을 운영하기로 결정함에 따라서 해당 애플리케이션도 MinIO 오브젝트 스토리지를 적극적으로 이용할 수 있게끔 마이그레이션하고, 파일들을 MinIO에 업로드해야 했습니다.&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;파일들은 모두 합해 약 35GiB 정도 되었고, 파일의 수는 약 17,000개 정도 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;* 마이그레이션과 관련한 내용은 &lt;a href=&quot;https://stringju.tistory.com/41&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;여기&lt;/a&gt;에서 확인하실 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Web UI를 통한 첫번째 시도&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;791&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cygNip/dJMcaa5w2g8/aZKKGEMEckkHRs2G4NvhZ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cygNip/dJMcaa5w2g8/aZKKGEMEckkHRs2G4NvhZ1/img.png&quot; data-alt=&quot;MinIO Object browser. minio 제공&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cygNip/dJMcaa5w2g8/aZKKGEMEckkHRs2G4NvhZ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcygNip%2FdJMcaa5w2g8%2FaZKKGEMEckkHRs2G4NvhZ1%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;1280&quot; height=&quot;791&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;791&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;MinIO Object browser. minio 제공&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 마이그레이션한 애플리케이션의 QA 진행을 위해 1차로 파일들을 업로드해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 MinIO에서 제공되는 Object Browser를 이용해 파일을 업로드 하려고 했습니다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 파일을 업로드하니 약 2시간이 지나있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 파악하기 위해 Claude에게 물어봤습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;379&quot; data-origin-height=&quot;282&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lMz1D/dJMcaakaala/MBuKy7kKeypMgefgxeI7hk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lMz1D/dJMcaakaala/MBuKy7kKeypMgefgxeI7hk/img.png&quot; data-alt=&quot;minio 관리자 페이지를 통해 파일을 업로드하면 받는 response header&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lMz1D/dJMcaakaala/MBuKy7kKeypMgefgxeI7hk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlMz1D%2FdJMcaakaala%2FMBuKy7kKeypMgefgxeI7hk%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;379&quot; height=&quot;282&quot; data-origin-width=&quot;379&quot; data-origin-height=&quot;282&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;minio 관리자 페이지를 통해 파일을 업로드하면 받는 response header&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 파일 업로드에 사용한크롬 브라우저는 HTTP/1.1 프로토콜 기준으로 도메인당 최대 6개의 HTTP 요청을 처리한다고 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;약 17,000개의 파일을 업로드하니 그만큼 각 파일별로 HTTP POST(PUT) 요청이 발생하게 됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;MinIO 관리자 페이지는 각 파일을 PutObject API로 파일을 전송합니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;17,000번의 독립적인 요청으로 인해 그만큼의 TCP 핸드셰이크와 HTTP 오버헤드가 발생하게 되는 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;파일 중 약 15,000개가 개별 용량이 100MB가 안되는 작은 파일이기에 오버헤드 비율이 더 높아질 수 밖에 없었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;또한 파일을 로컬 컴퓨터에서 브라우저로 메모리에 올리기에 메모리 사용량도 크게 늘었을 것입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이것은 명백한 제 실수이긴 합니다만, 작업할 때 유선랜을 사용하지 않고 무선랜을 사용해 네트워크 레이턴시가 더 심하게 발생했을 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;파일 업로드의 대안, Rclone&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;216&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYcZgl/dJMcagYXTDZ/ekmGfM5KMirdsYPfbktQVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYcZgl/dJMcagYXTDZ/ekmGfM5KMirdsYPfbktQVK/img.png&quot; data-alt=&quot;Rclone 로고.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYcZgl/dJMcagYXTDZ/ekmGfM5KMirdsYPfbktQVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYcZgl%2FdJMcagYXTDZ%2FekmGfM5KMirdsYPfbktQVK%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;717&quot; height=&quot;216&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;216&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Rclone 로고.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Rclone은 Go 언어로 만든 클라우드 스토리지 특화 업로드 및 다운로드 프로그램입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Rclone의 성능을 볼 수 있는 극단적인 사례가 하나 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;각 대학교에서 제공하는 구글 드라이브는 Google Workspace for Education 서비스로부터 제공이 되는데, 2018년에는 해당 약관에 대역폭에 대한 제한이 없었다고 합니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이를 파악한 캘리포니아 대학교의 한 학생이 rclone을 이용해 무려 총 128TB의 파일들을 구글 드라이브에 400MB/s (MB/s입니다. Mbps가 아니에요.) 가 넘어가는 속도로 업로드를 시도한 사례가 있었습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;아무튼, MinIO는 Amazon S3와 호환되는 API를 제공하기 때문에 rclone에서 S3를 Storage로 지정하여 바로 연결할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Rclone 설치 및 설정&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Rclone을 설치하는 방법은 간단합니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772905985806&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;curl https://rclone.org/install.sh | sudo bash&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot;&gt;위 명령어를 통해 간단하게 rclone을 설치할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772905998356&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;rclone config

n) New remote
name&amp;gt; minio
Storage&amp;gt; s3
provider&amp;gt; Minio
env_auth&amp;gt; false
access_key_id&amp;gt; YOUR_ACCESS_KEY
secret_access_key&amp;gt; YOUR_SECRET_KEY
region&amp;gt; us-east-1
endpoint&amp;gt; http://MINIO_HOST:9000
location_constraint&amp;gt;
acl&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;설정 명령어를 통해 S3 스토리지, minio provider를 지정하여 세팅할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로 아래 명령어를 통해 버킷 목록이 나오면 정상적으로 연결된 것이라고 할 수 있겠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772906009224&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;rclone lsd minio:&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Rclone 파일 업로드 및 검증&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;파일 업로드는 rclone copy 명령어를 이용해 진행할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;저의 경우에는 아래처럼 사용했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772906033915&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;rclone copy ./webdata minio:mybucket/webdata \
  --progress \
  --fast-list \
  --transfers 8 \
  --checkers 16 \
  --retries 10 \
  --low-level-retries 20 \
  --s3-upload-concurrency 4 \
  --check-first \&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;progress 옵션은 실시간 전송 현황을 콘솔에 표시합니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;fast-list 옵션은 메모리를 더 사용하는 대신 목록 조회시 API 호출을 줄여 속도를 향상시킵니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;transfer 옵션은 동시에 전송할 파일의 개수입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;checker 옵션은 전송 전 파일 비교를 수행하는 병렬 스레드의 수입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;retries 옵션은 전체 작업 실패시 재시도 횟수입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;low-level-retries 옵션은 개별 HTTP 요청 실패시 저수준 재시도 횟수입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;s3-upload-concurrency 옵션은 단일 파일의 멀티파트 업로드 내 병렬 청크 수입니다. 대용량 파일에 효과적이라고 합니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;check-first는 전송 시작 전 모든 파일 체크를 먼저 완료한 뒤에 업로드를 시작하는 옵션입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;업로드가 완료되면 아래 명령어를 통해 누락된 파일 또는 손상된 파일이 있는지 추가로 검증할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1772906050004&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;rclone check ./webdata minio:mybucket/webdata --one-way --fast-list&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Rclone을 이용해 병렬처리를 잘 활용하여 파일을 업로드했더니, 35GiB 파일을 모두 업로드하는데 약 10분 정도가 소요되었습니다. 웹 콘솔에 모두 반영되는데까지는 약 5분정도 추가로 더 걸렸습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Rclone을 이용한 덕분에 매우 큰 시간단축을 이뤘습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실제로 파일을 이전하는 문제 때문에 서비스를 이전하는 총 다운타임을 4-5시간 정도로 잡았는데 실제 작업시간은 1시간 밖에 걸리지 않았습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;마무리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 SCG에서 팀블로그를 개설하면서 저는 처음 여기에 글을 쓰게 되었는데요. 잘 읽으셨을지 모르겠네요.&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;a href=&quot;https://stringju.tistory.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;제 개인 블로그&lt;/a&gt;에도 한번 놀러와주세요. 감사합니다!&lt;/p&gt;</description>
      <category>Infra</category>
      <category>minio</category>
      <category>시스템컨설턴트그룹</category>
      <category>인프라</category>
      <category>파일 업로드</category>
      <author>현주씌</author>
      <guid isPermaLink="true">https://scg-skku.tistory.com/3</guid>
      <comments>https://scg-skku.tistory.com/3#entry3comment</comments>
      <pubDate>Sun, 8 Mar 2026 02:56:10 +0900</pubDate>
    </item>
    <item>
      <title>[개발로그] 웹 개발 단체가 네이티브 앱을 만들어야 할 때 벌어지는 일  -React + Tauri</title>
      <link>https://scg-skku.tistory.com/2</link>
      <description>&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;손전등 켜는 거&amp;hellip; 웹에서는 안 되잖아요?&quot;&lt;/p&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;이 한마디에서 시작된, SCG의 모바일 앱 개발 대장정을 기록합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;프롤로그: 새로운 의뢰, 그리고 낯선 영역&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;어느 날, SCG에 새로운 프로젝트 의뢰가 들어왔습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;요구사항을 정리해보니, 이건 단순한 웹사이트가 아니었어요. 푸시 알림, 생체 인증, 네이티브 UI 위젯, 앱스토어 배포까지. 지금까지 SCG가 해왔던 웹 개발의 영역을 한참 벗어나는 스펙이었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;우리는 25년 동안 웹을 해온 단체입니다. React, Next.js, TypeScript &amp;mdash; 이런 건 눈 감고도 합니다. 그런데 모바일 네이티브? iOS 빌드? APK 사이닝? 이건 처음이었어요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;보통 이런 상황에서 선택지는 두 가지입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;외주 맡기자&quot; 아니면, &quot;우리가 직접 부딪혀보자.&quot;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;SCG는 당연히 후자를 택했습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Chapter 1. 스택 전쟁 &amp;mdash; &quot;Flutter냐, WebView냐, 그것이 문제로다&quot;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트의 첫 번째 관문은 기술 스택 선정이었습니다. 이 단계에서 구성원들 사이에 상당히 치열한 논의가 오갔는데요, 크게 세 가지 선택지가 테이블 위에 올라왔습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Flutter.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;모든 걸 네이티브 수준으로 만들 수 있다는 점에서 매력적이었습니다. 실제로 팀 내에서도 개발이 용이하다는 평가가 나왔어요. 툴링이 압도적이고, VS Code 안에서 DevTools까지 쓸 수 있고, Hot Reload도 훌륭하다고요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 저희의 고민은 다른 곳에 있었습니다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;Flutter에 익숙한 개발자를 지속적으로 공급받을 수 있을지가 가장 큰 문제입니다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;학생 개발 단체의 숙명이죠. 매 학기 구성원이 바뀝니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;누군가가 졸업하면 그 자리를 새로운 신입 멤버가 채워야 하는데, Flutter 숙련자를 매번 구할 수 있을까요?&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기능 구현 수준의 학습은 가능하겠지만, 코드 리뷰를 해주고 좋은 아키텍처로 설계할 수 있는 수준의 개발자는 웹에 비해 희소합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;거기에 한 가지 더. Flutter는 OTA(Over-The-Air) 업데이트가 공식적으로 지원되지 않습니다. Shorebird라는 서드파티 솔루션이 있긴 한데, iOS에서는 약관 위반의 회색지대에 놓여 있었어요. 전문 QA 인력이 없는 학생 단체에서 매번 앱 심사를 거쳐 릴리즈하기엔 에너지 소모가 너무 컸습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Capacitor + React.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;오래된 만큼 플랫폼 연동이 풍부하다는 장점이 있었지만, 팀원이 직접 PoC를 돌려본 결과 &quot;툴링이 별로&quot;라는 냉정한 평가가 돌아왔습니다. DevTools도 수동으로 Chrome Inspect를 써야 했고, 개발 서버 연동도 ionic 프레임워크 없이는 수동이었어요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Tauri + React.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;여기서 반전이 일어났습니다. Tauri는 상대적으로 신생이지만, HMR 지원도 잘 되고, Android Studio 설치 없이도 빌드가 가능했어요. 무엇보다 웹 기술 그대로 &amp;mdash; React + Vite &amp;mdash; 를 쓸 수 있다는 점이 결정적이었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 결정적인 한 마디가 나왔습니다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;nav bar는 네이티브 위젯으로 관리하고, 주 화면은 웹뷰로 관리하는 등 섞었을 때 장점이 많은 것 같습니다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 한 줄이 방향을 바꿨어요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Chapter 2. &quot;코드 섞어 쓰는 건 항상 지옥입니다&quot;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Flutter + WebView 하이브리드라는 매력적인 아이디어가 나왔지만, 곧바로 반론이 터졌습니다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;코드 섞어 쓰는 건 항상 지옥입니다&amp;hellip;&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;핵심 쟁점은 이거였어요. 웹뷰 안에서 Flutter가 제공하는 네이티브 UI 엘리먼트를 positioning하는 것이 기술적으로 가능한지, 그리고 가능하더라도 유지보수가 지속 가능한지.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;토스급의 sleek한 UX를 구현할 수 있을지에 대한 질문도 나왔습니다. 한 팀원이 실제로 React에서 iOS 스타일의 bottom drawer를 구현해본 경험을 공유했는데 &amp;mdash; &quot;리액트로 거의 흑마법을 써야 합니다&quot;라는 코멘트와 함께 말이죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;JavaScript와 네이티브 영역을 이어주는 브릿지 함수, IPC(Inter-Process Communication) 같은 솔루션도 논의됐지만 &quot;trivial하지 않을 것 같다&quot;는 의견이 지배적이었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;결론적으로, 논의는 이쪽으로 수렴하기 시작했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;대부분의 기능은 웹으로 충분하다. 네이티브가 필요한 영역은 극히 제한적이다.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;푸시 알림, 외부 링크 열기, 결제 연동 정도. 나머지는 전부 웹으로 커버할 수 있었습니다. 그리고 웹뷰 방식은 모바일 웹과 PC를 최소 공수로 동시에 커버할 수 있다는 강력한 장점이 있었어요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 최종 방향이 정해졌습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Tauri + React (WebView 기반) + 필요한 곳만 네이티브 플러그인.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Chapter 3. 프로토타입의 탄생 &amp;mdash; &quot;의외로 큰 난관은 없었습니다&quot;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;방향이 정해지자 속도가 붙었습니다. 우리는 PoC(Proof of Concept) 프로토타입 스펙을 정의했어요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; 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;/li&gt;
&lt;li&gt;손전등 상태를 웹뷰 내에 표시&lt;/li&gt;
&lt;li&gt;외부 링크를 디바이스 기본 브라우저로 이동&lt;/li&gt;
&lt;li&gt;커스텀 스킴 처리 (카카오톡, 페이북 등)&lt;/li&gt;
&lt;li&gt;내부 링크는 SPA처럼 동작&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Tauri로 작업을 시작했는데, 결과는 예상 밖이었습니다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;import { toggle } from &quot;@sosweetham/tauri-plugin-torch-api&quot;;

function App() {
  const [flashOn, setFlashOn] = useState(false);
  useEffect(() =&amp;gt; { toggle(flashOn) }, [flashOn]);

  return (
    &amp;lt;main className=&quot;container&quot;&amp;gt;
      &amp;lt;button onClick={() =&amp;gt; setFlashOn((f) =&amp;gt; !f)}&amp;gt;
        Toggle Flash ({flashOn ? &quot;Off&quot; : &quot;On&quot;})
      &amp;lt;/button&amp;gt;
    &amp;lt;/main&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;손전등? 플러그인 하나로 해결&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;생체인증?&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;import { authenticate } from &quot;@tauri-apps/plugin-biometric&quot;;
// ...
&amp;lt;button onClick={() =&amp;gt; authenticate(&quot;몰?루&quot;)}&amp;gt;생체 인증&amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이것도 해결&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;알림 전송까지.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;import { requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
// ...
&amp;lt;button onClick={() =&amp;gt; sendNotification(&quot;몰?루&quot;)}&amp;gt;알림 전송&amp;lt;/button&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;전부 해결.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;안드로이드 APK를 빌드해서 self-sign하고, Cloudflare 터널로 웹 서버를 올리고, 실제 디바이스에서 테스트까지. 이 모든 과정이 한 시간도 채 걸리지 않았습니다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;역시 플러그인이 사기네요.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;맞는 말이었어요. Tauri 플러그인 생태계가 생각보다 훨씬 풍부했습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Chapter 4. iOS, 그 험난한 길&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;안드로이드에서의 성공에 고무되어, 다음 타겟은 iOS였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기서부터 진짜 전쟁이 시작됐어요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음부터 순탄치 않았습니다. npm run tauri ios dev를 실행했더니 Xcode가 열리고, 시뮬레이터가 뜨고, 55701 포트로 뭔가 통신하기 시작하는데 &amp;mdash; 빌드가 실패합니다. 에러 로그도 제대로 안 보여요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;우리는 Xcode에 익숙하지 않아 로그를 찾는 것부터 난관이었습니다. 결국 Slack 허들(음성 통화)을 켜고 화면공유를 하면서 실시간 디버깅에 돌입했죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그 뒤에 이어진 1시간 29분의 기록은, 그야말로 처절했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 1: Rust 에디션 호환성.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Cargo가 2024 edition을 요구했는데 맞지 않았어요. rustup update로 해결.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 2: 네트워크.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;iOS 디바이스는 개발 컴퓨터와 같은 와이파이 네트워크에 있어야 했습니다. 근데 학교 와이파이가 기기 간 통신을 막고 있었어요. 안드로이드는 USB로 그냥 되는데, iOS는 와이파이가 필수.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 3: 디바이스 페어링 무한루프.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;Device is busy&quot;라는 메시지가 반복적으로 나타나면서 앱 설치가 안 됐습니다. Xcode에서 언페어링(unpair)을 시도하는데, 우클릭이 안 먹히고, 설정이 꼬이고&amp;hellip;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 4: 1Password(Warp) 간섭.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;알고 보니 개발자의 맥북에 켜져 있던 VPN 앱이 로컬 네트워크 통신을 방해하고 있었습니다. 이걸 끄자 페어링이 됐어요.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 5: 로컬 네트워크 퍼미션.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;iOS는 앱이 로컬 네트워크에 접근하려면 Info.plist에 권한을 명시해야 합니다. 이걸 설정하지 않으면 웹뷰가 로컬 개발 서버에 접근할 수 없었어요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 모든 문제를 하나씩 격파하고, 마침내 &amp;mdash;&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;오, 됐다, 됐다. 완벽해요. 네이티브 완전 떠요.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;손전등 토글, 생체인증(Face ID), 네이티브 다이얼로그, 알림 전송. 전부 iOS 실기기에서 완벽하게 동작했습니다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;와 미쳤는데. 이거 DX 괜찮은데요.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;웹 코드를 수정하면 iOS 기기에서 즉시 반영되는 HMR(Hot Module Replacement)도 확인. 이 순간, Tauri + WebView 조합의 가능성이 완전히 검증됐습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Chapter 5. 런타임에서 갈라지는 세계&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프로토타입을 통해 흥미로운 발견도 있었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;웹 브라우저에서 같은 URL을 열어보니, PC 크롬에서는 알림 권한 요청 팝업이 뜹니다. 네이티브 앱에서만 되는 줄 알았던 기능이 웹에서도 작동한 거죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 발견이 아키텍처 결정에 중요한 영향을 미쳤습니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;// 앱 환경인지 판별하는 한 줄
const isApp = '__TAURI__' in window;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 한 줄이면 충분했어요. 기본적으로는 웹사이트를 개방하고, 네이티브에서만 가능한 기능(생체인증, 푸시 알림 등)은 런타임 분기로 처리하면 됩니다. 개발할 때도 그냥 브라우저에서 모바일 viewport로 세팅해두고 작업하면 돼요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; 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;신입 멤버 온보딩 비용 최소화 (React만 알면 됨)&lt;/li&gt;
&lt;li&gt;대부분의 업데이트는 서버사이드(웹)에서만 하면 끝&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Chapter 6. 아직 남은 전투들&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프로토타입은 성공적이었지만, 프로덕션까지는 아직 갈 길이 멀어요. 우리가 앞으로 풀어야 할 과제들을 정리해봤습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. 앱스토어 배포와 심사&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;애플 개발자 프로그램 연회비 ₩129,000. 금액 자체보다, 앱 심사를 통과해야 한다는 게 핵심입니다. 권한 요청에 대한 명확한 사유, 사용하지 않는 권한의 제거, 심사 가이드라인 준수 등을 꼼꼼히 챙겨야 합니다. 생각보다 까다로운 영역이에요.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. 푸시 알림 &amp;mdash; 로컬이 아닌 리모트&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프로토타입에서 구현한 건 로컬 알림이었습니다. 실제 서비스에서는 FCM(Firebase Cloud Messaging)을 통한 리모트 푸시가 필요해요. Tauri에 FCM 플러그인이 있긴 하지만, 서버 인프라 구축과 토큰 관리까지 고려해야 합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. 오프라인 대응과 PWA&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Tauri 앱 안에 PWA(Progressive Web App)를 래핑하면 오프라인에서도 동작하는 앱을 만들 수 있습니다. Vite PWA 플러그인을 사용하면 캐싱과 서비스 워커 설정을 간단하게 할 수 있어요. 한 팀원의 표현을 빌리자면 &quot;마트로슈카 인형급 래핑&quot;이지만, 이게 OTA 업데이트 문제에 대한 가장 현실적인 해답이기도 합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. SPA 라우팅과 외부 접근&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;React Router로 SPA 라우팅을 구현하되, 외부에서 특정 경로로 직접 접근할 수 있어야 합니다. 해시(#) 라우팅을 쓸지, History API를 쓸지. 웹과 앱 양쪽에서 모두 자연스럽게 동작하도록 설계해야 해요.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. 네이티브 Alert의 부재&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;의외의 발견이었는데, iOS 웹뷰에서는 JavaScript의 window.alert()가 먹히지 않았습니다. 대신 Tauri의 다이얼로그 플러그인을 사용해야 했어요. 이런 식으로 웹에서 당연히 되는 것들이 네이티브 래퍼 안에서는 안 되는 케이스가 곳곳에 숨어 있을 수 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;6. 지속 가능한 인력 구조&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이건 기술적 과제가 아니라 조직적 과제입니다. 1학기 스터디에서 React를 배우는 신입 멤버들이, 2학기에 이 프로젝트에 자연스럽게 합류할 수 있어야 합니다. Tauri + React 조합을 선택한 가장 큰 이유가 바로 이 지점이에요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;에필로그: &quot;그만큼 안전하다는 거죠&quot;&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;iOS 디버깅 중, 허들에서 나온 대화가 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;디바이스 페어링을 할 때마다 매번 &quot;Trust this computer?&quot;를 물어보는 iOS에 지쳐가던 그때, 반 농담으로 던진 재원님의 한 마디...&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;그만큼 안전하다는 거죠.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;웃긴 말이었지만, 어떻게 보면 이번 프로토타이핑의 교훈을 관통하는 말이기도 합니다. iOS 개발이 까다로운 데에는 이유가 있고, 네이티브 앱 개발이 복잡한 데에도 이유가 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;중요한 건, 그 복잡성 앞에서 멈추지 않고 하나씩 격파해 나가는 것.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;SCG는 웹 개발 단체입니다. 하지만 이번 프로젝트를 통해 우리의 웹 기술이 네이티브 영역까지 확장될 수 있다는 걸 직접 증명했어요. 하루 저녁, Slack 허들 하나, 디바이스 두 대. 그리고 끈질긴 삽질.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;&quot;iOS 유기가 마려워지는 하루였습니다 ㅋㅋㅋㅋ&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;유기하진 않았습니다. 대신 정복했어요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;결정 사항 선택 이유&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 100px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;프레임워크&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;Tauri + React&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;웹 기술 재사용, 신입 멤버 온보딩 용이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;UI 구현&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;95% WebView + 5% 네이티브 플러그인&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;유지보수 단순화, 모바일 웹 동시 대응&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;업데이트 전략&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;PWA 래핑으로 캐싱/OTA 해결&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;앱 심사 없이 빠른 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;환경 분기&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;'__TAURI__' in window&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;하나의 코드베이스로 웹/앱 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;라우팅&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;React Router (해시 라우팅)&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;정적 배포 + 외부 링크 대응&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 글은 SCG 내부 실제 기술 논의를 바탕으로 작성되었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실시간으로 의견을 나누고, 즉석에서 프로토타입을 만들고, 함께 삽질하는 &amp;mdash; 이게 SCG가 문제를 푸는 방식입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;함께 삽질하고 싶은 분, 언제든 환영합니다!&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Frontend</category>
      <category>hybridapp</category>
      <category>SCG</category>
      <category>Tauri</category>
      <category>webview</category>
      <category>시스템컨설턴트그룹</category>
      <author>장재원</author>
      <guid isPermaLink="true">https://scg-skku.tistory.com/2</guid>
      <comments>https://scg-skku.tistory.com/2#entry2comment</comments>
      <pubDate>Tue, 3 Mar 2026 16:44:42 +0900</pubDate>
    </item>
    <item>
      <title>SCG 블로그를 개설합니다!</title>
      <link>https://scg-skku.tistory.com/1</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;SCG 개발 블로그를 시작합니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요, 성균관대학교 소프트웨어융합대학 학생 개발 단체 SCG(System Consultant Group)입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SCG는 올해로 25년째 운영되고 있는 단체로, 단과대학 내 서비스들을 직접 개발하고 유지보수하는 일을 하고 있습니다. 학교 시스템을 실제로 운영하면서 겪는 장애 대응, 인프라 관리, 코드 리뷰, 문서화 같은 경험들이 매 학기 쌓이는데요&amp;mdash;사실 이런 내용들이 밖으로 잘 나가지 않는 게 늘 아쉬웠습니다.&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;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 블로그를 시작하나요?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 말하면, 예전부터 &quot;블로그 해야지&quot;라는 이야기는 계속 나왔습니다. 근데 매번 흐지부지됐어요. 바쁘기도 하고, 글 쓰는 게 개발하는 것보다 훨씬 귀찮기도 하고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 매년 활동이 끝나고 나면 항상 같은 후회를 하게 됩니다. &quot;아 그때 정리해둘 걸.&quot; 스터디에서 공부한 내용, 프로젝트 하면서 삽질한 기록, 서버 터졌을 때 어떻게 대응했는지&amp;mdash;이런 것들이 슬랙 스레드 어딘가에 묻혀 있거나, 노션 깊숙한 곳에 잠들어 있거든요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 진짜로 해보려 합니다.&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;/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;완벽한 글보다는, 나중에 돌아봤을 때 &quot;아 우리 이런 거 했었지&quot; 하고 떠올릴 수 있는 기록을 남기는 게 먼저라고 생각합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;앞으로의 계획&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단은 티스토리에서 팀 블로그 형태로 운영을 시작합니다. 회원들이 부담 없이 글을 쓸 수 있는 환경을 먼저 만드는 게 중요하다고 판단했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장기적으로는 SCG 자체 블로그를 개발하는 것도 논의 중입니다. 작성 프로세스를 더 매끄럽게 만들거나, 회원들의 활동 이력과 연결되는 구조를 만들어보고 싶은 욕심도 있고요. 하지만 그건 차근차근, 하나씩 해보려 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마무리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;25년간 이어져 온 단체인 만큼, 그동안 쌓인 경험과 노하우가 정말 많습니다. 다만 그걸 밖으로 꺼내는 데에는 좀 게을렀던 것 같습니다. 이 블로그가 그 시작점이 되었으면 합니다.&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;/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;SCG 운영진 일동&lt;/b&gt;&lt;/p&gt;</description>
      <category>Culture</category>
      <category>SCG</category>
      <category>성균관대</category>
      <category>시스템컨설턴트그룹</category>
      <author>장재원</author>
      <guid isPermaLink="true">https://scg-skku.tistory.com/1</guid>
      <comments>https://scg-skku.tistory.com/1#entry1comment</comments>
      <pubDate>Wed, 25 Feb 2026 14:15:25 +0900</pubDate>
    </item>
  </channel>
</rss>