<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발에 AtoZ까지</title>
    <link>https://atoz-developer.tistory.com/</link>
    <description>ai 시대 살아남는 개발자가 되기까지</description>
    <language>ko</language>
    <pubDate>Thu, 30 Jul 2026 13:24:25 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>AtoZ 개발자</managingEditor>
    <image>
      <title>개발에 AtoZ까지</title>
      <url>https://tistory1.daumcdn.net/tistory/4416937/attach/e27cb6d9ee394900a2d166597bdde1db</url>
      <link>https://atoz-developer.tistory.com</link>
    </image>
    <item>
      <title>[Spring Boot] 에러 응답을 ProblemDetail로 표준화하기 &amp;mdash; HTTP 상태 코드 제어 2026년판</title>
      <link>https://atoz-developer.tistory.com/189</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  API마다 에러 모양이 다 다릅니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REST API를 만들다 보면 어느 순간 이런 상태가 됩니다. 어떤 컨트롤러는 &lt;code&gt;{&quot;message&quot;: &quot;...&quot;}&lt;/code&gt;를 내려보내고, 어떤 컨트롤러는 &lt;code&gt;{&quot;error&quot;: &quot;...&quot;}&lt;/code&gt;를 내려보냅니다. 정작 예외를 못 잡은 자리에서는 화이트라벨 에러 페이지가 그대로 나갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다 프런트엔드에서 연락이 옵니다. &quot;에러 포맷 좀 통일해 주실 수 있을까요.&quot; 그때부터 공통 에러 응답 클래스를 직접 만들기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그런데 그걸 직접 만들 필요가 없습니다.&lt;/b&gt; HTTP 에러 응답 본문에는 이미 표준 규격이 있고, Spring은 그걸 프레임워크 안에 넣어 뒀습니다. &lt;code&gt;ProblemDetail&lt;/code&gt;이라는 이름입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 정리하고 갈 것은 네 가지입니다. 상태 코드와 본문을 정하는 방법을 어떻게 구분해서 쓰는지, &lt;code&gt;ProblemDetail&lt;/code&gt;이 정확히 무슨 규격인지, 설정 한 줄로 켜는 방법, 그리고 켠 다음 사람들이 당황하는 지점입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 제가 운영 중인 서비스에 적용한 기록이 아니라, &lt;b&gt;Spring 공식 레퍼런스와 RFC 원문, 이슈 트래커를 기준으로&lt;/b&gt; 정리한 내용입니다. 확인일은 2026년 7월 28일입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직전 글에서 &lt;a href=&quot;https://atoz-developer.tistory.com/188&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;파라미터 이름을 못 찾는 에러&lt;/a&gt;를 다뤘습니다. 요청을 제대로 받게 만들었으면, 그다음은 잘못된 요청에 무엇을 돌려줄 것인가입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  상태 코드와 본문을 정하는 방법은 네 가지입니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2021년에 정리했던 &lt;a href=&quot;https://atoz-developer.tistory.com/121&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP Status Code 제어&lt;/a&gt; 글에서는 상태 코드 자체에 집중했습니다. 지금은 선택지가 하나 더 늘었습니다. &lt;b&gt;본문 형식까지 표준으로 정해 주는 방법&lt;/b&gt;이 생겼기 때문입니다.&lt;/p&gt;
&lt;table style=&quot;height: 171px;&quot; width=&quot;846&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방법&lt;/th&gt;
&lt;th&gt;상태 코드&lt;/th&gt;
&lt;th&gt;응답 본문&lt;/th&gt;
&lt;th&gt;언제 쓰나&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@ResponseStatus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;고정&lt;/td&gt;
&lt;td&gt;직접 만듦&lt;/td&gt;
&lt;td&gt;예외 하나가 항상 같은 상태 코드일 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ResponseEntity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;코드에서 결정&lt;/td&gt;
&lt;td&gt;직접 만듦&lt;/td&gt;
&lt;td&gt;정상 응답에서 상태를 분기할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ResponseStatusException&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;던질 때 결정&lt;/td&gt;
&lt;td&gt;프레임워크가 만듦&lt;/td&gt;
&lt;td&gt;서비스 로직에서 바로 던지고 싶을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ProblemDetail&lt;/code&gt; / &lt;code&gt;ErrorResponse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt; 필드가 결정&lt;/td&gt;
&lt;td&gt;&lt;b&gt;표준 형식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;에러 응답 전체를 통일하고 싶을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-four-ways.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;860&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/It31B/dJMcabkQXpx/IEBkzgKCjersbNwFixeAn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/It31B/dJMcabkQXpx/IEBkzgKCjersbNwFixeAn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/It31B/dJMcabkQXpx/IEBkzgKCjersbNwFixeAn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIt31B%2FdJMcabkQXpx%2FIEBkzgKCjersbNwFixeAn1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1980&quot; height=&quot;860&quot; data-filename=&quot;diagram-1-four-ways.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;860&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞의 세 가지는 &lt;b&gt;상태 코드를 어떻게 정할지&lt;/b&gt;에 대한 답입니다. 본문은 여전히 개발자가 만듭니다. 그래서 팀마다 모양이 갈립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 번째가 다른 점은 &lt;b&gt;본문 모양까지 규격으로 정해져 있다&lt;/b&gt;는 것입니다. 그래서 프런트엔드가 &quot;이 API는 어떤 필드로 오나요&quot;를 매번 묻지 않아도 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  ProblemDetail은 RFC 9457이라는 표준입니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring이 새로 만든 형식이 아닙니다. Spring MVC 레퍼런스는 이렇게 시작합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The Spring Framework supports the &quot;Problem Details for HTTP APIs&quot; specification, RFC 9457.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 RFC는 인터넷 표준 문서를 뜻합니다. 그리고 RFC 9457은 예전에 많이 인용되던 &lt;b&gt;RFC 7807을 대체(obsolete)한 최신판&lt;/b&gt;입니다. 아직 &quot;7807 기준&quot;이라고 적힌 글이 많은데, 지금 Spring 문서는 9457을 가리킵니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-spring-errorresponses.png&quot; data-origin-width=&quot;880&quot; data-origin-height=&quot;587&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9AjFc/dJMcabyhkIm/kw9NnGaSppP0pAmAnVEvz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9AjFc/dJMcabyhkIm/kw9NnGaSppP0pAmAnVEvz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9AjFc/dJMcabyhkIm/kw9NnGaSppP0pAmAnVEvz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9AjFc%2FdJMcabyhkIm%2Fkw9NnGaSppP0pAmAnVEvz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;880&quot; height=&quot;587&quot; data-filename=&quot;official-spring-errorresponses.png&quot; data-origin-width=&quot;880&quot; data-origin-height=&quot;587&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표준이 정한 필드는 다섯 개입니다.&lt;/p&gt;
&lt;table style=&quot;height: 211px;&quot; width=&quot;844&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;필드&lt;/th&gt;
&lt;th&gt;RFC 9457의 정의&lt;/th&gt;
&lt;th&gt;실무에서의 의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;문제 유형을 식별하는 URI 참조. 없으면 기본값은 &lt;code&gt;about:blank&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;이 에러가 &quot;어떤 종류&quot;인지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;문제 유형에 대한 짧은 사람이 읽을 수 있는 요약&lt;/td&gt;
&lt;td&gt;사람이 보는 제목&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;원 서버가 만든 HTTP 상태 코드&lt;/td&gt;
&lt;td&gt;400, 404, 409 등&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;이번 발생 건에 대한 사람이 읽을 수 있는 설명&lt;/td&gt;
&lt;td&gt;이 요청에서 구체적으로 뭐가 틀렸는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;instance&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;문제가 발생한 특정 지점을 식별하는 URI 참조&lt;/td&gt;
&lt;td&gt;보통 요청 경로&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답 형태는 이렇게 나갑니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;type&quot;: &quot;about:blank&quot;,
  &quot;title&quot;: &quot;Bad Request&quot;,
  &quot;status&quot;: 400,
  &quot;detail&quot;: &quot;Invalid request content.&quot;,
  &quot;instance&quot;: &quot;/api/orders&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Content-Type도 일반 JSON이 아닙니다. &lt;code&gt;application/problem+json&lt;/code&gt;을 씁니다. XML이면 &lt;code&gt;application/problem+xml&lt;/code&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-rfc9457-members.png&quot; data-origin-width=&quot;932&quot; data-origin-height=&quot;609&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c0xn2C/dJMcahehn8H/JpFPONE7SZ3sKNXf1ygjhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c0xn2C/dJMcahehn8H/JpFPONE7SZ3sKNXf1ygjhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c0xn2C/dJMcahehn8H/JpFPONE7SZ3sKNXf1ygjhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc0xn2C%2FdJMcahehn8H%2FJpFPONE7SZ3sKNXf1ygjhK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;932&quot; height=&quot;609&quot; data-filename=&quot;official-rfc9457-members.png&quot; data-origin-width=&quot;932&quot; data-origin-height=&quot;609&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 자주 놓치는 규칙이 하나 있습니다. RFC는 &lt;code&gt;detail&lt;/code&gt;을 프로그램이 파싱하지 말라고 못 박습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consumers SHOULD NOT parse the &quot;detail&quot; member for information; extensions are more suitable and less error-prone ways to obtain such information.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 프런트엔드가 &lt;code&gt;detail&lt;/code&gt; 문자열을 잘라서 분기하면 안 됩니다. 기계가 읽을 값이 필요하면 &lt;b&gt;확장 필드를 따로 만들어야 합니다.&lt;/b&gt; 이 규칙을 알고 시작하면 나중에 응답 스펙을 다시 갈아엎는 일이 줄어듭니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚡ 설정 한 줄로 켜집니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot에서는 기본적으로 꺼져 있습니다. 켜면 기존 에러 응답 모양이 바뀌기 때문에, 일부러 옵트인 방식으로 두었습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# application.properties
