<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Nowkwon</title>
        <link>https://velog.io/</link>
        <description>Frontend-Dev</description>
        <lastBuildDate>Sun, 22 Jun 2025 11:25:50 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Nowkwon</title>
            <url>https://velog.velcdn.com/images/now-kwon/profile/3c5ff2e8-0d85-4960-be29-97e8f6430850/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Nowkwon. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/now-kwon" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[싱글 스레드에서도 경쟁 상태는 발생한다]]></title>
            <link>https://velog.io/@now-kwon/%EC%8B%B1%EA%B8%80-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%97%90%EC%84%9C%EB%8F%84-%EA%B2%BD%EC%9F%81-%EC%83%81%ED%83%9C%EB%8A%94-%EB%B0%9C%EC%83%9D%ED%95%9C%EB%8B%A4</link>
            <guid>https://velog.io/@now-kwon/%EC%8B%B1%EA%B8%80-%EC%8A%A4%EB%A0%88%EB%93%9C%EC%97%90%EC%84%9C%EB%8F%84-%EA%B2%BD%EC%9F%81-%EC%83%81%ED%83%9C%EB%8A%94-%EB%B0%9C%EC%83%9D%ED%95%9C%EB%8B%A4</guid>
            <pubDate>Sun, 22 Jun 2025 11:25:50 GMT</pubDate>
            <description><![CDATA[<h2 id="이상한-진행률-바-동작">이상한 진행률 바 동작</h2>
<p>최근 프로젝트에서 여러 이미지를 <strong>동시에 업로드</strong>하고 진행률을 Progress Bar로 표시하는 기능을 구현했습니다. 그런데 진행률이 순차적으로 증가하지 않고 <strong>증가와 감소를 반복</strong>하는 문제를 경험했습니다.</p>
<p align=center><img width=600 src="https://velog.velcdn.com/images/redhero8830/post/a153e72d-bc10-4136-99d3-f20a4d2ddfb9/image.gif"/></p>

<p>디버깅 결과, 요청은 ( 1 → 2→ 3 → ... ) 순서로 시작되지만 <strong>응답은 ( 5 → 1 → 2 → ... ) 순으로 무작위</strong>로 도착하는 것을 확인했습니다. 바로 이것이 비정상적인 진행률 증가의 원인이었습니다.</p>
<p align=center><img width=600 src="https://velog.velcdn.com/images/redhero8830/post/ec2cd67b-0c17-4237-9800-2a8e77a1580c/image.png"/></p>

<hr>
<h2 id="race-condition이란">Race Condition이란?</h2>
<p>Race Condition(경쟁 상태)은 <strong>여러 개의 프로세스나 스레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 상황</strong>입니다.</p>
<p>주로 <strong>멀티스레드</strong> 환경에서 흔히 발생하는 문제로, Java나 C++ 개발자들은 락(Lock), 뮤텍스(Mutex), 세마포어(Semaphore) 같은 동기화 기법으로 이를 해결해왔습니다.</p>
<pre><code class="language-java">// Java의 전형적인 Race Condition
private int counter = 0;

public void increment() {
    counter++;  // 여러 스레드가 동시 접근하면 결과 예측 불가
}</code></pre>
<p>하지만 <strong>JavaScript 환경에서도 동일한 문제가 발생</strong>할 수 있습니다.</p>
<hr>
<h2 id="javascript에서-경쟁-상태가-발생하는-이유">JavaScript에서 경쟁 상태가 발생하는 이유</h2>
<h3 id="javascript는-싱글스레드인데-왜">JavaScript는 싱글스레드인데 왜?</h3>
<p>많은 프론트엔드 개발자들이 <strong>&quot;JavaScript는 싱글스레드니까 경쟁 상태가 없겠지?&quot;</strong>라고 생각할 수 있습니다. 하지만 이는 정확하지 않습니다.</p>
<h3 id="브라우저는-멀티스레드-환경">브라우저는 멀티스레드 환경</h3>
<p>JavaScript 엔진은 싱글스레드지만, <strong>브라우저는 멀티스레드 환경</strong>입니다.</p>
<pre><code>브라우저 환경
├── JavaScript 엔진 (싱글스레드)
│   └── Call Stack
├── Web API (멀티스레드)
│   ├── Network 요청 (fetch, XMLHttpRequest)
│   ├── Timer (setTimeout, setInterval)
│   └── 기타 비동기 API
└── Event Loop + Task Queue (Microtask Queue, Macrotask Queue)</code></pre><h3 id="비동기-작업에서-순서는-보장되지-않는다">비동기 작업에서 순서는 보장되지 않는다</h3>
<p><img src="https://velog.velcdn.com/images/redhero8830/post/9da4a929-c8b9-423f-ab13-ca7660fadecc/image.png" alt="event-loop"></p>
<p>이벤트 루프는 다음과 같은 흐름으로 동작합니다.</p>
<blockquote>
</blockquote>
<ol>
<li>메인 스레드(Call Stack)에서 JavaScript 실행</li>
<li>비동기 작업(fetch, setTimeout 등)은 브라우저의 <strong>Web API 스레드</strong>로 위임</li>
<li>완료된 콜백은 <strong>Callback Queue</strong>에 등록</li>
<li>Call Stack이 비는 순간, Event Loop는 Callback Queue에서 콜백을 꺼내 실행</li>
</ol>
<p>여러 비동기 작업을 병렬로 처리할 때, <strong>요청 순서와 응답 순서가 다를 수 있습니다.</strong></p>
<pre><code class="language-js">// 요청 순서: 1 → 2 → 3
fetch(&#39;/api/1&#39;);  // 3초 후 응답
fetch(&#39;/api/2&#39;);  // 1초 후 응답  
fetch(&#39;/api/3&#39;);  // 2초 후 응답

// 콜백큐 도착 순서: 2 → 3 → 1</code></pre>
<p>🚨 여기서 핵심은 Web API에서 처리되는 비동기 작업들은 <strong>각자 다른 처리 시간</strong>을 가지므로, <strong>요청한 순서와 완료되는 순서가 다를 수 있다는 것입니다.</strong> 이것이 바로 JavaScript에서 경쟁 상태가 발생하는 근본적인 이유입니다.</p>
<p>이런 이유로, <strong>프론트엔드에서도 다양한 형태의 경쟁 상태가 발생할 수 있습니다.</strong> 예를 들어,</p>
<ol>
<li>빠르게 여러 API 호출을 했는데, <strong>나중에 보낸 요청의 응답이 먼저 도착</strong>해 이전 값을 덮어쓰는 경우</li>
<li>비동기 로직에서 <strong>공유 상태(state)</strong>에 동시에 접근해, 순서가 꼬이는 경우</li>
</ol>
<p>제가 경험한 문제는 두 번째 유형입니다. 바로 클라이언트에서 <strong>여러 비동기 작업이 동일한 자원을 공유</strong>함으로 경쟁 상태가 발생해 순서 제어에 실패한 경우입니다.</p>
<hr>
<h2 id="문제-상황-분석">문제 상황 분석</h2>
<p>그럼 이제 제가 작성한 문제가 있던 코드를 살펴보겠습니다.</p>
<pre><code class="language-ts">const uploadImages = async (images) =&gt; {
    let process = 0;  // 공유 자원

    await Promise.all(
        presignedUrls.map(async (urlInfo, index) =&gt; {
            // (문제) 요청 시점에 미리 진행률 계산
            process++;
            const progressPercent = getProgressPercent(process, images.length);

            const response = await mediaAPI.uploadImagesToS3(urlInfo.url, images[index].image);

            // 🚨 응답 순서와 상관없이 미리 계산된 값으로 UI 업데이트
            setProgress((prev) =&gt; ({ ...prev, upload: progressPercent }));

            return response;
        }),
    );
};</code></pre>
<p><code>process</code> 변수는 <strong>모든 비동기 작업이 공유하는 자원</strong>입니다. 그리고 <code>process++</code> 작업은 비동기 처리 이전인 <strong>요청 시점</strong>에 실행됩니다. 하지만 <code>setProgress</code>는 비동기 처리가 끝난 <strong>응답 시점</strong>에 실행됩니다. 이 때 응답 순서 보장되지 않으므로 경쟁 상태 문제가 발생했던 것입니다.</p>
<h3 id="실행-흐름-분석">실행 흐름 분석</h3>
<p><strong>실제 실행 순서</strong></p>
<pre><code>1. 요청1 시작: process = 1, progressPercent = 8%
2. 요청2 시작: process = 2, progressPercent = 15%  
3. 요청3 시작: process = 3, progressPercent = 23%
...</code></pre><p><strong>하지만 응답은 다른 순서로!</strong></p>
<pre><code>1. 요청3 완료 → UI에 23% 표시
2. 요청1 완료 → UI에 8% 표시 (🚨 진행률 감소!)
3. 요청2 완료 → UI에 15% 표시
...</code></pre><p>진행률이 ( 23% → 8% → 15% → ... )로 감소하는 비정상적인 동작</p>
<hr>
<h2 id="해결-방법-응답-순서-기준으로-상태-관리">해결 방법: 응답 순서 기준으로 상태 관리</h2>
<p>문제의 핵심은 UI가 <strong>요청 시점에 의존</strong>하고 있었다는 것입니다.</p>
<p>해결책은 간단합니다. <strong>응답이 완료된 시점에 상태를 업데이트</strong>하면 됩니다.</p>
<pre><code class="language-js">const uploadImagesToS3 = async (images) =&gt; {
    let process = 0;

    await Promise.all(
        presignedUrls.map(async (urlInfo, index) =&gt; {
            // 비동기 처리 먼저 실행
            const response = await mediaAPI.uploadImagesToS3(urlInfo.url, images[index].image);

            // ✅ 응답 후에 진행률 계산 및 상태 업데이트
            process++;
            const progressPercent = getProgressPercent(process, images.length);
            setProgress((prev) =&gt; ({ ...prev, upload: progressPercent }));

            return response;
        }),
    );
};</code></pre>
<p>물론 <code>async/await</code> 외에도 Promise 메서드를 사용해서 동일하게 해결할 수 있습니다. <strong>중요한 것은 어떤 문법을 사용하든 비동기 처리 완료 후에 상태를 업데이트하는 것입니다.</strong></p>
<p><strong>개선된 실행 흐름</strong></p>
<pre><code>1. 요청2 완료 → process=1, 8% 표시
2. 요청3 완료 → process=2, 15% 표시  
3. 요청1 완료 → process=3, 23% 표시
...</code></pre><p align=center><img width=600 src="https://velog.velcdn.com/images/redhero8830/post/55ac8cea-2c13-429c-b362-c254c5615c73/image.gif"/></p>

<p>이제 <code>process</code>는 실제 완료된 요청을 기반으로 정확히 증가하고, 진행률도 의도대로 계산되어 UI가 부드럽게 갱신됩니다.</p>
<p align=center><img width=600 src="https://velog.velcdn.com/images/redhero8830/post/72f574c0-409d-44c7-9760-1e1eb84201cd/image.png"/></p>

<p>또한 요청 순서와 독립적으로 응답 완료 순서대로 진행률을 증가시키는 것도 확인할 수 있습니다.</p>
<hr>
<h2 id="그-외-프론트엔드-race-condition-사례">그 외 프론트엔드 Race Condition 사례</h2>
<p>프론트엔드에서는 다양한 상황에서 경쟁 상태가 발생할 수 있습니다.</p>
<p>여러 API 호출을 했을 때, <strong>나중에 보낸 요청의 응답이 먼저 도착</strong>해 이전 값을 덮어쓰는 경우에 대한 몇 가지 예시입니다.</p>
<h3 id="검색-자동완성">검색 자동완성</h3>
<p>검색 관련된 비동기 처리를 한다고 했을 때, 아래 코드의 경우 경쟁 상태가 발생할 수 있습니다.</p>
<pre><code class="language-js">const search = async (query) =&gt; {
    const results = await searchAPI(query);
    setSearchResults(results);
};</code></pre>
<p>자동완성 또는 사용자가 연속으로 검색을 요청했을 때, 이전 검색 결과가 나중에 도착해 최신 결과를 덮어쓸 수 있습니다. 이 경우 <strong>사용자는 원하는 검색 결과를 보지 못하여 사용자 경험이 저하</strong>될 수 있습니다.</p>
<p><strong>🚨 사용자 인터렉션에 따라 서비스의 상태 일관성이 유지되는 것은 매우 중요합니다.</strong></p>
<p>요청한 비동기 작업을 중단할 수 있게 해주는 Web API <a href="https://developer.mozilla.org/ko/docs/Web/API/AbortController">AbortController</a>를 활용하면 간단하게 해결할 수 있습니다. 즉, 사용자가 <strong>새로운 요청을 하면 기존 처리 중인 요청을 취소하고 최신 요청을 처리</strong>하는 겁니다.</p>
<pre><code class="language-js">let controller = null;

const search = async (query) =&gt; {
    if (controller) {
        controller.abort(); // 이전 요청 취소
    }

    controller = new AbortController(); // 최신 요청에 대한 새 컨트롤러 생성

    const results = await searchAPI(query, { signal: controller.signal });
    setSearchResults(results);
};</code></pre>
<h3 id="페이지네이션"><strong>페이지네이션</strong></h3>
<p>페이지네이션의 경우에도 경쟁 상태가 발생할 수 있습니다. 만약 사용자가 <strong>1페이지를 요청하고 응답 전 2페이지를 요청</strong>했다고 가정해봅시다. 이때 2페이지 응답보다 1페이지 응답이 나중에 온다면 어떻게 될까요? <strong>사용자는 2페이지에서 1페이지에 대한 내용을 보게 될 수 있습니다.</strong></p>
<pre><code class="language-js">const loadPage = async (page) =&gt; {
    const data = await getPage(page);
    setCurrentPageContents(data);
};</code></pre>
<p>이번에는 AbortController가 아닌 요청된 페이지와 현재 페이지가 같은지 조건문을 통해서 해결해보겠습니다.</p>
<pre><code class="language-js">const loadPage = async (page) =&gt; {
    const data = await getPage(page);

    if (page === currentPage) {
        setCurrentPageContents(data);
    }
};</code></pre>
<p><code>page</code>는 요청 시점에 사용자가 원하는 페이지입니다. 그리고 <code>currentPage</code>는 현재 사용자가 보고 있는 페이지입니다. 두 페이지가 일치하는 경우에만 상태를 업데이트해주면 사용자는 의도한 내용을 볼 수 있게 될 것입니다. </p>
<hr>
<h2 id="마무리">마무리</h2>
<p>프론트엔드 개발자들은 종종 &quot;JavaScript는 싱글스레드니까 동시성 문제는 없다&quot;고 생각합니다. 하지만 <strong>비동기 작업이 복잡해질수록 경쟁 상태는 빈번하게 발생</strong>합니다.</p>
<p>글의 핵심만 요약해봤습니다.</p>
<blockquote>
</blockquote>
<ol>
<li>JavaScript는 싱글 스레드지만, <strong>Web API는 멀티 스레드로 동작</strong>한다.</li>
<li><strong>비동기 응답 순서는 요청 순서와 다를 수 있다</strong></li>
<li>공유 상태는 반드시 <strong>응답 완료 시점에 업데이트</strong>한다</li>
<li>필요 시, <strong>이전 요청 취소 또는 최신 요청 검증</strong>을 통해 경쟁 상태를 방지한다</li>
</ol>
<p>작은 변수 하나라도 비동기 흐름과 결합되면 예상치 못한 문제를 만들 수 있습니다. 이제는 <strong>프론트엔드 개발자에게도 경쟁 상태를 읽고 제어할 수 있는 역량이 필수</strong>라고 생각합니다.</p>
<hr>
<h2 id="참고자료">참고자료</h2>
<ul>
<li><a href="https://developer.mozilla.org/ko/docs/Web/JavaScript/EventLoop">MDN Event Loop</a></li>
<li><a href="https://www.youtube.com/watch?v=8aGhZQkoFbQ">Jake Archibald&#39;s Event Loop talk</a></li>
<li><a href="https://developer.chrome.com/blog/inside-browser-part1">Chrome의 멀티 프로세스 아키텍처</a></li>
</ul>
<p><img src="https://velog.velcdn.com/images/now-kwon/post/b20ba5f4-9886-4af2-bc14-ba3c3edf6fb9/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/now-kwon/post/f8d22b3d-1f59-4cb8-a1b7-0af093a4c2ce/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[모바일에서 내 프로젝트가 제대로 동작할까?]]></title>
            <link>https://velog.io/@now-kwon/%EC%A7%84%EC%A7%9C-%EB%AA%A8%EB%B0%94%EC%9D%BC%EC%97%90%EC%84%9C-%EB%82%B4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EA%B0%80-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9E%91%EB%8F%99%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@now-kwon/%EC%A7%84%EC%A7%9C-%EB%AA%A8%EB%B0%94%EC%9D%BC%EC%97%90%EC%84%9C-%EB%82%B4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EA%B0%80-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%9E%91%EB%8F%99%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Sun, 08 Jun 2025 05:50:47 GMT</pubDate>
            <description><![CDATA[<p>이제 반응형, 모바일 퍼스트는 프론트엔드 개발자에게 선택이 아닌 필수가 되었습니다. 저 역시 데스크탑보다 모바일 화면에 더 많은 시간을 들여 작업해왔습니다.</p>
<p>그런데 어느 순간, 문득 이런 질문이 들었습니다.</p>
<blockquote>
<p>❓ 내가 만든 화면, <strong>진짜 모바일 환경에서 의도한 대로 동작하고 있을까?</strong></p>
</blockquote>
<p>단순히 브라우저 크기만 줄인다고 해서 그것이 모바일과 동일한 환경이라고 할 수는 없습니다. <strong>실제 모바일 환경은 훨씬 복잡하고, 다양한 조건과 제약이 존재합니다.</strong></p>
<p>이번에는 실제 프로젝트를 진행하며 겪은 모바일 환경에서 경험했던 이야기와 그 과정에서 얻은 인사이트를 공유해보려 합니다.</p>
<ol>
<li>Android 운영체제</li>
<li>모바일 LTE 네트워크 환경</li>
</ol>
<hr>
<h2 id="1-모바일-운영체제을-고려하라">1. 모바일 운영체제을 고려하라</h2>
<p>대부분의 개발자들은 <strong>크롬 브라우저</strong>에서 작업하고 테스트합니다. 하지만 데스크탑만 해도 크롬, 사파리, 파이어폭스 등 다양한 브라우저 환경이 존재하죠. 프론트엔드 개발자에게 <strong>크로스 브라우징 역량</strong>은 이제 필수 역량이 되었습니다.</p>
<p>모바일에서는 대표적으로 Android와 iOS 환경이 존재합니다. 만약 여러분의 개발 환경이 Mac이라면 스마트폰도 대부분 아이폰일 확률이 높을 겁니다. <strong>그러다 보면 Android 환경 테스트는 놓치기 쉽죠.</strong></p>
<p><img src="https://velog.velcdn.com/images/redhero8830/post/0cfada58-aff5-4c27-b3f0-80e6b59ac547/image.png" alt="여러 브라우저 아이콘"></p>
<h3 id="🌍-전라도-사진이-아프리카에-있다고">🌍 &quot;전라도 사진이... 아프리카에 있다고?&quot;</h3>
<p>최근, <strong><a href="https://triptyche.world">사용자가 업로드한 여행 사진의 EXIF 위치 정보를 기반으로 경로를 자동 생성해주는 서비스</a></strong>를 만들고 있었습니다. 모든 테스트는 저와 팀원이 가진 아이폰으로 이루어졌고, 메타데이터 추출 과정에서 문제없이 작동했습니다.</p>
<p>그런데 어느 날, 백엔드 개발자 분이 MVP를 <strong>부모님(갤럭시 사용자)</strong>에게 보여드린 후 전화를 걸었습니다.</p>
<blockquote>
<p>🚨 “OO야… <strong>전라도 여행 사진이 왜 아프리카 바다에 있지</strong>...?&quot;</p>
</blockquote>
<p>보내준 화면을 보니 분명 전라도 여행을 다녀온 부모님의 사진들이 아프리카 가나 아래 바다 한가운데에 표시되어 있었습니다. 위치를 확인해보니 <strong>위도와 경도가 모두 0인 지점</strong>이었습니다. 😱 </p>
<p><img src="https://velog.velcdn.com/images/now-kwon/post/4974ac14-5607-4f08-b83f-de0e4a5624b9/image.png" alt="사진들이 모두 좌표 (0, 0) 지점인 아프리카 서쪽 바다에 표시됨"></p>
<p>한 시니어 개발자 분이 하셨던 말이 떠올랐습니다. </p>
<blockquote>
<p><strong>사용자에게 문제가 생기면, 먼저 그 사용자의 환경부터 체크하라</strong> </p>
</blockquote>
<p>사용자의 환경은 위치, 네트워크 설정, OS, 버전 등을 말합니다.</p>
<p>확인해보니 <strong>부모님의 스마트폰이 갤럭시</strong>였고, 같은 이미지를 데스크탑 또는 아이폰(iOS)으로 업로드하면 위치 정보가 정상적으로 표시되었습니다. *<em>갤럭시에서만 위치 정보가 0으로 처리되고 있던 것입니다. *</em></p>
<blockquote>
<p>❓ 혹시 Android에서는 위치 정보를 제거하거나 누락시키는 것이 아닐까?</p>
</blockquote>
<p>우선 좌표 정보가 랜덤이 아닌 0으로 바뀌는 점에서 위의 가설을 세웠고, 사실을 확인하기 위해 여러 문서와 이슈들을 찾아봤습니다. (Stack Overflow를 뒤지다 보니 비슷한 고통을 겪은 개발자들의 흔적이 많았습니다… 😥) </p>
<p><img src="https://velog.velcdn.com/images/now-kwon/post/6b9a9d9c-d304-4066-a218-3976f5cacc91/image.png" alt="Google Issue Tracker에 수십 개의 댓글"></p>
<p>구글 이슈 트래커에서도 관련 주제에 많은 댓글이 달려있습니다.</p>
<h3 id="🕵️-범인은-android-10이었다">🕵️ 범인은 Android 10이었다</h3>
<p>Android 10(API 29)부터는 사용자의 개인 정보 보호 강화를 위해 외부 저장소 접근 방식에 제한이 생겼습니다. 특히 <strong>미디어 파일의 EXIF 메타데이터에서 위치 정보 접근</strong>이 제한됩니다.</p>
<p><a href="https://developer.android.com/training/data-storage/shared/media?hl=ko#media-location-permission">미디어 위치 정보 액세스 권한</a>에 따르면,</p>
<blockquote>
<p>Android 10 이상에서 EXIF 위치 정보에 접근하기 위해서는 <strong>앱이 <code>ACCESS_MEDIA_LOCATION</code> 권한을 선언하고, 사용자에게 런타임에 명시적인 동의를 받아야 합니다.</strong></p>
</blockquote>
<p>즉, Android 10부터는 범위 지정 저장소 정책이 도입되면서 기본적으로 이미지에서 민감한 위치 정보를 숨기며, 접근하려면 <strong>런타임에 명시적 사용자 동의와 권한 요청이 필요</strong>해진 것입니다. 추가로 <code>setRequireOriginal()</code> API를 호출하여 사진에 접근할 수 있어야 합니다.</p>
<p><img src="https://velog.velcdn.com/images/now-kwon/post/cc5033d6-9924-4580-b874-f531fcbc1132/image.png" alt=""></p>
<h3 id="exif-위치-정보-접근-흐름">EXIF 위치 정보 접근 흐름</h3>
<ol>
<li>앱 매니페스트에서 <code>ACCESS_MEDIA_LOCATION</code> 선언</li>
<li>런타임 권한 요청 (사용자 동의 필요)</li>
<li><code>MediaStore.setRequireOriginal()</code> 호출하여 원본 파일 접근</li>
</ol>
<p><img src="https://velog.velcdn.com/images/now-kwon/post/796b72fa-cab9-4402-b2ab-7e22d43e79b5/image.png" alt=""></p>
<h3 id="웹-브라우저의-한계">웹 브라우저의 한계</h3>
<p>Android 네이티브 앱에서는 다음과 같이 위치 정보에 접근할 수 있습니다.</p>
<pre><code class="language-kotlin">// Android 네이티브 앱에서의 권한 요청 예시
if (Build.VERSION.SDK_INT &gt;= Build.VERSION_CODES.Q) {
    // ACCESS_MEDIA_LOCATION 권한 요청
    requestPermissions(arrayOf(Manifest.permission.ACCESS_MEDIA_LOCATION))
}</code></pre>
<p>하지만 웹 브라우저는 네이티브 앱이 아니므로 런타임에 직접 권한 요청을 할 수 없습니다. 따라서 <code>&lt;input type=&quot;file&quot;&gt;</code>로 업로드한 이미지는 EXIF 메타데이터 접근이 차단됐던 겁니다.</p>
<p>네이티브 앱에서 권한 요청 팝업이 뜨는 이유가 이 때문이라는 것을 알 수 있었습니다.</p>
<p>사실 Scoped Storage, Photo Picker, 사진 URI 처리 방식 등 알아야 할 게 정말 많더라고요. 하지만 이 글은 프론트엔드 개발자 관점에서 작성한 내용이라, Android 네이티브 개발에 관심 있으신 분들은 글 마지막 참고 자료의 공식 문서를 보시면 좋을 것 같아요. ☺️ (<del>어쨌든, 웹에서는 이런 권한 요청이 불가능하니까...</del>)</p>
<h3 id="💡-기술이-안-된다면-ux로-해결한다">💡 기술이 안 된다면, UX로 해결한다</h3>
<p>이 문제를 완전히 해결하려면 네이티브 앱을 통해 <code>ACCESS_MEDIA_LOCATION</code> 권한을 받아야 합니다. 하지만 MVP 단계에서는 그럴 여유가 없었습니다.</p>
<p>그래서 <strong>UX적인 우회 방법</strong>을 선택했습니다. <code>User-Agent</code>를 감지해서 Android 사용자에게는 미리 안내하는 방법입니다.</p>
<pre><code class="language-js">// Android 환경 감지
const detectAndroid = () =&gt; {
  const userAgent = navigator.userAgent.toLowerCase();
  return userAgent.includes(&#39;android&#39;)
};

// 사용 예시
if (detectAndroid()) {
  // Android 사용자에게 안내 문구 표시
  showModal();
}</code></pre>
<p>Android 환경에서 접속한 사용자에게는 파일 업로드 시 위치 정보가 누락될 수 있다는 안내 문구를 표시하고, 데스크탑 업로드를 유도하는 UI를 설계했습니다.</p>
<p><img src="https://velog.velcdn.com/images/now-kwon/post/0779f1d5-e27b-4714-89e6-1c917705ae49/image.png" alt="Android 환경일 경우, UI 표시"></p>
<p>완벽한 해결책은 아니었지만, 적어도 사용자들이 혼란스러워하지 않게 됐습니다. 때로는 <strong>기술적으로 완벽하지 않더라도, 사용자가 이해할 수 있는 명확한 안내가 더 중요</strong>할 수 있다는 걸 알 수 있었습니다.</p>
<p>나중에 백엔드 개발자 부모님도 PC로 다시 써보시고는 좋아하셨다고 하더라고요. 😊</p>
<hr>
<h2 id="2-모바일-네트워크-환경은-다르다">2. 모바일 네트워크 환경은 다르다</h2>
<p>운영체제만큼이나, 네트워크 환경도 모바일에서 문제를 발생시키는 중요한 요인입니다. 모바일의 경우, 배터리 소모 최소화 및 자원 절약 등의 이유로 데스크탑 환경에 비해 제한적입니다. 특히 <strong>LTE 네트워크</strong>는 생각보다 훨씬 &quot;예민한 환경&quot;이라는 걸 프로젝트를 진행하면서 직접 경험하게 됐습니다.</p>
<h3 id="🤔-왜-모바일에서는-업로드가-느리지">🤔 “왜 모바일에서는 업로드가 느리지?”</h3>
<p><a href="https://triptyche.world/signin">동일 서비스</a>에서 모바일 환경에서 이미지를 업로드할 때, 이상하게도 속도가 너무 느렸습니다. 같은 이미지인데 데스크탑에서는 빠르게 올라가는 것에 비해, 모바일에서는 몇 초씩 지연되더라고요.</p>
<p>처음엔 이미지 크기나 해상도 때문일 거라 생각했지만, 실제로는 다른 문제가 있었습니다.</p>
<blockquote>
<p><strong>이미지 업로드 흐름</strong></p>
</blockquote>
<ol>
<li>백엔드에서 이미지별 Pre-signed URL 발급</li>
<li>클라이언트에서 해당 S3 URL로 이미지 업로드</li>
<li>업로드 완료 후, 백엔드로 메타데이터 전송</li>
</ol>
<hr>
<h3 id="🧭-네트워크-패널로-원인을-찾아보자">🧭 네트워크 패널로 원인을 찾아보자</h3>
<p>크롬의 개발자 도구 Network 탭 → <strong>폭포수(Waterfall) 차트</strong>를 분석했습니다. 프론트엔드 개발자에게 이 차트는 매우 유용합니다. 실제로 HTTP 요청이 어떻게 동작하는지 직접 확인할 수 있거든요.</p>
<p>간단하게 어떤 내용들이 있는지 보겠습니다. URL창에 <code>www.naver.com</code>를 검색했을 때 첫 요청에 대한 차트입니다.</p>
<p><img src="https://velog.velcdn.com/images/redhero8830/post/94f5a01b-be7f-4310-9a84-981e9f2f0d27/image.png" alt="www.naver.com "></p>
<p>✅ 크게 <strong>리소스 예약, 연결 시작, 요청/응답</strong> 3가지 단계로 구성됩니다.</p>
<p><strong>리소스 예약</strong> 단계는 브라우저가 요청을 실제로 시작하기 전 대기하는 시간입니다. 브라우저는 <strong>도메인당 동시 연결 수를 보통 6개로 제한</strong>하기 때문에, 높은 우선순위 요청이 먼저 처리되길 기다리는 시간이라고 생각하시면 됩니다.</p>
<p><strong>연결 시작</strong> 단계에서는 DNS 조회, 초기 연결(TCP 핸드셰이크), SSL(HTTPS의 경우 TLS 핸드셰이크)이 순차적으로 진행됩니다.</p>
<p><strong>요청/응답</strong> 단계에서는 실제 HTTP 요청 전송, 서버 응답 대기(TTFB), 콘텐츠 다운로드가 이루어집니다. 여기서 <strong>TTFB(Time To First Byte)는 서버 성능의 핵심 지표</strong>라고 볼 수 있어요.</p>
<blockquote>
<p><strong>서버 응답 시작(TTFB)</strong>: &quot;여기 10MB 이미지 파일이야!&quot; <strong>(1 Byte)</strong>
<strong>콘텐츠 다운로드</strong>: 실제로 10MB를 네트워크를 통해 받는 시간 <strong>(999,999 Byte)</strong></p>
</blockquote>
<p>그럼 이제 데스크탑 환경과 모바일 환경을 비교해보겠습니다. iOS의 Web Inspector를 활용하기 위해 사파리 환경에서 테스트를 진행했습니다. <del>(개인적으로는 사파리 UI가 가장 예쁩니다.. 역시 애플 🍎)</del></p>
<hr>
<h3 id="🖥-데스크탑에서는-keep-alive가-잘-작동했다">🖥 데스크탑에서는 Keep-Alive가 잘 작동했다</h3>
<p><img src="https://velog.velcdn.com/images/redhero8830/post/17e6a356-9f00-4130-9438-af08a5727057/image.png" alt="데스크탑 환경 폭포수 차트"></p>
<p>데스크탑에서는 <strong>세 번의 요청이 모두 연결 시작 단계를 생략합니다. 즉 기존 연결을 재사용</strong>하는 것입니다. 특히 S3 업로드(2번 요청)가 끝난 뒤 3초가 지나도, 메타데이터 전송(3번 요청)에서는 <strong>연결 재사용</strong>이 이루어졌습니다.</p>
<p><strong>HTTP/1.1부터 Keep-Alive 속성이 생겼습니다.</strong> 요청마다 매번 연결 시작 단계를 거치면 매우 비효율적이기 때문에 특정 시간 동안 기존 HTTP 연결을 재사용하는 거죠. </p>
<h3 id="📱-모바일-lte에서는-예상과-달랐다">📱 모바일 LTE에서는 예상과 달랐다</h3>
<p><img src="https://velog.velcdn.com/images/redhero8830/post/a0b40acd-b462-499d-af0c-9fc3b7e5d93f/image.png" alt="모바일 LTE 환경 폭포수 차트"></p>
<p>데스크탑과 동일하게 S3 업로드(2번 요청)에서는 연결 단계를 생략하죠. <strong>그런데 세 번째 요청에서 다시 연결을 시도합니다.</strong></p>
<p><img src="https://velog.velcdn.com/images/now-kwon/post/2e5085c4-dc19-4d0c-be9f-5efadd5eebc4/image.png" alt="nginx keepalive_timeout"></p>
<p>서비스에서 사용하는 <strong>Nginx의 Keep-Alive timeout 기본 설정은 75초</strong>인데, 모바일에서는 <code>timeout</code> 설정과 상관없이 <strong>약 3.6초 만에 연결이 종료되는 것을 확인할 수 있었어요.</strong></p>
<blockquote>
<p>❗ <strong>왜 75초가 아닌 3.6초 만에 끊겼을까?</strong></p>
</blockquote>
<p>흥미로운 점은 <strong>모바일이라고 해도 Wi-Fi 환경에서는 데스크탑과 동일하게 연결이 유지된다는 것</strong>이었습니다.</p>
<hr>
<h3 id="❗-모바일-lte-환경에서는-keep-alive이-무의미할-수-있다">❗ 모바일 LTE 환경에서는 Keep-Alive이 무의미할 수 있다</h3>
<p>모바일 LTE 환경에서는 다양한 상황에서 연결을 종료할 수 있습니다. 예를 들면 Network 탭에서 <strong>Fast 3G에서 Slow 3G로 바꾸면 다음 요청에서 다시 연결을 시도</strong>하는 것을 볼 수 있습니다.</p>
<p><strong>주요 원인 중 하나로 추정되는 것은 모바일 통신사의 NAT 게이트웨이</strong> 제약입니다.</p>
<p><img src="https://velog.velcdn.com/images/redhero8830/post/65206295-3efc-426f-a363-47a0ef508cdb/image.png" alt="NAT 흐름"></p>
<p>모바일 네트워크에서는 수많은 기기들이 제한된 <strong>공인 IP를 공유</strong>해야 합니다. 이를 위해 통신사들은 NAT 게이트웨이를 운영하는데, 여기서 다음과 같은 제약이 발생할 수 있습니다.</p>
<p><strong>배터리 절약</strong>
모바일 기기일수록 배터리 관리가 중요합니다. 배터리 절약을 위해 일정 시간 후 연결을 강제 종료합니다. 이는 서버에서 설정한 Keep-Alive 설정과 무관하게 동작합니다.</p>
<p><strong>포트 테이블 용량 제한</strong>
여러 명의 사용자가 동시에 연결하기 때문에, 각 연결을 유지하는 테이블의 용량이 제한적입니다. 따라서 오래된 연결은 강제로 정리시킵니다.</p>
<p><strong>네트워크 상태 변화</strong>
LTE에서 3G로, 또는 기지국 간 이동 시 연결이 끊어질 수 있습니다.</p>
<blockquote>
<p><strong>여기서 중요한 건 Nginx의 Keep-Alive <code>timeout</code> 설정이 바뀌는 게 아니라는 점입니다. 서버에서는 여전히 75초를 유지하지만 클라이언트에서 임의로 끊는 것이죠.</strong></p>
</blockquote>
<p>특히 국내 3사(SKT, KT, LG U+) 모두 NAT 타임아웃이 다르므로, 정확한 종료 시간은 예측할 수 없습니다.</p>
<p><img src="https://velog.velcdn.com/images/redhero8830/post/fa00c324-0abb-47eb-b947-f2cdf4f4e5f9/image.png" alt="Stack Overflow에 올라온 WireShark 분석"></p>
<p><a href="https://stackoverflow.com/questions/41482187/keep-alive-not-working-properly-on-ios">Stack Overflow에 올라온 WireShark 분석 글</a>에 따르면,  Wi-Fi 환경과 달리 LTE 환경은 즉시 연결을 종료한다고 합니다. 공식 문서는 아니기 때문에 참고만 했습니다.</p>
<hr>
<h3 id="⛔-timeout을-늘리는-건-해결책이-아니다">⛔ timeout을 늘리는 건 해결책이 아니다</h3>
<p>다시 처음 문제로 돌아가서, <strong>사용자들이 경험하는 &quot;느린 업로드&quot;는 단순히 200ms 정도의 재연결 오버헤드만은 아니라고 생각했습니다.</strong> 대용량 이미지 처리 시 모바일 브라우저의 제한된 자원 등의 복합적인 요인(CPU, 메모리, 네트워크, 배터리 등)들이 작용했을 거예요.</p>
<p>결국 위 문제를 해결하기 위해 <strong>디바이스와 네트워크 연결에 의존하지 않는 최적화</strong>가 필요했습니다.</p>
<h3 id="💡-이미지-최적화로-전송-자체를-빠르게-하자">💡 이미지 최적화로 전송 자체를 빠르게 하자</h3>
<p>단순히 연결을 유지하는 것이 아니라 <strong>전송 속도를 개선하기 위해, 이미지 자체를 최적화하는 접근으로 해결했습니다.</strong></p>
<blockquote>
<ol>
<li>클라이언트에서 리사이징</li>
<li>압축 + WebP 변환</li>
</ol>
</blockquote>
<p><img src="https://velog.velcdn.com/images/redhero8830/post/f2005149-0efa-4a01-9a74-ba1f201d3ffb/image.png" alt="이미지 최적화 전후 사진"></p>
<p>이미지 최적화를 통해 모바일 뿐만 아니라 데스크탑에서도 체감할 수 있을 만큼 개선됐습니다.</p>
<blockquote>
</blockquote>
<ul>
<li>이미지 용량: 평균 4MB → 250KB <strong>(약 94% 감소)</strong></li>
<li>업로드 시간: 평균 23초 → 2.4초 [40MB, 약 15장 기준] <strong>(약 90% 감소)</strong></li>
</ul>
<p>추가로 서비스 전반의 요청 캐싱(TanStack Query)도 병행하여 전체 네트워크 부하를 줄였습니다.</p>
<p>결국 <strong>디바이스 성능이나 네트워크 연결에 의존하기보다,
전송할 데이터 자체를 줄이고, 요청 빈도를 낮추는 방법</strong>로 문제를 해결했습니다.</p>
<hr>
<h2 id="결론-모바일은-작은-데스크탑이-아니다">결론: 모바일은 작은 데스크탑이 아니다.</h2>
<p><strong>B2C 서비스에서는 모바일 사용자가 70~90%</strong>를 차지하는 경우가 많습니다.</p>
<p>그런데도 단순히 화면만 줄여서 테스트하는 건 부족합니다. 실제 디바이스, 네트워크 환경까지 감안해야 모바일 환경에서 정상적으로 동작하는지 예측할 수 있습니다. 그렇지 않으면 <strong>사용자 대부분에게 불편한 경험을 제공할 수 있습니다.</strong></p>
<blockquote>
<p><strong>CI/CD 파이프라인에 모바일 환경 자동 테스트 포함, 네트워크 불안정에 대비한 재시도, 사용자 안내 UI 설계 등 대응하는 전략이 필요합니다.</strong></p>
</blockquote>
<h3 id="🧪-모바일에서-테스트하는-몇-가지-방법">🧪 모바일에서 테스트하는 몇 가지 방법</h3>
<p>마지막으로 간단하게 모바일에서 테스트할 수 있는 몇 가지 방법을 소개해드리겠습니다.</p>
<p><strong>1. iOS의 Web Inspector</strong>
Mac + iPhone 환경이라면 Mac의 Safari 개발자 메뉴에서 아이폰으로 접속한 웹페이지의 개발자 도구를 통해 디버깅을 할 수 있습니다. USB로 연결한 후 Safari &gt; 개발 메뉴에서 기기를 선택하면 됩니다.</p>
<p align=center><img width=440 src='https://velog.velcdn.com/images/now-kwon/post/5a0b1d55-3194-43fd-8d7d-a520cfc57899/image.png'/></p>

<p><strong>2. Chrome DevTools의 모바일 시뮬레이션</strong>
터치 제스처를 테스트해볼 수 있고, Network 탭의 3G 쓰로틀링으로 실제 모바일 네트워크 환경에서의 성능을 확인할 수 있습니다. 완벽한 실기기 대체는 아니지만, 빠르게 테스트해보기 좋습니다.</p>
<p align=center><img width=440 src='https://velog.velcdn.com/images/redhero8830/post/c68435f5-1eec-4e88-8b59-d212769522de/image.png'/></p>

<p><strong>3. BrowserStack 같은 클라우드 테스트 도구</strong>
실제 다양한 기기를 구매할 수는 없으니까, BrowserStack이나 LambdaTest 같은 서비스를 활용하는 것도 좋은 방법입니다. 다양한 OS 버전과 브라우저 조합을 확인할 수 있습니다. </p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<h4 id="android-관련-자료">Android 관련 자료</h4>
<p><a href="https://developer.android.com/training/data-storage/shared/media?hl=ko#media-location-permission">미디어 위치 정보 액세스 권한</a>
<a href="https://developer.android.com/about/versions/10/privacy/changes?hl=ko#scoped-storage">Android 10의 개인정보 보호 변경사항</a>
<a href="https://developer.android.com/training/permissions/requesting?hl=ko">Android 런타임 권한 요청</a>
<a href="https://developer.android.com/training/data-storage?hl=ko#scoped-storage">범위 지정 저장소(Scoped Storage)란?</a>
<a href="https://developer.android.com/training/data-storage/shared/media#photo_picker">사진 선택 도구(Photo Picker)란?</a>
<a href="https://issuetracker.google.com/issues/243294058?pli=1">EXIF 관련 구글 이슈 트래커</a></p>
<h4 id="네트워크-관련-자료">네트워크 관련 자료</h4>
<p><a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#keepalive_timeout">Nginx keepalive_timeout 관련 공식문서</a>
<a href="https://stackoverflow.com/questions/41482187/keep-alive-not-working-properly-on-ios">iOS에서의 Keep Alive</a>
<a href="https://kejdev.github.io/etc/2024/03/11/NAT%28NetworkAddressTranslation%29.html">NAT (Network Address Translation) 이란?</a>
<a href="https://www.browserstack.com/guide/web-inspector-on-iphone">What is Web Inspector on iPhone?</a></p>
]]></description>
        </item>
    </channel>
</rss>