spring.mvc.problemdetails.enabled=true&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# application.yml
spring:
  mvc:
    problemdetails:
      enabled: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한 줄을 켜면, Spring MVC가 스스로 던지는 예외들이 표준 형식으로 나가기 시작합니다. 잘못된 JSON, 타입 변환 실패, 지원하지 않는 메서드 같은 것들입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가능한 이유는 프레임워크 예외들이 이미 규격을 구현해 두었기 때문입니다. 레퍼런스의 표현은 이렇습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ErrorResponse&lt;/code&gt; &amp;mdash; contract to expose HTTP error response details including HTTP status, response headers, and a body in the format of RFC 9457 &amp;hellip; All Spring MVC exceptions implement this.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;WebFlux를 쓰신다면&lt;/b&gt; 속성 이름이 다릅니다. &lt;code&gt;spring.webflux.problemdetails.enabled&lt;/code&gt;를 켜시면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✍️ 내 도메인 예외도 같은 형식으로 내보내기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프레임워크 예외만 표준이 되면 반쪽입니다. &lt;code&gt;주문을 찾을 수 없음&lt;/code&gt; 같은 내 예외도 같은 모양으로 나가야 의미가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방법은 어렵지 않습니다. 레퍼런스는 이렇게 안내합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;You can return &lt;code&gt;ProblemDetail&lt;/code&gt; or &lt;code&gt;ErrorResponse&lt;/code&gt; from any &lt;code&gt;@ExceptionHandler&lt;/code&gt; or from any &lt;code&gt;@RequestMapping&lt;/code&gt; method to render an RFC 9457 response.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@ExceptionHandler&lt;/code&gt;에서 &lt;code&gt;ProblemDetail&lt;/code&gt;을 그냥 반환하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleOrderNotFound(OrderNotFoundException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, &quot;주문번호 &quot; + e.getOrderId() + &quot; 를 찾을 수 없습니다.&quot;);
        pd.setTitle(&quot;주문을 찾을 수 없음&quot;);
        pd.setType(URI.create(&quot;https://api.example.com/problems/order-not-found&quot;));
        // 기계가 읽을 값은 detail이 아니라 확장 필드로
        pd.setProperty(&quot;orderId&quot;, e.getOrderId());
        pd.setProperty(&quot;timestamp&quot;, Instant.now());
        return pd;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답은 이렇게 나갑니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;type&quot;: &quot;https://api.example.com/problems/order-not-found&quot;,
  &quot;title&quot;: &quot;주문을 찾을 수 없음&quot;,
  &quot;status&quot;: 404,
  &quot;detail&quot;: &quot;주문번호 A-1024 를 찾을 수 없습니다.&quot;,
  &quot;instance&quot;: &quot;/api/orders/A-1024&quot;,
  &quot;orderId&quot;: &quot;A-1024&quot;,
  &quot;timestamp&quot;: &quot;2026-07-28T00:31:00Z&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 두 가지가 자동으로 채워집니다. &lt;code&gt;status&lt;/code&gt; 값이 &lt;b&gt;HTTP 상태 코드를 결정&lt;/b&gt;하고, &lt;code&gt;instance&lt;/code&gt;를 비워 두면 &lt;b&gt;현재 요청 경로로 채워집니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 문구를 다국어로 관리하고 싶다면 메시지 코드 규칙도 준비돼 있습니다. &lt;code&gt;problemDetail.title.[예외 클래스 전체 이름]&lt;/code&gt;과 &lt;code&gt;problemDetail.[예외 클래스 전체 이름]&lt;/code&gt; 형태로 프로퍼티 파일에 넣으면 &lt;code&gt;MessageSource&lt;/code&gt;를 통해 값이 채워집니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 켜고 나서 당황하는 지점들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기부터가 실제로 시간을 잡아먹는 부분입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;커스텀 핸들러가 조용히 무시됩니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 골치 아픈 경우입니다. &lt;code&gt;spring.mvc.problemdetails.enabled=true&lt;/code&gt;를 켠 상태에서 &lt;code&gt;@RestControllerAdvice&lt;/code&gt;에 &lt;code&gt;@ExceptionHandler(MethodArgumentNotValidException.class)&lt;/code&gt;를 만들어 두면, &lt;b&gt;그 핸들러가 동작하지 않을 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경고도 에러도 안 뜹니다. 프레임워크가 자동 등록한 핸들러가 먼저 잡아 버리기 때문입니다. Spring Framework 이슈 트래커에도 같은 내용이 올라와 있고, Spring 팀은 이를 문서로 보완할 사안으로 분류했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 두 가지입니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 방법 1. 내 핸들러의 우선순위를 올린다
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice
public class ApiExceptionHandler { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;// 방법 2. 프레임워크 기본 핸들러를 상속해서 필요한 것만 재정의한다
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
    @Override
    protected ResponseEntity&amp;lt;Object&amp;gt; handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        // 여기서 ProblemDetail을 손봐서 반환
        return super.handleMethodArgumentNotValid(ex, headers, status, request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ResponseEntityExceptionHandler&lt;/code&gt;는 레퍼런스가 &quot;Spring MVC의 모든 예외와 &lt;code&gt;ErrorResponseException&lt;/code&gt;을 처리하는 &lt;code&gt;@ControllerAdvice&lt;/code&gt;용 편의 기반 클래스&quot;라고 설명하는 클래스입니다. 전부 다 재정의할 필요는 없고, 손볼 것만 오버라이드하면 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;브라우저로 열면 여전히 HTML이 나옵니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ProblemDetail&lt;/code&gt;은 API 응답용입니다. 브라우저가 HTML을 요청하면 Spring Boot는 여전히 화이트라벨 에러 뷰를 내려보냅니다. API 서버라면 문제가 안 되지만, 같은 애플리케이션이 화면도 같이 렌더링한다면 두 경로를 따로 생각하셔야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Boot 4로 올렸다면 속성 이름이 바뀌었습니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 놓치기 쉬운 부분입니다. &lt;code&gt;server.error.*&lt;/code&gt;로 익숙하던 에러 관련 속성들이 &lt;b&gt;Spring Boot 4.0에서 &lt;code&gt;spring.web.error.*&lt;/code&gt;로 옮겨졌습니다.&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;height: 261px;&quot; width=&quot;851&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spring Boot 3.x까지&lt;/th&gt;
&lt;th&gt;Spring Boot 4.0부터&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.error.include-message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spring.web.error.include-message&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.error.include-stacktrace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spring.web.error.include-stacktrace&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.error.include-binding-errors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spring.web.error.include-binding-errors&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.error.include-exception&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spring.web.error.include-exception&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.error.include-path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spring.web.error.include-path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.error.path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spring.web.error.path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.error.whitelabel.enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spring.web.error.whitelabel.enabled&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-before-after.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;910&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4flru/dJMcajptgp8/4nZknGtWCkuujWmQLIyQMK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4flru/dJMcajptgp8/4nZknGtWCkuujWmQLIyQMK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4flru/dJMcajptgp8/4nZknGtWCkuujWmQLIyQMK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4flru%2FdJMcajptgp8%2F4nZknGtWCkuujWmQLIyQMK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1980&quot; height=&quot;910&quot; data-filename=&quot;diagram-2-before-after.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;910&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;속성 이름이 틀리면 조용히 무시됩니다. &quot;분명히 &lt;code&gt;include-message&lt;/code&gt;를 켰는데 메시지가 안 나온다&quot;면 이쪽을 확인해 보시면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  적용 전에 확인할 것들&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;☐ 프런트엔드와 &lt;b&gt;에러 응답 포맷을 바꾸기로&lt;/b&gt; 합의했는가 (기존 클라이언트가 깨질 수 있음)&lt;/li&gt;
&lt;li&gt;☐ &lt;code&gt;spring.mvc.problemdetails.enabled&lt;/code&gt;(WebFlux면 &lt;code&gt;spring.webflux.problemdetails.enabled&lt;/code&gt;)를 켰는가&lt;/li&gt;
&lt;li&gt;☐ 기존 &lt;code&gt;@ExceptionHandler&lt;/code&gt;가 무시되지 않는지 확인했는가 (&lt;code&gt;@Order&lt;/code&gt; 또는 상속)&lt;/li&gt;
&lt;li&gt;☐ 기계가 읽어야 하는 값을 &lt;code&gt;detail&lt;/code&gt; 문자열이 아니라 &lt;b&gt;확장 필드&lt;/b&gt;로 넣었는가&lt;/li&gt;
&lt;li&gt;☐ &lt;code&gt;type&lt;/code&gt;을 팀에서 관리하는 URI로 정했는가 (안 정하면 전부 &lt;code&gt;about:blank&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;☐ 브라우저 요청 경로(화이트라벨)도 함께 검토했는가&lt;/li&gt;
&lt;li&gt;☐ Spring Boot 4로 올렸다면 &lt;code&gt;spring.web.error.*&lt;/code&gt;로 속성명을 바꿨는가&lt;/li&gt;
&lt;li&gt;☐ 에러 응답에 내부 정보(스택트레이스, 쿼리, 내부 식별자)가 새지 않는가&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  이런 경우에 쓰시면 됩니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;바로 적용하시면 좋은 경우&lt;/b&gt;는 새로 시작하는 API이거나, 클라이언트가 아직 소수인 내부 API입니다. 포맷을 직접 설계하는 시간을 아끼고, 표준을 아는 사람이 합류했을 때 설명할 게 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단계적으로 가셔야 하는 경우&lt;/b&gt;는 이미 외부에 공개된 API입니다. 응답 본문 모양이 바뀌는 건 클라이언트 입장에서 파괴적 변경입니다. 새 버전 경로에서 먼저 쓰거나, 확장 필드에 기존 키를 같이 담아 이행 기간을 두는 편이 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;굳이 안 쓰셔도 되는 경우&lt;/b&gt;는 화면을 렌더링하는 서버 사이드 애플리케이션입니다. 이건 애초에 사람이 보는 에러 페이지가 필요한 자리라, 표준 JSON이 해결해 주는 게 없습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;➡️ 이어서 보면 좋은 글&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 요청을 제대로 받는 이야기의 다음 편입니다. 파라미터 바인딩 쪽에서 막히셨다면 &lt;a href=&quot;https://atoz-developer.tistory.com/188&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;파라미터 이름을 못 찾는 에러&lt;/a&gt; 글을 먼저 보시면 됩니다. 상태 코드 자체를 어떻게 고를지가 헷갈리신다면 &lt;a href=&quot;https://atoz-developer.tistory.com/121&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP Status Code 제어&lt;/a&gt; 글에 정리해 두었습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  참고 자료&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-rest-exceptions.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Framework 레퍼런스 &amp;mdash; Error Responses (ProblemDetail&amp;middot;ErrorResponse)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9457.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 9457 &amp;mdash; Problem Details for HTTP APIs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-boot/reference/web/servlet.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Boot 레퍼런스 &amp;mdash; Servlet Web Applications / Error Handling&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Configuration-Changelog&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Boot 4.0 Configuration Changelog (속성 이름 변경 목록)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-framework/issues/35982&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;spring-framework issue #35982 &amp;mdash; 커스텀 @ExceptionHandler가 무시되는 문제&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://endoflife.date/spring-boot&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Boot 릴리스&amp;middot;지원 종료 일정 (endoflife.date)&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>백엔드</category>
      <category>HTTP 상태 코드</category>
      <category>ProblemDetail</category>
      <category>REST API</category>
      <category>RFC 9457</category>
      <category>spring boot</category>
      <category>Spring Framework</category>
      <category>백엔드</category>
      <category>에러 응답</category>
      <category>예외 처리</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/189</guid>
      <comments>https://atoz-developer.tistory.com/189#entry189comment</comments>
      <pubDate>Thu, 30 Jul 2026 09:29:17 +0900</pubDate>
    </item>
    <item>
      <title>9월 전기요금, 8월보다 덜 써도 더 나옵니다 &amp;mdash; 여름 할인 종료와 400kWh 경계선</title>
      <link>https://atoz-developer.tistory.com/190</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;8월이 지나면 대개 마음이 놓입니다. 에어컨 켜는 날이 줄어드니 다음 고지서는 당연히 가벼울 거라고 생각하게 됩니다. 그런데 막상 열어 보면 금액이 별로 안 줄었거나, 오히려 늘어 있는 경우가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계량기가 고장 난 것도, 한전이 잘못 계산한 것도 아닙니다. &lt;b&gt;여름에만 넓혀 주던 누진 구간이 8월로 끝나기 때문&lt;/b&gt;입니다. 누진제는 쉽게 말하면 많이 쓸수록 뒤에 붙는 전기의 단가가 비싸지는 구조인데, 그 단가가 바뀌는 경계선이 9월에 아래로 내려옵니다. 쓰는 양을 조금 줄여도 경계선이 더 많이 내려오면 요금은 올라갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 세 가지를 정리합니다. 첫째, 여름 할인이 정확히 언제까지인지 확인합니다. 둘째, 9월에 같은 양을 쓰면 얼마가 더 나오는지 공시 단가로 직접 계산해 봅니다. 셋째, 우리 집 고지서에서 그 전환 시점을 찾는 법과 9월에 할 일을 정리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;br /&gt;&lt;b&gt;  &lt;/b&gt;핵심(3줄 요약)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여름 누진 완화는 &lt;b&gt;8월 31일 사용분까지&lt;/b&gt;입니다. 9월 사용분부터는 원래 구간으로 돌아갑니다.&lt;/li&gt;
&lt;li&gt;그래서 9월의 기준 숫자는 450kWh가 아니라 &lt;b&gt;400kWh&lt;/b&gt;입니다. 여기를 넘기면 기본요금이 1,600원에서 7,300원으로 뜁니다.&lt;/li&gt;
&lt;li&gt;지금 할 일은 하나입니다. 고지서에서 &lt;b&gt;'사용기간'&lt;/b&gt;을 찾아 우리 집 검침일이 며칠인지 확인하세요.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;여름 할인은 '8월 31일 사용분'에서 끝납니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여름에 누진 구간이 넓어지는 건 한전이 그때그때 결정하는 이벤트가 아닙니다. 2019년 7월 1일 산업통상자원부가 발표한 누진제 개편으로 &lt;b&gt;매년 자동 적용되는 상시 제도&lt;/b&gt;가 됐습니다. 전년도에 한시적으로 했던 여름철 요금 할인을 아예 못 박은 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내용은 두 줄입니다. 1단계 구간을 200kWh에서 &lt;b&gt;300kWh&lt;/b&gt;로, 2단계 상한을 400kWh에서 &lt;b&gt;450kWh&lt;/b&gt;로 올려 줍니다. 단가와 기본요금은 그대로 두고 경계선만 위로 밀어 주는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 놓치기 쉬운 부분이 적용 기간입니다. &lt;b&gt;7월 1일부터 8월 31일까지의 사용분&lt;/b&gt;에만 적용됩니다. 한전 기본공급약관도 여름철을 7월 1일부터 8월 31일까지로 규정하고 있어, 계절을 나누는 기준선 자체가 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 9월 1일부터 쓰는 전기는 봄&amp;middot;가을 요금 체계로 계산됩니다. 날씨가 아직 더워도 제도는 이미 가을입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-kdi-summer-tier.png&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;358&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rhbYA/dJMcaazsJXq/lxHmRDRfTSM9UG2s5LixjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rhbYA/dJMcaazsJXq/lxHmRDRfTSM9UG2s5LixjK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rhbYA/dJMcaazsJXq/lxHmRDRfTSM9UG2s5LixjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrhbYA%2FdJMcaazsJXq%2FlxHmRDRfTSM9UG2s5LixjK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;712&quot; height=&quot;358&quot; data-filename=&quot;official-kdi-summer-tier.png&quot; data-origin-width=&quot;712&quot; data-origin-height=&quot;358&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9월엔 경계선이 450에서 400으로 내려옵니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말로는 잘 안 와닿으니 두 시기를 나란히 놓아 보겠습니다. 단가와 기본요금은 두 시기가 똑같고, &lt;b&gt;구간 경계만 다릅니다.&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;height: 139px;&quot; width=&quot;850&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th&gt;1단계&lt;/th&gt;
&lt;th&gt;2단계&lt;/th&gt;
&lt;th&gt;3단계&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;7~8월 사용분&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;300kWh 이하&lt;/td&gt;
&lt;td&gt;301~450kWh&lt;/td&gt;
&lt;td&gt;450kWh 초과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;9월 사용분부터&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;200kWh 이하&lt;/td&gt;
&lt;td&gt;201~400kWh&lt;/td&gt;
&lt;td&gt;400kWh 초과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기본요금 (원/월)&lt;/td&gt;
&lt;td&gt;910&lt;/td&gt;
&lt;td&gt;1,600&lt;/td&gt;
&lt;td&gt;7,300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전력량요금 (원/kWh)&lt;/td&gt;
&lt;td&gt;120.0&lt;/td&gt;
&lt;td&gt;214.6&lt;/td&gt;
&lt;td&gt;307.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;주택용 저압 기준. 요금표는 법제처 '찾기쉬운 생활법령정보'의 2026년 6월 15일 기준 자료를 따랐습니다. 이 금액 외에 기후환경요금&amp;middot;연료비조정요금이 사용량에 따라 붙고, 마지막에 부가가치세 10%와 전력산업기반기금 2.7%가 더해집니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1단계 상한이 100kWh, 2단계 상한이 50kWh씩 내려옵니다. 그래서 8월엔 1단계였던 가구가 9월엔 2단계가 되고, 8월엔 2단계였던 가구가 9월엔 3단계가 되는 일이 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아파트처럼 고압으로 공급받는 가구는 단가가 더 낮습니다. 같은 자료 기준으로 기본요금은 730/1,260/6,060원, 전력량요금은 105.0/174.0/242.3원입니다. 금액은 이렇게 다르지만, 7&amp;middot;8월에만 구간이 넓어졌다가 9월에 원래대로 돌아오는 구조는 똑같습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-tier-shift.png&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;784&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XyQUy/dJMb998j916/4dPtKM6v1W0ZMjxLpIcauK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XyQUy/dJMb998j916/4dPtKM6v1W0ZMjxLpIcauK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XyQUy/dJMb998j916/4dPtKM6v1W0ZMjxLpIcauK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXyQUy%2FdJMb998j916%2F4dPtKM6v1W0ZMjxLpIcauK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1900&quot; height=&quot;784&quot; data-filename=&quot;diagram-1-tier-shift.png&quot; data-origin-width=&quot;1900&quot; data-origin-height=&quot;784&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-easylaw-rate.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceGecd/dJMcahrQcl1/xJt0gJgeDLFiYRyAu02ZA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceGecd/dJMcahrQcl1/xJt0gJgeDLFiYRyAu02ZA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceGecd/dJMcahrQcl1/xJt0gJgeDLFiYRyAu02ZA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceGecd%2FdJMcahrQcl1%2FxJt0gJgeDLFiYRyAu02ZA0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;432&quot; data-filename=&quot;official-easylaw-rate.png&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;432&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;같은 350kWh인데 요금이 1만 원 가까이 차이 납니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구간이 내려온다는 말의 무게는 숫자로 봐야 실감이 납니다. 위 표의 공시 단가로, &lt;b&gt;사용량은 그대로 두고 8월과 9월만 바꿔&lt;/b&gt; 계산해 봤습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;한 달 사용량&lt;/th&gt;
&lt;th&gt;8월 사용분&lt;/th&gt;
&lt;th&gt;9월 사용분&lt;/th&gt;
&lt;th&gt;차이&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;300kWh&lt;/td&gt;
&lt;td&gt;36,910원&lt;/td&gt;
&lt;td&gt;47,060원&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+10,150원&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;350kWh&lt;/td&gt;
&lt;td&gt;48,330원&lt;/td&gt;
&lt;td&gt;57,790원&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+9,460원&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400kWh&lt;/td&gt;
&lt;td&gt;59,060원&lt;/td&gt;
&lt;td&gt;68,520원&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+9,460원&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;450kWh&lt;/td&gt;
&lt;td&gt;69,790원&lt;/td&gt;
&lt;td&gt;89,585원&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+19,795원&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500kWh&lt;/td&gt;
&lt;td&gt;90,855원&lt;/td&gt;
&lt;td&gt;104,950원&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+14,095원&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;기본요금과 전력량요금만 더한 금액입니다. 기후환경요금은 사용량이 같으면 두 시기가 같으므로 차액에 영향을 주지 않지만, 부가가치세와 전력산업기반기금은 요금에 비례하므로 실제 청구서의 차이는 위 금액보다 약 13% 더 커집니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표에 마이너스가 한 줄도 없습니다. &lt;b&gt;사용량을 그대로 두면 요금은 반드시 오릅니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 450kWh 줄을 보시면 차이가 2만 원 가까이 됩니다. 8월엔 딱 2단계 끝자락이라 기본요금이 1,600원인데, 9월엔 3단계라 7,300원이 붙기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 전기를 많이 쓰는 집만 손해를 보는 게 아닙니다. 오히려 &lt;b&gt;인상률로 보면 300kWh 쓰는 집이 27.5%로 가장 크게 뜁니다.&lt;/b&gt; 8월엔 1단계 안에 들어와 기본요금이 910원이었는데, 9월엔 2단계라 1,600원이 되고 100kWh가 통째로 비싼 단가로 넘어가기 때문입니다. &quot;우리 집은 많이 안 쓰니까 괜찮다&quot;는 생각이 9월엔 잘 안 통합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &quot;덜 썼는데 더 나왔다&quot;는 말이 실제로 성립합니다. 8월에 350kWh를 쓰던 집이 9월에 10%를 아껴 315kWh로 줄였다고 해 봅시다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;8월 350kWh &amp;rarr; 48,330원
9월 315kWh &amp;rarr; 50,279원  (1,600 + 24,000 + 115kWh &amp;times; 214.6)

사용량은 35kWh 줄었는데 요금은 1,949원 늘었습니다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 집이 9월에 8월과 같은 요금을 내려면 &lt;b&gt;약 306kWh까지&lt;/b&gt; 내려와야 합니다. 350kWh 기준으로 13% 가까이 줄여야 겨우 본전인 셈입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-bill-compare.png&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;1176&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5gdzc/dJMcahrQcl4/fcXpq27WD6jvbvxp353muK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5gdzc/dJMcahrQcl4/fcXpq27WD6jvbvxp353muK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5gdzc/dJMcahrQcl4/fcXpq27WD6jvbvxp353muK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5gdzc%2FdJMcahrQcl4%2FfcXpq27WD6jvbvxp353muK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1564&quot; height=&quot;1176&quot; data-filename=&quot;diagram-2-bill-compare.png&quot; data-origin-width=&quot;1564&quot; data-origin-height=&quot;1176&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9월엔 400kWh를 1kWh만 넘겨도 6천 원이 붙습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여름에 450kWh가 무서운 숫자였다면, 가을부터는 그 자리를 &lt;b&gt;400kWh&lt;/b&gt;가 대신합니다. 이유는 단가가 아니라 &lt;b&gt;기본요금&lt;/b&gt;에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본요금은 얼마를 쓰든 붙는 고정 금액인데, 2단계 1,600원에서 3단계 7,300원으로 한 번에 5,700원 올라갑니다. 사용량이 1kWh만 넘어가도 이 5,700원이 통째로 붙습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 계산해 보면 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[9월 사용분 기준 &amp;middot; 주택용 저압 &amp;middot; 기본요금 + 전력량요금]

400kWh &amp;rarr; 68,520원  (기본 1,600 + 전력량 66,920)
401kWh &amp;rarr; 74,527원  (기본 7,300 + 전력량 67,227)

1kWh 차이 = 6,007원
부가세 10%와 전력산업기반기금 2.7%까지 더하면 약 6,800원&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전기 1kWh는 900W짜리 에어컨을 한 시간 조금 넘게 돌리는 양입니다. 그 한 시간이 6천 원이 되는 지점이 400kWh입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 이 구조는 여름에도 똑같이 작동했습니다. 하계 구간에서 450kWh를 넘길 때도 1kWh 차이로 6,007원이 붙습니다. 경계선의 위치만 달라질 뿐, &lt;b&gt;경계선을 넘길 때 손해가 크다는 성질은 그대로&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;내 고지서는 언제부터 '9월 사용분'일까요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 많은 분이 헷갈립니다. &quot;9월 사용분&quot;이 달력의 9월 1일부터 30일까지라고 생각하기 쉬운데, 꼭 그렇지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전기요금은 달력 기준이 아니라 &lt;b&gt;검침일 기준 한 달&lt;/b&gt;로 계산됩니다. 한전은 고객마다 미리 정해 둔 정기검침일에 계량기를 읽고, 그 날부터 다음 검침일 전날까지를 한 달치로 묶습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 검침일이 며칠이냐에 따라 상황이 달라집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;검침일이 월말인 가구&lt;/b&gt; &amp;mdash; 8월 사용분과 9월 사용분이 깔끔하게 나뉩니다. 여름 할인이 완전히 끝난 첫 고지서는 10월 초에 받습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;검침일이 월 중순인 가구&lt;/b&gt; &amp;mdash; 예를 들어 15일이라면 8월 15일부터 9월 14일까지가 한 달치입니다. &lt;b&gt;한 장의 고지서 안에 여름과 가을이 섞입니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;섞였을 때 어떻게 나눠 계산하는지는 약관 본문에 없습니다. 한전 기본공급약관은 계절이 바뀔 때의 요금 구분 계산 방법을 세칙에 위임해 두고 있습니다. 그러니 금액을 직접 추정하려 애쓰기보다, 고지서에 적힌 &lt;b&gt;'사용기간'&lt;/b&gt; 날짜를 그대로 확인하시는 편이 빠릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인할 곳은 두 군데입니다. 종이 고지서나 이메일 고지서의 상단에 '사용기간 2026.08.15 ~ 2026.09.14' 같은 형식으로 적혀 있고, 한전ON(online.kepco.co.kr)에 세대를 등록해 두었다면 청구 내역에서도 볼 수 있습니다. 이 날짜가 우리 집의 진짜 달력입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;검침일을 바꾸면 요금이 줄어들까요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 대목에서 꼭 나오는 이야기가 검침일 변경입니다. 예전에는 한전이 일방적으로 정했지만, 공정거래위원회가 해당 약관 조항을 불공정하다고 보고 시정한 뒤로는 &lt;b&gt;고객이 검침일 변경을 요청할 수 있습니다.&lt;/b&gt; 신청은 한전 고객센터(국번 없이 123)나 한전ON에서 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 기대와 실제는 좀 다릅니다. 결론부터 말씀드리면 &lt;b&gt;총 사용량이 같다면 1년 치 요금이 크게 줄지는 않습니다.&lt;/b&gt; 검침일을 옮기는 건 요금을 깎는 게 아니라, 사용량이 한 고지서에 몰리는 걸 나누는 일에 가깝습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;효과가 있을 수 있는 경우와 없는 경우를 나눠 보면 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;효과를 볼 수 있는 경우&lt;/b&gt; &amp;mdash; 검침일이 하필 냉방 성수기 한가운데(예: 7월 중순~8월 중순)에 걸려 있어서, 가장 더운 4주가 한 장의 고지서에 통째로 담기는 가구. 이 경우 누진 3단계에 깊이 들어가기 쉽습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;효과가 거의 없는 경우&lt;/b&gt; &amp;mdash; 이미 검침일이 월말이라 달력 월과 거의 일치하는 가구. 또는 연중 사용량이 고른 가구. 나눌 봉우리가 없으니 바꿔도 달라지는 게 적습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;오히려 손해일 수 있는 경우&lt;/b&gt; &amp;mdash; 여름 할인 구간을 잘 쓰고 있던 가구가, 검침일을 옮겨 여름 사용분 일부를 가을 구간으로 밀어 버리는 경우입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 조건과 재변경 제한은 상황에 따라 다르게 안내되므로, 바꾸기로 마음먹으셨다면 123으로 우리 집 검침일과 최근 사용 패턴을 함께 물어보시는 편이 확실합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9월 사용량을 미리 어림잡는 계산&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고지서를 받고 나서 놀라는 것보다, 중간에 한 번 계산해 보는 편이 낫습니다. 어림값은 곱셈 하나면 나옵니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[한 달 사용량 어림잡기]

월 사용량(kWh) = 소비전력(W) &amp;divide; 1000 &amp;times; 하루 사용시간(h) &amp;times; 사용일수

예) 소비전력 900W 에어컨을 하루 5시간, 20일 사용
   &amp;rarr; 0.9kW &amp;times; 5h &amp;times; 20일 = 90kWh
   여기에 냉방 외 기본 사용량(냉장고&amp;middot;조명&amp;middot;세탁 등)을 더합니다.

[9월 목표 잡기]
   3단계 진입선 = 400kWh
   &amp;rarr; 냉방 외 기본 사용량이 250kWh인 집이라면
     에어컨에 쓸 수 있는 여유는 약 150kWh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제품 라벨에서 값을 가져올 때 한 가지만 주의하시면 됩니다. &lt;b&gt;'냉방능력'과 '소비전력'은 다른 값&lt;/b&gt;입니다. 냉방능력은 얼마나 넓은 공간을 시원하게 하는지를 나타내는 성능이고, 요금 계산에 넣어야 하는 건 전기를 끌어 쓰는 소비전력(W)입니다. 숫자가 큰 냉방능력을 넣으면 실제보다 훨씬 과하게 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인버터 에어컨이라면 라벨의 소비전력이 최대 출력 기준이라, 실제 평균은 이보다 낮습니다. 위 계산은 넉넉하게 잡은 상한으로 보시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 우리 집이 어디쯤인지 정확히 보고 싶다면 한전ON의 '우리 집 전기요금 미리보기'에서 계량기 지침값을 넣어 확인할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;늦더위를 넘기는 9월용 우선순위&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;9월은 두 가지가 겹치는 달입니다. 아직 덥고, 구간은 이미 좁습니다. 그래서 여름과 같은 방식으로 대응하면 안 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선순위를 이렇게 잡으시면 됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;9월 초 열흘을 관리하세요.&lt;/b&gt; 좁아진 구간에서는 초반에 몰아 쓰면 남은 3주가 통째로 비싼 구간에 들어갑니다. 월 초에 하루 사용량을 한 번 재 보는 것만으로도 감이 잡힙니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;'안 켜도 되는 날'을 찾아보세요.&lt;/b&gt; 9월은 창문만 열어도 잘 만한 밤이 섞여 있습니다. 온도를 몇 도 올리는 절약은 효과가 조건에 따라 달라지지만, 아예 안 켠 날은 그 시간만큼 사용량이 확실하게 0입니다. 위 계산식에서 '사용일수'를 줄이는 쪽이 가장 계산이 분명합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;제습 목적이면 제습 기능을 확인하세요.&lt;/b&gt; 9월 초에는 더위보다 습도가 문제인 날이 있습니다. 다만 기종에 따라 제습 운전이 냉방과 소비전력 차이가 크지 않은 경우도 있으니, 사용설명서의 소비전력 표기를 보고 판단하시는 게 정확합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;선풍기&amp;middot;서큘레이터를 늦여름까지 두세요.&lt;/b&gt; 9월에 에어컨을 안 켜고 버틸 수 있는 날이 늘어납니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;온도 설정, 실외기 관리, 필터 청소처럼 여름 내내 유효한 기본기는 이전 글에 정리해 두었습니다. &lt;a href=&quot;https://atoz-developer.tistory.com/179&quot;&gt;여름 전기요금 폭탄, 진짜 핵심은 '450kWh'였습니다&lt;/a&gt;를 함께 보시면 9월 대응이 한결 수월합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;'9월 전기요금' 이야기 중 사실이 아닌 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이맘때가 되면 확인되지 않은 이야기가 같이 돌아다닙니다. 공식 자료로 확인해 보니 다음 두 가지는 사실과 다릅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&quot;9월에도 여름 할인이 적용된다&quot;&lt;/b&gt; &amp;mdash; 아닙니다. 하계 확대 구간은 7월 1일부터 8월 31일까지의 사용분에만 적용됩니다. 9월 고지서를 받는다고 해서 9월에 쓴 전기에 여름 구간이 적용되는 게 아니라, 그 고지서에 담긴 &lt;b&gt;사용기간&lt;/b&gt;이 8월 31일 이전인지가 기준입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;구간을 넘으면 전체 사용량이 비싼 단가로 계산된다&quot;&lt;/b&gt; &amp;mdash; 아닙니다. 누진은 초과분에만 붙습니다. 3단계에 들어가도 앞의 200kWh는 여전히 120.0원으로 계산됩니다. 다만 &lt;b&gt;기본요금은 전체에 하나만 붙고, 그 금액이 단계에 따라 통째로 바뀝니다.&lt;/b&gt; 경계선에서 요금이 튀는 건 단가가 아니라 이 기본요금 때문입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자를 외우기보다 이 두 가지만 구분하시면, 근거 없는 절약 팁에 흔들리지 않습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;에너지캐시백은 9월 검침분도 대상입니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의외로 많이 놓치는 게 한전 주택용 에너지캐시백입니다. 과거 같은 달 평균보다 전기를 덜 쓰면 절감량만큼 돌려주는 제도인데, 여름에만 하는 게 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한전 안내 기준으로 2026년에는 &lt;b&gt;7월부터 12월 검침분까지&lt;/b&gt; 절감 문턱이 기존 3%에서 1%로 낮아졌습니다. 9월 검침분도 여기에 포함됩니다. 1%만 줄여도 신청 자격이 되니 문턱이 낮은 편입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;신청은 한전ON에서 할 수 있고, 지급 단가와 세부 조건은 안내가 갱신될 수 있으니 신청 화면에서 확인하시는 게 정확합니다. 여름에 신청을 미뤄 두셨다면 지금이 늦지 않은 시점입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9월 전기요금 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복사해서 메모앱에 붙여 두고 하나씩 지워 나가시면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[9월 전기요금 체크리스트]

□ 고지서에서 '사용기간' 날짜를 찾아 우리 집 검침일 확인
□ 이번 고지서에 8월분과 9월분이 섞여 있는지 확인
□ 9월 목표 사용량 정하기
   - 평소 400kWh 이상 쓰는 집 &amp;rarr; 400kWh 이하 (3단계 진입선)
   - 평소 200~350kWh 쓰는 집 &amp;rarr; 200kWh 선을 의식 (2단계 진입선)
□ 냉방 외 기본 사용량이 몇 kWh인지 파악 &amp;rarr; 에어컨 여유분 계산
□ 한전ON '우리 집 전기요금 미리보기'로 월 중간 점검 1회
□ 9월 초 열흘 사용량을 특히 관리
□ 에어컨은 온도보다 '켜는 날 수'를 줄이는 쪽으로
□ 에너지캐시백 신청 여부 확인 (2026년 7~12월 검침분, 문턱 1%)
□ 검침일 변경은 우리 집 사용 패턴을 123에 확인한 뒤 결정&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9월에 기억할 숫자는 하나입니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;9월 고지서가 안 줄어드는 건 우리가 게을러서가 아닙니다. 여름에만 빌려 쓰던 넉넉한 구간이 8월 31일로 반납되기 때문입니다. 구조를 알고 나면 대응은 단순해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여름의 450kWh 자리에 이제 &lt;b&gt;400kWh&lt;/b&gt;가 들어옵니다. 그리고 그 선을 넘겼는지 판단하는 기준일은 달력이 아니라 우리 집 검침일입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 할 수 있는 일도 하나뿐입니다. 최근 고지서를 열어 &lt;b&gt;'사용기간'&lt;/b&gt; 네 글자를 찾아보세요. 그 날짜를 알고 나면 9월에 언제부터 조심해야 하는지가 저절로 정해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 이야기를 주로 올리는 블로그지만, 지난 &lt;a href=&quot;https://atoz-developer.tistory.com/179&quot;&gt;여름 편&lt;/a&gt;에 이어 생활 요금 이야기를 한 번 더 정리했습니다. 다음에는 겨울 난방비 구간이 어떻게 달라지는지 같은 방식으로 짚어 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;이 글은 2026년 7월 29일 기준 공식 자료를 바탕으로 작성했습니다. 참고 자료는 한국전력공사 기본공급약관 제8장(요금의 계산 및 납부), 산업통상자원부 2019년 7월 1일 누진제 개편 발표(KDI 경제정보센터 정책자료), 법제처 '찾기쉬운 생활법령정보' 전기 사용 안내(페이지 기준일 2026년 6월 15일)입니다. 요금 제도와 캐시백 조건은 바뀔 수 있으니 최신 내용은 한전ON에서 다시 확인하시기 바랍니다.&lt;/i&gt;&lt;/p&gt;</description>
      <category>일상생활</category>
      <category>9월 전기요금</category>
      <category>검침일</category>
      <category>누진제</category>
      <category>에너지캐시백</category>
      <category>전기요금 계산</category>
      <category>전기요금 누진제</category>
      <category>전기요금 절약</category>
      <category>하계 누진구간</category>
      <category>한전on</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/190</guid>
      <comments>https://atoz-developer.tistory.com/190#entry190comment</comments>
      <pubDate>Wed, 29 Jul 2026 19:14:08 +0900</pubDate>
    </item>
    <item>
      <title>HTTPS 자물쇠가 지켜주는 것과 못 지켜주는 것 &amp;mdash; TLS가 실제로 하는 일</title>
      <link>https://atoz-developer.tistory.com/187</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리는 매일 HTTPS를 씁니다. 주소창에 자물쇠가 보이면 왠지 안심이 되죠. 그런데 이런 질문에 바로 답하기는 은근히 어렵습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;SSL이랑 TLS는 뭐가 다른가요?&quot;&lt;/li&gt;
&lt;li&gt;&quot;자물쇠가 있으면 이 사이트는 안전한 건가요?&quot;&lt;/li&gt;
&lt;li&gt;&quot;인증서는 대체 뭘 증명하는 거죠?&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 결론을 말씀드리면, HTTPS는 &lt;b&gt;HTTP를 TLS라는 보안 계층으로 감싼 것&lt;/b&gt;이고, TLS는 &lt;b&gt;전송 구간 암호화&amp;middot;무결성&amp;middot;서버 도메인 확인&lt;/b&gt; 이 세 가지를 해 줍니다. 하지만 자물쇠는 &lt;b&gt;&quot;이 도메인과 암호화된 연결&quot;&lt;/b&gt; 이라는 뜻이지, &lt;b&gt;&quot;믿을 만한 사이트&quot;&lt;/b&gt; 라는 보증은 아닙니다. 피싱 사이트도 버젓이 HTTPS를 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 예전에 다룬 &lt;a href=&quot;https://atoz-developer.tistory.com/107&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Socket 통신과 HTTP 통신&lt;/a&gt;에서 한 걸음 더 들어가, 그 HTTP를 안전하게 감싸는 TLS가 실제로 무슨 일을 하는지 정리합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  오늘 정리하는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 다 읽으면 아래를 스스로 설명할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SSL과 TLS, HTTPS가 &lt;b&gt;서로 어떤 관계인지&lt;/b&gt; 한 문장으로 말할 수 있습니다.&lt;/li&gt;
&lt;li&gt;TLS 핸드셰이크가 &lt;b&gt;비대칭&amp;rarr;대칭 암호로&lt;/b&gt; 안전한 연결을 만드는 과정을 그릴 수 있습니다.&lt;/li&gt;
&lt;li&gt;인증서가 &lt;b&gt;무엇을 증명하고 무엇을 증명하지 않는지&lt;/b&gt; 구분할 수 있습니다.&lt;/li&gt;
&lt;li&gt;자물쇠가 &lt;b&gt;지켜주는 것과 못 지켜주는 것&lt;/b&gt;을 나눠 판단할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암호학 수식이 아니라, &lt;b&gt;동작의 큰 그림과 오해의 지점&lt;/b&gt;을 잡는 것이 목적입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  근거는 어디서 왔나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시작 전에 밝혀 둡니다. 이 글은 제가 TLS 서버를 직접 뜯어본 경험담이 아니라, &lt;b&gt;표준 규격과 공식 문서에서 확인한 내용&lt;/b&gt;을 그림과 함께 정리한 것입니다. 참고한 자료는 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RFC 8446&lt;/b&gt;(TLS 1.3) &amp;middot; &lt;b&gt;RFC 5246&lt;/b&gt;(TLS 1.2) &amp;mdash; 프로토콜 규격&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 5280&lt;/b&gt;(X.509) &amp;middot; &lt;b&gt;CA/Browser Forum Baseline Requirements&lt;/b&gt; &amp;mdash; 인증서와 발급 규칙&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 6066&lt;/b&gt;(SNI) &amp;middot; &lt;b&gt;RFC 9110&lt;/b&gt;(https URI) &amp;mdash; 부가 동작&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MDN Web Docs&lt;/b&gt; &amp;mdash; Transport Layer Security 설명&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전에 따라 세부는 달라질 수 있으니(특히 TLS 1.2와 1.3), 아래는 &lt;b&gt;현재 표준인 TLS 1.3 기준&lt;/b&gt;이고 확인일은 &lt;b&gt;2026년 7월 26일&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  TLS와 SSL, HTTPS는 무슨 관계인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름부터 정리하면 오해가 절반은 풀립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SSL&lt;/b&gt;은 TLS의 &lt;b&gt;옛 이름&lt;/b&gt;입니다. 넷스케이프가 만든 초기 버전이 SSL이었고, 표준화되면서 이름이 &lt;b&gt;TLS&lt;/b&gt;로 바뀌었습니다. SSL 3.0 이하는 안전하지 않아 오래전에 폐기됐습니다. 그래서 지금 &quot;SSL 인증서&quot;, &quot;SSL 적용&quot;이라고 부르는 건 사실상 &lt;b&gt;TLS를 가리키는 관용어&lt;/b&gt;입니다. 실제로 동작하는 건 TLS라고 생각하시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HTTPS&lt;/b&gt;는 별도의 프로토콜이 아니라 &lt;b&gt;HTTP를 TLS 위에서 주고받는 것&lt;/b&gt;입니다. 평범한 HTTP 요청&amp;middot;응답을 TLS가 암호화된 통로로 감싸는 구조죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 TLS는 무엇을 보장할까요? MDN은 TLS가 연결을 &lt;b&gt;세 가지 방식으로&lt;/b&gt; 지킨다고 설명합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-mdn-tls.png&quot; data-origin-width=&quot;2560&quot; data-origin-height=&quot;1880&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvi4nI/dJMcacYhP3s/kGKRfxoGjc5JYkHYq3YEX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvi4nI/dJMcacYhP3s/kGKRfxoGjc5JYkHYq3YEX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvi4nI/dJMcacYhP3s/kGKRfxoGjc5JYkHYq3YEX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbvi4nI%2FdJMcacYhP3s%2FkGKRfxoGjc5JYkHYq3YEX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2560&quot; height=&quot;1880&quot; data-filename=&quot;official-mdn-tls.png&quot; data-origin-width=&quot;2560&quot; data-origin-height=&quot;1880&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 1. MDN &quot;Transport Layer Security&quot; 문서. TLS는 ① 암호화(Encryption, 전송 중 도청 방지) ② 무결성(Integrity, 몰래 변조 못 함) ③ 인증(Authentication, 상대가 주장하는 그 대상이 맞음)의 세 가지를 제공하며, 웹에서 HTTP에 적용한 것이 HTTPS이자 중간자(MITM) 공격에 대한 방어라고 설명합니다.&amp;nbsp;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 세 번째 &quot;인증&quot;이 뒤에서 볼 오해의 핵심입니다. 웹에서는 보통 &lt;b&gt;서버가 자기 신원(도메인)을 클라이언트에게 증명&lt;/b&gt;하고, 클라이언트는 대개 증명하지 않습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  안전한 연결은 어떻게 만들어지나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암호화를 하려면 양쪽이 &lt;b&gt;같은 비밀 열쇠&lt;/b&gt;를 나눠 가져야 합니다. 그런데 도청당할 수 있는 네트워크에서 열쇠를 어떻게 안전하게 나눌까요? 이걸 푸는 과정이 &lt;b&gt;TLS 핸드셰이크&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 아이디어는 두 종류의 암호를 &lt;b&gt;역할을 나눠 쓰는&lt;/b&gt; 것입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비대칭 암호&lt;/b&gt;(공개키/개인키) &amp;mdash; 안전하지만 느립니다. 그래서 &lt;b&gt;열쇠를 합의하고 서버를 확인하는&lt;/b&gt; 핸드셰이크에만 씁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대칭 암호&lt;/b&gt;(하나의 공유 열쇠) &amp;mdash; 빠릅니다. 그래서 핸드셰이크 뒤 &lt;b&gt;실제 데이터 전송&lt;/b&gt;에 씁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-handshake.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bD9Mtg/dJMcahFfvM1/zDptilwktOH0jV05WbLI8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bD9Mtg/dJMcahFfvM1/zDptilwktOH0jV05WbLI8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bD9Mtg/dJMcahFfvM1/zDptilwktOH0jV05WbLI8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbD9Mtg%2FdJMcahFfvM1%2FzDptilwktOH0jV05WbLI8k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1000&quot; data-filename=&quot;diagram-1-handshake.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1000&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 2. TLS 1.3 핸드셰이크. 브라우저와 서버가 각자 임시 공개키(key_share)를 주고받아 같은 대칭 세션키를 계산하고, 서버는 인증서와 서명으로 신원을 증명합니다. 핸드셰이크가 끝나면 이후 데이터는 빠른 대칭키로 오갑니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TLS 1.3 기준으로 흐름을 풀면 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브라우저가 &lt;code&gt;ClientHello&lt;/code&gt;에 지원 암호 목록과 &lt;b&gt;임시 공개키&lt;/b&gt;(key_share)를 담아 보냅니다.&lt;/li&gt;
&lt;li&gt;서버가 &lt;code&gt;ServerHello&lt;/code&gt;로 쓸 암호를 정하고 &lt;b&gt;자신의 임시 공개키&lt;/b&gt;를 돌려줍니다. 이 시점에 양쪽은 각자 계산으로 &lt;b&gt;같은 대칭 세션키&lt;/b&gt;를 얻습니다.&lt;/li&gt;
&lt;li&gt;서버가 &lt;b&gt;인증서&lt;/b&gt;와 &lt;code&gt;CertificateVerify&lt;/code&gt; &lt;b&gt;서명&lt;/b&gt;을 보내 &quot;이 도메인의 인증서와 그 개인키를 내가 갖고 있다&quot;를 증명하고, &lt;code&gt;Finished&lt;/code&gt;로 마무리합니다.&lt;/li&gt;
&lt;li&gt;이 모든 게 &lt;b&gt;한 번의 왕복(1-RTT)&lt;/b&gt; 안에 끝나고, 이후 HTTP 데이터는 대칭키로 암호화돼 오갑니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지만 더 짚겠습니다. 첫째, TLS 1.3은 매번 &lt;b&gt;임시 키&lt;/b&gt;로 열쇠를 합의합니다. 그래서 서버의 장기 키가 나중에 유출돼도 &lt;b&gt;과거에 주고받은 통신은 풀리지 않습니다&lt;/b&gt;(전방향 비밀성). 예전 TLS 1.2에는 클라이언트가 서버의 RSA 공개키로 비밀을 암호화해 넘기는 방식(static RSA)이 있었는데, 이 경우 전방향 비밀성이 없어 TLS 1.3에서는 제거됐습니다. 둘째, TLS 1.3에서는 &lt;b&gt;인증서까지 암호화&lt;/b&gt;돼 오갑니다(TLS 1.2에서는 평문이었습니다).&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  그럼 왜 중간에서 못 훔쳐보나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핸드셰이크가 끝나면 데이터는 &lt;b&gt;AEAD&lt;/b&gt;라는 대칭 암호로 보호됩니다. AEAD는 한 번의 연산으로 &lt;b&gt;기밀성(내용 숨김)과 무결성(변조 감지)을 동시에&lt;/b&gt; 제공합니다. TLS 1.3은 아예 AEAD만 허용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 자주 나오는 질문. &quot;중간에서 누가 서버인 척하면 되지 않나요?&quot; 이게 바로 &lt;b&gt;중간자(MITM) 공격&lt;/b&gt;인데, TLS가 막는 지점이 정확히 여기입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-rfc8446-tls.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;880&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lbZWW/dJMcahyxnmb/DJY4983Jx6JUgoFdjyKyBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lbZWW/dJMcahyxnmb/DJY4983Jx6JUgoFdjyKyBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lbZWW/dJMcahyxnmb/DJY4983Jx6JUgoFdjyKyBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlbZWW%2FdJMcahyxnmb%2FDJY4983Jx6JUgoFdjyKyBk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;880&quot; data-filename=&quot;official-rfc8446-tls.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;880&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 3. RFC 8446(TLS 1.3, 2018). 초록에서 TLS를 &quot;도청&amp;middot;변조&amp;middot;위조를 막도록 설계된 방식으로 클라이언트와 서버가 통신하게 해 주는&quot; 프로토콜로 규정합니다.&amp;nbsp;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트는 &lt;b&gt;인증서만으로는 서버를 증명하지 못한다&lt;/b&gt;는 것입니다. 인증서는 공개돼 있어 누구나 복사할 수 있으니까요. 진짜 증명은 서버가 핸드셰이크 기록에 &lt;b&gt;개인키로 서명&lt;/b&gt;하는 &lt;code&gt;CertificateVerify&lt;/code&gt;에서 이뤄집니다. 개인키가 없으면 이 서명을 만들 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 그 인증서는 브라우저가 &lt;b&gt;이미 신뢰하는 뿌리(트러스트 스토어의 루트 CA)까지 연결(체인)돼야&lt;/b&gt; 인정됩니다(RFC 5280의 인증 경로 검증). 그래서 공격자가 아무 인증서나 들이밀어도 신뢰 체인에 연결되지 않으면 브라우저가 거부합니다. 공개 CA가 남의 도메인 인증서를 함부로 발급해 주지도 않습니다 &amp;mdash; CA/Browser Forum 규칙상 &lt;b&gt;발급 전에 신청자가 그 도메인을 통제하는지 검증&lt;/b&gt;해야 하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 투명한 TLS 가로채기는 &lt;b&gt;클라이언트가 이미 신뢰하는 CA가 그 기기에 심어져 있을 때만&lt;/b&gt; 가능합니다. 회사가 관리 PC에 사내 루트 CA를 설치해 트래픽을 검사하는 경우가 대표적인데, 이건 &quot;이미 신뢰를 심어 둔&quot; 특수 상황입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  인증서는 무엇을 &quot;증명&quot;하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분이 가장 큰 오해를 부릅니다. 기본으로 쓰는 &lt;b&gt;도메인 검증(DV) 인증서&lt;/b&gt;가 증명하는 것은 딱 하나, &lt;b&gt;&quot;이 인증서 소유자가 그 도메인을 통제한다&quot;&lt;/b&gt; 입니다. 그 사이트를 운영하는 &lt;b&gt;회사가 어디인지, 정직한지&lt;/b&gt;는 증명하지 않습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인증서는 &lt;b&gt;공개키를 도메인 이름에 묶어&lt;/b&gt; CA가 서명한 문서입니다(X.509).&lt;/li&gt;
&lt;li&gt;브라우저는 리프(서버) 인증서 &amp;rarr; 중간 CA &amp;rarr; 루트 CA로 이어지는 &lt;b&gt;체인이 신뢰 루트까지 닿는지&lt;/b&gt;, 유효기간이 지났는지, 도메인 이름이 맞는지를 검사합니다.&lt;/li&gt;
&lt;li&gt;어디에도 연결되지 않은 &lt;b&gt;자체 서명(self-signed) 인증서&lt;/b&gt;는 브라우저가 &quot;신뢰할 수 없음&quot; 경고를 띄웁니다. 개인 테스트 서버에서 흔히 보는 그 경고입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;인증서가 유효하다 = 그 사이트가 안전하다&lt;/b&gt;가 아닙니다. &quot;지금 연결된 상대가 그 도메인의 인증서를 가진 서버가 맞다&quot;까지만 보장합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  그래서 자물쇠가 못 지켜주는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 오해를 정면으로 정리하겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-protect.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1010&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dDlqNc/dJMcahL2o6W/0hjulrnAJT0yp9HIcB6iq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dDlqNc/dJMcahL2o6W/0hjulrnAJT0yp9HIcB6iq1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dDlqNc/dJMcahL2o6W/0hjulrnAJT0yp9HIcB6iq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdDlqNc%2FdJMcahL2o6W%2F0hjulrnAJT0yp9HIcB6iq1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1010&quot; data-filename=&quot;diagram-2-protect.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1010&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 4. 자물쇠는 전송 구간 암호화&amp;middot;무결성&amp;middot;서버 도메인 신원을 지켜 줍니다. 반대로 그 사이트가 안전&amp;middot;정직한지, 서버 안에서 데이터가 안전한지, 어떤 도메인을 방문하는지까지는 지켜 주지 않습니다.&lt;/i&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&quot;안전한 사이트&quot;라는 보증이 아닙니다.&lt;/b&gt; 피싱&amp;middot;악성 사이트도 유효한 HTTPS를 씁니다. 자물쇠는 &quot;이 도메인과의 연결이 암호화됐다&quot;는 뜻이지 &quot;이 사이트를 믿어도 된다&quot;가 아닙니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버에 도착한 뒤의 안전은 별개입니다.&lt;/b&gt; TLS는 &quot;가는 길(전송 구간)&quot;만 지킵니다. 서버가 데이터를 어떻게 저장&amp;middot;처리하는지는 TLS의 몫이 아닙니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;어떤 사이트에 접속하는지는 가려지지 않습니다.&lt;/b&gt; 접속할 도메인 이름은 핸드셰이크 초반 &lt;code&gt;ClientHello&lt;/code&gt;의 &lt;b&gt;SNI&lt;/b&gt;에 담겨 보통 &lt;b&gt;평문으로&lt;/b&gt; 나갑니다. 그래서 네트워크 관찰자는 내용은 못 봐도 &quot;어느 도메인에 갔는지&quot;는 볼 수 있습니다. 이 부분을 가리려는 것이 ECH(Encrypted Client Hello)입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 더. HTTPS를 강제하고 사용자가 인증서 경고를 무시하고 들어가지 못하게 하려면 &lt;b&gt;HSTS&lt;/b&gt;(Strict-Transport-Security 헤더)를 함께 씁니다. 중간에서 HTTPS를 HTTP로 깎아내리는 공격을 막는 장치입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 개발자가 챙길 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스에 HTTPS를 적용&amp;middot;점검할 때 이 정도를 짚으면 됩니다. 필요하면 복사해서 쓰세요.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;[적용]
- [ ] 모든 페이지&amp;middot;리소스를 HTTPS로 제공하나? (혼합 콘텐츠 없음)
- [ ] HTTP 접속을 HTTPS로 리다이렉트하나?
- [ ] HSTS(Strict-Transport-Security) 헤더를 넣었나?

[인증서]
- [ ] 인증서 만료 전에 자동 갱신되나? (만료 = 접속 차단)
- [ ] 인증서의 도메인(SAN)이 실제 서비스 도메인과 맞나?
- [ ] 중간 CA 인증서까지 체인이 완전한가? (일부 클라이언트에서 체인 끊김 주의)

[버전&amp;middot;설정]
- [ ] TLS 1.2/1.3만 허용하고 옛 SSL/TLS는 껐나?

[오해 점검]
- [ ] &quot;자물쇠 = 안전한 사이트&quot;로 설명하고 있지 않나? (도메인 연결 암호화일 뿐)
- [ ] 사용자 입력&amp;middot;서버 저장 보안을 &quot;HTTPS 썼으니 됐다&quot;로 넘기지 않았나?&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, HTTPS는 &lt;b&gt;HTTP를 TLS로 감싼 것&lt;/b&gt;이고, TLS는 핸드셰이크에서 &lt;b&gt;비대칭 암호로 열쇠를 합의하고 서버를 확인&lt;/b&gt;한 뒤 &lt;b&gt;대칭 암호로 데이터를 주고받습니다.&lt;/b&gt; 그리고 자물쇠가 지켜주는 건 &lt;b&gt;전송 구간의 암호화&amp;middot;무결성과 서버 도메인 신원&lt;/b&gt;까지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기억할 한 가지는 이것입니다. &lt;b&gt;자물쇠는 &quot;안전한 사이트&quot;라는 뜻이 아니라 &quot;이 도메인과 암호화된 연결&quot;이라는 뜻입니다.&lt;/b&gt; 인증서(기본 DV)는 도메인 통제를 증명할 뿐, 그 사이트의 정직함까지 보장하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 깊이 들어가고 싶다면, 이 위에서 오가는 통신 자체는 &lt;a href=&quot;https://atoz-developer.tistory.com/107&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Socket 통신과 HTTP 통신 비교&lt;/a&gt;를, 로그인 상태와 인증은 &lt;a href=&quot;https://atoz-developer.tistory.com/184&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;세션 vs JWT 인증&lt;/a&gt;과 &lt;a href=&quot;https://atoz-developer.tistory.com/185&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;소셜 로그인과 OAuth 2.0&lt;/a&gt;을, 다른 출처로 요청이 오갈 때 나는 &lt;a href=&quot;https://atoz-developer.tistory.com/183&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CORS 에러&lt;/a&gt;를 함께 보시면 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어 주셔서 감사합니다  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8446&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc5246&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 5246: The TLS Protocol Version 1.2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc5280&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 5280: Internet X.509 Public Key Infrastructure Certificate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6066&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 6066: TLS Extensions &amp;mdash; Server Name Indication&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9110&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 9110: HTTP Semantics &amp;mdash; https URI Scheme&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/Security/Transport_Layer_Security&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Transport Layer Security (TLS)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Strict-Transport-Security (HSTS)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cabforum.org/working-groups/server/baseline-requirements/documents/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CA/Browser Forum: Baseline Requirements for TLS Server Certificates&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프론트엔드</category>
      <category>http</category>
      <category>https</category>
      <category>SSL</category>
      <category>ssl인증서</category>
      <category>TLS</category>
      <category>TLS핸드셰이크</category>
      <category>네트워크</category>
      <category>웹보안</category>
      <category>인증서</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/187</guid>
      <comments>https://atoz-developer.tistory.com/187#entry187comment</comments>
      <pubDate>Wed, 29 Jul 2026 10:18:49 +0900</pubDate>
    </item>
    <item>
      <title>비밀번호는 어떻게 저장하나 &amp;mdash; &amp;quot;암호화&amp;quot;도 &amp;quot;SHA-256&amp;quot;도 답이 아닌 이유</title>
      <link>https://atoz-developer.tistory.com/186</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자체 회원가입 기능을 만들 때, 비밀번호를 DB에 어떻게 넣을지 검색해 보면 조언이 갈립니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;그냥 저장하면 안 되니까, 암호화해서 저장하면 되죠.&quot;&lt;/li&gt;
&lt;li&gt;&quot;SHA-256으로 해시하고 salt만 붙이면 안전해요.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 다 그럴듯하지만, 둘 다 &lt;b&gt;오늘날 기준으로는 위험한 방법&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면 두 가지입니다. 첫째, 비밀번호는 &lt;b&gt;암호화(양방향)가 아니라 되돌릴 수 없는 단방향 해시&lt;/b&gt;로 저장합니다. 둘째, 그 해시는 SHA-256 같은 &lt;b&gt;빠른 해시가 아니라, 일부러 느리게 만든 전용 해시(bcrypt&amp;middot;Argon2id)&lt;/b&gt; 를 salt와 함께 써야 합니다. 이 두 가지가 왜 그런지 짚고, 그다음 무엇을 어떤 값으로 쓸지까지 정리하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 로그인을 만드는 과정에서 &lt;a href=&quot;https://atoz-developer.tistory.com/184&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;세션 vs JWT 인증&lt;/a&gt;(상태를 어디 둘까), &lt;a href=&quot;https://atoz-developer.tistory.com/185&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;소셜 로그인과 OAuth 2.0&lt;/a&gt;(외부 계정으로 로그인)에 이어, &lt;b&gt;자체 회원가입이면 비밀번호 자체는 어떻게 저장하나&lt;/b&gt;를 다루는 편입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  오늘 정리하는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 다 읽으면 아래를 스스로 판단할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비밀번호를 &lt;b&gt;암호화하면 왜 안 되는지&lt;/b&gt;, 해싱과 무엇이 다른지 설명할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SHA-256 + salt가 왜 부족한지&lt;/b&gt;, &quot;느린 해시&quot;가 무슨 뜻인지 말할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;salt와 pepper&lt;/b&gt;의 역할을 구분할 수 있습니다.&lt;/li&gt;
&lt;li&gt;내 상황에 &lt;b&gt;Argon2id&amp;middot;bcrypt&amp;middot;PBKDF2 중 무엇을 어떤 파라미터로&lt;/b&gt; 쓸지 고를 수 있습니다.&lt;/li&gt;
&lt;li&gt;로그인 검증과 &lt;b&gt;work factor 업그레이드&lt;/b&gt;, 그리고 비밀번호 정책의 기준을 잡습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 라이브러리 사용법이 아니라, &lt;b&gt;무엇을 왜 고르는지&lt;/b&gt; 판단하는 기준을 손에 넣는 것이 목적입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  근거부터 짚고 갑니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 말씀드리면, 이 글은 제 특정 서비스의 운영 경험담이 아니라 &lt;b&gt;공식 표준과 보안 가이드에서 확인해 정리한 것&lt;/b&gt;입니다. 근거로 삼은 자료는 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;OWASP&lt;/b&gt; &amp;mdash; Password Storage / Authentication Cheat Sheet (알고리즘&amp;middot;파라미터의 실무 기준)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;NIST SP 800-63B&lt;/b&gt; &amp;mdash; Digital Identity Guidelines (검증자 요구사항&amp;middot;비밀번호 정책)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 9106&lt;/b&gt;(Argon2) &amp;middot; &lt;b&gt;RFC 7914&lt;/b&gt;(scrypt) &amp;middot; &lt;b&gt;RFC 8018&lt;/b&gt;(PBKDF2) &amp;mdash; 각 알고리즘 규격&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 4648&lt;/b&gt; &amp;mdash; base64 등 인코딩의 정의&lt;/li&gt;
&lt;li&gt;&lt;b&gt;언어 표준 라이브러리&lt;/b&gt; &amp;mdash; PHP &lt;code&gt;password_hash&lt;/code&gt;, Python &lt;code&gt;hashlib&lt;/code&gt;/&lt;code&gt;hmac&lt;/code&gt;, Node &lt;code&gt;crypto&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념과 원칙은 표준이라 잘 바뀌지 않지만, &lt;b&gt;권장 파라미터 수치는 하드웨어에 맞춰 갱신&lt;/b&gt;됩니다. 아래 수치는 &lt;b&gt;확인일(2026년 7월 25일) OWASP 기준&lt;/b&gt;이며, 실제 적용 전에는 본인이 쓰는 라이브러리&amp;middot;OWASP 문서의 현재 값으로 확인하시는 편이 안전합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  비밀번호는 &quot;암호화&quot;하는 게 아닙니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 풀 오해가 &quot;암호화해서 저장&quot;입니다. 세 가지 개념을 먼저 나누겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;인코딩(예: base64)&lt;/b&gt; &amp;mdash; 쉽게 말하면 &lt;b&gt;모양만 바꾸는 변환&lt;/b&gt;입니다. 키도 비밀도 없어 누구나 즉시 되돌립니다. RFC 4648도 &quot;base 인코딩은 비밀번호 같은 정보를 눈에만 안 띄게 할 뿐, 어떤 기밀성도 주지 않는다&quot;고 못박습니다. base64로 &quot;숨긴&quot; 비밀번호는 사실상 평문입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;암호화(encryption)&lt;/b&gt; &amp;mdash; &lt;b&gt;양방향&lt;/b&gt; 변환입니다. 키가 있으면 원문으로 되돌릴 수 있습니다. 그래서 &lt;b&gt;키 하나가 유출되면 저장된 비밀번호 전체가 원문으로 풀립니다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;해싱(hashing)&lt;/b&gt; &amp;mdash; &lt;b&gt;단방향&lt;/b&gt; 함수입니다. 해시에서 원문을 되돌릴 수 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;비밀번호 인증에는 원문을 되돌릴 필요가 아예 없다&lt;/b&gt;는 점입니다. 로그인은 &quot;이 비밀번호가 맞나?&quot;라는 예/아니오 비교일 뿐, 저장된 값을 다시 읽어 낼 일이 없습니다. 그래서 되돌릴 수 없는 단방향 해시가 오히려 정답입니다. OWASP도 이렇게 정리합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Passwords should never be stored in plain text. Instead, they must be protected using strong, slow hashing algorithms such as Argon2id, bcrypt, or PBKDF2.&quot; (비밀번호는 절대 평문으로 저장하면 안 되며, Argon2id&amp;middot;bcrypt&amp;middot;PBKDF2 같은 강하고 느린 해싱 알고리즘으로 보호해야 한다.)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 암호화는 &quot;원문을 꼭 되찾아야 하는&quot; 드문 예외에만 쓰고, 로그인 비밀번호는 해당하지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-storage.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caNIIj/dJMcabLWxTB/PlrwaXN7JP60pzleolDna0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caNIIj/dJMcabLWxTB/PlrwaXN7JP60pzleolDna0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caNIIj/dJMcabLWxTB/PlrwaXN7JP60pzleolDna0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcaNIIj%2FdJMcabLWxTB%2FPlrwaXN7JP60pzleolDna0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1200&quot; data-filename=&quot;diagram-1-storage.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 1. 평문&amp;middot;인코딩&amp;middot;암호화&amp;middot;빠른 해시는 모두 &quot;되돌리거나 빠르게 추측&quot;할 수 있어 부적합합니다. 올바른 방법은 unique salt를 붙여 느린 적응형 해시(Argon2id&amp;middot;bcrypt 등)로 저장하는 것입니다.&lt;/i&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  SHA-256으로 해시하면 왜 안 되나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;그럼 단방향 해시니까 SHA-256이면 되겠네&quot;가 두 번째 오해입니다. SHA-256이나 MD5는 &lt;b&gt;범용 해시&lt;/b&gt;라 &lt;b&gt;아주 빠르게&lt;/b&gt; 계산되도록 설계돼 있습니다. 파일 무결성 검사 같은 데는 좋지만, 비밀번호에는 바로 그 &quot;빠름&quot;이 약점이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공격자가 유출된 해시 목록을 손에 넣으면, GPU로 &lt;b&gt;초당 수십억 번&lt;/b&gt; 후보 비밀번호를 해시해 맞는 걸 찾습니다. 빠른 해시일수록 이 무차별 대입이 빨라집니다. OWASP가 비밀번호에는 SHA 계열 같은 빠른 해시를 쓰지 말라고 하는 이유입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;salt를 붙이면 되지 않나요?&quot;라고 하실 수 있는데, &lt;b&gt;salt만으로는 부족합니다.&lt;/b&gt; salt는 뒤에서 보듯 미리 계산해 둔 표(rainbow table)를 무력화할 뿐, &lt;b&gt;한 번의 추측을 느리게 만들지는 못합니다.&lt;/b&gt; 즉 salted SHA-256도 여전히 초당 수십억 번 대입에 노출됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 필요한 것이 &lt;b&gt;느린 적응형 해시&lt;/b&gt;입니다. 쉽게 말하면 &lt;b&gt;계산에 일부러 시간&amp;middot;메모리를 많이 쓰게 만든&lt;/b&gt; 해시입니다. 여기엔 &lt;b&gt;work factor(작업 계수)&lt;/b&gt; 라는 손잡이가 있어서, 하드웨어가 빨라질수록 값을 올려 &lt;b&gt;공격 비용을 계속 비싸게&lt;/b&gt; 유지할 수 있습니다. OWASP의 튜닝 기준은 &lt;b&gt;해시 한 번에 1초 미만&lt;/b&gt;이 걸리도록 맞추고, 시간이 지나면 값을 올리는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇습니다.&lt;/p&gt;
&lt;table style=&quot;height: 157px;&quot; width=&quot;866&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;속도&lt;/th&gt;
&lt;th&gt;비밀번호 저장에&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MD5 &amp;middot; SHA-1 &amp;middot; SHA-256 (빠른 해시)&lt;/td&gt;
&lt;td&gt;매우 빠름&lt;/td&gt;
&lt;td&gt;❌ 무차별 대입에 취약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MD5/SHA + salt&lt;/td&gt;
&lt;td&gt;매우 빠름&lt;/td&gt;
&lt;td&gt;❌ rainbow table만 막음, per-guess는 그대로&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bcrypt &amp;middot; scrypt &amp;middot; Argon2id (느린 적응형)&lt;/td&gt;
&lt;td&gt;느림(조절 가능)&lt;/td&gt;
&lt;td&gt;✅ 권장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  salt와 pepper는 각각 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;느린 해시로 넘어가기 전에, 자주 헷갈리는 두 개념을 정리하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;salt&lt;/b&gt;는 비밀번호마다 붙이는 &lt;b&gt;고유한 무작위 값&lt;/b&gt;입니다. 역할은 둘입니다. 첫째, 미리 해시를 계산해 둔 표(rainbow table)를 무력화합니다. 둘째, &lt;b&gt;같은 비밀번호를 쓴 두 사용자의 해시가 서로 달라져서&lt;/b&gt; 비밀번호 재사용이 드러나지 않습니다. salt는 &lt;b&gt;비밀이 아니어도 되고, 해시와 함께 저장&lt;/b&gt;합니다(RFC 8018).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 실무 포인트. &lt;b&gt;bcrypt&amp;middot;Argon2&amp;middot;scrypt 같은 모던 알고리즘은 salt를 알아서 만들어 결과 문자열에 넣어 줍니다.&lt;/b&gt; 그러니 salt를 직접 만들어 따로 보관하려 하지 마세요. 예를 들어 PHP &lt;code&gt;password_hash()&lt;/code&gt;는 직접 넘긴 salt를 무시(폐기)하고 자동 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;pepper&lt;/b&gt;는 다릅니다. &lt;b&gt;앱 전체가 공유하는 하나의 비밀&lt;/b&gt;을 추가로 섞는 것으로, &lt;b&gt;DB 바깥&lt;/b&gt;(설정, 비밀 볼트, HSM)에 둡니다. DB만 털렸을 때 공격자가 pepper를 모르면 오프라인 대입을 못 하게 하는 &lt;b&gt;심층 방어&lt;/b&gt;입니다. 다만 pepper 단독으로는 안전을 보장하지 못하고, 나중에 값을 바꾸려면(회전) 사용자 비밀번호 재설정이 필요한 한계가 있습니다. NIST도 &quot;pepper&quot;라는 단어는 안 쓰지만, 해시와 분리 보관하는 &lt;b&gt;비밀 키를 이용한 방식&lt;/b&gt;을 권합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 그래서 뭘 쓰나 &amp;mdash; Argon2id&amp;middot;bcrypt&amp;middot;PBKDF2&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 알고리즘 선택입니다. OWASP의 우선순위는 명확합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-owasp-storage.png&quot; data-origin-width=&quot;2600&quot; data-origin-height=&quot;1800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yHUwt/dJMcaf1I9C5/8PoggbMZvcKYNpQiI0C4Wk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yHUwt/dJMcaf1I9C5/8PoggbMZvcKYNpQiI0C4Wk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yHUwt/dJMcaf1I9C5/8PoggbMZvcKYNpQiI0C4Wk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyHUwt%2FdJMcaf1I9C5%2F8PoggbMZvcKYNpQiI0C4Wk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2600&quot; height=&quot;1800&quot; data-filename=&quot;official-owasp-storage.png&quot; data-origin-width=&quot;2600&quot; data-origin-height=&quot;1800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 2. OWASP Password Storage Cheat Sheet. 비밀번호는 평문으로 저장하지 말고 Argon2id&amp;middot;bcrypt&amp;middot;PBKDF2 같은 강하고 느린 해싱으로 보호하라고 명시합니다.&amp;nbsp;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권장 순서와 확인일(2026-07-25) 기준 최소 파라미터는 다음과 같습니다.&lt;/p&gt;
&lt;table style=&quot;height: 172px;&quot; width=&quot;861&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;알고리즘&lt;/th&gt;
&lt;th&gt;OWASP 최소 파라미터&lt;/th&gt;
&lt;th&gt;언제&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Argon2id&lt;/b&gt; (1순위)&lt;/td&gt;
&lt;td&gt;메모리 &lt;b&gt;19 MiB&lt;/b&gt; &amp;middot; 반복 &lt;b&gt;2&lt;/b&gt; &amp;middot; 병렬 &lt;b&gt;1&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;새로 만드는 시스템의 기본값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;scrypt&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;N=2^17&lt;/b&gt;(128 MiB) &amp;middot; r=8 &amp;middot; p=1&lt;/td&gt;
&lt;td&gt;Argon2 라이브러리가 없을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;bcrypt&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;work factor(cost) &lt;b&gt;10 이상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;레거시&amp;middot;bcrypt만 가능할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PBKDF2&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;HMAC-SHA256 &lt;b&gt;60만 회&lt;/b&gt;(SHA512는 22만)&lt;/td&gt;
&lt;td&gt;FIPS-140 규제가 필요할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Argon2id&lt;/b&gt;는 &lt;b&gt;메모리 하드&lt;/b&gt; 해시입니다. 쉽게 말하면 계산에 많은 RAM을 쓰게 만들어, 메모리가 부족한 GPU&amp;middot;ASIC의 대량 병렬 대입을 어렵게 합니다(RFC 9106). 새 프로젝트라면 1순위입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;bcrypt&lt;/b&gt;를 쓸 때는 유명한 함정 하나. &lt;b&gt;입력을 72바이트까지만&lt;/b&gt; 보고 그 뒤는 조용히 잘라 냅니다. 긴 비밀번호&amp;middot;패스프레이즈를 그대로 넣으면 뒷부분이 무시되니, 길이 제한을 두거나 이 특성을 알고 써야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PBKDF2&lt;/b&gt;는 반복 횟수로만 비용을 올리는 방식이라, RFC 8018의 옛 기준(1,000회)이 아니라 &lt;b&gt;현재 권장치(SHA-256 기준 60만 회)&lt;/b&gt; 로 맞춰야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-rfc9106-argon2.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cOn1Sw/dJMcabrx6ZM/n4u6WXSMdU343dCvbJepQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cOn1Sw/dJMcabrx6ZM/n4u6WXSMdU343dCvbJepQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cOn1Sw/dJMcabrx6ZM/n4u6WXSMdU343dCvbJepQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcOn1Sw%2FdJMcabrx6ZM%2Fn4u6WXSMdU343dCvbJepQ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;1560&quot; data-filename=&quot;official-rfc9106-argon2.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1560&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 3. RFC 9106(Argon2, 2021). Argon2를 &quot;비밀번호 해싱을 위한 메모리 하드(memory-hard) 함수&quot;로 정의하는 IRTF 규격입니다. 메모리를 많이 쓰게 만들어 GPU&amp;middot;ASIC의 대량 병렬 대입을 어렵게 하며, 비밀번호 저장에는 Argon2id 변형을 씁니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 실무 원칙은 이것입니다. &lt;b&gt;직접 구현하지 말고 언어 내장 API를 쓰세요.&lt;/b&gt; PHP &lt;code&gt;password_hash()&lt;/code&gt;, Python &lt;code&gt;hashlib&lt;/code&gt;(scrypt/pbkdf2)&amp;middot;&lt;code&gt;argon2-cffi&lt;/code&gt;, Node &lt;code&gt;crypto.scrypt&lt;/code&gt; 같은 표준 도구는 salt 자동 생성&amp;middot;파라미터 관리&amp;middot;향후 알고리즘 교체까지 고려돼 있습니다. 아래는 방식만 보여 주는 의사코드입니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;# ❌ 하지 말 것 &amp;mdash; 빠른 해시는 salt를 붙여도 비밀번호엔 부적합
stored = sha256(salt + password)          # GPU로 초당 수십억 회 대입 가능

# ✅ 해야 할 것 &amp;mdash; 느린 적응형 해시 (salt&amp;middot;파라미터는 결과 문자열에 자동 포함)
stored = argon2id_hash(password)
# 예: &quot;$argon2id$v=19$m=19456,t=2,p=1$&amp;lt;salt&amp;gt;$&amp;lt;hash&amp;gt;&quot;

# 로그인 검증 &amp;mdash; 원문 복원이 아니라 재해시 비교 (라이브러리 verify 함수 사용)
ok = argon2id_verify(stored, password)    # PHP password_verify / bcrypt.compare 등&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  어떤 알고리즘을 고를지 (결정 트리)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말로 풀면 아래 순서로 고르면 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;940&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cvxWi9/dJMcabSzGfS/YhUvKlJnZR7NaU6mrg1Zrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cvxWi9/dJMcabSzGfS/YhUvKlJnZR7NaU6mrg1Zrk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cvxWi9/dJMcabSzGfS/YhUvKlJnZR7NaU6mrg1Zrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcvxWi9%2FdJMcabSzGfS%2FYhUvKlJnZR7NaU6mrg1Zrk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;940&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;940&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 4. 새 시스템이면 Argon2id가 기본입니다. 라이브러리 사정&amp;middot;규제&amp;middot;레거시에 따라 scrypt&amp;middot;PBKDF2&amp;middot;bcrypt로 갈라지고, 어느 쪽이든 직접 구현 대신 언어 내장 API를 씁니다.&lt;/i&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;새로 만드는 시스템&lt;/b&gt; &amp;rarr; &lt;b&gt;Argon2id&lt;/b&gt;(19 MiB&amp;middot;t=2&amp;middot;p=1)를 기본값으로.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Argon2 라이브러리를 쓰기 어렵다&lt;/b&gt; &amp;rarr; &lt;b&gt;scrypt&lt;/b&gt;(N=2^17).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;FIPS-140 규제가 필요하다&lt;/b&gt; &amp;rarr; &lt;b&gt;PBKDF2&lt;/b&gt;(HMAC-SHA256 60만 회).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;레거시 시스템이라 bcrypt만 가능&lt;/b&gt; &amp;rarr; &lt;b&gt;bcrypt&lt;/b&gt;(cost 10 이상, 72바이트 한계 유의).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  로그인 검증과 나중에 값 올리기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장했으면 검증입니다. 앞서 봤듯 &lt;b&gt;원문을 되돌리는 게 아니라&lt;/b&gt;, 로그인 때 들어온 비밀번호를 &lt;b&gt;저장된 salt&amp;middot;파라미터로 다시 해시해 비교&lt;/b&gt;합니다. salt와 알고리즘&amp;middot;cost는 저장된 해시 문자열 안에 들어 있어서 따로 보관할 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지만 주의하세요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;라이브러리의 verify 함수를 쓰세요.&lt;/b&gt; 직접 해시해서 문자열을 &lt;code&gt;==&lt;/code&gt;로 비교하지 말고 &lt;code&gt;password_verify&lt;/code&gt;&amp;middot;&lt;code&gt;bcrypt.compare&lt;/code&gt;&amp;middot;&lt;code&gt;argon2.verify&lt;/code&gt;를 씁니다. 굳이 직접 비교해야 하면 &lt;b&gt;상수 시간 비교&lt;/b&gt;(&lt;code&gt;hash_equals&lt;/code&gt;&amp;middot;&lt;code&gt;hmac.compare_digest&lt;/code&gt;&amp;middot;&lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;)를 써서 타이밍 공격을 막습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;work factor는 나중에 올릴 수 있습니다.&lt;/b&gt; 기준을 높였다면, &lt;b&gt;로그인이 성공한 순간&lt;/b&gt; 저장된 해시의 파라미터가 낮은지 확인하고, 방금 들어온 평문으로 &lt;b&gt;새 파라미터로 다시 해시해 덮어씁니다&lt;/b&gt;(lazy 재해시). 단 다시 로그인하지 않는 사용자의 해시는 갱신되지 않으니, 오래된 방식은 별도 폐기 계획이 필요합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  비밀번호 규칙은 어떻게 정하나 (NIST 기준)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, 저장 방식 못지않게 자주 틀리는 것이 &quot;비밀번호 정책&quot;입니다. NIST SP 800-63B의 방향은 예전 상식과 꽤 다릅니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;긴 패스프레이즈를 허용&lt;/b&gt;하세요. 최대 길이를 넉넉히(최소 64자) 두고 공백&amp;middot;유니코드를 받습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;조합 규칙을 강요하지 마세요.&lt;/b&gt; &quot;대문자+숫자+특수문자&quot; 강제는 이제 권장되지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;주기적 강제 변경을 요구하지 마세요.&lt;/b&gt; 유출 정황이 있을 때만 변경을 요구합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유출된 비밀번호 목록과 대조&lt;/b&gt;해, 이미 털린 흔한 비밀번호는 막습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;입력 전체를 검증&lt;/b&gt;하고, 조용히 잘라 내지 않습니다(bcrypt 72바이트 한계를 특히 주의).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 비밀번호 저장 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 회원가입에 비밀번호 저장을 붙일 때 이 순서로 짚으면 됩니다. 필요하면 복사해서 쓰세요.&lt;/p&gt;
&lt;pre class=&quot;less&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;[저장 방식]
- [ ] 평문&amp;middot;양방향 암호화&amp;middot;base64 인코딩으로 저장하고 있지 않나?
- [ ] 단방향 해시로 저장하나?
- [ ] SHA-256/MD5 같은 빠른 해시(+salt 포함)를 쓰고 있지 않나?

[알고리즘&amp;middot;파라미터]
- [ ] 새 시스템이면 Argon2id(19MiB&amp;middot;t=2&amp;middot;p=1)를 기본으로 골랐나?
- [ ] (규제) FIPS면 PBKDF2-HMAC-SHA256 60만 회 이상인가?
- [ ] (레거시) bcrypt면 cost 10+ 이고 72바이트 한계를 처리했나?
- [ ] 직접 구현 대신 언어 내장 API(password_hash 등)를 쓰나?
- [ ] 해시 한 번이 1초 미만이 되도록 파라미터를 튜닝했나?

[salt &amp;middot; pepper]
- [ ] salt는 라이브러리가 자동 생성&amp;middot;저장하게 두었나? (직접 보관 금지)
- [ ] (선택) pepper를 쓴다면 DB 밖(볼트/HSM)에 두었나?

[검증 &amp;middot; 정책]
- [ ] 검증은 라이브러리 verify 함수(또는 상수 시간 비교)를 쓰나?
- [ ] work factor를 올릴 때 로그인 시 lazy 재해시를 넣었나?
- [ ] 조합 규칙 강요&amp;middot;주기적 강제 변경을 빼고, 유출 목록 대조를 넣었나?&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 비밀번호 저장에서 먼저 잡을 두 가지는 이것입니다. 첫째, &lt;b&gt;암호화가 아니라 되돌릴 수 없는 단방향 해시&lt;/b&gt;로 저장합니다. 둘째, &lt;b&gt;SHA-256 같은 빠른 해시가 아니라, salt가 들어간 느린 적응형 해시&lt;/b&gt;(새 시스템이면 Argon2id)를 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 가지와 &quot;직접 구현하지 말고 언어 내장 API를 쓴다&quot;만 지켜도 대부분의 비밀번호 사고를 피할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 &quot;로그인 만들기&quot; 묶음으로, 로그인 상태를 세션에 둘지 토큰에 둘지는 &lt;a href=&quot;https://atoz-developer.tistory.com/184&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;세션 vs JWT 인증&lt;/a&gt;에서, 구글&amp;middot;카카오 같은 외부 계정으로 로그인하는 방법은 &lt;a href=&quot;https://atoz-developer.tistory.com/185&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;소셜 로그인과 OAuth 2.0&lt;/a&gt;에서 다뤘습니다. 로그인 요청이 다른 출처로 오갈 때 나는 &lt;a href=&quot;https://atoz-developer.tistory.com/183&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CORS 에러&lt;/a&gt;도 같은 결의 글입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어 주셔서 감사합니다  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OWASP: Password Storage Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OWASP: Authentication Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pages.nist.gov/800-63-3/sp800-63b.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;NIST SP 800-63B: Digital Identity Guidelines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9106.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 9106: Argon2 Memory-Hard Function&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc7914.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 7914: The scrypt Password-Based Key Derivation Function&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8018.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 8018: PKCS #5 &amp;mdash; Password-Based Cryptography (PBKDF2)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc4648.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 4648: Base16/32/64 Data Encodings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.php.net/manual/en/function.password-hash.php&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;PHP Manual: password_hash()&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프론트엔드</category>
      <category>Argon2</category>
      <category>bcrypt</category>
      <category>OWASP</category>
      <category>Salt</category>
      <category>비밀번호저장</category>
      <category>비밀번호해싱</category>
      <category>웹보안</category>
      <category>인증</category>
      <category>해시함수</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/186</guid>
      <comments>https://atoz-developer.tistory.com/186#entry186comment</comments>
      <pubDate>Tue, 28 Jul 2026 07:16:56 +0900</pubDate>
    </item>
    <item>
      <title>[에러] Name for argument of type [java.lang.String] not specified &amp;mdash; Spring Boot 3.2부터 파라미터 이름이 사라진 이유</title>
      <link>https://atoz-developer.tistory.com/188</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  버전만 올렸는데 컨트롤러가 통째로 죽었습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전만 올렸는데 컨트롤러가 통째로 500을 뱉는 경험, 한 번쯤 하셨을 겁니다. Spring Boot를 3.1에서 3.2 이상으로 올린 직후에 특히 자주 나옵니다. 로그를 열어 보면 이런 문장이 찍혀 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;java.lang.IllegalArgumentException:
Name for argument of type [java.lang.String] not specified,
and parameter name information not available via reflection.
Ensure that the compiler uses the '-parameters' flag.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;황당한 건 컨트롤러 코드에는 파라미터 이름이 멀쩡히 적혀 있다는 점입니다. &lt;code&gt;@RequestParam String keyword&lt;/code&gt;처럼 평범하게 쓴 코드가 갑자기 문제가 됩니다. 그래서 컨트롤러를 아무리 들여다봐도 원인이 안 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론부터 말씀드리면 원인은 코드가 아니라 컴파일 옵션입니다.&lt;/b&gt; 이 글을 다 읽으시면 네 가지가 정리됩니다. 왜 이름이 사라졌는지, Maven&amp;middot;Gradle&amp;middot;Kotlin&amp;middot;IDE에서 각각 어디를 고치는지, 고친 게 진짜 적용됐는지 확인하는 방법, 그리고 전송 방식별로 파라미터를 받는 2026년 기준 정리입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 제가 직접 마이그레이션한 기록이 아니라, &lt;b&gt;Spring 공식 릴리스 노트&amp;middot;레퍼런스 문서와 프레임워크 소스를 기준으로&lt;/b&gt; 정리한 내용입니다. 확인일은 2026년 7월 28일이고, 근거 링크는 글 마지막에 모아 두었습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  이름이 사라진 진짜 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 메시지가 이미 &lt;code&gt;-parameters&lt;/code&gt; 플래그를 쓰라고 알려 주긴 합니다. 문제는 그 플래그를 &lt;b&gt;어디에&lt;/b&gt; 넣어야 하는지, 그리고 &lt;b&gt;왜 어제까지는 없어도 됐는지&lt;/b&gt;를 안 알려 준다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바 컴파일러는 파라미터 이름을 &lt;b&gt;리플렉션이 읽을 수 있는 자리에 기본적으로 남기지 않습니다.&lt;/b&gt; 디버깅용 정보에는 이름이 남지만, 그건 표준 리플렉션 API가 들여다보는 자리가 아닙니다. 그래서 옵션 없이 컴파일하면 실행 중에는 &lt;code&gt;arg0&lt;/code&gt;, &lt;code&gt;arg1&lt;/code&gt;처럼만 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 Spring은 파라미터 이름이 필요합니다. &lt;code&gt;@RequestParam String keyword&lt;/code&gt;에서 괄호 안에 이름을 안 적으면, 변수명 &lt;code&gt;keyword&lt;/code&gt;를 쿼리스트링 키로 써야 하니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 예전 Spring은 이름을 알아내려고 &lt;b&gt;클래스 파일의 디버그 정보를 직접 뜯어봤습니다.&lt;/b&gt; 그 역할을 하던 게 &lt;code&gt;LocalVariableTableParameterNameDiscoverer&lt;/code&gt;라는 클래스입니다. 쉽게 말하면, 리플렉션이 못 보는 디버그 영역까지 들어가 원래 변수명을 주워 오던 장치입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 장치가 &lt;b&gt;Spring Framework 6.1에서 제거됐습니다.&lt;/b&gt; 릴리스 노트의 표현은 이렇습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LocalVariableTableParameterNameDiscoverer&lt;/code&gt; has been removed in 6.1. Consequently, code within the Spring Framework and Spring portfolio frameworks no longer attempts to deduce parameter names by parsing bytecode.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Framework 6.1을 쓰는 버전이 바로 &lt;b&gt;Spring Boot 3.2&lt;/b&gt;입니다. 그래서 3.2로 올리는 순간부터 이 에러가 터지기 시작합니다. Spring Boot 3.2 릴리스 노트도 같은 이야기를 합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The version of Spring Framework used by Spring Boot 3.2 no longer attempts to deduce parameter names by parsing bytecode. If you experience issues with dependency injection or property binding, you should double check that you are compiling with the &lt;code&gt;-parameters&lt;/code&gt; option.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-boot32-notes.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1O7ai/dJMcafU4y0Y/lM1uRcnaYWH5krzOKujz2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1O7ai/dJMcafU4y0Y/lM1uRcnaYWH5krzOKujz2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1O7ai/dJMcafU4y0Y/lM1uRcnaYWH5krzOKujz2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1O7ai%2FdJMcafU4y0Y%2FlM1uRcnaYWH5krzOKujz2K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;1240&quot; data-filename=&quot;official-boot32-notes.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1240&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Spring은 자바 표준 리플렉션으로만 이름을 읽습니다. 그리고 자바 표준 리플렉션이 파라미터 이름을 읽으려면, &lt;b&gt;컴파일할 때 &lt;code&gt;-parameters&lt;/code&gt; 옵션이 켜져 있어야 합니다.&lt;/b&gt; 이 옵션은 자바 8부터 있던 기능인데, 기본값이 꺼짐이라 그동안 신경 쓸 일이 없었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-name-lookup.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;974&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3H4wu/dJMcaiEeusJ/7T7OUR1VUDnjg5Y1VkXvq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3H4wu/dJMcaiEeusJ/7T7OUR1VUDnjg5Y1VkXvq0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3H4wu/dJMcaiEeusJ/7T7OUR1VUDnjg5Y1VkXvq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb3H4wu%2FdJMcaiEeusJ%2F7T7OUR1VUDnjg5Y1VkXvq0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1980&quot; height=&quot;974&quot; data-filename=&quot;diagram-1-name-lookup.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;974&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇습니다. 예전에는 우회로가 있어서 옵션을 안 켜도 굴러갔고, 지금은 그 우회로가 없어졌습니다. 그래서 &lt;b&gt;원래부터 켰어야 할 옵션이 안 켜져 있었다는 사실이 이제야 드러난 것&lt;/b&gt;에 가깝습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚠️ 이 에러가 컨트롤러에서만 나는 게 아닙니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지가 &lt;code&gt;@RequestParam&lt;/code&gt;에서 처음 터지다 보니 웹 문제로 착각하기 쉽습니다. 하지만 파라미터 이름을 쓰는 곳은 더 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Framework 6.1 릴리스 노트는 영향 범위를 의존성 주입, 프로퍼티 바인딩, SpEL 등으로 안내합니다. 여기서 SpEL은 Spring이 쓰는 표현식 문법으로, 애너테이션 안에 &lt;code&gt;#파라미터명&lt;/code&gt; 같은 식을 적을 때 쓰입니다. 실제로 아래 자리들이 같이 깨질 수 있습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;깨지는 자리&lt;/th&gt;
&lt;th&gt;증상&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@RequestParam&lt;/code&gt;, &lt;code&gt;@PathVariable&lt;/code&gt;, &lt;code&gt;@RequestHeader&lt;/code&gt;, &lt;code&gt;@CookieValue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;요청 시 &lt;code&gt;IllegalArgumentException&lt;/code&gt;, 응답 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;같은 타입 빈이 여러 개라 이름으로 구분하던 주입&lt;/td&gt;
&lt;td&gt;후보를 못 좁혀 컨텍스트 로딩 실패&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@ConfigurationProperties&lt;/code&gt; 생성자 바인딩&lt;/td&gt;
&lt;td&gt;프로퍼티 값이 전부 null&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpEL에서 파라미터 이름을 참조하는 표현식&lt;/td&gt;
&lt;td&gt;표현식 평가 실패&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@Cacheable&lt;/code&gt;처럼 파라미터 이름으로 키를 만드는 자리&lt;/td&gt;
&lt;td&gt;캐시 키 생성 실패&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 컨트롤러 한 곳만 고치는 방식으로는 끝나지 않습니다. &lt;b&gt;빌드 전체에 옵션을 켜는 게 정답입니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ Maven&amp;middot;Gradle&amp;middot;Kotlin&amp;middot;IDE에서 고치는 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Maven을 쓰는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 흔한 원인은 &lt;code&gt;spring-boot-starter-parent&lt;/code&gt;를 &lt;b&gt;상속하지 않은 프로젝트&lt;/b&gt;입니다. Spring Boot Maven 플러그인 문서를 보면, 부모 POM이 제공하는 기본값 목록에 &quot;Compilation with &lt;code&gt;-parameters&lt;/code&gt;&quot;가 들어 있습니다. 즉 부모를 상속했다면 이미 켜져 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부모 대신 BOM만 &lt;code&gt;dependencyManagement&lt;/code&gt;로 가져다 쓰는 구조라면 직접 켜야 합니다. 아래를 &lt;code&gt;pom.xml&lt;/code&gt;에 넣으시면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;build&amp;gt;
  &amp;lt;plugins&amp;gt;
    &amp;lt;plugin&amp;gt;
      &amp;lt;groupId&amp;gt;org.apache.maven.plugins&amp;lt;/groupId&amp;gt;
      &amp;lt;artifactId&amp;gt;maven-compiler-plugin&amp;lt;/artifactId&amp;gt;
      &amp;lt;configuration&amp;gt;
        &amp;lt;parameters&amp;gt;true&amp;lt;/parameters&amp;gt;
      &amp;lt;/configuration&amp;gt;
    &amp;lt;/plugin&amp;gt;
  &amp;lt;/plugins&amp;gt;
&amp;lt;/build&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;maven-compiler-plugin&lt;/code&gt; 설정을 이미 직접 잡아 둔 프로젝트도 위험합니다. 부모의 설정을 덮어쓰면서 &lt;code&gt;&amp;lt;parameters&amp;gt;&lt;/code&gt;만 빠지는 일이 자주 생깁니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Gradle을 쓰는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot Gradle 플러그인 문서에는 이런 문장이 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Configures any &lt;code&gt;JavaCompile&lt;/code&gt; tasks to use the &lt;code&gt;-parameters&lt;/code&gt; compiler argument.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 플러그인과 &lt;code&gt;java&lt;/code&gt; 플러그인을 같이 쓰고 있다면 이미 켜져 있다는 뜻입니다. 문제는 Spring Boot 플러그인을 안 붙인 모듈입니다. 멀티모듈 프로젝트에서 도메인 모듈이나 공통 모듈에만 에러가 나는 경우가 여기에 해당합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런 모듈에는 아래를 넣습니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;tasks.withType(JavaCompile).configureEach {
    options.compilerArgs.add('-parameters')
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin DSL(&lt;code&gt;build.gradle.kts&lt;/code&gt;)이라면 이렇게 씁니다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;tasks.withType&amp;lt;JavaCompile&amp;gt;().configureEach {
    options.compilerArgs.add(&quot;-parameters&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Groovy 소스가 섞여 있으면 &lt;code&gt;GroovyCompile&lt;/code&gt; 쪽에도 &lt;code&gt;parameters&lt;/code&gt; 옵션을 켜야 한다고 릴리스 노트가 안내합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kotlin으로 쓴 프로젝트라면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin은 자바가 아니라 Kotlin 컴파일러가 클래스를 만듭니다. 그래서 &lt;code&gt;-parameters&lt;/code&gt;가 아니라 &lt;b&gt;Kotlin 쪽 옵션인 &lt;code&gt;-java-parameters&lt;/code&gt;&lt;/b&gt; 를 켜야 합니다. 릴리스 노트에도 &quot;With the Kotlin compiler, use the &lt;code&gt;-java-parameters&lt;/code&gt; flag.&quot;라고 못 박혀 있습니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;kotlin {
    compilerOptions {
        freeCompilerArgs.add(&quot;-java-parameters&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바와 Kotlin을 같이 쓰는 프로젝트라면 &lt;b&gt;두 옵션을 모두&lt;/b&gt; 켜야 합니다. 한쪽만 켜면 그쪽 소스만 멀쩡하고 나머지에서 같은 에러가 계속 납니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;IDE로 직접 빌드하는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 많이 헤맵니다. 빌드 스크립트는 멀쩡한데 IDE에서 실행하면 계속 에러가 나는 상황입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IDE가 Gradle이나 Maven에 빌드를 위임하지 않고 &lt;b&gt;자체 컴파일러로 클래스를 만들면&lt;/b&gt;, 빌드 스크립트의 옵션이 적용되지 않습니다. Spring Framework 6.1 릴리스 노트도 IntelliJ IDEA&amp;middot;Eclipse&amp;middot;VSCode에서 별도 설정이 필요할 수 있다고 명시합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 확실한 해결은 &lt;b&gt;빌드를 IDE가 아니라 Gradle/Maven에 위임하도록 바꾸는 것&lt;/b&gt;입니다. 그게 어렵다면 IDE의 자바 컴파일러 설정에 &lt;code&gt;-parameters&lt;/code&gt;를 추가하면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 진짜 적용됐는지 확인하는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션을 넣고 나면 &quot;이게 실제로 먹었나&quot;가 궁금해집니다. 빌드가 성공했다고 적용된 게 아니라서, 클래스 파일을 직접 확인하는 편이 빠릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방법 1. 클래스 파일을 열어 봅니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;javap -v build/classes/java/main/com/example/MyController.class | grep -A5 MethodParameters&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;MethodParameters&lt;/code&gt; 항목이 보이고 그 아래에 &lt;code&gt;keyword&lt;/code&gt; 같은 실제 이름이 있으면 성공입니다. 아무것도 안 나오면 옵션이 안 먹은 겁니다. Maven이라면 경로를 &lt;code&gt;target/classes/...&lt;/code&gt;로 바꾸시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방법 2. 애플리케이션 안에서 코드로 확인합니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;import java.lang.reflect.Method;
import java.lang.reflect.Parameter;

public class ParameterNameCheck {
    public static void main(String[] args) throws Exception {
        Method m = MyController.class.getDeclaredMethod(&quot;search&quot;, String.class);
        for (Parameter p : m.getParameters()) {
            System.out.println(p.getName() + &quot; / isNamePresent=&quot; + p.isNamePresent());
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;isNamePresent=true&lt;/code&gt;이고 이름이 &lt;code&gt;arg0&lt;/code&gt;이 아니라 실제 변수명으로 찍히면 정상입니다. &lt;code&gt;arg0 / isNamePresent=false&lt;/code&gt;가 나오면 아직 안 켜진 상태입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  고쳤는데도 같은 에러가 계속 난다면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옵션을 넣었는데도 그대로인 경우가 있습니다. 확인 순서를 정해 두면 시간을 아낄 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫째, 이전 빌드 결과가 남아 있는 경우입니다.&lt;/b&gt; 옵션을 바꿔도 이미 컴파일된 클래스는 다시 만들어지지 않을 수 있습니다. &lt;code&gt;./gradlew clean build&lt;/code&gt; 또는 &lt;code&gt;mvn clean package&lt;/code&gt;로 전체를 다시 만들어 보시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;둘째, 문제가 내 모듈이 아니라 의존하는 라이브러리인 경우입니다.&lt;/b&gt; 외부 JAR이 &lt;code&gt;-parameters&lt;/code&gt; 없이 빌드됐다면 내 빌드 설정으로는 해결되지 않습니다. 이럴 때는 해당 지점에 &lt;b&gt;이름을 명시적으로 적는 게 유일한 방법&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 이름을 직접 적으면 컴파일 옵션과 무관하게 동작합니다
@GetMapping(&quot;/search&quot;)
public List&amp;lt;Item&amp;gt; search(@RequestParam(&quot;keyword&quot;) String keyword) { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;셋째, 멀티모듈에서 일부 모듈만 빠진 경우입니다.&lt;/b&gt; 루트에만 설정을 넣고 하위 모듈에 적용이 안 된 구성이 흔합니다. 에러가 나는 클래스가 어느 모듈 소속인지부터 확인하시는 게 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;넷째, Java Agent가 이름을 지우고 있는 경우입니다.&lt;/b&gt; 이건 잘 안 알려진 경우인데, 릴리스 노트에 따로 주의가 적혀 있습니다. APM이나 모니터링 도구처럼 실행 시점에 바이트코드를 건드리는 에이전트를 붙이면, &lt;b&gt;런타임에 파라미터 이름 정보가 사라질 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Note that if you are using Java Agents in your deployment, such agents can modify the bytecode and lose the &quot;parameters&quot; information at runtime. This has been fixed in JDK 19 and might get backported to oder JDK versions.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 빌드는 멀쩡한데 운영 환경에서만 터지는 상황이 생길 수 있습니다. 로컬에서 재현이 안 되고 서버에서만 500이 난다면 이쪽을 의심해 보시면 됩니다. 릴리스 노트는 이 문제를 겪는다면 최신 JDK로 올리는 걸 권합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-sf61-notes.png&quot; data-origin-width=&quot;801&quot; data-origin-height=&quot;462&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmiOnV/dJMcacKP8rR/fURz5wRjfil0v9rI0C4mjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmiOnV/dJMcacKP8rR/fURz5wRjfil0v9rI0C4mjK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmiOnV/dJMcacKP8rR/fURz5wRjfil0v9rI0C4mjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmiOnV%2FdJMcacKP8rR%2FfURz5wRjfil0v9rI0C4mjK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;801&quot; height=&quot;462&quot; data-filename=&quot;official-sf61-notes.png&quot; data-origin-width=&quot;801&quot; data-origin-height=&quot;462&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다섯째, 메시지 문구가 조금 다른 경우입니다.&lt;/b&gt; Spring Boot 3.2.0 초기에 올라온 이슈들에는 아래 문구도 보입니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Name for argument of type [java.util.UUID] not specified,
and parameter name information not found in class file either.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문구는 달라도 원인은 같습니다. 현재 프레임워크 소스에 들어 있는 문구는 &lt;code&gt;... not available via reflection. Ensure that the compiler uses the '-parameters' flag.&lt;/code&gt; 쪽입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  겸사겸사 정리하는 전송 방식별 파라미터 받기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 에러를 고치다 보면 &quot;그래서 어떤 상황에 뭘 써야 하지&quot;가 다시 헷갈립니다. 2021년에 정리했던 &lt;a href=&quot;https://atoz-developer.tistory.com/111&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;전송방식에 따른 Parameter 받는 방법&lt;/a&gt; 글을 2026년 기준으로 다시 세워 봤습니다.&lt;/p&gt;
&lt;table style=&quot;height: 217px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;클라이언트가 보내는 방식&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;Content-Type&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;받는 방법&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 40px;&quot;&gt;
&lt;td style=&quot;height: 40px;&quot;&gt;쿼리스트링 (&lt;code&gt;/search?keyword=강남&lt;/code&gt;)&lt;/td&gt;
&lt;td style=&quot;height: 40px;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;height: 40px;&quot;&gt;&lt;code&gt;@RequestParam String keyword&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 39px;&quot;&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;경로에 값이 박힌 형태 (&lt;code&gt;/items/42&lt;/code&gt;)&lt;/td&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;&lt;code&gt;@PathVariable Long id&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 39px;&quot;&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;HTML 폼 전송 (POST)&lt;/td&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;&lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;&lt;code&gt;@RequestParam&lt;/code&gt; 또는 &lt;code&gt;@ModelAttribute ItemForm form&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;JSON 본문&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;code&gt;application/json&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;code&gt;@RequestBody ItemRequest req&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 39px;&quot;&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;파일 업로드&lt;/td&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;&lt;code&gt;multipart/form-data&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 39px;&quot;&gt;&lt;code&gt;@RequestParam MultipartFile file&lt;/code&gt; 또는 &lt;code&gt;@RequestPart&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;헤더 값&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;해당 없음&lt;/td&gt;
&lt;td style=&quot;height: 20px;&quot;&gt;&lt;code&gt;@RequestHeader(&quot;X-Api-Key&quot;) String key&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8aWw3/dJMcaa0s5FD/3A3MlGnS3fzgvP6DOGGjv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8aWw3/dJMcaa0s5FD/3A3MlGnS3fzgvP6DOGGjv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8aWw3/dJMcaa0s5FD/3A3MlGnS3fzgvP6DOGGjv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8aWw3%2FdJMcaa0s5FD%2F3A3MlGnS3fzgvP6DOGGjv0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1980&quot; height=&quot;1336&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1336&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몇 가지 헷갈리기 쉬운 지점만 덧붙입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;애너테이션을 안 붙이면 타입으로 결정됩니다.&lt;/b&gt; Spring MVC 레퍼런스는 이렇게 설명합니다. 문자열이나 숫자 같은 단순 타입이면 &lt;code&gt;@RequestParam&lt;/code&gt;으로, 그 밖에는 &lt;code&gt;@ModelAttribute&lt;/code&gt;로 해석합니다. 그래서 애너테이션을 생략한 코드도 파라미터 이름에 의존하게 되고, 이번 에러의 사정권에 들어옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;@RequestBody&lt;/code&gt;와 &lt;code&gt;@ModelAttribute&lt;/code&gt;는 목적이 다릅니다.&lt;/b&gt; JSON 본문은 메시지 컨버터가 객체로 바꿔 주고, 폼 데이터는 데이터 바인딩으로 채워집니다. JSON을 보내면서 &lt;code&gt;@ModelAttribute&lt;/code&gt;를 쓰면 값이 안 들어옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PUT&amp;middot;PATCH로 폼 데이터를 보내면 값이 비어 있을 수 있습니다.&lt;/b&gt; 이건 Spring이 아니라 Servlet 규격 문제입니다. Spring 레퍼런스의 설명은 이렇습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The Servlet API requires &lt;code&gt;ServletRequest.getParameter*()&lt;/code&gt; methods to support form field access only for HTTP POST.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Spring은 &lt;code&gt;FormContentFilter&lt;/code&gt;로 PUT&amp;middot;PATCH&amp;middot;DELETE의 &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; 요청을 감싸 줍니다. Spring Boot에서는 &lt;code&gt;spring.mvc.formcontent.filter.enabled&lt;/code&gt;가 기본값 &lt;code&gt;true&lt;/code&gt;라 자동으로 켜져 있습니다. 이 값을 꺼 둔 프로젝트에서 PUT 폼 전송이 안 되는 사례가 여기서 나옵니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  업그레이드 전에 훑어 볼 체크리스트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;☐ 빌드 도구에 &lt;code&gt;-parameters&lt;/code&gt;가 켜져 있는가 (Maven &lt;code&gt;&amp;lt;parameters&amp;gt;true&amp;lt;/parameters&amp;gt;&lt;/code&gt; / Gradle &lt;code&gt;options.compilerArgs&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;☐ &lt;code&gt;spring-boot-starter-parent&lt;/code&gt;를 안 쓰는 프로젝트인가&lt;/li&gt;
&lt;li&gt;☐ &lt;code&gt;maven-compiler-plugin&lt;/code&gt; 설정을 직접 덮어쓰면서 &lt;code&gt;&amp;lt;parameters&amp;gt;&lt;/code&gt;를 빠뜨리지 않았는가&lt;/li&gt;
&lt;li&gt;☐ 멀티모듈이라면 &lt;b&gt;모든 모듈&lt;/b&gt;에 적용됐는가&lt;/li&gt;
&lt;li&gt;☐ Kotlin 소스가 있다면 &lt;code&gt;-java-parameters&lt;/code&gt;도 같이 켰는가&lt;/li&gt;
&lt;li&gt;☐ IDE가 자체 컴파일러로 빌드하고 있지는 않은가&lt;/li&gt;
&lt;li&gt;☐ &lt;code&gt;javap -v&lt;/code&gt;에 &lt;code&gt;MethodParameters&lt;/code&gt;가 보이는가&lt;/li&gt;
&lt;li&gt;☐ &lt;code&gt;@ConfigurationProperties&lt;/code&gt; 생성자 바인딩 값이 null로 안 들어오는가&lt;/li&gt;
&lt;li&gt;☐ 외부 라이브러리에서 나는 에러라면 이름을 명시적으로 적었는가&lt;/li&gt;
&lt;li&gt;☐ 로컬은 되는데 서버만 실패한다면 Java Agent와 JDK 버전을 확인했는가&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  이런 경우에 이 방법을 쓰시면 됩니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;바로 &lt;code&gt;-parameters&lt;/code&gt;를 켜시면 되는 경우&lt;/b&gt;는 내가 빌드를 통제할 수 있는 프로젝트입니다. 설정 두세 줄이면 끝나고, 애너테이션을 전부 손볼 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이름을 하나씩 명시하는 편이 나은 경우&lt;/b&gt;도 있습니다. 빌드 설정을 바꾸기 어려운 조직이거나, 문제 지점이 외부 라이브러리에 있는 경우입니다. 다만 이건 그 지점만 막는 방식이라, 나중에 다른 곳에서 또 터질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;버전을 당분간 올리지 않기로 한 팀&lt;/b&gt;이라면 이 에러 자체를 만나지 않습니다. 다만 그건 문제를 해결한 게 아니라 미룬 것입니다. 2026년 7월 기준으로 Spring Boot 3.5의 오픈소스 지원은 2026년 6월 30일에 끝났고, 최신 GA는 2026년 6월 10일에 나온 4.1입니다. 상용 연장 지원을 따로 받는 조직이 아니라면, 언젠가는 넘어와야 하는 구간입니다. 그때 막히는 지점이 결국 컴파일 옵션 한 줄이라는 점만 기억해 두시면 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;➡️ 다음 글에서 이어서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파라미터를 제대로 받게 만들었다면, 그다음 관문은 &lt;b&gt;에러가 났을 때 무엇을 내려보낼 것인가&lt;/b&gt;입니다. 예전에 정리했던 &lt;a href=&quot;https://atoz-developer.tistory.com/121&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP Status Code 제어&lt;/a&gt; 글도 지금은 손볼 데가 생겼습니다. Spring Boot 3부터는 &lt;code&gt;ProblemDetail&lt;/code&gt;이라는 표준 에러 응답 형식이 프레임워크에 들어와 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 그 &lt;code&gt;ProblemDetail&lt;/code&gt;로 상태 코드와 에러 본문을 함께 설계하는 방법을 정리하겠습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  참고 자료&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-6.1-Release-Notes&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Framework 6.1 Release Notes &amp;mdash; Parameter Name Retention&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2-Release-Notes&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Boot 3.2 Release Notes &amp;mdash; Parameter Name Discovery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/arguments.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring MVC 레퍼런스 &amp;mdash; Method Arguments&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/reference/web/webmvc/filters.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Framework 레퍼런스 &amp;mdash; Filters (FormContentFilter)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-boot/gradle-plugin/reacting.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Boot Gradle Plugin &amp;mdash; Reacting to Other Plugins&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-boot/maven-plugin/using.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Boot Maven Plugin &amp;mdash; Using the Plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://endoflife.date/spring-boot&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Spring Boot 릴리스&amp;middot;지원 종료 일정 (endoflife.date)&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>백엔드</category>
      <category>java</category>
      <category>Kotlin</category>
      <category>requestparam</category>
      <category>spring boot</category>
      <category>Spring Boot 3.2</category>
      <category>Spring Framework</category>
      <category>백엔드</category>
      <category>에러 해결</category>
      <category>파라미터 바인딩</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/188</guid>
      <comments>https://atoz-developer.tistory.com/188#entry188comment</comments>
      <pubDate>Tue, 28 Jul 2026 00:05:05 +0900</pubDate>
    </item>
    <item>
      <title>소셜 로그인, OAuth 2.0만으로 붙이면 안 되는 이유 &amp;mdash; 인증과 인가부터 구분하기</title>
      <link>https://atoz-developer.tistory.com/185</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구글&amp;middot;카카오&amp;middot;네이버 로그인을 처음 붙일 때, 검색해 보면 방법이 두 갈래로 갈립니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;access token 받아서 프로필 API 부르면 로그인 끝나요.&quot;&lt;/li&gt;
&lt;li&gt;&quot;그렇게 하면 안 돼요. PKCE 쓰고 OIDC의 id_token을 검증해야 해요.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 &lt;code&gt;response_type=code&lt;/code&gt;, &lt;code&gt;code_verifier&lt;/code&gt;, &lt;code&gt;nonce&lt;/code&gt; 같은 파라미터가 쏟아지면, 도대체 뭘 기준으로 붙여야 안전한지 막힙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, &lt;b&gt;OAuth 2.0은 &quot;로그인(인증) 프로토콜&quot;이 아닙니다.&lt;/b&gt; OAuth는 &quot;이 앱이 당신 자원에 접근해도 될까요?&quot;를 다루는 &lt;b&gt;인가(authorization)&lt;/b&gt; 규격이고, &quot;이 사람이 누구인가?&quot;를 다루는 &lt;b&gt;인증(authentication)&lt;/b&gt; 은 그 위에 얹는 &lt;b&gt;OpenID Connect(OIDC)&lt;/b&gt; 의 몫입니다. 이 둘을 섞어서 이해하니까, access token으로 로그인을 판단하는 위험한 코드가 나옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 지난 &lt;a href=&quot;https://atoz-developer.tistory.com/184&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;세션 vs JWT 인증, 뭘 쓰고 토큰은 어디에 저장하나&lt;/a&gt; 끝에서 예고했던 후속입니다. 소셜 로그인 흐름이 실제로 어떻게 도는지, 그리고 무엇을 고르면 되는지 기준을 잡아 드립니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  오늘 정리하는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 다 읽으면 아래를 스스로 판단할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OAuth 2.0(인가)과 OpenID Connect(인증)의 차이를 &lt;b&gt;한 문장으로&lt;/b&gt; 설명할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&quot;access token을 디코드해서 로그인&quot;이 &lt;b&gt;왜 위험한지&lt;/b&gt; 말할 수 있습니다.&lt;/li&gt;
&lt;li&gt;내 서비스(서버 웹앱&amp;middot;SPA&amp;middot;모바일 앱)에 &lt;b&gt;어떤 플로우가 맞는지&lt;/b&gt; 조건으로 고를 수 있습니다.&lt;/li&gt;
&lt;li&gt;구글&amp;middot;카카오&amp;middot;네이버가 실제로 &lt;b&gt;무엇이 다른지&lt;/b&gt;(누가 id_token을 주는지) 구분할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 라이브러리 설정법이 아니라, &lt;b&gt;어떤 방식을 왜 고르는지&lt;/b&gt; 판단하는 기준을 손에 넣는 것이 목적입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  근거부터 짚고 갑니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 말씀드리면, 이 글은 제 특정 서비스의 운영 경험담이 아니라 &lt;b&gt;표준 규격과 공식 문서에서 확인해 정리한 것&lt;/b&gt;입니다. 근거로 삼은 자료는 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RFC 6749 / 6750&lt;/b&gt;(OAuth 2.0 &amp;middot; Bearer 토큰) &amp;mdash; 인가 프레임워크의 뼈대&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 7636&lt;/b&gt;(PKCE) &amp;mdash; 인가 코드 가로채기 방어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 8252&lt;/b&gt;(네이티브 앱용 OAuth) &amp;mdash; 모바일 앱의 로그인 방식&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 9700&lt;/b&gt;(OAuth 2.0 보안 Best Current Practice, 2025) &amp;mdash; implicit 폐기&amp;middot;PKCE 권고&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OpenID Connect Core 1.0&lt;/b&gt; &amp;mdash; 인증 계층과 ID Token&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Google Identity &amp;middot; Kakao Developers &amp;middot; Naver Developers&lt;/b&gt; &amp;mdash; 각 제공자 공식 문서&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념과 판단 기준은 표준이라 잘 바뀌지 않지만, 제공자별 세부 동작과 엔드포인트는 &lt;b&gt;확인일(2026년 7월 24일 기준)&lt;/b&gt; 이며, 실제 적용 전에는 본인이 붙이는 제공자의 현재 공식 문서로 확인하시는 편이 안전합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  OAuth는 &quot;로그인 프로토콜&quot;이 아닙니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 풀어야 할 오해가 이것입니다. 많은 소셜 로그인 튜토리얼이 이렇게 끝납니다. &quot;access token을 받아서 그걸로 프로필 API를 부르고, 응답에 온 사용자 ID로 로그인 처리하세요.&quot; 그런데 이건 &lt;b&gt;인증을 인가 도구로 대신한&lt;/b&gt; 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;용어부터 나눠 보겠습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;인가(authorization)&lt;/b&gt; &amp;mdash; &quot;이 앱이 사용자의 무엇에(예: 이메일, 캘린더) 접근해도 되는가&quot;의 위임입니다. OAuth 2.0이 다루는 영역이고, 그 결과물이 &lt;b&gt;access token&lt;/b&gt;입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인증(authentication)&lt;/b&gt; &amp;mdash; &quot;지금 로그인한 이 사람이 누구인가&quot;의 확인입니다. OAuth 2.0에는 이걸 위한 규격이 &lt;b&gt;없습니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RFC 6749는 OAuth 2.0을 자원에 대한 &lt;b&gt;제한적&amp;middot;위임된 접근&lt;/b&gt;을 위한 프레임워크로 정의하고, 클라이언트에게 사용자의 신원을 알려 주는 방법은 정의하지 않습니다. 즉 &lt;b&gt;OAuth는 인가만 다룹니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 access token의 성격입니다. access token은 &lt;b&gt;클라이언트 입장에서는 속을 알 수 없는(opaque) 값&lt;/b&gt;이고, 애초에 클라이언트가 받아 열어 보라고 만든 게 아닙니다. RFC 6749는 access token의 대상(audience)이 클라이언트가 아니라고 보며, 클라이언트가 그 내용을 해석하지 말아야 한다고 봅니다. 게다가 access token은 &lt;b&gt;bearer 토큰&lt;/b&gt;이라, 쉽게 말하면 &lt;b&gt;가진 사람이면 누구나 쓸 수 있는 &quot;소지자용&quot; 값&lt;/b&gt;이라(RFC 6750) 그 자체로는 &lt;b&gt;누가 제시했는지(신원)를 증명하지 못합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &quot;access token으로 로그인&quot;은 실제 공격으로 이어집니다. OpenID Connect 규격이 &lt;b&gt;토큰 치환(token substitution)&lt;/b&gt; 이라 부르는 시나리오인데, 쉽게 말하면 &lt;b&gt;다른(악의적인) 앱을 위해 발급된 access token을 슬쩍 끼워 넣으면, 순진한 클라이언트가 엉뚱한 사용자로 로그인시켜 버리는&lt;/b&gt; 문제입니다. access token에는 &quot;이 토큰이 어느 앱을 위한 것인지&quot;를 클라이언트가 검증할 장치가 없기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, &lt;b&gt;로그인은 access token이 아니라 뒤에서 볼 OIDC의 ID Token으로 판단해야 합니다.&lt;/b&gt; 이 한 가지만 잡아도 소셜 로그인 사고의 큰 축을 피합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  인가 코드 흐름은 왜 이렇게 도나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 표준 소셜 로그인은 어떻게 도는지 보겠습니다. 웹 앱의 기본은 &lt;b&gt;Authorization Code(인가 코드) 방식&lt;/b&gt;입니다. 등장인물은 넷입니다(RFC 6749).&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;자원 소유자&lt;/b&gt; &amp;mdash; 로그인하는 사용자 본인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;클라이언트&lt;/b&gt; &amp;mdash; 우리가 만드는 앱(서버 또는 프런트)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인가 서버&lt;/b&gt; &amp;mdash; 구글&amp;middot;카카오처럼 로그인을 처리하고 토큰을 발급하는 쪽&lt;/li&gt;
&lt;li&gt;&lt;b&gt;자원 서버&lt;/b&gt; &amp;mdash; access token으로 접근하는 API(예: 프로필 API)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 핵심은 &lt;b&gt;인가 코드(authorization code)&lt;/b&gt; 라는 중간 표입니다. 이건 토큰이 아니라, &lt;b&gt;아주 짧게 살고(최대 10분 권장) 한 번만 쓸 수 있는&lt;/b&gt;(RFC 6749) 일회용 교환권입니다. 왜 굳이 코드를 한 단계 끼울까요? 그래야 &lt;b&gt;진짜 토큰이 브라우저를 거치지 않기&lt;/b&gt; 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-flow.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cRfu5K/dJMcaazrpxQ/FVf5xuL5mK9mkzRaudZmk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cRfu5K/dJMcaazrpxQ/FVf5xuL5mK9mkzRaudZmk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cRfu5K/dJMcaazrpxQ/FVf5xuL5mK9mkzRaudZmk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcRfu5K%2FdJMcaazrpxQ%2FFVf5xuL5mK9mkzRaudZmk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1040&quot; data-filename=&quot;diagram-1-flow.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1040&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 1. 소셜 로그인의 표준 흐름. 브라우저(front channel)로는 code_challenge를 담은 인가 요청과, 돌아오는 짧은 인가 코드만 오갑니다. 실제 토큰 교환은 서버끼리(back channel)에서 code_verifier와 함께 이뤄지고, 이때 인가 서버가 인증용 ID Token과 인가용 access token을 함께 내줍니다. 그래서 access&amp;middot;ID 토큰은 브라우저에 노출되지 않습니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름을 말로 풀면 이렇습니다. 앞부분은 &lt;b&gt;브라우저를 통하는 front channel&lt;/b&gt;, 뒷부분은 &lt;b&gt;서버끼리 직접 통하는 back channel&lt;/b&gt;로 나뉩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;① 인가 요청 (front channel)&lt;/b&gt; &amp;mdash; 앱이 사용자를 인가 서버 로그인 화면으로 보냅니다. 이때 &lt;code&gt;response_type=code&lt;/code&gt;, &lt;code&gt;client_id&lt;/code&gt;, &lt;code&gt;redirect_uri&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, 그리고 뒤에 볼 &lt;code&gt;code_challenge&lt;/code&gt;를 함께 보냅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;② 로그인&amp;middot;동의&lt;/b&gt; &amp;mdash; 사용자가 인가 서버(구글&amp;middot;카카오)에서 로그인하고 권한에 동의합니다. 비밀번호는 &lt;b&gt;우리 앱이 아니라 인가 서버에만&lt;/b&gt; 들어갑니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;③ 인가 코드 반환 (front channel)&lt;/b&gt; &amp;mdash; 인가 서버가 &lt;code&gt;redirect_uri&lt;/code&gt;로 &lt;b&gt;짧은 인가 코드&lt;/b&gt;만 돌려줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;④ 토큰 교환 (back channel)&lt;/b&gt; &amp;mdash; 앱 서버가 그 코드와 &lt;code&gt;code_verifier&lt;/code&gt;를 인가 서버의 토큰 엔드포인트로 보냅니다. 이 통신은 브라우저를 안 거칩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;⑤ 토큰 발급&lt;/b&gt; &amp;mdash; 인가 서버가 &lt;b&gt;access token&lt;/b&gt;(인가용)과, OIDC라면 &lt;b&gt;ID Token&lt;/b&gt;(인증용)을 함께 내줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;⑥ 검증&lt;/b&gt; &amp;mdash; 앱이 ID Token의 서명과 값을 검증하고 나서야 로그인을 확정합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 access token은 브라우저 주소창&amp;middot;기록에 남지 않습니다. 참고로 access token을 자원 서버에 보낼 때는 &lt;b&gt;&lt;code&gt;Authorization: Bearer&lt;/code&gt; 헤더&lt;/b&gt;로 보내고, &lt;b&gt;URL 쿼리스트링에 담지 않습니다.&lt;/b&gt; 쿼리스트링은 로그&amp;middot;Referer 헤더&amp;middot;브라우저 기록으로 새기 쉬워 규격도 권장하지 않습니다(RFC 6750 &amp;sect;2.3).&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ PKCE는 이제 모두가 씁니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방금 흐름에서 &lt;code&gt;code_challenge&lt;/code&gt;와 &lt;code&gt;code_verifier&lt;/code&gt;가 나왔습니다. 이게 &lt;b&gt;PKCE&lt;/b&gt;(쉽게 말하면 &quot;인가 코드가 중간에 탈취돼도 못 쓰게 묶어 두는 장치&quot;)입니다. 원래는 모바일&amp;middot;SPA 같은 &lt;b&gt;public 클라이언트&lt;/b&gt;(코드가 사용자 기기에 있어 비밀을 숨길 수 없는 앱)의 &lt;b&gt;인가 코드 가로채기&lt;/b&gt;를 막으려고 나왔습니다(RFC 7636).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작은 간단합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱이 무작위 문자열 &lt;code&gt;code_verifier&lt;/code&gt;를 만들고, 그걸 해시한 &lt;code&gt;code_challenge = BASE64URL(SHA256(code_verifier))&lt;/code&gt;를 &lt;b&gt;① 인가 요청&lt;/b&gt;에 담아 보냅니다.&lt;/li&gt;
&lt;li&gt;진짜 &lt;code&gt;code_verifier&lt;/code&gt;는 &lt;b&gt;④ 토큰 교환&lt;/b&gt; 때만 보냅니다.&lt;/li&gt;
&lt;li&gt;인가 서버는 받은 &lt;code&gt;code_verifier&lt;/code&gt;를 다시 해시해, 처음 받은 &lt;code&gt;code_challenge&lt;/code&gt;와 맞는지 대조합니다(RFC 7636 &amp;sect;4.6).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 인가 코드를 중간에서 훔쳐도, &lt;code&gt;code_verifier&lt;/code&gt;가 없으면 토큰으로 바꿀 수 없습니다. 해시 방식은 &lt;b&gt;S256을 씁니다.&lt;/b&gt; 규격도 &quot;S256을 쓸 수 있으면 반드시 S256, &lt;code&gt;plain&lt;/code&gt;은 SHA-256이 불가능할 때만&quot;으로 못박습니다(RFC 7636 &amp;sect;4.2).&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;# ① 인가 요청 (브라우저 리다이렉트, front channel)
GET /authorize?
  response_type=code
  &amp;amp;client_id=MY_APP
  &amp;amp;redirect_uri=https://myapp.com/callback   # 등록값과 &quot;정확히&quot; 일치해야 함
  &amp;amp;scope=openid profile email                # openid 를 넣어야 OIDC(로그인)
  &amp;amp;state=xyz...          # CSRF 방어용 임의값 (돌아올 때 대조)
  &amp;amp;nonce=abc...          # ID Token 재전송 방어용 (OIDC)
  &amp;amp;code_challenge=BASE64URL(SHA256(code_verifier))
  &amp;amp;code_challenge_method=S256

# ④ 토큰 교환 (서버 &amp;rarr; 인가 서버, back channel)
POST /token
  grant_type=authorization_code
  &amp;amp;code=RECEIVED_CODE
  &amp;amp;redirect_uri=https://myapp.com/callback
  &amp;amp;client_id=MY_APP
  &amp;amp;code_verifier=원본_무작위값   # 서버가 challenge 와 대조
  # confidential 클라이언트면 여기서 client_secret 로 인증&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 요즘 가장 많이 바뀐 부분입니다. &lt;b&gt;PKCE는 이제 모바일&amp;middot;SPA 전용이 아닙니다.&lt;/b&gt; OAuth 2.0 보안 BCP(RFC 9700)는 &lt;b&gt;모든 클라이언트가 Authorization Code 방식에 PKCE를 쓰라&lt;/b&gt;고 하며, public 클라이언트는 필수, &lt;b&gt;서버 웹앱 같은 confidential 클라이언트도 권고&lt;/b&gt;입니다(&amp;sect;2.1.1). client_secret이 있어도 PKCE는 인가 코드 주입 공격을 따로 막아 주니, 둘은 겹치는 게 아니라 보완 관계입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로, 예전 글에서 흔히 보이던 두 방식은 &lt;b&gt;이제 쓰면 안 됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;implicit 방식&lt;/b&gt;(&lt;code&gt;response_type=token&lt;/code&gt;, 인가 서버가 토큰을 브라우저로 바로 던지는 방식) &amp;mdash; RFC 9700이 &lt;b&gt;쓰지 말라(SHOULD NOT)&lt;/b&gt; 고 하고(&amp;sect;2.1.2), 다음 버전인 OAuth 2.1 초안은 &lt;b&gt;아예 제거&lt;/b&gt;합니다. SPA도 이제 implicit이 아니라 Authorization Code + PKCE를 씁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ROPC 방식&lt;/b&gt;(사용자 아이디&amp;middot;비번을 앱이 직접 받아 토큰으로 바꾸는 방식) &amp;mdash; RFC 9700이 &lt;b&gt;금지(MUST NOT)&lt;/b&gt; 합니다(&amp;sect;2.4). 소셜 로그인을 흉내 내려고 남의 서비스 비번을 우리 앱이 받는 순간, 그건 소셜 로그인이 아니라 비번 유출 통로입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-rfc9700-implicit.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1560&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ybHDX/dJMcaae8GBD/TSJiHTbVJy2Q7n2jETdKJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ybHDX/dJMcaae8GBD/TSJiHTbVJy2Q7n2jETdKJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ybHDX/dJMcaae8GBD/TSJiHTbVJy2Q7n2jETdKJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FybHDX%2FdJMcaae8GBD%2FTSJiHTbVJy2Q7n2jETdKJ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;1560&quot; data-filename=&quot;official-rfc9700-implicit.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1560&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 2. RFC 9700(OAuth 2.0 Security Best Current Practice &amp;middot; BCP 240 &amp;middot; 2025년 1월). RFC 6749/6750/6819를 갱신하며 &quot;안전하지 않다고 판단되는 일부 동작 방식을 폐기(deprecate)한다&quot;고 초록에 명시합니다. 오른쪽 목차의 2.1.2 Implicit Grant와 2.4 ROPC가 그 대상입니다.&lt;/i&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  소셜 로그인은 OIDC의 ID Token으로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 인증입니다. &lt;b&gt;OpenID Connect(OIDC)&lt;/b&gt; 는 쉽게 말하면 &lt;b&gt;OAuth 2.0 위에 &quot;이 사람이 누구인지&quot;를 얹은 얇은 인증 계층&lt;/b&gt;입니다(OIDC Core &amp;sect;1). 앞의 인가 요청 &lt;code&gt;scope&lt;/code&gt;에 &lt;b&gt;&lt;code&gt;openid&lt;/code&gt;&lt;/b&gt; 를 넣으면, 인가 서버가 토큰과 함께 &lt;b&gt;ID Token&lt;/b&gt;을 내줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ID Token은 &lt;b&gt;서명된 JWT&lt;/b&gt;입니다. (JWT가 왜 &quot;암호화가 아니라 서명&quot;인지, base64 디코드만으로 내용이 읽히는 이유는 지난 &lt;a href=&quot;https://atoz-developer.tistory.com/184&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;세션 vs JWT 글&lt;/a&gt;에서 다뤘습니다.) 필수로 담기는 값은 &lt;code&gt;iss&lt;/code&gt;(발급자), &lt;code&gt;sub&lt;/code&gt;(사용자 고유 ID), &lt;code&gt;aud&lt;/code&gt;(이 토큰을 받을 대상=우리 client_id), &lt;code&gt;exp&lt;/code&gt;(만료), &lt;code&gt;iat&lt;/code&gt;(발급 시각)입니다(OIDC Core &amp;sect;2). 이 중 &lt;b&gt;&lt;code&gt;sub&lt;/code&gt;가 우리가 사용자 식별에 써야 할 안정적인 키&lt;/b&gt;입니다. 이메일은 바뀔 수 있으니 기준으로 삼지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-oidc-idtoken.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1640&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/clf7KR/dJMcabygDpp/DKVZVE88esMA8fibM3pymK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/clf7KR/dJMcabygDpp/DKVZVE88esMA8fibM3pymK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/clf7KR/dJMcabygDpp/DKVZVE88esMA8fibM3pymK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fclf7KR%2FdJMcabygDpp%2FDKVZVE88esMA8fibM3pymK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;1640&quot; data-filename=&quot;official-oidc-idtoken.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1640&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 3. OpenID Connect Core 1.0 규격. OIDC는 OAuth 2.0 위에서 클라이언트가 &quot;인가 서버가 수행한 인증에 근거해 최종 사용자의 신원을 검증&quot;하게 해 주는 계층이라고 정의합니다. 즉 로그인(인증)은 OAuth가 아니라 이 계층의 몫입니다.&amp;nbsp;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건, ID Token을 &lt;b&gt;그냥 디코드해서 믿으면 안 된다&lt;/b&gt;는 점입니다. 받은 즉시 아래를 &lt;b&gt;검증&lt;/b&gt;해야 합니다(OIDC Core &amp;sect;3.1.3.7).&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;서명 검증&lt;/b&gt; &amp;mdash; 제공자가 공개한 키(JWKS)로 JWT 서명을 확인합니다. 서명 검증 없이 payload만 읽는 건 위조에 무방비입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;iss&lt;/code&gt;&lt;/b&gt; &amp;mdash; 우리가 기대한 발급자와 정확히 일치하는가.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;aud&lt;/code&gt;&lt;/b&gt; &amp;mdash; 우리 &lt;code&gt;client_id&lt;/code&gt;가 대상에 포함되는가(다른 앱용 토큰 거부).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/b&gt; &amp;mdash; 만료되지 않았는가.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;nonce&lt;/code&gt;&lt;/b&gt; &amp;mdash; ①에서 보낸 nonce와 같은 값이 담겨 있는가.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;nonce&lt;/code&gt;와 &lt;code&gt;state&lt;/code&gt;는 자주 헷갈리는데, &lt;b&gt;막는 게 다릅니다.&lt;/b&gt; &lt;code&gt;state&lt;/code&gt;는 &lt;b&gt;CSRF&lt;/b&gt;(요청 위조)를 막는 값이고, &lt;code&gt;nonce&lt;/code&gt;는 &lt;b&gt;ID Token 재전송(replay)&lt;/b&gt; 을 막으려고 토큰과 내 세션을 묶는 값입니다(OIDC Core &amp;sect;3.1.2.1). PKCE가 코드 가로채기를 막는 것과도 또 다릅니다. 셋을 &quot;다 넣는다&quot;고 생각하면 편합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 정보를 더 받고 싶으면 access token으로 &lt;b&gt;UserInfo 엔드포인트&lt;/b&gt;를 부를 수 있는데, 이건 ID Token 검증을 &lt;b&gt;대체하는 게 아니라 보조&lt;/b&gt;합니다. 이때도 UserInfo 응답의 &lt;code&gt;sub&lt;/code&gt;가 ID Token의 &lt;code&gt;sub&lt;/code&gt;와 같은지 확인해야 합니다(OIDC Core &amp;sect;5.3.2).&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  내 앱엔 어떤 방식이 맞나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 결정은 두 단계입니다. 먼저 &lt;b&gt;목적&lt;/b&gt;, 그다음 &lt;b&gt;클라이언트 유형&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dsdyQl/dJMcahrOVHL/lv5KrFdzCtANFLM5ddGV31/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dsdyQl/dJMcahrOVHL/lv5KrFdzCtANFLM5ddGV31/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dsdyQl/dJMcahrOVHL/lv5KrFdzCtANFLM5ddGV31/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdsdyQl%2FdJMcahrOVHL%2Flv5KrFdzCtANFLM5ddGV31%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1200&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 4. 목적(인증인지 자원 접근인지)을 먼저 정하고, 그다음 클라이언트 유형에 맞는 플로우를 고릅니다. 대부분의 경우 답은 &quot;Authorization Code + PKCE&quot;로 모입니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계 &amp;mdash; 목적이 무엇인가&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&quot;이 사람이 누구인지&quot; 로그인&lt;/b&gt; &amp;rarr; &lt;b&gt;OIDC&lt;/b&gt;를 씁니다. &lt;code&gt;scope&lt;/code&gt;에 &lt;code&gt;openid&lt;/code&gt;를 넣고, &lt;b&gt;ID Token을 검증&lt;/b&gt;해 로그인 처리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;그 사용자의 자원에 접근&quot;&lt;/b&gt;(예: 구글 캘린더 읽기) &amp;rarr; OAuth &lt;b&gt;access token&lt;/b&gt;으로 자원 서버 API를 부릅니다.&lt;/li&gt;
&lt;li&gt;대부분의 소셜 로그인은 둘 다입니다. &lt;b&gt;로그인은 ID Token, 부가 자원 접근은 access token&lt;/b&gt; 으로 역할을 나눕니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계 &amp;mdash; 클라이언트 유형에 맞는 플로우&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;서버 웹앱(confidential)&lt;/b&gt; &amp;mdash; Authorization Code + PKCE. client_secret은 &lt;b&gt;서버에만&lt;/b&gt; 두고 토큰 교환도 서버에서 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SPA(브라우저 단독, public)&lt;/b&gt; &amp;mdash; Authorization Code + PKCE. secret은 프런트에 두지 않습니다. 받은 토큰을 브라우저 어디에 둘지는 &lt;a href=&quot;https://atoz-developer.tistory.com/184&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;지난 글의 저장 위치 기준&lt;/a&gt;을 따릅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네이티브 모바일 앱(public)&lt;/b&gt; &amp;mdash; Authorization Code + PKCE. 그리고 &lt;b&gt;반드시 시스템 브라우저&lt;/b&gt;(또는 인앱 브라우저 탭)로 로그인하고, &lt;b&gt;임베디드 웹뷰는 쓰지 않습니다&lt;/b&gt;(RFC 8252). 웹뷰는 사용자의 비번을 앱이 엿볼 수 있어 신뢰가 깨집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버 대 서버(M2M, 사용자 없음)&lt;/b&gt; &amp;mdash; 여기만 예외로 &lt;b&gt;Client Credentials&lt;/b&gt; 방식을 씁니다. 사용자가 없으니 OIDC 로그인이 아닙니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 유형과 무관하게 공통으로 지킬 것: &lt;b&gt;&lt;code&gt;redirect_uri&lt;/code&gt;는 등록값과 정확히 일치&lt;/b&gt;해야 합니다(부분&amp;middot;와일드카드 매칭 금지, RFC 9700 &amp;sect;2.1). 그리고 &lt;b&gt;implicit&amp;middot;ROPC는 쓰지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  구글&amp;middot;카카오&amp;middot;네이버는 실제로 다릅니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 &quot;소셜 로그인&quot;이라도 세 제공자가 주는 게 다릅니다. 특히 &lt;b&gt;ID Token(OIDC)을 주느냐&lt;/b&gt;가 갈립니다. 아래는 각 공식 문서 기준(확인일 2026-07-24)이며, 세부 파라미터&amp;middot;엔드포인트는 반드시 각 제공자 최신 문서로 확인하세요.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;제공자&lt;/th&gt;
&lt;th&gt;기본 플로우&lt;/th&gt;
&lt;th&gt;OIDC ID Token&lt;/th&gt;
&lt;th&gt;로그인 판단 방법&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구글&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Authorization Code&lt;/td&gt;
&lt;td&gt;&lt;b&gt;발급&lt;/b&gt; (OIDC 지원)&lt;/td&gt;
&lt;td&gt;ID Token 검증(iss&amp;middot;aud&amp;middot;exp&amp;middot;서명&amp;middot;nonce)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;카카오&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Authorization Code (코드&amp;rarr;토큰 2단계)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;옵션&lt;/b&gt;(기본 꺼짐)&lt;/td&gt;
&lt;td&gt;OIDC 켜고 &lt;code&gt;openid&lt;/code&gt; scope &amp;rarr; id_token, 아니면 프로필 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;네이버&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;OAuth 2.0 (code + state)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;없음&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;access token으로 프로필 API 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;구글&lt;/b&gt;은 OpenID Connect를 정식 지원해 &lt;b&gt;ID Token(JWT)&lt;/b&gt; 을 내줍니다. 로그인은 이 ID Token을 검증해서 판단하면 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;카카오&lt;/b&gt;는 OIDC를 지원하지만 &lt;b&gt;기본적으로 꺼져 있습니다.&lt;/b&gt; 앱 설정에서 OpenID Connect를 켜고 &lt;code&gt;scope&lt;/code&gt;에 &lt;b&gt;&lt;code&gt;openid&lt;/code&gt;&lt;/b&gt; 를 넣어야 &lt;code&gt;id_token&lt;/code&gt;이 나옵니다. 켜지 않으면 access token만 나오고, 사용자 정보는 별도 프로필 API로 조회합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네이버&lt;/b&gt;는 &lt;b&gt;OIDC ID Token을 발급하지 않습니다.&lt;/b&gt; 자체 access token을 준 뒤 프로필 API로 사용자 정보를 조회하는 구조입니다. 그래서 네이버는 &quot;OIDC id_token 검증&quot; 대신, &lt;b&gt;access token 취급에 주의&lt;/b&gt;(전송 중 유출&amp;middot;범위)하고 &lt;code&gt;state&lt;/code&gt;로 CSRF를 막는 데 초점을 둡니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은, &lt;b&gt;&quot;구글에서 되던 id_token 코드가 네이버엔 없다&quot;&lt;/b&gt; 같은 상황이 정상이라는 점입니다. 제공자가 OIDC를 주는지부터 확인하고 붙이면 헤매지 않습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 소셜 로그인 붙이기 전 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 소셜 로그인을 설계할 때 이 순서로 짚으면 됩니다. 필요하면 복사해서 쓰세요.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;[목적 &amp;mdash; 무엇을 하려는가]
- [ ] 로그인(인증)이면 OIDC를 쓰고 ID Token으로 판단하나? (access token 아님)
- [ ] 자원 접근(인가)만 필요하면 그 scope의 access token만 받나?

[플로우 &amp;mdash; 클라이언트 유형]
- [ ] 서버 웹앱/SPA/모바일 모두 Authorization Code + PKCE 인가?
- [ ] implicit(response_type=token)&amp;middot;ROPC(비번 직접 수집)를 쓰고 있지 않나?
- [ ] 모바일 앱은 임베디드 웹뷰가 아니라 시스템 브라우저로 로그인하나?
- [ ] client_secret 을 프런트/모바일 코드에 넣지 않았나? (public = secret 불가)

[PKCE]
- [ ] code_challenge_method 를 S256 으로 고정했나? (plain 금지)
- [ ] code_challenge 는 인가 요청, code_verifier 는 토큰 요청에만 보내나?

[ID Token 검증 &amp;mdash; OIDC일 때]
- [ ] 서명(JWKS)&amp;middot;iss&amp;middot;aud&amp;middot;exp 를 모두 검증하나? (디코드만 믿지 않기)
- [ ] 보낸 nonce 와 토큰의 nonce 가 일치하는지 확인하나?
- [ ] 사용자 식별은 이메일이 아니라 sub 로 하나?

[공통]
- [ ] redirect_uri 를 등록값과 &quot;정확히&quot; 일치시켰나? (와일드카드 금지)
- [ ] state 로 CSRF 를 막고, access token 은 헤더로만 보내나? (쿼리스트링 금지)
- [ ] 붙일 제공자가 OIDC id_token 을 주는지 공식 문서로 확인했나? (네이버는 없음)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결정을 내렸다면 다음 한 발은 이렇습니다. 붙일 제공자의 &lt;b&gt;OAuth/OIDC 개발 문서&lt;/b&gt;에서 &lt;code&gt;authorize&lt;/code&gt;&amp;middot;&lt;code&gt;token&lt;/code&gt; 엔드포인트와 &lt;code&gt;openid&lt;/code&gt; scope 지원 여부부터 찾고, 쓰는 언어의 &lt;b&gt;OIDC 클라이언트 라이브러리&lt;/b&gt;(ID Token 검증을 대신 해 줍니다)를 고르시면 자연스럽게 이어집니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 소셜 로그인에서 먼저 잡을 두 가지는 이것입니다. 첫째, &lt;b&gt;OAuth 2.0은 인가고 인증이 아니다&lt;/b&gt; &amp;mdash; 로그인은 access token이 아니라 &lt;b&gt;OIDC의 ID Token을 검증&lt;/b&gt;해서 판단합니다. 둘째, &lt;b&gt;플로우는 (거의 항상) Authorization Code + PKCE&lt;/b&gt; 이고, implicit과 ROPC는 폐기됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 가지와, &quot;제공자마다 id_token을 주는지 다르다&quot;는 것만 기억해도 대부분의 소셜 로그인 사고를 피할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 웹 인증&amp;middot;보안 묶음으로, 토큰을 어디에 저장하고 JWT 서명을 어떻게 다루는지는 &lt;a href=&quot;https://atoz-developer.tistory.com/184&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;세션 vs JWT 인증, 뭘 쓰고 토큰은 어디에 저장하나&lt;/a&gt;에서 이어집니다. 로그인 요청이 다른 출처로 오갈 때 나는 &lt;a href=&quot;https://atoz-developer.tistory.com/183&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CORS 에러&lt;/a&gt;나, 그 아래의 &lt;a href=&quot;https://atoz-developer.tistory.com/107&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Socket과 HTTP 통신 비교&lt;/a&gt;, &lt;a href=&quot;https://atoz-developer.tistory.com/121&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP 상태 코드 제어&lt;/a&gt;도 같은 결의 글입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어 주셔서 감사합니다  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6749&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 6749: The OAuth 2.0 Authorization Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc6750&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 6750: OAuth 2.0 Bearer Token Usage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc7636&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 7636: Proof Key for Code Exchange (PKCE)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8252&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 8252: OAuth 2.0 for Native Apps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9700.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 9700: Best Current Practice for OAuth 2.0 Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://openid.net/specs/openid-connect-core-1_0.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OpenID Connect Core 1.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OAuth 2.1 (IETF draft)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/identity/openid-connect/openid-connect&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Google Identity: OpenID Connect&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/en/kakaologin/rest-api&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Kakao Developers: Kakao Login (REST API &amp;middot; OpenID Connect)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.naver.com/docs/login/api/api.md&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Naver Developers: 네이버 아이디로 로그인&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OWASP: OAuth 2.0 Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프론트엔드</category>
      <category>IDToken</category>
      <category>oauth</category>
      <category>oauth2</category>
      <category>oidc</category>
      <category>OpenIDConnect</category>
      <category>pkce</category>
      <category>소셜로그인</category>
      <category>웹보안</category>
      <category>인증과인가</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/185</guid>
      <comments>https://atoz-developer.tistory.com/185#entry185comment</comments>
      <pubDate>Sat, 25 Jul 2026 00:14:40 +0900</pubDate>
    </item>
    <item>
      <title>세션 vs JWT 인증, 뭘 쓰고 토큰은 어디에 저장하나</title>
      <link>https://atoz-developer.tistory.com/184</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 기능을 새로 붙일 때, 검색해 보면 조언이 정반대로 갈립니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;요즘은 무조건 JWT죠. 서버 확장도 쉽고요.&quot;&lt;/li&gt;
&lt;li&gt;&quot;아니에요, 세션이 더 안전해요. JWT는 로그아웃도 안 되잖아요.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 &quot;토큰은 localStorage에 저장하면 된다&quot;는 글과 &quot;localStorage는 위험하니 쿠키에 넣어라&quot;는 글까지 겹치면, 도대체 뭘 기준으로 골라야 할지 막힙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, &lt;b&gt;이건 하나의 승패 싸움이 아니라 사실 두 개의 다른 결정입니다.&lt;/b&gt; ① 로그인 상태를 &lt;b&gt;서버에 둘지(세션) 토큰에 둘지(JWT)&lt;/b&gt;, 그리고 ② 그 자격증명을 &lt;b&gt;브라우저 어디에 저장할지(쿠키 vs localStorage)&lt;/b&gt;. 이 둘을 섞어서 설명하니까 계속 헷갈리는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 예전 &lt;a href=&quot;https://atoz-developer.tistory.com/183&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;CORS 에러, 왜 나고 어떻게 푸나&lt;/a&gt; 같은 웹 기초 글과 같은 결로, 이 두 결정을 나눠서 기준을 잡아 드립니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  오늘 정리하는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 다 읽으면 아래를 스스로 판단할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;세션 방식과 JWT 방식이 &lt;b&gt;실제로 무엇이 다른지&lt;/b&gt; 한 문장으로 설명할 수 있습니다.&lt;/li&gt;
&lt;li&gt;내 서비스에 &lt;b&gt;세션이 맞는지 JWT가 맞는지&lt;/b&gt; 조건으로 고를 수 있습니다.&lt;/li&gt;
&lt;li&gt;토큰(또는 세션ID)을 &lt;b&gt;localStorage에 둘지 httpOnly 쿠키에 둘지&lt;/b&gt;, XSS&amp;middot;CSRF 트레이드오프로 결정할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&quot;JWT는 왜 로그아웃이 애매한가&quot;의 정체와 &lt;b&gt;짧은 토큰 + 리프레시 토큰&lt;/b&gt; 패턴을 이해합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 라이브러리 설정법이 아니라, &lt;b&gt;어떤 구조를 고를지 판단하는 기준&lt;/b&gt;을 손에 넣는 것이 목적입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  근거부터 짚고 갑니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 말씀드리면, 이 글은 제 특정 서비스의 운영 경험담이 아니라 &lt;b&gt;표준 문서와 공식 보안 가이드에서 확인해 정리한 것&lt;/b&gt;입니다. 근거로 삼은 자료는 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RFC 7519 / 7515&lt;/b&gt;(JSON Web Token &amp;middot; JSON Web Signature) &amp;mdash; JWT가 무엇인지의 규격&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 6265&lt;/b&gt;(HTTP 쿠키) &amp;mdash; 세션 쿠키의 동작&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RFC 9700&lt;/b&gt;(OAuth 2.0 보안 Best Current Practice, 2025) &amp;mdash; 리프레시 토큰 권고&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MDN&lt;/b&gt; &amp;mdash; 쿠키 속성(HttpOnly &amp;middot; Secure &amp;middot; SameSite)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OWASP&lt;/b&gt; &amp;mdash; 세션 관리 / JWT / CSRF 치트시트&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  사실은 질문이 두 개입니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흔히 &quot;세션 vs JWT vs 쿠키 vs localStorage&quot;를 한 줄에 놓고 비교하는데, 이들은 &lt;b&gt;같은 층위의 선택지가 아닙니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 결정은 두 단계로 나뉩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Q1. 인증 &quot;상태&quot;를 어디에 둘까?&lt;/b&gt; &amp;rarr; &lt;b&gt;세션&lt;/b&gt;(서버가 상태 보관) vs &lt;b&gt;JWT&lt;/b&gt;(토큰이 상태를 들고 감)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Q2. 그 자격증명(세션ID&amp;middot;토큰)을 브라우저 어디에 저장할까?&lt;/b&gt; &amp;rarr; &lt;b&gt;쿠키&lt;/b&gt; vs &lt;b&gt;localStorage&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 둘은 &lt;b&gt;독립적&lt;/b&gt;입니다. 세션ID를 쿠키가 아니라 헤더로 넘길 수도 있고, JWT를 localStorage가 아니라 쿠키에 담을 수도 있습니다. 그래서 &quot;JWT는 localStorage, 세션은 쿠키&quot;라는 공식은 사실이 아닙니다. Q1과 Q2를 따로 결정해야 답이 깔끔해집니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  세션과 JWT는 무엇이 다른가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Q1입니다. 용어부터 풀겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;세션 방식&lt;/b&gt;은 쉽게 말하면 &lt;b&gt;&quot;서버가 로그인 상태를 기억하고, 브라우저에는 열쇠(세션ID)만 쥐여 주는&quot;&lt;/b&gt; 방식입니다. HTTP는 원래 요청 사이에 상태가 없는데(stateless), 쿠키로 그 위에 상태를 얹는 것입니다. RFC 6265는 쿠키를 &quot;서버가 사용자 에이전트에 상태를 저장해, 대체로 무상태인 HTTP 위에서 상태 있는 세션을 유지하게 해 주는 것&quot;이라고 설명합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 브라우저가 들고 있는 세션ID는 &lt;b&gt;의미 없는 임의의 값&lt;/b&gt;이어야 합니다. OWASP는 세션ID를 &quot;정보가 담기지 않은 무의미한 값&quot;으로, 추측을 막기 위해 &lt;b&gt;값 길이는 128비트 이상, 엔트로피는 최소 64비트 이상&lt;/b&gt;으로 권고합니다. 즉 세션ID 자체에는 사용자 정보가 없고, 서버가 그 값을 자기 저장소에서 조회해 &quot;아, 이건 3번 사용자&quot;라고 알아내는 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JWT 방식&lt;/b&gt;은 반대입니다. 쉽게 말하면 &lt;b&gt;&quot;상태를 서버가 아니라 서명된 토큰 안에 담아 클라이언트가 들고 다니는&quot;&lt;/b&gt; 방식입니다. 서버는 매 요청마다 DB를 조회하는 대신, 토큰의 &lt;b&gt;서명만 검증&lt;/b&gt;해서 위&amp;middot;변조가 없었는지 확인합니다. 저장소 조회가 없으니 서버를 무상태(stateless)로 두기 좋고, 여러 서비스가 같은 토큰을 나눠 검증하기 편합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-flow.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8zuKF/dJMcadvWvRb/5ztkpQmr5DSaVAGb166mt0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8zuKF/dJMcadvWvRb/5ztkpQmr5DSaVAGb166mt0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8zuKF/dJMcadvWvRb/5ztkpQmr5DSaVAGb166mt0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8zuKF%2FdJMcadvWvRb%2F5ztkpQmr5DSaVAGb166mt0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1980&quot; height=&quot;1200&quot; data-filename=&quot;diagram-1-flow.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이에서 장단점이 그대로 따라옵니다. 세션은 서버에 상태가 있으니 &lt;b&gt;로그아웃&amp;middot;강제 차단이 즉시&lt;/b&gt; 됩니다. 서버에서 세션을 지우면 그 세션ID는 그 순간 무용지물이 되니까요. 대신 서버 여러 대로 확장하려면 세션 저장소를 공유해야 합니다(예: 요청을 같은 서버로 붙이는 sticky session, 또는 Redis 같은 공용 저장소 &amp;mdash; 이건 규격이 아니라 일반적인 아키텍처 관행입니다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT는 확장은 쉽지만, 뒤에서 볼 &lt;b&gt;&quot;즉시 무효화가 어렵다&quot;&lt;/b&gt; 는 약점을 안고 갑니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  JWT는 &quot;암호화&quot;가 아닙니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT에서 가장 많이 오해하는 지점입니다. JWT는 &lt;code&gt;header.payload.signature&lt;/code&gt; 세 부분을 점(&lt;code&gt;.&lt;/code&gt;)으로 이은 문자열이고, 각 부분은 &lt;b&gt;base64url로 &quot;인코딩&quot;&lt;/b&gt; 되어 있습니다. 인코딩은 암호화가 아니라 &lt;b&gt;누구나 되돌릴 수 있는 변환&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 서명된 JWT의 payload는 &lt;b&gt;암호화된 게 아니라 그냥 읽힙니다.&lt;/b&gt; jwt.io 공식 소개 문서도 &quot;서명된 토큰의 이 정보는 위&amp;middot;변조로부터는 보호되지만 &lt;b&gt;누구나 읽을 수 있으니, payload나 header에 비밀 정보를 넣지 말라&lt;/b&gt;&quot;고 명시합니다. 실제로 토큰을 복사해 base64 디코드만 하면 내용이 그대로 보입니다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;# JWT 예시 (aaaaa.bbbbb.ccccc 형태)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIn0.&amp;lt;서명&amp;gt;

# 앞 두 조각을 base64url 디코드하면 그냥 읽힘 (암호화 아님)
header  &amp;rarr; {&quot;alg&quot;:&quot;HS256&quot;,&quot;typ&quot;:&quot;JWT&quot;}
payload &amp;rarr; {&quot;sub&quot;:&quot;1234&quot;,&quot;role&quot;:&quot;admin&quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 서명은 무슨 역할일까요? &lt;b&gt;서명은 &quot;내용을 숨기는 것&quot;이 아니라 &quot;내용이 바뀌지 않았음을 증명하는 것&quot;&lt;/b&gt; 입니다. RFC 7519는 claim을 &quot;JWS(서명) 구조의 payload로, 또는 JWE(암호화) 구조의 평문으로&quot; 담아 &quot;전자서명 또는 무결성 보호, &lt;b&gt;그리고/또는&lt;/b&gt; 암호화&quot;할 수 있다고 정의합니다. 다시 말해 &lt;b&gt;평범한 서명 토큰(JWS)은 무결성만 보장&lt;/b&gt;하고, 내용을 숨기려면 별도로 암호화(JWE)해야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-rfc7519-jwt.png&quot; data-origin-width=&quot;840&quot; data-origin-height=&quot;396&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfYZEH/dJMcaccPp8D/DAwOxCSR1vUsd7JI87Zc10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfYZEH/dJMcaccPp8D/DAwOxCSR1vUsd7JI87Zc10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfYZEH/dJMcaccPp8D/DAwOxCSR1vUsd7JI87Zc10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfYZEH%2FdJMcaccPp8D%2FDAwOxCSR1vUsd7JI87Zc10%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;840&quot; height=&quot;396&quot; data-filename=&quot;official-rfc7519-jwt.png&quot; data-origin-width=&quot;840&quot; data-origin-height=&quot;396&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 실무 규칙 두 가지가 나옵니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;payload에 비밀번호&amp;middot;주민번호 같은 민감정보를 넣지 마세요.&lt;/b&gt; 누구나 읽힙니다. 넣어야 한다면 값 자체를 따로 암호화하거나 JWE를 씁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서버는 검증할 알고리즘을 코드에 고정하세요.&lt;/b&gt; 토큰 header의 &lt;code&gt;alg&lt;/code&gt; 값을 그대로 믿으면, 공격자가 &lt;code&gt;alg&lt;/code&gt;를 &lt;code&gt;none&lt;/code&gt;으로 바꿔 서명을 무력화하거나(RFC 7515의 &quot;Unsecured JWS&quot;), RS256을 HS256으로 바꿔치기하는 알고리즘 혼동 공격에 노출됩니다. OWASP는 &quot;검증 측은 자체 설정/하드코딩 로직으로 무결성을 검증해야지, 토큰 header의 정보로 검증 알고리즘을 고르면 안 된다&quot;고 못박습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 서명 방식은 하나의 비밀키를 공유하는 &lt;b&gt;HMAC(HS256)&lt;/b&gt; 과, 개인키로 서명하고 공개키로 검증하는 &lt;b&gt;비대칭키(RS256&amp;middot;ES256)&lt;/b&gt; 로 나뉩니다. 여러 서비스가 토큰을 검증한다면 비대칭키가 안전합니다. HMAC은 검증할 수 있는 쪽이면 &lt;b&gt;새 토큰도 만들 수 있어서&lt;/b&gt;, 한 서비스가 뚫리면 키를 공유한 전체가 뚫리기 때문입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 토큰을 어디에 저장할까 &amp;mdash; localStorage vs httpOnly 쿠키&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Q2입니다. 세션ID든 JWT든, 그 값을 브라우저 어딘가에 보관해야 합니다. 후보는 크게 &lt;b&gt;localStorage&lt;/b&gt;와 &lt;b&gt;쿠키&lt;/b&gt;이고, 둘은 공격 표면이 정반대입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;localStorage&lt;/b&gt;는 페이지의 &lt;b&gt;모든 자바스크립트가 읽을 수 있습니다.&lt;/b&gt; 그래서 XSS(쉽게 말하면 내 페이지에 남의 스크립트가 실행되는 취약점)가 하나라도 뚫리면 저장된 토큰이 통째로 유출됩니다. OWASP는 이 점을 매우 강하게 경고합니다. 요지는 &lt;b&gt;인증 토큰&amp;middot;세션ID&amp;middot;JWT&amp;middot;리프레시 토큰 같은 자격증명을 localStorage나 sessionStorage에 저장하지 말라&lt;/b&gt;는 것입니다. 이 저장소는 해당 출처의 모든 자바스크립트가 접근할 수 있어, XSS 하나로 전부 노출되기 때문입니다. 대신 localStorage는 브라우저가 요청에 자동으로 실어 보내지 않으므로, 뒤에 볼 CSRF에는 상대적으로 덜 노출됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;httpOnly 쿠키&lt;/b&gt;는 반대입니다. &lt;code&gt;HttpOnly&lt;/code&gt; 속성이 붙은 쿠키는 &lt;b&gt;자바스크립트가 읽을 수 없어서&lt;/b&gt;(예: &lt;code&gt;document.cookie&lt;/code&gt;로 접근 불가), XSS가 나도 &lt;b&gt;토큰 값 자체를 훔쳐 가긴 어렵습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-mdn-httponly.png&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;195&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGbOTC/dJMcabEXRiN/sKQ2QbKgNofgha3gdrTrOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGbOTC/dJMcabEXRiN/sKQ2QbKgNofgha3gdrTrOK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGbOTC/dJMcabEXRiN/sKQ2QbKgNofgha3gdrTrOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGbOTC%2FdJMcabEXRiN%2FsKQ2QbKgNofgha3gdrTrOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;632&quot; height=&quot;195&quot; data-filename=&quot;official-mdn-httponly.png&quot; data-origin-width=&quot;632&quot; data-origin-height=&quot;195&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 쿠키에는 대신 다른 약점이 있습니다. &lt;b&gt;쿠키는 브라우저가 목적지 도메인에 맞춰 자동으로 실어 보냅니다.&lt;/b&gt; 사용자가 의도했는지와 무관하게요. 바로 이 &quot;자동 전송&quot; 때문에 &lt;b&gt;CSRF(크로스 사이트 요청 위조)&lt;/b&gt; 가 성립합니다. 악성 사이트가 내 은행 사이트로 요청을 유도하면, 브라우저가 알아서 로그인 쿠키를 붙여 보내는 것이죠. 그래서 쿠키 인증은 &lt;b&gt;CSRF 방어를 따로&lt;/b&gt; 해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 저장소의 트레이드오프를 표로 보면 이렇습니다. 어느 쪽도 XSS 앞에서 &quot;안전&quot;한 게 아니라, &lt;b&gt;뚫렸을 때의 실패 방식이 다릅니다.&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;width: 859px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;width: 110px;&quot;&gt;저장 위치&lt;/th&gt;
&lt;th style=&quot;width: 155px;&quot;&gt;XSS(스크립트 주입)&lt;/th&gt;
&lt;th style=&quot;width: 208px;&quot;&gt;CSRF(요청 위조)&lt;/th&gt;
&lt;th style=&quot;width: 386px;&quot;&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 110px;&quot;&gt;&lt;b&gt;localStorage&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 155px;&quot;&gt;⚠️ JS로 읽혀 &lt;b&gt;탈취됨&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 208px;&quot;&gt;상대적으로 덜함(자동 전송 아님)&lt;/td&gt;
&lt;td style=&quot;width: 386px;&quot;&gt;OWASP는 토큰 저장을 &lt;b&gt;권장하지 않음&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 110px;&quot;&gt;&lt;b&gt;httpOnly 쿠키&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 155px;&quot;&gt;값 읽기 차단(&lt;b&gt;탈취 방어&lt;/b&gt;)&lt;/td&gt;
&lt;td style=&quot;width: 208px;&quot;&gt;⚠️ 자동 전송이라 &lt;b&gt;노출&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 386px;&quot;&gt;&lt;code&gt;SameSite&lt;/code&gt;+CSRF 토큰으로 방어 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 오해를 더 풀면, &lt;b&gt;httpOnly가 XSS를 막아 주는 건 아닙니다.&lt;/b&gt; 토큰 &quot;값&quot;을 훔쳐 다른 곳에서 재사용하는 걸 막을 뿐, XSS 스크립트가 그 페이지 안에서 자동 전송되는 쿠키를 타고 요청을 보내는 것까지는 못 막습니다. 그래서 &lt;b&gt;진짜 방어는 저장 위치 선택이 아니라 XSS 자체를 없애는 것&lt;/b&gt;(출력 인코딩, 입력 검증, 엄격한 CSP)입니다. 저장 위치는 그다음의 피해 최소화 장치입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠키 쪽 CSRF 방어의 핵심은 &lt;b&gt;&lt;code&gt;SameSite&lt;/code&gt; 속성&lt;/b&gt;입니다. &lt;code&gt;SameSite&lt;/code&gt;는 쿠키를 크로스 사이트 요청에 보낼지를 제어합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Strict&lt;/b&gt; &amp;mdash; 같은 사이트 요청에만 전송(가장 엄격, OWASP가 세션 쿠키에 권장)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Lax&lt;/b&gt; &amp;mdash; 같은 사이트 + 안전한 메서드(GET)로 상위 이동(링크 클릭)할 때까지 전송, &lt;b&gt;크로스 사이트 POST에는 안 보냄&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;None&lt;/b&gt; &amp;mdash; 크로스 사이트에도 전송하되 반드시 &lt;code&gt;Secure&lt;/code&gt; 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 브라우저는 &lt;code&gt;SameSite&lt;/code&gt;를 지정하지 않으면 대체로 &lt;b&gt;Lax&lt;/b&gt;로 취급합니다. 다만 이건 규격이 아니라 브라우저 동작이고(크로미엄 계열 기준, 파이어폭스&amp;middot;사파리는 조금 다릅니다), 명시적으로 지정한 Lax와 미묘한 차이도 있습니다. &lt;b&gt;확실히 하려면 직접 지정하세요.&lt;/b&gt; 그리고 &lt;code&gt;SameSite&lt;/code&gt;만으로 CSRF가 완전히 막히는 건 아닙니다 &amp;mdash; OWASP는 이를 심층 방어의 한 겹으로 보고, &lt;b&gt;CSRF 토큰(동기화 토큰)&lt;/b&gt; 을 기본 방어로 함께 쓰라고 권합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSRF 토큰은 쉽게 말하면, 서버가 요청(폼)마다 예측 불가능한 값을 하나 심어 두고, 요청이 올 때 그 값이 함께 왔는지 대조하는 장치입니다. 악성 사이트는 이 값을 미리 알 수 없으므로 위조 요청이 걸러집니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;# 세션ID/토큰을 쿠키에 담는 안전한 기본형 (OWASP 권장)
Set-Cookie: session=&amp;lt;임의의 긴 값&amp;gt;; HttpOnly; Secure; SameSite=Strict; Path=/&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  JWT는 왜 로그아웃이 애매한가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT를 세션처럼 쓰려다 가장 먼저 부딪히는 벽이 &lt;b&gt;&quot;로그아웃(무효화)&quot;&lt;/b&gt; 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션은 서버에 상태가 있으니 지우면 끝입니다. 하지만 &lt;b&gt;서명된 JWT는 exp(만료 시각)까지 그 자체로 유효&lt;/b&gt;합니다. OWASP JWT 치트시트도 &quot;토큰은 만료될 때에만 무효가 되며, 사용자가 토큰을 명시적으로 무효화할 내장 기능이 없다&quot;고 설명합니다. 클라이언트에서 토큰을 지워도, 그 토큰이 어디선가 복사돼 있으면 만료 전까지는 계속 통합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 강제로 무효화하려면? 서버가 &lt;b&gt;&quot;이 토큰은 폐기됨&quot; 목록(deny list)&lt;/b&gt; 을 들고 매번 대조해야 합니다. 그런데 이러면 &lt;b&gt;다시 서버에 상태가 생겨서&lt;/b&gt;, JWT의 장점인 무상태성이 사라집니다. OWASP도 &quot;이 경우 세션이 완전히 무상태가 아니게 되어 JWT의 이점을 상쇄할 수 있다&quot;고 지적합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 현실적인 정석은 &lt;b&gt;&quot;짧은 access 토큰 + 리프레시 토큰&quot;&lt;/b&gt; 조합입니다(RFC 9700 권고).&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;access 토큰은 수명을 짧게&lt;/b&gt;(예: 몇 분~수십 분) 둡니다. 유출돼도 금방 만료되니 피해 창이 좁아집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;리프레시 토큰&lt;/b&gt;은 수명이 길고, access 토큰이 만료되면 새 토큰을 받아오는 데 씁니다. 이 리프레시 토큰이 &lt;b&gt;서버에서 무효화할 수 있는 진짜 열쇠&lt;/b&gt;입니다.&lt;/li&gt;
&lt;li&gt;리프레시 토큰은 &lt;b&gt;httpOnly &amp;middot; Secure 쿠키&lt;/b&gt;에 담아 JS가 못 읽게 하고(XSS 방어), 사용할 때마다 새것으로 교체(&lt;b&gt;회전, rotation&lt;/b&gt;)합니다. 이러면 탈취된 옛 토큰이 재사용될 때 이를 감지해 토큰 계열 전체를 무효화할 수 있습니다.&lt;/li&gt;
&lt;li&gt;access 토큰은 아예 저장하지 않고 &lt;b&gt;메모리(JS 변수)&lt;/b&gt; 에만 두는 패턴도 널리 씁니다. 새로고침하면 리프레시 토큰으로 다시 받으면 되니까요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 &quot;JWT는 로그아웃이 안 된다&quot;가 아니라, &lt;b&gt;무효화 지점을 리프레시 토큰으로 옮기고 access 토큰은 짧게 굴리는 것&lt;/b&gt;이 답입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  그래서 뭘 고르나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 두 결정을 순서대로 내리면 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1320&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RCVSx/dJMcabSu6Hn/tLz6zJtuyA7brZ3EEXpD7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RCVSx/dJMcabSu6Hn/tLz6zJtuyA7brZ3EEXpD7k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RCVSx/dJMcabSu6Hn/tLz6zJtuyA7brZ3EEXpD7k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRCVSx%2FdJMcabSu6Hn%2FtLz6zJtuyA7brZ3EEXpD7k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1980&quot; height=&quot;1320&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1320&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 4. 먼저 상태를 어디 둘지(Q1) 정하고, 그다음 자격증명을 어디 저장할지(Q2) 정합니다. 두 결정은 독립적입니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q1. 상태를 어디에 둘까 &amp;mdash; 세션 vs JWT&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;세션이 맞는 경우&lt;/b&gt; &amp;mdash; 단일 서버&amp;middot;모놀리스거나 세션 저장소를 두기 쉽고, &lt;b&gt;로그아웃&amp;middot;강제 차단이 즉시&lt;/b&gt; 되어야 하며, 결제&amp;middot;관리자처럼 민감한 세션일 때. 단순하고 무효화가 확실합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JWT가 맞는 경우&lt;/b&gt; &amp;mdash; 여러 서비스&amp;middot;MSA에서 &lt;b&gt;서버 조회 없이&lt;/b&gt; 검증하고 싶거나, 외부 클라이언트가 토큰을 소비하거나, &lt;b&gt;수명이 짧은&lt;/b&gt; access 토큰으로 쓸 때. 확장성이 강점입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 문장 기준: &lt;b&gt;&quot;즉시 끊는 것&quot;이 중요하면 세션, &quot;무상태 확장&quot;이 중요하면 JWT&lt;/b&gt; 입니다. 그리고 JWT를 오래 유지해야 하면 앞의 리프레시 토큰 패턴을 함께 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q2. 자격증명을 어디 저장할까 &amp;mdash; 쿠키 vs localStorage&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본값은 &lt;b&gt;&lt;code&gt;HttpOnly; Secure; SameSite&lt;/code&gt; 쿠키&lt;/b&gt;입니다(OWASP 권장). 토큰 값이 JS에 노출되지 않고, &lt;code&gt;SameSite&lt;/code&gt;+CSRF 토큰으로 CSRF도 막습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;localStorage에 토큰을 두는 것은 OWASP가 권장하지 않습니다.&lt;/b&gt; 굳이 Bearer 헤더 방식을 써야 한다면, 긴 수명 토큰 대신 &lt;b&gt;짧은 access 토큰은 메모리에, 리프레시 토큰은 httpOnly 쿠키에&lt;/b&gt; 두는 쪽이 안전합니다.&lt;/li&gt;
&lt;li&gt;저장 위치는 사고가 났을 때 피해를 줄이는 장치일 뿐, 그 자체로 앱을 안전하게 만들어 주지는 않습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 &lt;b&gt;소규모&amp;middot;단일 서버 사이드 프로젝트&lt;/b&gt;라면 &lt;b&gt;세션 + HttpOnly 쿠키&lt;/b&gt;면 충분합니다. 리프레시 토큰 회전이나 access 토큰 메모리 보관 같은 장치는, 서비스가 여러 개로 나뉘거나 외부 클라이언트가 토큰을 쓰게 될 때 붙이면 됩니다. 처음부터 전부 갖추려 하지 않아도 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 인증 방식 정할 때 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 로그인 기능을 설계할 때 이 순서로 짚으면 됩니다. 필요하면 복사해서 쓰세요.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;[Q1 &amp;mdash; 상태를 어디에 둘까]
- [ ] 로그아웃/강제 차단이 &quot;즉시&quot; 되어야 하나? &amp;rarr; 예: 세션 유리
- [ ] 여러 서비스가 서버 조회 없이 검증해야 하나? &amp;rarr; 예: JWT 유리
- [ ] JWT로 간다면 access 토큰 수명을 짧게 잡았나? (분 단위)
- [ ] 오래 유지가 필요하면 &quot;리프레시 토큰(회전)&quot;을 별도로 뒀나?

[Q2 &amp;mdash; 자격증명을 어디 저장할까]
- [ ] 기본은 HttpOnly + Secure + SameSite 쿠키로 두었나?
- [ ] 토큰/세션ID를 localStorage에 저장하고 있지 않나? (OWASP 권장 아님)
- [ ] 쿠키를 쓰면 CSRF 방어(SameSite + CSRF 토큰)를 넣었나?

[JWT 위생 &amp;mdash; 서명/내용]
- [ ] payload에 비밀번호&amp;middot;개인정보 같은 민감정보를 넣지 않았나? (그대로 읽힘)
- [ ] 서버가 검증 알고리즘을 코드에 고정했나? (alg:none / 알고리즘 혼동 방지)
- [ ] 여러 서비스가 검증하면 비대칭키(RS256/ES256)를 썼나?

[모두 공통]
- [ ] XSS 자체를 막는 방어(출력 인코딩&amp;middot;CSP)를 먼저 갖췄나?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결정을 내렸다면 다음 한 발은 이렇습니다. 세션으로 간다면 쓰는 프레임워크의 &lt;b&gt;세션 미들웨어&lt;/b&gt;부터, 쿠키 방식이면 &lt;b&gt;&lt;code&gt;Set-Cookie&lt;/code&gt; 옵션(HttpOnly&amp;middot;Secure&amp;middot;SameSite)&lt;/b&gt; 설정부터, JWT면 검증 라이브러리의 &lt;b&gt;알고리즘 고정 옵션&lt;/b&gt;부터 찾아보시면 자연스럽게 이어집니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, &quot;세션 vs JWT&quot;는 이기고 지는 문제가 아니라 &lt;b&gt;두 개의 다른 결정&lt;/b&gt;입니다. 상태를 서버에 둘지 토큰에 둘지(Q1), 그리고 그 자격증명을 어디에 저장할지(Q2). 즉시 무효화가 중요하면 세션이, 무상태 확장이 중요하면 JWT가 어울리고, 저장은 &lt;b&gt;httpOnly 쿠키&lt;/b&gt;가 기본입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 JWT의 payload는 &lt;b&gt;암호화가 아니라 그냥 읽힌다&lt;/b&gt;는 것, 어떤 저장 위치를 골라도 &lt;b&gt;진짜 방어선은 XSS를 막는 것&lt;/b&gt;이라는 두 가지만 기억해도 대부분의 사고를 피할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 웹 기초 묶음으로, 통신 방식 자체가 궁금하다면 &lt;a href=&quot;https://atoz-developer.tistory.com/107&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Socket 통신과 HTTP 통신 비교&lt;/a&gt;를, 응답 상태 코드를 다루는 법은 &lt;a href=&quot;https://atoz-developer.tistory.com/121&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP Status Code 제어&lt;/a&gt;를 함께 보셔도 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 여기서 짧게 짚은 &lt;b&gt;OAuth 2.0 / 소셜 로그인 흐름&lt;/b&gt;이 실제로 어떻게 돌아가는지 이어서 정리해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어 주셔서 감사합니다  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc7519&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 7519: JSON Web Token (JWT)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc7515&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 7515: JSON Web Signature (JWS)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc6265&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 6265: HTTP State Management Mechanism (Cookies)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9700.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RFC 9700: Best Current Practice for OAuth 2.0 Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Set-Cookie (HttpOnly &amp;middot; Secure &amp;middot; SameSite)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Using HTTP cookies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OWASP: Session Management Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_Cheat_Sheet.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OWASP: JSON Web Token Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;OWASP: Cross-Site Request Forgery Prevention Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jwt.io/introduction&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;jwt.io: Introduction to JSON Web Tokens&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프론트엔드</category>
      <category>CSRF</category>
      <category>Httponly</category>
      <category>jwt</category>
      <category>samesite</category>
      <category>XSS</category>
      <category>세션인증</category>
      <category>웹보안</category>
      <category>쿠키</category>
      <category>토큰인증</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/184</guid>
      <comments>https://atoz-developer.tistory.com/184#entry184comment</comments>
      <pubDate>Tue, 21 Jul 2026 22:16:58 +0900</pubDate>
    </item>
    <item>
      <title>CORS 에러, 왜 나고 어떻게 푸나 &amp;mdash; 원인부터 해결까지</title>
      <link>https://atoz-developer.tistory.com/183</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드 개발하다 이 빨간 줄, 다들 한 번쯤 보셨을 겁니다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;Access to fetch at 'https://api.example.com/users' from origin
'https://myapp.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명 URL도 맞고, Postman에서는 200이 잘 떨어지는데, 브라우저에서만 막힙니다. 그래서 fetch 코드를 이리저리 고쳐 보지만 소용이 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, &lt;b&gt;이건 대부분 내 JavaScript 코드의 버그가 아닙니다.&lt;/b&gt; 서버가 &quot;이 출처는 허용한다&quot;는 응답 헤더를 안 보냈고, 그걸 본 &lt;b&gt;브라우저가 응답을 막은 것&lt;/b&gt;입니다. 그래서 프론트에서 아무리 고쳐도 안 풀리는 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 예전 &lt;a href=&quot;https://atoz-developer.tistory.com/107&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Socket 통신과 HTTP 통신 비교&lt;/a&gt; 같은 네트워크 기초 글과 같은 결로, CORS를 &quot;왜 나는지&quot;부터 &quot;어디서 고치는지&quot;까지 정리합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  오늘 끝내는 목표&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 다 읽으면 아래를 할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CORS 에러가 왜 나는지 &lt;b&gt;한 문장으로 설명&lt;/b&gt;할 수 있습니다.&lt;/li&gt;
&lt;li&gt;내 요청이 &lt;b&gt;단순 요청인지, 프리플라이트가 붙는지, 자격증명이 걸렸는지&lt;/b&gt; 진단할 수 있습니다.&lt;/li&gt;
&lt;li&gt;상황에 맞는 &lt;b&gt;올바른 해결책(서버 헤더 설정 / 개발 프록시)&lt;/b&gt; 을 고를 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 메시지를 없애는 게 아니라, &lt;b&gt;어디를 고쳐야 하는지 판단하는 기준&lt;/b&gt;을 손에 넣는 것이 목적입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  근거부터 짚고 갑니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 말씀드리면, 이 글은 제 특정 프로젝트의 디버깅 경험담이 아니라 &lt;b&gt;웹 표준(WHATWG Fetch)과 MDN 공식 문서에서 확인해 정리한 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저&amp;middot;서버 프레임워크마다 설정 방법의 세부는 다르지만, 여기서 다루는 &lt;b&gt;개념과 판단 기준은 표준이라 바뀌지 않습니다.&lt;/b&gt; 헤더 이름과 규칙은 &lt;b&gt;확인일(2026년 7월 19일, MDN&amp;middot;Fetch Standard 기준)&lt;/b&gt; 이며, 구체적인 설정 문법은 본인이 쓰는 서버/도구의 현재 문서로 확인하시는 편이 안전합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  CORS가 대체 뭔가요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;용어부터 풀겠습니다. &lt;b&gt;CORS(Cross-Origin Resource Sharing)&lt;/b&gt; 는 쉽게 말하면 &lt;b&gt;&quot;다른 출처(origin)의 자원을 브라우저에서 읽어도 되는지&quot;를 서버가 헤더로 허락하는 방식&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;b&gt;출처(origin)&lt;/b&gt; 는 &lt;b&gt;&lt;code&gt;스킴 + 호스트 + 포트&lt;/code&gt;&lt;/b&gt; 세 가지를 묶은 것입니다. 세 개가 모두 같아야 &quot;같은 출처&quot;입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;https://myapp.com&lt;/code&gt; &amp;rarr; &lt;code&gt;https://myapp.com/users&lt;/code&gt; : &lt;b&gt;같은 출처&lt;/b&gt;(경로만 다름)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;https://myapp.com&lt;/code&gt; &amp;rarr; &lt;code&gt;http://myapp.com&lt;/code&gt; : &lt;b&gt;다른 출처&lt;/b&gt;(스킴 http vs https)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;https://myapp.com&lt;/code&gt; &amp;rarr; &lt;code&gt;https://api.myapp.com&lt;/code&gt; : &lt;b&gt;다른 출처&lt;/b&gt;(호스트가 다름)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;https://myapp.com&lt;/code&gt; &amp;rarr; &lt;code&gt;https://myapp.com:8080&lt;/code&gt; : &lt;b&gt;다른 출처&lt;/b&gt;(포트가 다름)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에는 &lt;b&gt;동일 출처 정책(Same-Origin Policy)&lt;/b&gt; 이라는 보안 규칙이 있습니다. 쉽게 말하면 &quot;A 사이트에서 실행되는 스크립트가 B 사이트의 응답을 마음대로 읽지 못하게&quot; 막는 것입니다. 로그인된 웹메일이나 사내망 데이터를 악성 사이트가 몰래 긁어가는 걸 막기 위한 장치입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다른 출처로 &lt;code&gt;fetch&lt;/code&gt;를 보내면, 서버가 &lt;b&gt;&quot;이 출처는 읽어도 된다&quot;&lt;/b&gt; 는 응답 헤더(&lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;)를 붙여 줘야 브라우저가 응답을 프론트로 넘겨줍니다. 이 헤더가 없거나 안 맞으면, &lt;b&gt;요청 자체는 서버에 도착해 200이 떨어졌더라도 브라우저가 응답을 가로막고&lt;/b&gt; CORS 에러를 띄웁니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-concept.png&quot; data-origin-width=&quot;2080&quot; data-origin-height=&quot;860&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Dc8nI/dJMcacKIJN7/wLRcmT7STUpEycSzh6RXD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Dc8nI/dJMcacKIJN7/wLRcmT7STUpEycSzh6RXD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Dc8nI/dJMcacKIJN7/wLRcmT7STUpEycSzh6RXD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDc8nI%2FdJMcacKIJN7%2FwLRcmT7STUpEycSzh6RXD1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2080&quot; height=&quot;860&quot; data-filename=&quot;diagram-1-concept.png&quot; data-origin-width=&quot;2080&quot; data-origin-height=&quot;860&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-mdn-cors.png&quot; data-origin-width=&quot;2640&quot; data-origin-height=&quot;2300&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brrXVJ/dJMcafACDqE/NNBG6tkuKuGPgkcMQ6cFuk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brrXVJ/dJMcafACDqE/NNBG6tkuKuGPgkcMQ6cFuk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brrXVJ/dJMcafACDqE/NNBG6tkuKuGPgkcMQ6cFuk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrrXVJ%2FdJMcafACDqE%2FNNBG6tkuKuGPgkcMQ6cFuk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2640&quot; height=&quot;2300&quot; data-filename=&quot;official-mdn-cors.png&quot; data-origin-width=&quot;2640&quot; data-origin-height=&quot;2300&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 2. MDN &quot;Cross-Origin Resource Sharing (CORS)&quot; 문서. CORS를 &quot;서버가 자신 이외의 출처를 브라우저가 로드하도록 허용함을 알리는 HTTP 헤더 기반 메커니즘&quot;으로 정의하고 &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; 등 응답 헤더를 설명합니다. &lt;/i&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&quot;Postman&amp;middot;curl에서는 되는데 왜 브라우저만?&quot;&lt;/b&gt; &amp;mdash; CORS는 &lt;b&gt;브라우저의 &lt;code&gt;fetch&lt;/code&gt;/&lt;code&gt;XMLHttpRequest&lt;/code&gt;에만&lt;/b&gt; 적용됩니다. Postman&amp;middot;curl&amp;middot;서버 간(server-to-server) 통신은 동일 출처 정책을 구현하지 않으므로 CORS를 아예 신경 쓰지 않습니다. 그래서 &quot;Postman은 되는데 브라우저만 막힌다&quot;가 지극히 정상입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;CORS가 내 API를 보호해 주나?&quot;&lt;/b&gt; &amp;mdash; 아닙니다. CORS는 &lt;b&gt;서버를 지키는 보안 장치가 아니라, 사용자의 브라우저를 지키는 장치&lt;/b&gt;입니다. curl로는 그냥 뚫리니까요. 서버 보안은 인증/인가로 따로 해야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  왜 OPTIONS 요청이 먼저 날아가나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Network 탭을 보면, 내가 보낸 적 없는 &lt;b&gt;&lt;code&gt;OPTIONS&lt;/code&gt; 요청&lt;/b&gt;이 실제 요청 앞에 하나 더 붙는 걸 볼 때가 있습니다. 이게 &lt;b&gt;프리플라이트(preflight)&lt;/b&gt;, 쉽게 말하면 브라우저가 서버에 미리 &quot;이런 요청 보내도 돼?&quot;라고 묻는 사전 확인입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 요청에 붙는 건 아닙니다. 아래 &lt;b&gt;단순 요청(simple request)&lt;/b&gt; 조건을 전부 만족하면 프리플라이트 없이 바로 갑니다(공식 문서 기준).&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메서드가 &lt;b&gt;&lt;code&gt;GET&lt;/code&gt; &amp;middot; &lt;code&gt;HEAD&lt;/code&gt; &amp;middot; &lt;code&gt;POST&lt;/code&gt;&lt;/b&gt; 중 하나&lt;/li&gt;
&lt;li&gt;직접 설정한 헤더가 안전 목록(&lt;code&gt;Accept&lt;/code&gt;, &lt;code&gt;Accept-Language&lt;/code&gt;, &lt;code&gt;Content-Language&lt;/code&gt;, &lt;code&gt;Content-Type&lt;/code&gt;, &lt;code&gt;Range&lt;/code&gt;) 안에만 있음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Content-Type&lt;/code&gt;이 &lt;b&gt;&lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; &amp;middot; &lt;code&gt;multipart/form-data&lt;/code&gt; &amp;middot; &lt;code&gt;text/plain&lt;/code&gt;&lt;/b&gt; 중 하나&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나라도 어기면 프리플라이트가 붙습니다. 실무에서 프리플라이트를 부르는 대표 3가지는 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;Content-Type: application/json&lt;/code&gt;&lt;/b&gt; 으로 JSON을 보낼 때 (가장 흔함)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;Authorization&lt;/code&gt;&lt;/b&gt; 헤더나 커스텀 &lt;code&gt;X-...&lt;/code&gt; 헤더를 붙일 때&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;PUT&lt;/code&gt; &amp;middot; &lt;code&gt;DELETE&lt;/code&gt; &amp;middot; &lt;code&gt;PATCH&lt;/code&gt;&lt;/b&gt; 메서드를 쓸 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리플라이트가 붙으면 순서는 이렇습니다. 브라우저가 &lt;code&gt;OPTIONS&lt;/code&gt;로 &lt;b&gt;&lt;code&gt;Access-Control-Request-Method&lt;/code&gt;&lt;/b&gt;(쓸 메서드)와 &lt;b&gt;&lt;code&gt;Access-Control-Request-Headers&lt;/code&gt;&lt;/b&gt;(보낼 헤더)를 먼저 보내고, 서버가 &lt;b&gt;&lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; &amp;middot; &lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt; &amp;middot; &lt;code&gt;Access-Control-Allow-Headers&lt;/code&gt;&lt;/b&gt; 로 답합니다. 이 답이 요청과 맞아야 &lt;b&gt;그때서야 실제 요청이 나갑니다.&lt;/b&gt; 프리플라이트가 실패하면 실제 요청은 아예 보내지지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-mdn-preflight.png&quot; data-origin-width=&quot;2640&quot; data-origin-height=&quot;2300&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/banhtD/dJMb991uTh3/rk9ycLfeREtH7Qq8WgSPK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/banhtD/dJMb991uTh3/rk9ycLfeREtH7Qq8WgSPK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/banhtD/dJMb991uTh3/rk9ycLfeREtH7Qq8WgSPK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbanhtD%2FdJMb991uTh3%2Frk9ycLfeREtH7Qq8WgSPK0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2640&quot; height=&quot;2300&quot; data-filename=&quot;official-mdn-preflight.png&quot; data-origin-width=&quot;2640&quot; data-origin-height=&quot;2300&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 3. MDN &quot;Preflight request&quot; 문서. OPTIONS 요청에 &lt;code&gt;Access-Control-Request-Method&lt;/code&gt;&amp;middot;&lt;code&gt;Access-Control-Request-Headers&lt;/code&gt;가 실리고, 서버가 대응하는 &lt;code&gt;Access-Control-Allow-*&lt;/code&gt;로 답하는 교환을 설명합니다.&amp;nbsp;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매번 &lt;code&gt;OPTIONS&lt;/code&gt;가 붙는 게 부담이면, 서버가 &lt;b&gt;&lt;code&gt;Access-Control-Max-Age&lt;/code&gt;&lt;/b&gt;(초 단위)로 프리플라이트 결과를 캐시하게 할 수 있습니다. 다만 브라우저가 상한을 둡니다(Chromium 계열 최대 2시간=7200초, Firefox 최대 24시간=86400초).&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  쿠키&amp;middot;토큰을 같이 보낼 때(자격증명)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 세션 쿠키나 인증 토큰을 함께 보내는 요청, 즉 &lt;b&gt;자격증명(credentials) 요청&lt;/b&gt;(&lt;code&gt;fetch&lt;/code&gt;의 &lt;code&gt;credentials: 'include'&lt;/code&gt;, XHR의 &lt;code&gt;withCredentials = true&lt;/code&gt;)에는 함정이 하나 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때는 서버의 &lt;b&gt;&lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;에 와일드카드 &lt;code&gt;*&lt;/code&gt; 를 쓸 수 없습니다.&lt;/b&gt; 반드시 요청한 출처를 그대로 적어 줘야 합니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;# ❌ 자격증명 요청인데 와일드카드 &amp;rarr; 브라우저가 응답 차단
Access-Control-Allow-Origin: *

# ✅ 구체적인 출처를 명시 + 자격증명 허용
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Access-Control-Allow-Credentials&lt;/code&gt;는 값이 &lt;b&gt;&lt;code&gt;true&lt;/code&gt; 하나뿐&lt;/b&gt;이고 대소문자를 가립니다. 그리고 자격증명 요청에서는 &lt;code&gt;Access-Control-Allow-Headers&lt;/code&gt;나 &lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt;에 쓴 &lt;code&gt;*&lt;/code&gt; 도 와일드카드가 아니라 &lt;b&gt;글자 그대로 &lt;code&gt;*&lt;/code&gt;라는 헤더 이름&lt;/b&gt;으로 취급되므로, 허용할 헤더&amp;middot;메서드를 하나하나 적어 줘야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콘솔에 &lt;code&gt;Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'&lt;/code&gt; 가 뜬다면 정확히 이 경우입니다. 서버 설정에서 &lt;code&gt;*&lt;/code&gt; 를 구체적인 출처로 바꾸면 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  그래서 어디를 고쳐야 하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제 해결입니다. 핵심 원칙 하나만 기억하면 됩니다. &lt;b&gt;CORS 에러는 대부분 서버에서 고칩니다.&lt;/b&gt; 브라우저는 보안상 에러의 상세 이유를 JS에 알려주지 않으므로(그래서 &lt;code&gt;try/catch&lt;/code&gt;로 못 잡습니다), 먼저 &lt;b&gt;DevTools 콘솔과 Network 탭&lt;/b&gt;에서 원인을 확인하고 아래 순서로 판단합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;2080&quot; data-origin-height=&quot;1000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rKxI4/dJMcahrIjyu/dxj9JoI5XAO5NkKB7OQk41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rKxI4/dJMcahrIjyu/dxj9JoI5XAO5NkKB7OQk41/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rKxI4/dJMcahrIjyu/dxj9JoI5XAO5NkKB7OQk41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrKxI4%2FdJMcahrIjyu%2Fdxj9JoI5XAO5NkKB7OQk41%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2080&quot; height=&quot;1000&quot; data-filename=&quot;diagram-2-decision.png&quot; data-origin-width=&quot;2080&quot; data-origin-height=&quot;1000&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;① 서버를 내가 고칠 수 있다 &amp;rarr; 서버에 허용 헤더를 추가합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 정석입니다. 서버(또는 CORS 미들웨어)가 요청 출처에 맞는 헤더를 응답에 붙이게 설정합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 출처를 허용해야 하면 요청 &lt;code&gt;Origin&lt;/code&gt;을 확인해 그에 맞는 값을 돌려주고, 이때 &lt;code&gt;Vary: Origin&lt;/code&gt; 을 함께 보내 캐시가 출처별로 응답을 구분하게 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;② 외부 API라 서버를 못 고친다 &amp;rarr; 내가 통제하는 프록시로 우회합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 &lt;b&gt;같은 출처로 인식&lt;/b&gt;하게 만들면 CORS 자체가 적용되지 않습니다. 개발 중에는 번들러의 개발 서버 프록시가 편합니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot; data-lang=&quot;js&quot;&gt;&lt;code&gt;// vite.config.js &amp;mdash; /api 로 시작하는 요청을 외부 API로 대신 전달
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
      },
    },
  },
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면 브라우저는 내 개발 서버(같은 출처)와만 통신하므로 CORS 헤더가 필요 없어집니다. 운영 환경에서는 내가 만든 백엔드가 대신 외부 API를 호출(server-to-server라 CORS 무관)해 응답을 돌려주는 방식으로 같은 효과를 냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결이 아닌 것 두 가지도 알아 두세요.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;fetch(url, { mode: 'no-cors' })&lt;/code&gt;&lt;/b&gt; &amp;mdash; 에러는 사라진 것처럼 보이지만, 응답이 &lt;b&gt;opaque&lt;/b&gt;(상태 0, 헤더&amp;middot;본문 읽기 불가)가 됩니다. 데이터를 얻는 게 아니라 못 읽게 되는 것이라, 로깅처럼 응답을 안 읽는 요청 말고는 해결책이 아닙니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;브라우저 보안 끄기&lt;/b&gt;(실행 플래그, CORS 해제 확장) &amp;mdash; 내 컴퓨터에서만 검사를 끄는 것이라, 실제 사용자 브라우저는 여전히 막습니다. 디버깅용이지 배포용 해결이 아닙니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ CORS 에러 만났을 때 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러를 만나면 이 순서대로 짚으면 대부분 원인이 잡힙니다. 필요하면 복사해서 쓰세요.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;[진단 &amp;mdash; 콘솔/Network 탭 먼저]
- [ ] Network 탭에 OPTIONS 요청이 따로 있나? &amp;rarr; 프리플라이트가 붙는 요청
- [ ] 콘솔 에러 문구 확인: 'Allow-Origin missing'인가 / '*' + credentials인가 / preflight 실패인가
- [ ] Postman/curl로는 되나? &amp;rarr; 되면 서버는 정상, 브라우저 CORS 문제 확정

[요청 성격 파악]
- [ ] Content-Type이 application/json인가, Authorization/커스텀 헤더가 있나, PUT/DELETE인가 &amp;rarr; 프리플라이트 대상
- [ ] 쿠키/토큰을 같이 보내나(credentials: 'include')? &amp;rarr; Allow-Origin에 '*' 금지

[해결]
- [ ] 서버를 고칠 수 있다 &amp;rarr; Access-Control-Allow-Origin/-Methods/-Headers 추가 (자격증명이면 구체 출처 + Allow-Credentials: true)
- [ ] 외부 API라 못 고친다 &amp;rarr; 개발 프록시(vite/webpack) 또는 내 백엔드가 대신 호출
- [ ] no-cors&amp;middot;브라우저 보안끄기는 해결이 아님(임시 진단용만)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, CORS 에러는 &quot;내 코드가 틀렸다&quot;는 신호가 아니라 &lt;b&gt;&quot;서버가 이 브라우저 출처를 아직 허락하지 않았다&quot;&lt;/b&gt; 는 신호입니다. 그래서 대부분 프론트가 아니라 &lt;b&gt;서버(또는 프록시)에서&lt;/b&gt; 풀립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서는 늘 같습니다. 콘솔에서 원인을 읽고 &amp;rarr; 단순 요청인지 프리플라이트&amp;middot;자격증명이 걸렸는지 진단하고 &amp;rarr; 서버를 고칠 수 있으면 허용 헤더를, 못 고치면 프록시를 씁니다. &lt;code&gt;no-cors&lt;/code&gt;나 보안 끄기로 도망가지 않는 것이 핵심입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 네트워크 기초로, 통신 방식 자체가 궁금하다면 &lt;a href=&quot;https://atoz-developer.tistory.com/107&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Socket 통신과 HTTP 통신 비교&lt;/a&gt;를, 응답 상태 코드를 제대로 다루는 법은 &lt;a href=&quot;https://atoz-developer.tistory.com/121&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;HTTP Status Code 제어&lt;/a&gt;를 함께 보셔도 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 자주 만나는 &lt;b&gt;HTTP 상태 코드(401&amp;middot;403&amp;middot;404&amp;middot;500 등)를 상황별로 어떻게 읽고 대응&lt;/b&gt;하는지 이어서 정리해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어 주셔서 감사합니다  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Cross-Origin Resource Sharing (CORS)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Same-origin policy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Preflight request (Glossary)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS/Errors&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: CORS 에러 원인&amp;middot;해결&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Access-Control-Allow-Origin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Access-Control-Allow-Credentials&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MDN: Access-Control-Max-Age&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fetch.spec.whatwg.org/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;WHATWG Fetch Standard (CORS 프로토콜)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://vite.dev/config/server-options&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Vite: server.proxy 설정&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프론트엔드</category>
      <category>CORS</category>
      <category>fetch</category>
      <category>preflight</category>
      <category>네트워크</category>
      <category>동일출처정책</category>
      <category>브라우저</category>
      <category>웹개발</category>
      <category>크로스오리진</category>
      <category>프론트엔드</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/183</guid>
      <comments>https://atoz-developer.tistory.com/183#entry183comment</comments>
      <pubDate>Sun, 19 Jul 2026 23:13:46 +0900</pubDate>
    </item>
    <item>
      <title>CodePush가 끝났습니다 &amp;mdash; React Native OTA 업데이트를 EAS Update로 옮기기</title>
      <link>https://atoz-developer.tistory.com/182</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React Native 앱을 운영해 보셨다면 이런 경험이 있으실 겁니다. 오타 하나, 버튼 색 하나 고치자고 스토어 심사를 다시 기다리는 게 아까워서, &lt;b&gt;CodePush로 JavaScript만 무선(OTA, 심사 없이 앱에 바로 반영)으로 밀어 넣던&lt;/b&gt; 방식 말입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 그 CodePush가 이제 없습니다. Microsoft의 App Center가 &lt;b&gt;2025년 3월 31일 은퇴&lt;/b&gt;하면서 호스팅형 CodePush도 함께 종료됐고, 관련 저장소도 읽기 전용으로 아카이브됐습니다. 새로 OTA를 도입하려는 분도, 쓰던 걸 옮겨야 하는 분도 같은 질문 앞에 섭니다. &lt;b&gt;&quot;그럼 이제 뭘로 하지?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, Expo가 공식적으로 권장하는 대체재는 &lt;b&gt;EAS Update&lt;/b&gt;입니다. 다만 갈아타기 전에 꼭 짚어야 할 게 있습니다. OTA로 &lt;b&gt;바꿀 수 있는 것과 없는 것의 경계&lt;/b&gt;, 그리고 우리 팀에 EAS Update가 맞는지 아니면 자체 호스팅이 맞는지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;a href=&quot;https://atoz-developer.tistory.com/180&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo vs React Native CLI&lt;/a&gt;에서 시작해 &lt;a href=&quot;https://atoz-developer.tistory.com/181&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;development build로 네이티브 라이브러리 붙이기&lt;/a&gt;까지 이어 온 흐름의 다음 편입니다. 앱을 빌드했으면, 그다음은 &lt;b&gt;배포와 업데이트&lt;/b&gt;니까요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  오늘 끝내는 목표&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 다 읽으면 아래를 할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CodePush가 지금 &lt;b&gt;어떤 상태인지&lt;/b&gt; 한 문장으로 설명할 수 있습니다.&lt;/li&gt;
&lt;li&gt;OTA로 &lt;b&gt;바꿀 수 있는 변경과, 반드시 새 빌드가 필요한 변경&lt;/b&gt;을 구분할 수 있습니다.&lt;/li&gt;
&lt;li&gt;우리 팀이 &lt;b&gt;EAS Update로 갈지, 자체 호스팅이나 다른 도구를 볼지&lt;/b&gt; 판단 기준이 생깁니다.&lt;/li&gt;
&lt;li&gt;EAS Update의 &lt;b&gt;설정 흐름(명령&amp;middot;설정키)&lt;/b&gt; 을 처음부터 압니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능 하나를 소개하는 게 아니라, &lt;b&gt;&quot;코드를 고친 뒤 사용자 손에 전달하는 경로&quot;&lt;/b&gt; 를 다시 세우는 것이 이 글의 목적입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  근거부터 짚고 갑니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 말씀드리면, 여기 나오는 명령&amp;middot;설정&amp;middot;가격은 제가 실제 상용 앱의 OTA 파이프라인을 운영해 본 결과가 아니라 &lt;b&gt;Expo&amp;middot;Microsoft&amp;middot;Apple&amp;middot;Google의 공식 문서에서 확인해 정리한 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전과 수치는 &lt;b&gt;확인일(2026년 7월 19일, Expo SDK 57 &amp;middot; React Native 0.86 기준)&lt;/b&gt; 입니다. Expo의 가격과 무료 한도, 그리고 각 라이브러리 버전은 자주 바뀌므로, 실제 작업 전에는 본인 시점의 공식 문서로 다시 확인하시는 편이 안전합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  CodePush는 정말 끝났나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 현재 상태부터 정리하겠습니다. 여기가 흔들리면 뒤 결정이 다 흔들리기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;용어부터. OTA(Over-the-Air) 업데이트&lt;/b&gt;는 쉽게 말하면 앱스토어 심사를 거치지 않고 JavaScript&amp;middot;이미지 같은 부분만 서버에서 앱으로 바로 내려보내는 업데이트입니다. React Native 진영에서 이 방식을 대표하던 도구가 Microsoft의 &lt;b&gt;CodePush&lt;/b&gt;였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서로 확인한 현재 상태는 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;App Center는 2025년 3월 31일 은퇴했습니다.&lt;/b&gt; 이후로는 로그인도, API 호출도 되지 않습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;호스팅형 CodePush도 같은 날 완전히 종료&lt;/b&gt;됐고, 신규 앱 온보딩이 불가능합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CodePush의 두 저장소(클라이언트 SDK와 서버)는 2025년 5월 20일 읽기 전용으로 아카이브&lt;/b&gt;됐습니다. Microsoft는 더 이상 유지보수&amp;middot;지원&amp;middot;기여를 받지 않고, &quot;커뮤니티 포크용&quot;으로만 공개해 두었습니다.&lt;/li&gt;
&lt;li&gt;App Center 기능 중 지원이 연장된 것은 Analytics &amp;amp; Diagnostics뿐이고(2027년 3월까지), &lt;b&gt;CodePush는 그 연장 대상이 아닙니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 &quot;관리형 CodePush&quot;라는 선택지는 이제 존재하지 않습니다. Microsoft가 은퇴 페이지에서 안내하는 유일한 CodePush 경로도 &lt;b&gt;자체 호스팅용 오픈소스 서버&lt;/b&gt;인데, 그 서버 저장소마저 위처럼 아카이브 상태입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-codepush-archived.png&quot; data-origin-width=&quot;2800&quot; data-origin-height=&quot;2100&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cunXj4/dJMcaaszmwi/phG5hgXFbPKnKqUhST3Xh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cunXj4/dJMcaaszmwi/phG5hgXFbPKnKqUhST3Xh1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cunXj4/dJMcaaszmwi/phG5hgXFbPKnKqUhST3Xh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcunXj4%2FdJMcaaszmwi%2FphG5hgXFbPKnKqUhST3Xh1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2800&quot; height=&quot;2100&quot; data-filename=&quot;official-codepush-archived.png&quot; data-origin-width=&quot;2800&quot; data-origin-height=&quot;2100&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 1. GitHub의 microsoft/react-native-code-push 저장소. 상단에 &quot;2025년 5월 20일 소유자가 아카이브함, 읽기 전용&quot; 배너와 &quot;Public archive&quot; 배지가 붙어 있고, 마지막 릴리스는 v9.0.1(2024-12-19)에서 멈춰 있습니다. &lt;br /&gt;출처: github.com/microsoft/react-native-code-push, 확인일 2026-07-19.&lt;/i&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  OTA로 되는 것과 안 되는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;갈아탈 도구를 고르기 전에, 사실 더 중요한 건 이겁니다. &lt;b&gt;OTA로 애초에 바꿀 수 없는 게 있습니다.&lt;/b&gt; 여기를 오해하면 어떤 도구를 골라도 사고가 납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expo 공식 문서 기준으로, OTA 업데이트로 교체할 수 있는 건 &lt;b&gt;JavaScript 번들, 스타일, 이미지 같은 asset 등 &quot;네이티브가 아닌 부분&quot;뿐&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 아래는 OTA로 안 되고, &lt;b&gt;새 바이너리를 빌드해서 스토어에 다시 제출&lt;/b&gt;해야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네이티브 코드나 네이티브 의존성(라이브러리) 변경&lt;/li&gt;
&lt;li&gt;카메라&amp;middot;위치 같은 &lt;b&gt;앱 권한&lt;/b&gt; 변경&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Expo SDK 버전 업그레이드&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;그 밖에 새 앱 바이너리 버전이 필요한 모든 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-boundary.png&quot; data-origin-width=&quot;2060&quot; data-origin-height=&quot;944&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsDO3r/dJMcadQfNTD/ezI9UCKKdKfGMyXsQqH6ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsDO3r/dJMcadQfNTD/ezI9UCKKdKfGMyXsQqH6ok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsDO3r/dJMcadQfNTD/ezI9UCKKdKfGMyXsQqH6ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsDO3r%2FdJMcadQfNTD%2FezI9UCKKdKfGMyXsQqH6ok%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2060&quot; height=&quot;944&quot; data-filename=&quot;diagram-1-boundary.png&quot; data-origin-width=&quot;2060&quot; data-origin-height=&quot;944&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 2. JavaScript&amp;middot;asset 같은 비네이티브 변경만 OTA로 나가고, 네이티브&amp;middot;권한&amp;middot;SDK 변경은 새 빌드가 필요합니다. &lt;br /&gt;runtimeVersion이 둘 사이의 호환성 게이트 역할을 합니다.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서도 같은 기준을 표로 제시합니다. &quot;When to use EAS Update&quot; 항목을 보면 어떤 변경이 OTA로 되고 어떤 변경이 새 빌드를 요구하는지 한눈에 정리돼 있습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-eas-update-intro.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;2000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bb1DVi/dJMcahE87HK/8DMO4Q37bvrUEkkHwsnFmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bb1DVi/dJMcahE87HK/8DMO4Q37bvrUEkkHwsnFmK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bb1DVi/dJMcahE87HK/8DMO4Q37bvrUEkkHwsnFmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbb1DVi%2FdJMcahE87HK%2F8DMO4Q37bvrUEkkHwsnFmK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;2000&quot; data-filename=&quot;official-eas-update-intro.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;2000&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 3. Expo 공식 &quot;EAS Update 소개&quot; 문서의 When to use 표. JavaScript 버그 수정&amp;middot;문구/번역&amp;middot;UI 변경&amp;middot;롤아웃&amp;middot;CI 게시는 OTA로 가능(✓), 네이티브 코드&amp;middot;앱 권한&amp;middot;Expo SDK 버전&amp;middot;새 바이너리는 불가(✗)로 명시합니다. 출처: docs.expo.dev/eas-update/introduction, 확인일 2026-07-19.&lt;br /&gt;&lt;br /&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경계를 지켜 주는 장치가 &lt;b&gt;&lt;code&gt;runtimeVersion&lt;/code&gt;&lt;/b&gt; 입니다. 쉽게 말하면 &quot;이 빌드의 네이티브 코드와 호환되는 업데이트만 실행하라&quot;는 표식입니다. 공식 문서 표현으로, &lt;b&gt;업데이트는 빌드의 네이티브 코드와 호환돼야 그 빌드에서 실행&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;네이티브 코드가 바뀔 때마다, 업데이트를 게시하기 전에 새 빌드가 필요합니다.&lt;/b&gt; 앞서 &lt;a href=&quot;https://atoz-developer.tistory.com/181&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;development build 글&lt;/a&gt;에서 &quot;네이티브 라이브러리를 추가하면 다시 빌드해야 한다&quot;고 했던 그 원리가, 배포 단계에서도 그대로 이어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 실수하기 쉬운 지점이 있습니다. &lt;b&gt;네이티브를 바꿨는데 &lt;code&gt;runtimeVersion&lt;/code&gt;을 올리지 않으면&lt;/b&gt;, 버전이 &quot;거짓으로 일치&quot;해서 호환되지 않는 업데이트가 로드될 수 있습니다. 이때 expo-updates가 오류를 감지해 롤백을 &lt;b&gt;시도&lt;/b&gt;하긴 하지만, 이건 최선의 노력일 뿐 항상 보장되지는 않습니다. 안전은 결국 &quot;네이티브를 바꾸면 반드시 &lt;code&gt;runtimeVersion&lt;/code&gt;을 올린다&quot;는 규칙에서 나옵니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ EAS Update는 어떻게 동작하나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 대체재를 봅니다. &lt;b&gt;EAS Update&lt;/b&gt;는 쉽게 말하면 Expo가 운영하는 OTA 업데이트 서비스이고, 앱 쪽에서는 &lt;b&gt;&lt;code&gt;expo-updates&lt;/code&gt;&lt;/b&gt; 라이브러리가 그 역할을 맡습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 동작은 이렇습니다. 앱이 실행될 때 새 업데이트가 있는지 확인하고, 더 최신이면 내려받아 다음 실행 때(또는 설정에 따라) 반영합니다. 없으면 캐시된 최신 업데이트나 앱에 내장된 버전으로 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 확인 동작은 &lt;code&gt;app.json&lt;/code&gt;의 &lt;code&gt;updates&lt;/code&gt; 설정으로 조정하고, 원하는 시점에 직접 확인하고 싶으면 공식 &lt;code&gt;expo-updates&lt;/code&gt; API를 씁니다(공식 문서 기준).&lt;/p&gt;
&lt;pre class=&quot;javascript&quot; data-lang=&quot;js&quot;&gt;&lt;code&gt;import * as Updates from 'expo-updates';

async function checkForUpdate() {
  const result = await Updates.checkForUpdateAsync();
  if (result.isAvailable) {
    await Updates.fetchUpdateAsync();
    await Updates.reloadAsync(); // 새 업데이트로 앱을 다시 시작
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포를 이해하려면 두 단어만 잡으면 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;채널(channel)&lt;/b&gt; &amp;mdash; 쉽게 말하면 &quot;이 빌드가 구독하는 업데이트 줄&quot;입니다. &lt;b&gt;빌드 시점에 확정되어 앱의 네이티브 코드 안에 박힙니다.&lt;/b&gt; 보통 &lt;code&gt;production&lt;/code&gt;, &lt;code&gt;preview&lt;/code&gt;처럼 씁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;브랜치(branch)&lt;/b&gt; &amp;mdash; 업데이트들이 순서대로 쌓이는 목록입니다. git 브랜치에 커밋이 쌓이는 것과 비슷합니다. 브랜치의 가장 최근 업데이트가 활성 업데이트가 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널은 기본적으로 &lt;b&gt;같은 이름의 브랜치에 연결&lt;/b&gt;됩니다. 필요하면 채널이 가리키는 브랜치를 바꿀 수 있어서(&lt;code&gt;eas channel:edit production --branch version-2.0&lt;/code&gt;), &quot;staging에서 검증한 업데이트를 production으로 승격&quot; 같은 패턴이 가능합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 설정 흐름: 설치부터 첫 게시까지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큰 흐름을 먼저 보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-flow.png&quot; data-origin-width=&quot;2170&quot; data-origin-height=&quot;840&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uC3Bo/dJMcaixmrmW/1qMtNdPm2htQs77KYVnSR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uC3Bo/dJMcaixmrmW/1qMtNdPm2htQs77KYVnSR0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uC3Bo/dJMcaixmrmW/1qMtNdPm2htQs77KYVnSR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuC3Bo%2FdJMcaixmrmW%2F1qMtNdPm2htQs77KYVnSR0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2170&quot; height=&quot;840&quot; data-filename=&quot;diagram-2-flow.png&quot; data-origin-width=&quot;2170&quot; data-origin-height=&quot;840&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1단계 &amp;mdash; eas-cli 준비&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 EAS 명령줄 도구를 설치하고 로그인합니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;npm install --global eas-cli
eas login&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계 &amp;mdash; (필요 시) Expo 모듈 설치&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에 &lt;code&gt;expo&lt;/code&gt; 패키지가 아직 없는 순정 React Native 앱이라면, EAS Update를 붙이기 전에 Expo 모듈을 먼저 설치합니다. 이미 Expo로 만든 프로젝트라면 이 단계는 건너뜁니다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;npx install-expo-modules@latest&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 예전 안내에 있던 &quot;&lt;code&gt;npx expo install expo-updates&lt;/code&gt;를 &lt;br /&gt;따로 실행&quot; 단계는 현재 공식 &quot;Get started&quot; 가이드에는 없습니다. &lt;br /&gt;아래 &lt;code&gt;configure&lt;/code&gt;가 필요한 설정을 대신 잡아 줍니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계 &amp;mdash; 프로젝트 설정 (&lt;code&gt;eas update:configure&lt;/code&gt;)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 명령은 이 하나입니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;eas update:configure&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 명령이 자동으로 아래를 처리해 줍니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;app.json&lt;/code&gt;에 &lt;b&gt;&lt;code&gt;runtimeVersion&lt;/code&gt;&lt;/b&gt;, &lt;b&gt;&lt;code&gt;updates.url&lt;/code&gt;&lt;/b&gt; 을 쓰고 &lt;b&gt;&lt;code&gt;extra.eas.projectId&lt;/code&gt;&lt;/b&gt; 를 추가합니다.&lt;/li&gt;
&lt;li&gt;순정/네이티브 프로젝트(비 CNG)라면 &lt;code&gt;AndroidManifest.xml&lt;/code&gt;과 &lt;code&gt;Expo.plist&lt;/code&gt;의 관련 값도 함께 편집합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;EAS Build를 쓴다면 &lt;code&gt;eas.json&lt;/code&gt;의 &lt;code&gt;preview&lt;/code&gt;&amp;middot;&lt;code&gt;production&lt;/code&gt; 프로파일에 &lt;code&gt;channel&lt;/code&gt;을 자동으로 설정합니다.&lt;/b&gt; 채널을 프로파일마다 손으로 넣을 필요가 없다는 뜻입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;runtimeVersion&lt;/code&gt;의 기본값은 아래처럼 앱 버전을 따르는 정책입니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot; data-lang=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;expo&quot;: {
    &quot;runtimeVersion&quot;: { &quot;policy&quot;: &quot;appVersion&quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정책은 목적에 따라 고릅니다(공식 문서 기준).&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;appVersion&lt;/code&gt;&lt;/b&gt; &amp;mdash; 앱의 &lt;code&gt;version&lt;/code&gt; 값과 동일. 기본이자 가장 무난합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;nativeVersion&lt;/code&gt;&lt;/b&gt; &amp;mdash; &lt;code&gt;버전(빌드번호)&lt;/code&gt; 조합으로 더 촘촘하게 나눕니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;fingerprint&lt;/code&gt;&lt;/b&gt; &amp;mdash; &lt;code&gt;@expo/fingerprint&lt;/code&gt;가 네이티브 구성을 해시로 계산해 자동으로 버전을 매깁니다. 네이티브에 영향 주는 변경이 생기면 버전이 자동으로 바뀌어, 비호환 업데이트가 나갈 가능성을 크게 줄여 줍니다. 대신 빌드가 더 자주 필요해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4단계 &amp;mdash; 빌드 (채널&amp;middot;runtimeVersion 심기)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채널과 runtimeVersion이 박힌 빌드를 만듭니다. 이 빌드가 나중에 어떤 업데이트를 받을지가 여기서 확정됩니다.&lt;/p&gt;
&lt;pre class=&quot;brainfuck&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;eas build --profile production --platform all&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5단계 &amp;mdash; 업데이트 게시 (&lt;code&gt;eas update&lt;/code&gt;)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 JavaScript를 고친 뒤 OTA로 내보냅니다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;eas update --channel production --message &quot;결제 화면 문구 수정&quot; --environment production&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시할 때 알아 두면 좋은 세 가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;--channel&lt;/code&gt;(채널이 가리키는 브랜치에 게시)&lt;/b&gt; 또는 &lt;b&gt;&lt;code&gt;--branch&lt;/code&gt;(브랜치 직접 지정)&lt;/b&gt; 중 하나로 게시합니다. &lt;code&gt;--channel&lt;/code&gt;로 게시하면 채널이 가리키는 브랜치(기본은 같은 이름의 브랜치)에 올라가고, 그 브랜치가 아직 없으면 자동으로 생성됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;--environment&lt;/code&gt; 플래그는 Expo SDK 55 이상에서 필수&lt;/b&gt;입니다. (54 이하는 생략 시 로컬 &lt;code&gt;.env&lt;/code&gt;로 폴백합니다.)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단계적 배포(rollout)가 진행 중인 채널에는 &lt;code&gt;--channel&lt;/code&gt;을 쓸 수 없습니다.&lt;/b&gt; 어느 브랜치인지 확정할 수 없기 때문이며, 이때는 &lt;code&gt;--branch&lt;/code&gt;로 게시합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜치&amp;middot;채널 관리는 &lt;code&gt;eas branch:*&lt;/code&gt;, &lt;code&gt;eas channel:*&lt;/code&gt; 명령으로 하고, 필요하면 &lt;code&gt;eas update:republish&lt;/code&gt;(과거 업데이트 재게시)나 &lt;code&gt;eas update:roll-back-to-embedded&lt;/code&gt;(내장 버전으로 롤백)도 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  CodePush를 쓰던 앱이라면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 5단계는 새로 붙이는 경우이고, 이미 CodePush로 배포하던 앱은 정리할 게 더 있습니다. Expo 공식 마이그레이션 가이드가 짚는 핵심은 세 가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;기존 &lt;code&gt;react-native-code-push&lt;/code&gt; 의존성과 네이티브 연결을 제거합니다.&lt;/b&gt; 두 업데이트 시스템이 동시에 돌면 서로 업데이트를 덮어써 충돌하기 때문입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;codePush()&lt;/code&gt;로 앱을 감싸던 코드와 수동 &lt;code&gt;sync()&lt;/code&gt; 호출을 걷어냅니다.&lt;/b&gt; 그 역할은 &lt;code&gt;expo-updates&lt;/code&gt;가 대신합니다(기본은 실행 시 자동 확인).&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개념은 아래처럼 매핑됩니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;height: 76px; width: 844px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;th style=&quot;height: 18px; width: 452px;&quot;&gt;CodePush&lt;/th&gt;
&lt;th style=&quot;height: 18px; width: 392px;&quot;&gt;EAS Update&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px; width: 452px; text-align: center;&quot;&gt;Deployment (Staging / Production)&lt;/td&gt;
&lt;td style=&quot;height: 18px; width: 392px; text-align: center;&quot;&gt;Channel (preview / production) + Branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px; width: 452px; text-align: center;&quot;&gt;Deployment key로 빌드가 구독&lt;/td&gt;
&lt;td style=&quot;height: 20px; width: 392px; text-align: center;&quot;&gt;빌드에 박힌 channel로 구독&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;height: 20px; width: 452px; text-align: center;&quot;&gt;네이티브 호환성 개념이 약함&lt;/td&gt;
&lt;td style=&quot;height: 20px; width: 392px; text-align: center;&quot;&gt;&lt;b&gt;runtimeVersion으로 명시적 호환성 관리&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 큰 차이는 마지막 줄입니다. CodePush에서 느슨했던 &quot;이 업데이트가 이 빌드와 맞나&quot;를 EAS Update는 &lt;code&gt;runtimeVersion&lt;/code&gt;으로 분명하게 관리합니다. 그래서 마이그레이션의 절반은 도구를 바꾸는 일이 아니라, &lt;b&gt;&quot;네이티브가 바뀌면 새 빌드&quot;라는 습관을 세우는 일&lt;/b&gt;입니다. 구체적인 전환 순서는 아래 참고 자료의 Expo &quot;Migrate from CodePush&quot; 문서를 그대로 따르시면 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  앱 심사 규정은 괜찮을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;OTA로 앱을 고치는 게 스토어 정책상 괜찮나?&quot;는 자주 나오는 걱정입니다. 결론은 &lt;b&gt;범위를 지키면 허용&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Apple&lt;/b&gt; &amp;mdash; App Store 심사 지침 2.5.2는 앱이 자기 완결적이어야 하고 &quot;앱의 기능을 새로 도입하거나 바꾸는 코드를 내려받아 실행&quot;하는 것을 제한합니다. 다만 이건 절대 금지가 아니라 &lt;b&gt;기능이 바뀌는지를 보는 기준&lt;/b&gt;입니다. &lt;b&gt;해석형(interpreted) 코드에 대한 예외&lt;/b&gt; 덕분에, JavaScript만 내려보내 이미 심사된 앱을 고치는 것은 허용됩니다. 대신 지켜야 할 선이 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱스토어에 제출해 &lt;b&gt;심사받은 주 목적(primary purpose)을 OTA로 바꾸지 않을 것&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;심사 때 숨겨 둔 기능을 나중에 OTA로 &quot;잠금 해제&quot;하지 않을 것(지침 2.3.1, 숨은 기능 금지)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Google Play&lt;/b&gt; &amp;mdash; 기기&amp;middot;네트워크 악용 정책은 &quot;Google Play 외의 방법으로 앱을 수정&amp;middot;교체&amp;middot;업데이트하거나 실행 코드를 내려받는 것&quot;을 원칙적으로 금지합니다. 하지만 &lt;b&gt;인터프리터에서 도는 코드(웹뷰의 JavaScript 등)에는 이 제한이 적용되지 않는다&lt;/b&gt;는 예외가 있어, React Native의 JS 번들 OTA는 여기에 해당합니다. 단, OTA로 내려보내는 JS도 Play 정책을 지켜야 하며 심사 우회 용도로 쓰면 안 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇습니다. &lt;b&gt;심사된 앱의 범위 안에서 버그 수정&amp;middot;문구/번역 변경&amp;middot;UI 다듬기 같은 JS/asset 변경은 OTA로 나가도 됩니다.&lt;/b&gt; 반려는 &quot;OTA를 써서&quot;가 아니라 &quot;OTA로 심사된 앱의 본질을 바꿔서&quot; 생깁니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  EAS Update가 맞는 팀, 아닌 팀&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 결정입니다. 먼저 &lt;b&gt;모든 선택지에 공통으로 걸리는 조건&lt;/b&gt;부터 짚겠습니다. New Architecture(React Native의 새 네이티브 구조)는 RN 0.76부터 기본값이었고, &lt;b&gt;0.82부터는 레거시 비활성화 설정(&lt;code&gt;newArchEnabled=false&lt;/code&gt; 등)이 무시되어 사실상 강제&lt;/b&gt;됩니다(레거시 API 제거는 0.83부터 시작). 그래서 어떤 OTA 도구를 고르든 &lt;b&gt;New Architecture 지원 여부가 협상 불가 조건&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 전제 위에서, 성향별로 이렇게 갈립니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;이런 팀이라면&lt;/th&gt;
&lt;th&gt;선택&lt;/th&gt;
&lt;th&gt;New Arch&lt;/th&gt;
&lt;th&gt;상태&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;관리형 편의, 운영 부담 최소화, Expo 생태계&lt;/td&gt;
&lt;td&gt;&lt;b&gt;EAS Update&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;Expo 공식&amp;middot;활발&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodePush API를 그대로 유지하고 싶음(호스팅형)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Revopush&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;O (RN &amp;ge;0.76)&lt;/td&gt;
&lt;td&gt;활성, 클라이언트 fork&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;완전 통제 + 현대 OSS, 클라우드 인프라 운영 가능&lt;/td&gt;
&lt;td&gt;&lt;b&gt;hot-updater&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;활성 자체호스팅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최소&amp;middot;DIY, git 저장소나 작은 서버로 충분&lt;/td&gt;
&lt;td&gt;&lt;b&gt;react-native-ota-hot-update&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;O (v2+)&lt;/td&gt;
&lt;td&gt;활성 경량&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 &lt;b&gt;지금 피해야 할 선택&lt;/b&gt;도 분명합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;순정 &lt;code&gt;microsoft/react-native-code-push&lt;/code&gt; 단독&lt;/b&gt; &amp;mdash; 2025년 5월 아카이브됐고 &lt;b&gt;New Architecture를 지원하지 않습니다.&lt;/b&gt; New Arch가 강제되는 RN 0.82 이상에서는 설치 자체가 막힙니다. 게다가 백엔드도 이미 종료됐습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Ionic Appflow&lt;/b&gt; &amp;mdash; 신규 고객 판매가 중단됐고(기존 고객 접근은 2027년까지), 애초에 Live Updates는 Capacitor/Cordova의 &lt;b&gt;웹 asset&lt;/b&gt;을 배포하는 것이라 React Native 번들 OTA와는 대상이 다릅니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;롤백이 걱정이라면, EAS Update도 이전 업데이트를 다시 올리는 &lt;code&gt;eas update:republish&lt;/code&gt;와 앱에 내장된 버전으로 되돌리는 &lt;code&gt;eas update:roll-back-to-embedded&lt;/code&gt;를 제공해 CodePush의 롤백을 대체합니다. 단계적 배포(rollout)와 롤백의 실전 운용은 다음 글에서 따로 다루겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가격도 결정에 큰 변수입니다. 아래는 &lt;b&gt;MAU(월 활성 사용자, 그달에 업데이트를 한 번이라도 받은 고유 기기 수)&lt;/b&gt; 기준 공개 가격입니다(expo.dev/pricing, 확인일 2026-07-19).&lt;/p&gt;
&lt;table style=&quot;width: 831px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;width: 137px;&quot;&gt;플랜&lt;/th&gt;
&lt;th style=&quot;width: 116px;&quot;&gt;월요금&lt;/th&gt;
&lt;th style=&quot;width: 163px;&quot;&gt;포함 MAU&lt;/th&gt;
&lt;th style=&quot;width: 415px;&quot;&gt;초과 과금&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 137px;&quot;&gt;Free&lt;/td&gt;
&lt;td style=&quot;width: 116px;&quot;&gt;$0&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot;&gt;1,000&lt;/td&gt;
&lt;td style=&quot;width: 415px;&quot;&gt;없음(초과 시 서빙 중단)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 137px;&quot;&gt;Starter&lt;/td&gt;
&lt;td style=&quot;width: 116px;&quot;&gt;$19&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot;&gt;3,000&lt;/td&gt;
&lt;td style=&quot;width: 415px;&quot;&gt;MAU당 $0.005 &amp;middot; 대역폭 $0.10/GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 137px;&quot;&gt;Production&lt;/td&gt;
&lt;td style=&quot;width: 116px;&quot;&gt;$199&lt;/td&gt;
&lt;td style=&quot;width: 163px;&quot;&gt;50,000&lt;/td&gt;
&lt;td style=&quot;width: 415px;&quot;&gt;MAU당 $0.005 &amp;middot; 대역폭 $0.10/GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무료 1,000 MAU는 하드 캡이라 넘으면 업데이트 서빙이 멈춥니다. 사용자 규모가 커지면 유료 플랜이나 자체 호스팅을 검토해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;per-MAU 과금을 피하고 싶다면, &lt;code&gt;expo-updates&lt;/code&gt;는 Expo Updates 프로토콜을 구현한 &lt;b&gt;임의의 서버와도 동작&lt;/b&gt;합니다. Expo가 참조 서버를 공개하지만 &quot;데모&quot;일 뿐 프로덕션용은 아니라고 명시하므로, 프로덕션에서는 &lt;code&gt;expo-open-ota&lt;/code&gt; 같은 커뮤니티 구현으로 인프라 비용만 부담하는 방식이 있습니다(대신 직접 운영).&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 옮기기 전 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도구를 정하고 붙이기 전에 이 순서대로 짚으면 대부분의 사고를 줄일 수 있습니다. 필요하면 복사해서 쓰세요.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;[현재 상태 파악]
- [ ] 지금 CodePush를 쓰는가? &amp;rarr; 아카이브 상태 확인, 마이그레이션 시 CodePush 제거(중복 업데이트 충돌 방지)
- [ ] 우리 RN 버전은? 0.82 이상이면 New Architecture 전제 &amp;rarr; 대안의 New Arch 지원 필수 확인

[경계 확인]
- [ ] 이번 변경이 JS/asset만인가(OTA 가능) vs 네이티브&amp;middot;권한&amp;middot;SDK 변경인가(새 빌드 필요)
- [ ] 네이티브를 바꿨다면 runtimeVersion을 올리고 새 빌드를 했는가

[EAS Update 설정]
- [ ] npm install --global eas-cli &amp;amp;&amp;amp; eas login
- [ ] (순정 RN이면) npx install-expo-modules@latest
- [ ] eas update:configure  (app.json runtimeVersion/updates.url + eas.json channel 자동)
- [ ] eas build --profile production
- [ ] eas update --channel production --message &quot;...&quot; --environment production

[운영 판단]
- [ ] 예상 MAU가 무료 1,000을 넘는가 &amp;rarr; 유료 플랜 or 자체 호스팅 검토
- [ ] 앱스토어 범위 준수: 심사된 주 목적/숨은 기능 규정 위반 없는가&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, &quot;CodePush가 끝났다&quot;는 건 OTA를 포기하라는 뜻이 아니라 &lt;b&gt;경로를 다시 고르라는 신호&lt;/b&gt;입니다. Expo 생태계에 있다면 EAS Update가 가장 매끄럽고, 완전한 통제나 비용 통제가 필요하면 hot-updater 같은 자체 호스팅이 답이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 두 가지입니다. &lt;b&gt;OTA로 되는 것(JS&amp;middot;asset)과 안 되는 것(네이티브&amp;middot;권한&amp;middot;SDK)의 경계&lt;/b&gt;를 지키는 것, 그리고 &lt;b&gt;네이티브가 바뀌면 runtimeVersion을 올리고 새 빌드를 한다&lt;/b&gt;는 것입니다. 이 두 규칙만 지키면 도구는 상황에 맞게 고르면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;a href=&quot;https://atoz-developer.tistory.com/180&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo vs React Native CLI&lt;/a&gt;로 스택을 정하고, &lt;a href=&quot;https://atoz-developer.tistory.com/181&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;development build로 네이티브 라이브러리를 붙인&lt;/a&gt; 다음의 &quot;배포&quot; 편이었습니다. 아직 그 앞 단계에서 고민 중이라면 두 글을 먼저 보셔도 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 EAS Update에서 &lt;b&gt;단계적 배포(rollout)와 롤백&lt;/b&gt;을 실제로 어떻게 다루는지, 사고가 났을 때 되돌리는 흐름을 이어서 정리해 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어 주셔서 감사합니다  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/appcenter/retirement&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Microsoft Learn: App Center retirement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/microsoft/react-native-code-push&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GitHub: microsoft/react-native-code-push (아카이브)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/eas-update/codepush/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Migrate from CodePush&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/eas-update/introduction/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: EAS Update 소개(When to use)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/eas-update/getting-started/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Get started with EAS Update&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/eas-update/how-it-works/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: How EAS Update works (채널&amp;middot;브랜치)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/eas-update/runtime-versions/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Runtime versions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://expo.dev/pricing&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 요금 안내(expo.dev/pricing)&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://docs.expo.dev/billing/usage-based-pricing/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;사용량 기반 과금&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/versions/latest/sdk/updates/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: expo-updates API 레퍼런스&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://reactnative.dev/blog/2025/10/08/react-native-0.82&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;React Native 공식 블로그: 0.82 (New Architecture 강제)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.apple.com/app-store/review/guidelines/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Apple App Store Review Guidelines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.google.com/googleplay/android-developer/answer/16559646&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Google Play: Device and Network Abuse&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대안 OTA 프로젝트&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://revopush.org&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Revopush &amp;mdash; CodePush 클라이언트 fork(호스팅형)&lt;/a&gt; &amp;middot; &lt;code&gt;@revopush/react-native-code-push&lt;/code&gt; v2.6.0&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/gronxb/hot-updater&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;hot-updater (gronxb) &amp;mdash; 자체 호스팅 OSS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/vantuan88291/react-native-ota-hot-update&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;react-native-ota-hot-update (vantuan88291) &amp;mdash; 경량/DIY&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/microsoft/code-push-server&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;microsoft/code-push-server &amp;mdash; 자체 호스팅(아카이브)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프론트엔드</category>
      <category>codepush</category>
      <category>EAS Update</category>
      <category>expo</category>
      <category>expo-updates</category>
      <category>ota</category>
      <category>React Native</category>
      <category>runtimeVersion</category>
      <category>모바일앱개발</category>
      <category>앱업데이트</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/182</guid>
      <comments>https://atoz-developer.tistory.com/182#entry182comment</comments>
      <pubDate>Sun, 19 Jul 2026 13:25:22 +0900</pubDate>
    </item>
    <item>
      <title>Expo Go로 안 되는 네이티브 라이브러리, development build로 붙이기</title>
      <link>https://atoz-developer.tistory.com/181</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 &lt;a href=&quot;https://atoz-developer.tistory.com/180&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo vs React Native CLI 글&lt;/a&gt;에서, 새 프로젝트는 대부분 Expo로 시작하면 되고 네이티브가 필요하면 development build로 붙이면 된다고 정리했습니다. 이번 글은 그 &quot;붙이는 과정&quot;을 처음부터 따라가 보는 편입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expo로 개발하다 보면 이런 순간을 만납니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;카메라나 블루투스 라이브러리를 넣었는데 앱에서 &lt;b&gt;&quot;이 라이브러리는 Expo Go에서 지원되지 않습니다&quot;&lt;/b&gt; 같은 메시지가 뜬다.&lt;/li&gt;
&lt;li&gt;분명 &lt;code&gt;설치&lt;/code&gt;는 됐는데, Expo Go에서 실행하면 그 기능만 동작하지 않는다.&lt;/li&gt;
&lt;li&gt;검색해 보면 &quot;development build를 만들어라&quot;는데, 그게 뭔지, 어디서부터 손대야 할지 막막하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, 이건 막힌 게 아니라 &lt;b&gt;Expo Go의 범위를 벗어난 것&lt;/b&gt;뿐입니다. development build 하나만 만들면 그 라이브러리는 그대로 동작합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  오늘 끝내는 목표&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 다 읽으면 아래를 할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;왜 어떤 라이브러리는 Expo Go에서 안 되는지 &lt;b&gt;한 문장으로 설명&lt;/b&gt;할 수 있습니다.&lt;/li&gt;
&lt;li&gt;내 Expo 프로젝트에 서드파티 네이티브 라이브러리를 넣고 &lt;b&gt;development build로 실행&lt;/b&gt;하는 순서를 압니다.&lt;/li&gt;
&lt;li&gt;로컬에서 빌드할지, Mac이 없어 &lt;b&gt;EAS 클라우드로 빌드&lt;/b&gt;할지 고를 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치 한 번으로 끝나는 게 아니라, &lt;b&gt;네이티브 코드가 든 라이브러리를 실제 앱에 태우는 전체 흐름&lt;/b&gt;을 손에 익히는 것이 이 글의 목적입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  근거부터 짚고 갑니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 말씀드리면, 여기 나오는 명령과 설정은 제가 실제 앱에 붙여 운영해 본 결과가 아니라 &lt;b&gt;Expo와 각 라이브러리의 공식 문서에서 확인해 정리한 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 명령과 설정은 문서 기준으로 그대로 따라 할 수 있게 적었지만, Expo SDK&amp;middot;라이브러리 버전에 따라 세부가 달라질 수 있습니다. 특히 개별 라이브러리는 메이저 버전이 바뀌면 설치&amp;middot;설정이 통째로 달라지기도 합니다(뒤에서 실제 사례를 봅니다). 버전과 명령은 &lt;b&gt;확인일(2026년 7월 17일, Expo SDK 57 기준)&lt;/b&gt; 이며, 정확한 값은 본인 프로젝트의 SDK와 그 라이브러리의 현재 문서로 확인하시는 편이 안전합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  왜 어떤 라이브러리는 Expo Go에서 안 될까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Expo Go의 정체부터 정리하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Expo Go&lt;/b&gt;는 쉽게 말하면 앱스토어에서 받는 &lt;b&gt;미리 만들어진 고정 앱&lt;/b&gt;입니다. 그 안에는 Expo SDK에 포함된 네이티브 기능만 들어 있습니다. 그래서 공식 문서 표현대로, Expo Go에서는 &quot;Expo SDK에 포함된 네이티브 라이브러리, 또는 네이티브 코드가 없는 라이브러리&quot;만 쓸 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 카메라&amp;middot;블루투스&amp;middot;결제 같은 서드파티 라이브러리입니다. 이들은 &lt;b&gt;자기만의 네이티브 코드&lt;/b&gt;를 가지고 있어서, 미리 만들어진 Expo Go 안에는 그 코드가 없습니다. 그래서 실행하면 그 부분만 동작하지 않는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 쓰려는 라이브러리가 Expo Go에서 되는지 확인하는 방법은 두 가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;React Native Directory&lt;/b&gt;(reactnative.directory)에서 그 라이브러리에 &quot;✔️ Expo Go&quot; 태그가 있는지 봅니다.&lt;/li&gt;
&lt;li&gt;아래 네 가지 중 하나라도 해당하면 development build가 필요합니다(공식 문서 기준).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 라이브러리에 &lt;code&gt;android&lt;/code&gt; 또는 &lt;code&gt;ios&lt;/code&gt; 디렉터리가 들어 있다. - README에 linking 이야기가 나온다. - &lt;code&gt;AndroidManifest.xml&lt;/code&gt;, &lt;code&gt;Podfile&lt;/code&gt;, &lt;code&gt;Info.plist&lt;/code&gt;를 고치라고 한다. - config plugin을 제공한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-1-boundary.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cS5paT/dJMcaic57kd/zc7IS1y5hV3nwbdwzuKReK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cS5paT/dJMcaic57kd/zc7IS1y5hV3nwbdwzuKReK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cS5paT/dJMcaic57kd/zc7IS1y5hV3nwbdwzuKReK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcS5paT%2FdJMcaic57kd%2Fzc7IS1y5hV3nwbdwzuKReK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1980&quot; height=&quot;1080&quot; data-filename=&quot;diagram-1-boundary.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 1. Expo Go는 Expo SDK 기능만 담긴 고정 앱이라 서드파티 네이티브 코드를 못 싣습니다. 위 네 가지 중 하나라도 걸리면 development build로 넘어갑니다.&lt;/i&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  development build가 정확히 뭔가요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;development build는 쉽게 말하면 &lt;b&gt;내 프로젝트를 직접 빌드한, 개발용 앱&lt;/b&gt;입니다. 공식 문서는 이를 &quot;내 전용 Expo Go&quot;라고 표현합니다. Expo Go와 달리 이 앱에는 내가 넣은 네이티브 라이브러리가 함께 컴파일돼 들어가므로, 어떤 서드파티 네이티브 코드도 쓸 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 가능하게 하는 것이 &lt;b&gt;&lt;code&gt;expo-dev-client&lt;/code&gt;&lt;/b&gt; 라는 패키지입니다. 이 패키지는 앱에 개발용 런처(dev launcher) 화면을 붙여 줍니다. 어느 개발 서버에 붙을지 고르고, 개발자 메뉴를 열고, 네트워크 요청을 들여다보는 도구가 여기에 들어 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-devbuild-intro.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1800&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/boctQM/dJMcahyqye6/pEncMKx1e3KgxKF7c2daAK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/boctQM/dJMcahyqye6/pEncMKx1e3KgxKF7c2daAK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/boctQM/dJMcahyqye6/pEncMKx1e3KgxKF7c2daAK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FboctQM%2FdJMcahyqye6%2FpEncMKx1e3KgxKF7c2daAK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;1800&quot; data-filename=&quot;official-devbuild-intro.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;1800&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 2. Expo 공식 &quot;Development builds&quot; 문서. development build를 &quot;내가 원하는 네이티브 라이브러리를 쓰고 네이티브 설정을 바꿀 수 있는, 내 전용 Expo Go&quot;로 설명합니다. 출처: docs.expo.dev/develop/development-builds/introduction, 확인일 2026-07-17.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 흐름은 이렇습니다. &lt;code&gt;expo-dev-client&lt;/code&gt;를 넣고 &amp;rarr; 라이브러리를 설치&amp;middot;설정하고 &amp;rarr; 네이티브 프로젝트를 만들어 &amp;rarr; 한 번 빌드하면, 그 앱이 곧 &quot;내 라이브러리가 들어간 Expo Go&quot;가 됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 붙이는 순서: 설치부터 실행까지&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제 순서입니다. 큰 흐름을 먼저 보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;diagram-2-flow.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1540&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLEKvN/dJMcadbCq0z/uricIYCIeoB2s3EafHKXhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLEKvN/dJMcadbCq0z/uricIYCIeoB2s3EafHKXhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLEKvN/dJMcadbCq0z/uricIYCIeoB2s3EafHKXhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLEKvN%2FdJMcadbCq0z%2FuricIYCIeoB2s3EafHKXhK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1980&quot; height=&quot;1540&quot; data-filename=&quot;diagram-2-flow.png&quot; data-origin-width=&quot;1980&quot; data-origin-height=&quot;1540&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 3. 설치 &amp;rarr; 앱 설정(권한&amp;middot;config plugin) &amp;rarr; prebuild로 네이티브 생성 &amp;rarr; 빌드(로컬 run 또는 EAS 클라우드) &amp;rarr; dev client로 실행. 네이티브 라이브러리를 새로 추가하면 이 빌드를 다시 해야 합니다.&lt;/i&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1단계 &amp;mdash; dev client 넣고 라이브러리 설치하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 개발용 클라이언트를 넣습니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;npx expo install expo-dev-client&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음 쓰려는 라이브러리를 설치합니다. Expo 프로젝트에서는 &lt;code&gt;npm install&lt;/code&gt;보다 &lt;b&gt;&lt;code&gt;npx expo install&lt;/code&gt;&lt;/b&gt; 을 권장합니다. 지금 SDK와 호환되는 버전을 골라 주고, 알려진 충돌을 미리 경고해 주기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;# 예시 형식 (라이브러리 이름은 상황에 맞게)
npx expo install &amp;lt;라이브러리-이름&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계 &amp;mdash; 앱 설정: 권한과 config plugin&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이티브 라이브러리는 권한이나 네이티브 설정을 요구하는 경우가 많습니다. 여기서 중요한 원칙이 있습니다. &lt;b&gt;&lt;code&gt;android&lt;/code&gt;&amp;middot;&lt;code&gt;ios&lt;/code&gt; 폴더를 직접 손으로 고치지 마세요.&lt;/b&gt; Expo(CNG)에서는 이 폴더가 다시 생성될 때 손댄 내용이 사라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 두 가지 방법을 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 많은 라이브러리는 &lt;b&gt;config plugin&lt;/b&gt;을 제공합니다. &lt;code&gt;app.json&lt;/code&gt;(또는 &lt;code&gt;app.config.js&lt;/code&gt;)의 &lt;code&gt;plugins&lt;/code&gt; 배열에 등록하면, 네이티브 설정이 빌드 때 자동으로 반영됩니다. 옵션이 없으면 이름만, 옵션이 있으면 &lt;code&gt;[이름, 옵션]&lt;/code&gt; 형태로 씁니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot; data-lang=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;expo&quot;: {
    &quot;plugins&quot;: [
      &quot;some-native-library&quot;,
      [&quot;another-library&quot;, { &quot;someOption&quot;: &quot;value&quot; }]
    ]
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 권한처럼 간단한 것은 &lt;code&gt;app.json&lt;/code&gt;의 필드로 직접 넣습니다. 예를 들어 iOS 권한 문구는 &lt;code&gt;ios.infoPlist&lt;/code&gt;, Android 권한은 &lt;code&gt;android.permissions&lt;/code&gt;에 적습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계 &amp;mdash; 네이티브 프로젝트 만들기 (prebuild)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정을 마쳤으면 네이티브 프로젝트를 생성합니다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;npx expo prebuild&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 명령이 &lt;code&gt;app.json&lt;/code&gt; 설정과 설치한 라이브러리를 바탕으로 &lt;code&gt;android&lt;/code&gt;&amp;middot;&lt;code&gt;ios&lt;/code&gt; 폴더를 만들어 줍니다. 뒤 단계의 로컬 빌드 명령(&lt;code&gt;npx expo run:*&lt;/code&gt;)은 이 prebuild를 자동으로 실행하므로, 보통은 따로 부르지 않아도 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4단계 &amp;mdash; 빌드하기: 로컬 또는 EAS 클라우드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제로 앱을 빌드합니다. 두 갈래가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(a) 로컬 빌드&lt;/b&gt; &amp;mdash; Android Studio(안드로이드)나 Xcode(iOS)가 설치돼 있다면, 내 컴퓨터에서 바로 빌드합니다.&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;npx expo run:android
# 또는
npx expo run:ios&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;(b) EAS 클라우드 빌드&lt;/b&gt; &amp;mdash; 로컬에 네이티브 빌드 도구가 없거나, &lt;b&gt;Mac 없이 iOS 빌드&lt;/b&gt;가 필요하면 Expo의 클라우드 빌드(EAS)를 씁니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;# eas-cli 준비
npm install --global eas-cli
eas login
eas build:configure&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;eas.json&lt;/code&gt;의 development 프로필에 &lt;code&gt;developmentClient: true&lt;/code&gt;를 두고(개발용 빌드라는 뜻), 빌드를 겁니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot; data-lang=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;build&quot;: {
    &quot;development&quot;: {
      &quot;developmentClient&quot;: true,
      &quot;distribution&quot;: &quot;internal&quot;
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;brainfuck&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;eas build --platform android --profile development
# iOS는 --platform ios, 둘 다면 --platform all&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬이든 EAS든 결과물은 같은 development build입니다. EAS는 필수가 아니라, 로컬 빌드 환경을 갖추기 어려울 때의 선택지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 한 가지 오해는 없어야 합니다. EAS로 iOS를 빌드하더라도 Apple Developer 계정과 기기 등록(내부 배포용)은 여전히 필요합니다. &quot;Mac이 없어도 된다&quot;는 뜻이지 &quot;Apple 계정 없이 된다&quot;는 뜻은 아닙니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5단계 &amp;mdash; 실행하기 (dev client)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EAS로 빌드했다면 빌드가 끝난 뒤 나오는 QR 코드나 설치 링크로 기기에 앱을 설치합니다. 로컬 빌드(&lt;code&gt;npx expo run:*&lt;/code&gt;)는 빌드하면서 바로 기기&amp;middot;에뮬레이터에 설치됩니다. 앱이 설치됐으면 개발 서버를 켜서 붙입니다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;npx expo start --dev-client&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱을 열면 dev launcher가 뜨고, 같은 네트워크의 개발 서버에 연결하거나 QR로 붙습니다. 이후 &lt;b&gt;JavaScript만 고칠 때는 다시 빌드할 필요 없이&lt;/b&gt; 서버만 켜면 됩니다. 개발자 메뉴는 기기를 흔들거나 &lt;code&gt;Cmd+D&lt;/code&gt;(맥)&amp;middot;&lt;code&gt;Ctrl+D&lt;/code&gt;(윈도우/리눅스)로 엽니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  실제 예: 카메라 라이브러리 붙여보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;감이 잡히게 널리 쓰는 카메라 라이브러리 &lt;code&gt;react-native-vision-camera&lt;/code&gt;로 예를 들겠습니다. 이 라이브러리는 자체 네이티브 코드가 있어 &lt;b&gt;Expo Go에서는 동작하지 않고 development build가 필요합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 이 글에서 가장 강조하고 싶은 부분이 나옵니다. 이 라이브러리는 &lt;b&gt;버전이 올라가면서 설정 방식이 바뀌었습니다.&lt;/b&gt; 예전 V4에서는 &lt;code&gt;plugins&lt;/code&gt; 배열에 config plugin으로 등록하고 &lt;code&gt;cameraPermissionText&lt;/code&gt; 같은 옵션을 줬는데, &lt;b&gt;현재 V5(확인일 기준)에서는 그 config plugin이 사라졌습니다.&lt;/b&gt; 지금은 권한을 &lt;code&gt;app.json&lt;/code&gt;에 직접 넣습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인일 기준 V5 설치&amp;middot;설정은 이렇습니다(정확한 최신 값은 반드시 vision-camera 공식 문서로 확인하세요).&lt;/p&gt;
&lt;pre class=&quot;livescript&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;# V5는 Nitro 기반이라 함께 설치해야 하는 패키지가 있습니다
npm install react-native-vision-camera react-native-nitro-modules react-native-nitro-image&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 &lt;code&gt;npx expo install&lt;/code&gt;을 권장했는데 여기서는 &lt;code&gt;npm install&lt;/code&gt;을 씁니다. vision-camera는 함께 쓸 Nitro 패키지 버전을 라이브러리 쪽에서 지정하기 때문에, 공식 문서가 안내하는 설치법을 그대로 따르는 것입니다. 이처럼 &lt;b&gt;라이브러리 설치는 그 라이브러리의 문서를 우선&lt;/b&gt;하시면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;json&quot; data-lang=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;expo&quot;: {
    &quot;ios&quot;: {
      &quot;infoPlist&quot;: {
        &quot;NSCameraUsageDescription&quot;: &quot;사진&amp;middot;영상 촬영을 위해 카메라 접근이 필요합니다.&quot;,
        &quot;NSMicrophoneUsageDescription&quot;: &quot;영상 녹화를 위해 마이크 접근이 필요합니다.&quot;
      }
    },
    &quot;android&quot;: {
      &quot;permissions&quot;: [&quot;android.permission.CAMERA&quot;, &quot;android.permission.RECORD_AUDIO&quot;]
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;dockerfile&quot; data-lang=&quot;bash&quot;&gt;&lt;code&gt;# 네이티브 반영 후 development build 생성&amp;middot;실행
npx expo prebuild
npx expo run:ios      # 또는 npx expo run:android&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;official-vision-camera.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;3120&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nIwyJ/dJMcaf1CxEY/KsSKKEd0W6f6VW8hl0H9R0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nIwyJ/dJMcaf1CxEY/KsSKKEd0W6f6VW8hl0H9R0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nIwyJ/dJMcaf1CxEY/KsSKKEd0W6f6VW8hl0H9R0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnIwyJ%2FdJMcaf1CxEY%2FKsSKKEd0W6f6VW8hl0H9R0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2400&quot; height=&quot;3120&quot; data-filename=&quot;official-vision-camera.png&quot; data-origin-width=&quot;2400&quot; data-origin-height=&quot;3120&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;그림 4. react-native-vision-camera 공식 &quot;Getting Started&quot; 문서. Nitro Modules 기반이라 &lt;code&gt;npm install&lt;/code&gt;로 본체와 Nitro 의존성을 함께 설치하고, 권한은 &quot;Add permissions&quot;의 &lt;b&gt;Expo 탭&lt;/b&gt;에서 &lt;code&gt;app.json&lt;/code&gt;의 &lt;code&gt;ios.infoPlist&lt;/code&gt;&amp;middot;&lt;code&gt;android.permissions&lt;/code&gt;로 넣습니다(별도 config plugin 항목 없음). 출처: react-native-vision-camera.com/docs/guides, 확인일 2026-07-17.&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 교훈은 하나입니다. &lt;b&gt;오래된 블로그의 설치법을 그대로 믿지 말고, 그 라이브러리의 현재 문서를 보세요.&lt;/b&gt; V4 설정을 그대로 복사하면 V5에서는 동작하지 않습니다. 앞서 EAS든 config plugin이든 &quot;형식&quot;을 익혔다면, 구체적인 값은 늘 라이브러리 현재 문서에서 가져오는 게 안전합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚠️ 여기서 자주 막힙니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서에서 반복적으로 짚는 막힘 지점을 모았습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;네이티브 라이브러리를 추가했으면 다시 빌드해야 합니다.&lt;/b&gt; JavaScript 새로고침(Fast Refresh)으로는 네이티브 코드가 앱에 들어가지 않습니다. 설치만 하고 &quot;왜 안 되지?&quot; 하는 경우 대부분 여기입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;android&lt;/code&gt;&amp;middot;&lt;code&gt;ios&lt;/code&gt; 폴더를 손으로 고치지 마세요.&lt;/b&gt; &lt;code&gt;npx expo prebuild --clean&lt;/code&gt;을 실행하면 그 폴더를 지우고 다시 만들어서 수정이 사라집니다. 네이티브 변경은 config plugin이나 &lt;code&gt;app.json&lt;/code&gt; 필드로 하세요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;dev build의 네이티브와 JS가 어긋나면 실행 시 문제가 납니다.&lt;/b&gt; 네이티브 의존성이나 설정을 바꿨으면 새 development build를 만들어야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모든 라이브러리에 config plugin이 있는 건 아닙니다.&lt;/b&gt; 없으면 Expo의 out-of-tree config plugin 목록을 찾아보거나, 라이브러리의 수동 설정 안내를 따르거나, 직접 plugin을 작성합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;권한은 매니페스트를 직접 고치지 말고&lt;/b&gt; &lt;code&gt;app.json&lt;/code&gt;(또는 config plugin)으로 선언하세요. CNG가 다음 prebuild에서 덮어씁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ 붙이기 전 체크리스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;붙이기 전에 이 순서대로만 짚어도 대부분의 삽질을 줄일 수 있습니다. 필요하면 복사해서 쓰세요.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; data-lang=&quot;text&quot;&gt;&lt;code&gt;[확인]
- [ ] 이 라이브러리가 Expo Go에서 되는지 확인 (reactnative.directory / npx expo-doctor)
- [ ] android&amp;middot;ios 디렉터리, linking, 매니페스트 수정, config plugin 여부 체크 &amp;rarr; 하나라도 있으면 dev build

[설치&amp;middot;설정]
- [ ] npx expo install expo-dev-client
- [ ] 라이브러리 설치 (그 라이브러리의 현재 문서 기준 &amp;mdash; 버전마다 다름)
- [ ] 권한/네이티브 설정: config plugin 또는 app.json 필드로 (네이티브 폴더 직접 수정 금지)

[빌드&amp;middot;실행]
- [ ] 로컬: npx expo run:ios / npx expo run:android
- [ ] 클라우드: eas build --profile development (developmentClient: true)
- [ ] npx expo start --dev-client 로 실행

[유지]
- [ ] 네이티브 라이브러리 추가&amp;middot;변경할 때마다 development build 다시 생성&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, &quot;Expo Go에서 안 된다&quot;는 대부분 막힘이 아니라 &lt;b&gt;범위를 벗어난 신호&lt;/b&gt;입니다. development build를 한 번 만들면 그 뒤로는 어떤 네이티브 라이브러리든 붙일 수 있고, JavaScript 수정은 예전처럼 빠르게 반영됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 두 가지입니다. 네이티브를 바꾸면 다시 빌드한다는 것, 그리고 라이브러리 설정은 오래된 글이 아니라 &lt;b&gt;그 라이브러리의 현재 문서&lt;/b&gt;에서 가져온다는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;a href=&quot;https://atoz-developer.tistory.com/180&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo vs React Native CLI&lt;/a&gt;에서 &quot;Expo로 시작해도 네이티브는 development build로 붙이면 된다&quot;고 한 부분의 실전편이었습니다. 아직 Expo와 순정 CLI 사이에서 고민 중이라면 그 글을 먼저 보셔도 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 config plugin이 없는 라이브러리를 만났을 때, 아주 작은 config plugin을 직접 하나 만들어 보는 과정을 다뤄 보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽어 주셔서 감사합니다  &lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;참고 자료&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/develop/development-builds/introduction/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Introduction to development builds&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/develop/development-builds/create-a-build/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Create a development build&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/develop/development-builds/use-development-builds/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Use a development build&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/config-plugins/introduction/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Config plugins&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/workflow/continuous-native-generation/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Continuous Native Generation (CNG)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/workflow/using-libraries/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: Using libraries (Expo Go 호환 판단)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/eas/json/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Expo 공식 문서: eas.json 빌드 프로필&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://react-native-vision-camera.com/docs/guides&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;react-native-vision-camera 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프론트엔드</category>
      <category>config plugin</category>
      <category>development build</category>
      <category>eas</category>
      <category>expo</category>
      <category>expo-dev-client</category>
      <category>prebuild</category>
      <category>React Native</category>
      <category>react-native-vision-camera</category>
      <category>모바일앱개발</category>
      <author>AtoZ 개발자</author>
      <guid isPermaLink="true">https://atoz-developer.tistory.com/181</guid>
      <comments>https://atoz-developer.tistory.com/181#entry181comment</comments>
      <pubDate>Sat, 18 Jul 2026 08:28:50 +0900</pubDate>
    </item>
  </channel>
</rss>