<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Zero to Developer</title>
    <link>https://zeroto-dev.tistory.com/</link>
    <description>zeroto-dev 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Tue, 29 Sep 2026 04:23:05 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>디버거러너</managingEditor>
    <item>
      <title>[회고] 스케줄러 하나를 붙이려다 정책과 리뷰 단위를 다시 생각하게 된 이유</title>
      <link>https://zeroto-dev.tistory.com/14</link>
      <description>&lt;h2 data-heading=&quot;Onion Store와 내가 맡은 범위&quot; data-ke-size=&quot;size26&quot;&gt;Onion Store와 내가 맡은 범위&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로젝트 명: onion store&lt;/li&gt;
&lt;li&gt;인원: 5명&lt;/li&gt;
&lt;li&gt;프로젝트 기간: 10일&lt;/li&gt;
&lt;li&gt;배경: 한 명의 판매자가 직접 상품을 판매하는 단일 판매자 쇼핑몰&lt;/li&gt;
&lt;li&gt;주요 기능: 고객의 장바구니&amp;middot;주문&amp;middot;결제, 관리자의 상품&amp;middot;주문 관리, 채팅(cs)&lt;/li&gt;
&lt;li&gt;담당 : 결제, 환불&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;요약&quot; data-ke-size=&quot;size23&quot;&gt;요약&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;처음에는 결제 기능을 구현하고, 결제창을 닫은 사용자의 미완료 주문을 정리하는 스케줄러를 붙이면 된다고 생각했다. 하지만 결제창 이탈을 처리하려는 작은 요구사항은 결제 상태 확인, 웹훅, 금액 불일치 취소, 부분 환불, 외부 결제 취소 결과를 다시 확인하는 방법에 대한 고민까지 이어졌다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에는 짧은 기간의 팀 프로젝트에서 정책이 정해지지 않은 기능을 구현으로 먼저 해결하려 했을 때, 설계와 PR 단위가 왜 함께 어려워졌는지 돌아본다.&lt;/p&gt;
&lt;h2 data-heading=&quot;스케줄러 하나면 될 줄 알았다&quot; 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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 만료 시간이 지나면 주문과 결제 상태를 변경하고 재고를 복구하면 된다고 생각했다. 이때 서버에 남은 주문과 결제 대기 데이터를 삭제할지, 상태만 바꾸고 보관할지도 함께 고민했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;그런데 시간이 지났다는 이유만으로 정말 이 주문을 취소해도 될까?&lt;/b&gt;&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 결제창을 닫은 경우&lt;/li&gt;
&lt;li&gt;실제 결제는 성공했지만 우리 서버가 완료 요청의 응답을 받지 못한 경우&lt;/li&gt;
&lt;li&gt;결제대행사 처리 또는 네트워크 문제로 서버에 결과가 늦게 도착한 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상태들을 모두 같은 방식으로 취소하거나 삭제하면, 이미 결제가 완료된 주문의 상태를 잘못 처리하거나 원인을 확인할 데이터를 잃을 수 있다. 결제창을 닫았는지가 아니라 실제 결제가 어떤 상태인지 확인해야 했고, 상태가 종료된 뒤 어떤 데이터를 얼마나 남길지도 정해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹훅을 받으면 본문만 보고 상태를 바꾸지 않고, PortOne에 결제 정보를 다시 조회해 실제 결제 상태와 금액을 확인하도록 했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;결제 검증에서 환불 도메인까지 범위가 넓어졌다&quot; 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;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 있던 금액 불일치로 인한 시스템 취소 로직과 고객이 요청하는 환불을 같은 흐름으로 만들고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 전액 환불뿐 아니라 부분 환불, 고객 요청과 관리자 승인, 환불 수량 검증, 결제 상태 변경, 재고 복구를 함께 고려하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환불 정책도 단순하지 않았다. 진행 중인 환불은 결제당 하나만 허용하고, 관리자가 승인 후에 남은 수량 범위에서 다음 부분 환불을 허용하는 식으로 정리했다. 동시에 여러 건을 처리하지 않고, 완료된 환불을 누적해 관리하는 방식이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 외부 취소 요청의 결과가 바로 확정되지 않는 경우까지 붙었다. 요청 시간 초과나 응답 유실이 발생하면 취소가 실패한 것인지, 결제사에서는 이미 처리됐지만 우리 서버만 결과를 받지 못한 것인지 알 수 없다. 취소 식별자 저장, 같은 요청의 중복 실행 방지, 웹훅 도착 순서, 요청 상태를 다시 확인하는 스케줄러까지 구상하게 됐다.&lt;/p&gt;
&lt;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;h2 data-heading=&quot;회의를 미루기로 한 결과&quot; data-ke-size=&quot;size26&quot;&gt;회의를 미루기로 한 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 상태와 테이블, 스케줄러 정책이 필요하다고 느낀 시점은 프로젝트 4일 차, 팀의 중간 점검일이었다. 다른 팀원은 이미 각자 맡은 기능을 구현하고 있었고, 이때 다시 회의를 잡는 것이 현재 작업 흐름을 끊을 수 있다고 보았다. 그래서 팀은 추가 회의를 열기보다 각자 맡은 범위를 우선 진행하기로 합의했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 정책 결정은 뒤로 밀렸고, 구현을 진행하면서도 다음 질문들을 그때그때 다시 결정해야 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;대기 결제는 언제, 어떤 근거로 종료할 것인가&lt;/li&gt;
&lt;li&gt;종료된 대기 주문&amp;middot;결제 데이터는 삭제할 것인가, 상태 이력으로 남길 것인가&lt;/li&gt;
&lt;li&gt;결제 조회가 실패했을 때 바로 취소할 것인가, 재조회할 것인가&lt;/li&gt;
&lt;li&gt;스케줄러는 상태 변경과 재고 복구 중 어디까지 책임질 것인가&lt;/li&gt;
&lt;li&gt;금액 불일치 취소와 고객 환불은 같은 도메인으로 관리할 것인가&lt;/li&gt;
&lt;li&gt;부분 환불의 횟수, 수량, 승인 조건은 무엇인가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현하면서 계속 걸렸던 건 코드 자체보다 &quot;이게 맞는 정책인가?&quot;라는 질문이었다. 코드를 작성한 뒤에도 이 상태 전이가 맞는지, 실패하면 어디까지 되돌려야 하는지를 다시 결정해야 했다. 구현과 요구사항 검토를 동시에 하는 느낌이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 비용은 내 작업에만 머물지 않았다. 결제와 환불은 주문 상태와 재고 처리에도 영향을 주기 때문에, 이 흐름의 정책과 완료 시점이 흔들리면 다른 팀원의 작업 계획과 통합 일정도 함께 불확실해질 수 있었다. 데드라인이 가까워질수록 내 기능을 끝내야 한다는 압박과, 내 작업의 지연이 팀 전체 계획에 영향을 줄 수 있다는 부담을 함께 느꼈다.&lt;/p&gt;
&lt;h2 data-heading=&quot;팀 컨벤션이 환불 도메인에서 더 크게 느껴진 이유&quot; data-ke-size=&quot;size26&quot;&gt;팀 컨벤션이 환불 도메인에서 더 크게 느껴진 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 팀은 파사드 계층을 두고, 다른 도메인의 조회나 상태 변경도 해당 도메인의 Service를 통해 DTO로 주고받기로 합의했다. Repository를 다른 도메인에서 직접 호출하지 않도록 경계를 지키고, 구현 변경이 밖으로 퍼지는 것을 줄이기 위한 선택이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 규칙자체도 일반적인 기능의 방향을 잡는 데는 문제가 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 환불은 주문, 결제, 재고, 환불처럼 여러 도메인의 상태를 조율해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파사드가 흐름을 조합하고 각 서비스가 조회와 검증, 상태 변경을 맡는 구조를 지키려 할수록 서비스 간 호출과 DTO 변환이 늘어났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 서비스에 조회, 계산, 검증, 상태 변경이 모이면서 메서드가 지나치게 커지는 것처럼 느껴졌다. 환불 책임을 세 개의 서비스로 나누기도 했지만, 계산&amp;middot;조회&amp;middot;상태 변경을 더 분리해야 하는지 고민했고 CQRS 같은 구조까지 검토하게 됐다.&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;지금 다시 보면 여기서 CQRS까지 생각한 건 조금 멀리 갔던 것 같다. 서비스가 커지는 게 불편해서 구조부터 바꾸려고 했지만, 당시에는 환불 정책을 먼저 정리했어야 했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;구현보다 PR 단위가 더 어려웠다&quot; data-ke-size=&quot;size26&quot;&gt;구현보다 PR 단위가 더 어려웠다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 컨벤션에는 PR을 기능 단위로 만들고, 변경이 크면 분리하며, 두 명의 리뷰 승인을 받도록 정해 두었다. 코드 리뷰에서는 요구사항 충족 여부뿐 아니라 다른 도메인과의 의존성, 예외 상황, 불필요한 복잡성도 확인하기로 했다.&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;문제는 이 변경을 리뷰어가 이해할 수 있는 PR 단위로 나누는 일이었다. 환불 상태, 부분 환불 허용 기준, 외부 취소의 결과 불확실성, 스케줄러의 책임까지 함께 검토해야 하는 PR이 될 가능성이 컸다. 변경을 작게 나누면 각 PR이 최종 정책을 충분히 설명하지 못하거나, 중간 상태의 코드만 남겨 리뷰 기준이 모호해질 수 있었다.&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;클린 코드 관점에서 책임을 나누기 위한 리팩터링도 진행하고 있었다. 하지만 기능 정책 변경과 리팩터링이 한 번의 변경 묶음에 섞이면서, 팀원이 무엇을 기준으로 리뷰해야 하는지 더 불분명해졌다. 나는 이 작업을 팀이 이해하고 검토할 수 있는 독립적인 PR들로 나눌 자신이 없었다.&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;우선 정책을 합의하고 PR의 경계를 정했어야 했다. 결제 웹훅 동기화, 금액 불일치 취소, 고객 환불 요청과 승인, 외부 취소 결과 동기화, 미확정 상태 재조회 스케줄러는 서로 다른 PR단위로 나눌 수 있었을 것이다. 각 단계에서 이번 PR이 보장하는 상태와 다음 단계로 미루는 실패 처리를 적어 두었다면, 리뷰어도 코드와 정책을 함께 따라가기 쉬웠을 것이다.&lt;/p&gt;
&lt;h2 data-heading=&quot;AI로 구현할 수 있어도 멈춘 이유&quot; data-ke-size=&quot;size26&quot;&gt;AI로 구현할 수 있어도 멈춘 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름을 계속 확장하는 구현 자체는 AI의 도움으로 가능했을 것이다. 외부 취소 요청, 멱등 키, 웹훅 처리, 재조회 스케줄러, 서비스 분리도 각각의 코드 예시는 받을 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;기능이 동작한다고 해서 그 코드를 팀에 넣어도 되는 걸까?&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환불 상태 전이와 외부 결제 처리, 팀 컨벤션에 맞춘 책임 분리까지 결합되자 각 선택의 이유를 내가 팀원에게 설명하고 리뷰에서 방어할 수 있을지 확신이 없었다. AI가 제안한 코드를 연결해 기능을 완성할 수는 있어도, 왜 그 상태가 필요한지와 왜 이 계층이 그 책임을 가져야 하는지를 설명하지 못하면 팀 코드가 될 수 없다고 생각했다.&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;h2 data-heading=&quot;외부 API는 응답만 보면 끝나는 게 아니었다&quot; data-ke-size=&quot;size26&quot;&gt;외부 API는 응답만 보면 끝나는 게 아니었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제와 환불을 다루며 외부 API 연동은 요청을 보내고 응답을 받는 일로 끝나지 않는다는 것을 구체적으로 알게 됐다. 응답이 늦거나 사라질 수 있고, 결제사에서는 처리됐지만 우리 서버는 결과를 알지 못할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹훅처럼 별도의 경로로 결과가 도착할 수도 있다. 그래서 상태를 확정하는 근거, 재시도할 수 있는 요청인지, 결과가 불확실할 때 어떻게 남겨 둘지를 함께 고민하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트 화면이나 Swagger로 요청을 보내는 방식은 정상 흐름을 확인하는 데 유용했지만, 응답 유실, 웹훅과 Confirm 요청의 도착 순서, 같은 요청의 재시도처럼 외부 요인이 얽힌 상황을 충분히 확인하기에는 한계가 있었다.&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;이 경험을 통해 결제 상태 전이와 외부 API 응답별 처리를 코드로 검증할 수 있는 테스트 코드의 필요성을 더 크게 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상 흐름 밖에서 어떤 상태가 생길 수 있고, 그 상태를 누가 어떤 근거로 확정해야 하는지 고민해 본 경험이 남았다.&lt;/p&gt;
&lt;h2 data-heading=&quot;구현을 시작하기 전에 맞췄어야 했던 것들&quot; 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;기능이 정상적으로 끝났다고 볼 기준과 상태를 확정하는 근거&lt;/li&gt;
&lt;li&gt;지금 처리할 실패 흐름과 후속 작업으로 남길 복구 흐름&lt;/li&gt;
&lt;li&gt;팀원이 한 번의 PR에서 이해하고 리뷰할 수 있는 범위, 그리고 마감일까지 가능한 작업량&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 이 질문들을 구현하면서 하나씩 발견했다. 회의를 길게 하는 것이 목적은 아니다. 결정이 필요한 질문을 먼저 드러내고 팀의 방식에 맞춰 합의하면, 구현 중에 계속 정책을 다시 결정하는 일을 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마감일도 이 기능을 지금 구현할지, 핵심 흐름까지만 다룰지, 후속 작업으로 나눌지를 판단하는 기준이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 스케줄러 하나를 추가하려고 시작한 일이었다. 끝날 무렵에는 결제와 환불 기능을 어디까지 만들지보다&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;그 범위를 언제 팀과 다시 맞춰야 하는지가 더 중요하다고 느꼈다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Makgoli-SuyuK/onion-store&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/Makgoli-SuyuK/onion-store&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1789835154801&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Makgoli-SuyuK/onion-store: 결제와 판매자와 고객이 채팅을 할 수 있는 프로그램&quot; data-og-description=&quot;결제와 판매자와 고객이 채팅을 할 수 있는 프로그램. Contribute to Makgoli-SuyuK/onion-store development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Makgoli-SuyuK/onion-store&quot; data-og-url=&quot;https://github.com/Makgoli-SuyuK/onion-store&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/FpFBT/dJMb9aKZ6Y7/6RUpKowkPUAN0nH09v5FgK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/cmKPZx/dJMb82e8esj/cHphE2gZFKUvBzM8U5NzAK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/cUbYGl/dJMb83kOtfy/k3gVJAvdN6juzOunR5WVQ1/img.png?width=2256&amp;amp;height=1906&amp;amp;face=0_0_2256_1906&quot;&gt;&lt;a href=&quot;https://github.com/Makgoli-SuyuK/onion-store&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Makgoli-SuyuK/onion-store&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/FpFBT/dJMb9aKZ6Y7/6RUpKowkPUAN0nH09v5FgK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/cmKPZx/dJMb82e8esj/cHphE2gZFKUvBzM8U5NzAK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/cUbYGl/dJMb83kOtfy/k3gVJAvdN6juzOunR5WVQ1/img.png?width=2256&amp;amp;height=1906&amp;amp;face=0_0_2256_1906');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Makgoli-SuyuK/onion-store: 결제와 판매자와 고객이 채팅을 할 수 있는 프로그램&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;결제와 판매자와 고객이 채팅을 할 수 있는 프로그램. Contribute to Makgoli-SuyuK/onion-store development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>팀 프로젝트</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/14</guid>
      <comments>https://zeroto-dev.tistory.com/14#entry14comment</comments>
      <pubDate>Sun, 20 Sep 2026 01:27:23 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 기존 Todo API 리팩터링 - Boot 4 마이그레이션부터 QueryDSL 검색까지</title>
      <link>https://zeroto-dev.tistory.com/12</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Todo API를 Spring Boot 4.1.1 환경으로 옮기면서 단순히 의존성 버전만 올리는 데서 끝내지 않았다. JWT 인증 정보의 전달 방식, 매니저 등록 실패 시 남겨야 하는 로그의 트랜잭션 경계, 조회 API의 N+1과 집계 검색까지 함께 정리했다.&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;Java 17&amp;middot;Spring Boot 4.1.1&amp;middot;Gradle Wrapper 8.14.3 기반 실행 환경 정비&lt;/li&gt;
&lt;li&gt;HttpServletRequest attribute 기반 JWT 전달 구조를 Spring Security 표준 구조로 전환&lt;/li&gt;
&lt;li&gt;관리자 역할 변경 접근 로그와 담당자 등록 요청 로그의 목적 분리&lt;/li&gt;
&lt;li&gt;Todo 생성과 담당자 자동 등록, 요청 로그의 독립 트랜잭션 처리&lt;/li&gt;
&lt;li&gt;JPQL fetch join&amp;middot;@EntityGraph&amp;middot;QueryDSL을 역할에 따라 선택한 조회 개선&lt;/li&gt;
&lt;li&gt;제목&amp;middot;담당자 닉네임&amp;middot;생성일 조건을 받는 Projection 검색 API&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-heading=&quot;1. Spring Boot 4.1 실행 환경으로 마이그레이션&quot; data-ke-size=&quot;size26&quot;&gt;1. Spring Boot 4.1 실행 환경으로 마이그레이션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 작업은 기능 추가가 아니라 2년 전의 Spring Boot 3.3.3 실행 환경을 Spring Boot 4.1.1 기준으로 옮기는 일이었다. 이 작업은 build.gradle의 버전만 바꾸는 것으로 끝나지 않았다. Gradle Wrapper, Spring Boot 모듈, JJWT API, Web MVC 테스트 API가 함께 영향을 받았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구분 변경&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;플랫폼&lt;/td&gt;
&lt;td&gt;Spring Boot 3.3.3 &amp;rarr; 4.1.1, Gradle Wrapper 8.14.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Java 17 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spring 모듈&lt;/td&gt;
&lt;td&gt;WebMVC, AspectJ, RestClient, WebMVC test starter를 Boot 4 구성에 맞춤&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;JJWT 0.11.5 &amp;rarr; 0.13.0, 토큰 생성&amp;middot;Claims 파싱 코드 변경&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;테스트 API&lt;/td&gt;
&lt;td&gt;Boot 4의 WebMvcTest, MockitoBean API 반영&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터베이스&lt;/td&gt;
&lt;td&gt;MySQL 실행 환경과 H2 테스트 환경 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java 21도 LTS이지만 QueryDSL이나 현재 코드가 Java 21을 요구하지 않았고, Java 17 전용 문제도 없었다. 이번 과제에서는 언어 버전까지 동시에 올려 호환성 확인 범위를 넓히기보다, Java 17을 유지한 채 Spring Boot와 관련 의존성의 호환 작업에 집중했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이그레이션 범위도 기능과 직접 관련된 변경으로 제한했다. JJWT의 새 API에 맞춰 토큰 생성과 Claims 파싱을 바꾸고, Boot 4에서 달라진 테스트 API를 적용했다. 도메인 구조나 API 계약과 무관한 리팩터링까지 한 번에 섞으면, 이후 오류가 버전 호환성 문제인지 기능 변경 문제인지 구분하기 어려워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 애플리케이션은 MySQL과 환경변수 기반 JWT secret을 사용하고, 테스트는 H2와 테스트 전용 JWT key를 사용하도록 분리했다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# src/test/resources/application.yml
spring:
  datasource:
    url: jdbc:h2:mem:expert;MODE=MySQL;DB_CLOSE_DELAY=-1

  jpa:
    hibernate:
      ddl-auto: create

jwt:
  secret:
    key: abcdefghijklmnopqrstuvwxyz12345678901234567890
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 환경을 먼저 분리한 이유는 단순하다. 로컬 MySQL 상태나 개인 secret이 없으면 테스트도 시작하지 못하는 구조에서는 이후 인증&amp;middot;조회 리팩터링의 회귀를 판단하기 어렵다. 이 기준선 위에서 기능을 하나씩 검증하고 커밋으로 닫는 순서를 사용했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;2. JWT 필터의 역할을 인증으로 좁히기&quot; data-ke-size=&quot;size26&quot;&gt;2. JWT 필터의 역할을 인증으로 좁히기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조에서는 JWT 필터가 토큰 검증 후 사용자 정보를 request attribute에 넣고, 별도 HandlerMethodArgumentResolver가 이를 꺼내 Controller의 @Auth AuthUser 파라미터로 전달했다. URL 경로와 관리자 권한도 필터 안에서 직접 검사했다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;JwtFilter
 ├─ JWT 검증
 ├─ URL 검사
 ├─ 관리자 권한 검사
 └─ request attribute 저장

AuthUserArgumentResolver
 └─ request attribute &amp;rarr; AuthUser
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 인증 정보의 저장 위치와 인가 규칙이 Servlet API에 묶이고, URL 정책이 필터 내부에 흩어진다. Spring Security로 전환하면서 역할을 다음처럼 나눴다.&lt;/p&gt;
&lt;pre class=&quot;mathematica&quot;&gt;&lt;code&gt;JwtFilter
 └─ JWT 검증 &amp;rarr; Authentication 생성

SecurityContext
 └─ 현재 인증 사용자 보관

SecurityConfig
 └─ URL별 인증&amp;middot;인가 정책 결정

@Auth
 └─ SecurityContext의 principal을 Controller 파라미터로 전달
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;JWT는 &amp;#96;Authentication&amp;#96;을 만들고 SecurityContext에 저장한다&quot; data-ke-size=&quot;size23&quot;&gt;JWT는 Authentication을 만들고 SecurityContext에 저장한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필터는 Bearer 토큰이 있을 때만 Claims를 읽어 AuthUser와 권한을 만든다. 토큰이 없다는 이유만으로 필터가 응답을 끝내지 않는다. 해당 API가 인증을 요구하는지는 이후 SecurityConfig의 정책이 판단한다.&lt;/p&gt;
&lt;pre class=&quot;verilog&quot;&gt;&lt;code&gt;Claims claims = jwtUtil.extractClaims(token);
AuthUser authUser = createAuthUser(claims);

Authentication authentication = new UsernamePasswordAuthenticationToken(
        authUser,
        null,
        List.of(new SimpleGrantedAuthority(&quot;ROLE_&quot; + authUser.getUserRole().name()))
);

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security에서 SecurityContext는 현재 인증 정보를 보관하고, Authentication의 principal&amp;middot;authorities는 이후 인가에 사용된다. 새 SecurityContext를 만든 뒤 Holder에 넣는 방식도 공식 인증 구조의 예시와 맞춘다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller의 호출 형태는 유지했다. 다만 @Auth의 내부 구현을 @AuthenticationPrincipal 메타 어노테이션으로 바꿔 커스텀 ArgumentResolver를 제거했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(errorOnInvalidType = true)
public @interface Auth {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@PostMapping(&quot;/todos&quot;)
public ResponseEntity&amp;lt;TodoSaveResponse&amp;gt; saveTodo(
        @Auth AuthUser authUser,
        @Valid @RequestBody TodoSaveRequest request
) {
    return ResponseEntity.ok(todoService.saveTodo(authUser, request));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller 입장에서는 @Auth AuthUser를 그대로 쓰지만, 인증 정보의 출처는 request attribute에서 SecurityContext로 바뀌었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;URL 정책은 SecurityConfig에 모은다&quot; data-ke-size=&quot;size23&quot;&gt;URL 정책은 SecurityConfig에 모은다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인가 정책은 필터의 if 문이 아니라 SecurityFilterChain에 둔다.&lt;/p&gt;
&lt;pre class=&quot;x86asm&quot;&gt;&lt;code&gt;.authorizeHttpRequests(auth -&amp;gt; auth
        .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
        .requestMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
        .requestMatchers(&quot;/auth/**&quot;).permitAll()
        .requestMatchers(HttpMethod.GET, &quot;/todos&quot;, &quot;/todos/**&quot;, &quot;/users/**&quot;).permitAll()
        .anyRequest().authenticated()
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;USER 권한으로 /admin/**에 접근하면 인증은 성공했지만 권한이 부족하므로 403이다. 토큰 없이 인증이 필요한 API를 호출하면 401이다. 이 구분을 위해 AuthenticationEntryPoint와 AccessDeniedHandler도 각각 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security의 authorizeHttpRequests는 요청 경로별 규칙을 선언하는 방식이며, hasRole(&quot;ADMIN&quot;)은 ROLE_ADMIN authority를 기준으로 판단한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 DispatcherType.ERROR를 공개한 것은 오류 dispatch도 다시 인가 대상이 될 수 있기 때문이다. 보호 API가 예외를 냈을 때 원래 오류 응답 대신 인증 오류가 나오는 것을 막기 위한 설정이다.&lt;/p&gt;
&lt;h2 data-heading=&quot;3. 기존 관리자 역할 변경 AOP 로그를 SecurityContext 기준으로 리팩터링&quot; data-ke-size=&quot;size26&quot;&gt;3. 기존 관리자 역할 변경 AOP 로그를 SecurityContext 기준으로 리팩터링&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자 역할 변경 전 로그는 새로 추가한 DB 이력 기능이 아니라, 기존 AOP 기능을 인증 구조 변경에 맞춰 리팩터링한 작업이다. JWT 인증 정보의 저장 위치가 request attribute에서 SecurityContext로 바뀌었으므로 Aspect가 사용자 ID를 가져오는 방식도 함께 변경했다.&lt;/p&gt;
&lt;h3 data-heading=&quot;Controller 실행 직전에 로그를 남긴다&quot; data-ke-size=&quot;size23&quot;&gt;Controller 실행 직전에 로그를 남긴다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PATCH /admin/users/{userId}는 관리자만 호출할 수 있는 API다. 이 API의 실행 기록은 도메인 저장 데이터가 아니라 운영 중 확인할 애플리케이션 로그이므로 별도 테이블을 만들지 않고 AOP로 분리했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Before(&quot;execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))&quot;)
public void logBeforeChangeUserRole(JoinPoint joinPoint) {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication == null
            || !(authentication.getPrincipal() instanceof AuthUser authUser)) {
        log.warn(&quot;관리자 접근 로그 기록 실패 - 인증 사용자 정보를 확인할 수 없습니다.&quot;);
        return;
    }

    log.info(
            &quot;Admin Access Log - User ID: {}, Request Time: {}, Request URL: {}, Method: {}&quot;,
            authUser.getId(),
            LocalDateTime.now(),
            request.getRequestURI(),
            joinPoint.getSignature().getName()
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Before를 선택했기 때문에 Controller에 도달한 관리자 요청은 역할 변경 서비스가 이후 검증에서 실패하더라도 기록된다. 반대로 토큰이 없거나 유효하지 않아 401이 된 요청, USER 권한으로 접근해 403이 된 요청은 Security FilterChain에서 끝나므로 이 Aspect가 기록하지 않는다. 이 로그만으로 &amp;ldquo;역할 변경이 성공했다&amp;rdquo;라고 판단하면 안 된다. 성공 여부까지 감사해야 하는 요구가 생긴다면 결과와 오류 정보를 별도의 영속 로그로 설계해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Security 리팩터링 전에는 이 Aspect도 request attribute의 userId를 읽었다. 인증 정보의 출처를 SecurityContext로 통일하면서 AOP 역시 같은 principal을 사용하게 됐다.&lt;/p&gt;
&lt;h2 data-heading=&quot;4. Todo 생성은 작성자 담당자 등록까지 한 흐름으로 처리한다&quot; data-ke-size=&quot;size26&quot;&gt;4. Todo 생성은 작성자 담당자 등록까지 한 흐름으로 처리한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Todo를 만들 때 작성자를 첫 담당자로 등록하는 기능은 Todo 생성 흐름 안에서 함께 성공해야 한다. 그래서 Todo 생성 시 Manager를 컬렉션에 넣고, cascade = CascadeType.PERSIST로 Todo 저장에 맞춰 담당자도 저장되게 했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@OneToMany(mappedBy = &quot;todo&quot;, cascade = CascadeType.PERSIST)
private List&amp;lt;Manager&amp;gt; managers = new ArrayList&amp;lt;&amp;gt;();

public Todo(String title, String contents, String weather, User user) {
    this.title = title;
    this.contents = contents;
    this.weather = weather;
    this.user = user;
    this.managers.add(new Manager(user, this));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 Todo와 최초 담당자처럼 함께 성공해야 하는 데이터와 달리, 이후 담당자 등록 요청 이력은 업무 처리 실패 여부와 독립적으로 보존되어야 했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;5. 매니저 등록 요청 이력은 독립 트랜잭션으로 저장한다&quot; data-ke-size=&quot;size26&quot;&gt;5. 매니저 등록 요청 이력은 독립 트랜잭션으로 저장한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매니저 등록 요청 로그는 기존 AOP 로그를 확장한 것이 아니라, 새로 추가한 업무 요청 이력 기능이다. 존재하지 않는 담당자를 등록하려는 요청은 매니저 데이터로 저장되면 안 되지만, 누가 어떤 Todo에 어떤 사용자를 담당자로 지정하려고 했는지는 남겨야 한다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;매니저 등록 실패
&amp;rarr; Manager 저장은 롤백

요청 로그
&amp;rarr; 독립적으로 커밋
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 로그 저장 책임을 별도 Bean으로 분리하고 REQUIRES_NEW를 적용했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class ManagerAssignmentLogService {

    private final ManagerAssignmentLogRepository logRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveLog(Long requesterId, Long todoId, Long managerUserId) {
        logRepository.save(
                new ManagerAssignmentLog(requesterId, todoId, managerUserId)
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ManagerService는 업무 검증보다 먼저 별도 Bean의 로그 저장 메서드를 호출한다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@Transactional
public ManagerSaveResponse saveManager(
        AuthUser authUser,
        long todoId,
        ManagerSaveRequest managerSaveRequest
) {
    managerAssignmentLogService.saveLog(
            authUser.getId(),
            todoId,
            managerSaveRequest.getManagerUserId()
    );

    Todo todo = todoRepository.findById(todoId)
            .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;Todo not found&quot;));
    // Todo 작성자 확인

    User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
            .orElseThrow(() -&amp;gt; new InvalidRequestException(&quot;등록하려고 하는 담당자 유저가 존재하지 않습니다.&quot;));
    // 본인 등록 검증 후 Manager 저장
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;REQUIRES_NEW는 외부 트랜잭션에 참여하지 않고 독립된 물리 트랜잭션을 사용한다. 따라서 로그를 ManagerService 내부 자기 호출로 두지 않고 별도 Spring Bean으로 분리해 프록시 호출이 일어나도록 했다. saveLog()가 정상 반환되면 독립 트랜잭션은 커밋되고, 그 뒤 Todo&amp;middot;요청자&amp;middot;담당자 검증이 진행된다. 이후 예외가 발생해 외부 트랜잭션이 롤백되더라도 이미 커밋된 요청 이력은 남는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그 엔티티는 User, Todo와 연관관계를 만들지 않고 요청자&amp;middot;Todo&amp;middot;대상 담당자의 ID를 직접 저장한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Column(nullable = false)
private Long requesterId;

@Column(nullable = false)
private Long todoId;

@Column(nullable = false)
private Long managerUserId;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등록 대상 ID가 실제로 존재하지 않아도 요청 이력은 남겨야 하기 때문이다. @SpringBootTest 기반 통합 테스트에서는 존재하지 않는 담당자 ID로 요청을 실패시킨 뒤, Manager 수는 증가하지 않고 로그만 1건 증가하는지를 확인했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;6. 조회 목적에 따라 JPQL, EntityGraph, QueryDSL을 섞어 쓰기&quot; data-ke-size=&quot;size26&quot;&gt;6. 조회 목적에 따라 JPQL, EntityGraph, QueryDSL을 섞어 쓰기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 조회를 QueryDSL로 바꾸지 않았다. 조회의 성격에 따라 더 단순한 표현을 유지했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회 목적 선택 이유&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Todo 목록과 작성자&lt;/td&gt;
&lt;td&gt;JPQL JOIN FETCH&lt;/td&gt;
&lt;td&gt;날씨&amp;middot;수정일 조건과 page count 쿼리가 이미 선언형 쿼리로 명확했다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;댓글 목록과 작성자&lt;/td&gt;
&lt;td&gt;@EntityGraph(attributePaths = &quot;user&quot;)&lt;/td&gt;
&lt;td&gt;Repository 메서드 한 곳에 필요한 fetch plan만 붙일 수 있었다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Todo 단건과 작성자&lt;/td&gt;
&lt;td&gt;QueryDSL fetch join&lt;/td&gt;
&lt;td&gt;기존 QueryDSL Repository Fragment를 사용해 단건 조회만 확장했다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;조건 조합&amp;middot;집계 검색&lt;/td&gt;
&lt;td&gt;QueryDSL Projection&lt;/td&gt;
&lt;td&gt;동적 조건과 담당자&amp;middot;댓글 집계를 엔티티 전체 조회 없이 구성해야 했다.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 댓글 목록은 응답 DTO를 만들 때 작성자 정보를 읽는다. Comment만 먼저 조회한 뒤 댓글마다 getUser()를 호출하면 N+1이 생길 수 있다. 이 조회 경로에만 @EntityGraph를 적용했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@EntityGraph(attributePaths = &quot;user&quot;)
List&amp;lt;Comment&amp;gt; findAllByTodo_Id(Long todoId);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA는 Repository 메서드에 @EntityGraph를 붙여 필요한 연관 필드의 fetch plan을 지정할 수 있다. 전역 FetchType을 EAGER로 바꾸지 않고, 필요한 조회에만 범위를 한정한 이유다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-heading=&quot;QueryDSL 검색: 조건과 집계를 같은 join으로 풀지 않기&quot; data-ke-size=&quot;size23&quot;&gt;QueryDSL 검색: 조건과 집계를 같은 join으로 풀지 않기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 GET /todos/search는 제목 부분 일치, 담당자 닉네임 부분 일치, 생성일 범위를 선택 조건으로 받고 다음 세 값만 반환한다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;@Getter
@RequiredArgsConstructor
public class TodoSearchResponse {
    private final String title;
    private final long managerCount;
    private final long commentCount;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Todo &amp;rarr; Manager, Todo &amp;rarr; Comment를 한 쿼리에서 join한 뒤 집계하는 방식을 고려했다. 하지만 Todo 하나에 담당자 2명, 댓글 3개가 있다면 조인 결과는 최대 6행이 된다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;2 managers &amp;times; 3 comments = 6 rows
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 count()는 실제보다 큰 값을 만들 수 있다. 그래서 외부 쿼리는 Todo 한 행을 유지하고, 역할이 다른 값은 각각 서브쿼리로 분리했다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;List&amp;lt;TodoSearchResponse&amp;gt; content = jpaQueryFactory
        .select(Projections.constructor(
                TodoSearchResponse.class,
                todo.title,
                managerCount(),
                commentCount()
        ))
        .from(todo)
        .where(searchConditions(condition))
        .orderBy(todo.createdAt.desc(), todo.id.desc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;담당자 닉네임은 &amp;ldquo;이 닉네임을 가진 담당자가 존재하는 Todo인가&amp;rdquo;를 판단하는 조건이므로 exists 서브쿼리로 처리했다. 반면 managerCount는 검색어와 일치한 담당자 수가 아니라 해당 Todo의 전체 담당자 수여야 하므로 별도의 count 서브쿼리로 계산했다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;private BooleanExpression managerNicknameContains(String nickname) {
    if (!StringUtils.hasText(nickname)) {
        return null;
    }

    return JPAExpressions
            .selectOne()
            .from(manager)
            .join(manager.user, user)
            .where(
                    manager.todo.eq(todo),
                    user.nickname.contains(nickname)
            )
            .exists();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 조건은 BooleanExpression[]으로 한 번 만들고 content 쿼리와 count 쿼리가 함께 사용한다. 이후 조건을 추가할 때 한쪽 쿼리에만 조건을 넣는 실수를 줄이기 위해서다. PageableExecutionUtils.getPage()를 사용해 현재 조회 결과 수와 페이지 정보를 바탕으로 전체 건수를 추론할 수 있는 경우에는 별도의 count 쿼리를 실행하지 않도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 현재 요구사항에서 집계의 정확성과 코드의 역할 분리를 우선한 선택이다. 데이터가 충분히 커져 상관 서브쿼리 비용이 문제가 되면 실행 계획과 인덱스를 확인한 뒤 join&amp;middot;group by 방식 또는 다른 조회 전략을 비교해야 한다.&lt;/p&gt;
&lt;h2 data-heading=&quot;검증 기준과 남은 보완점&quot; data-ke-size=&quot;size26&quot;&gt;검증 기준과 남은 보완점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업은 기능 구현만으로 닫지 않고, 컴파일&amp;middot;자동 테스트 또는 재현 가능한 API 호출&amp;middot;쿼리 로그 확인&amp;middot;커밋 순으로 정리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 확인 방식&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 환경&lt;/td&gt;
&lt;td&gt;MySQL 실행 환경과 H2 테스트 환경 분리 후 ./gradlew clean test 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;인증&amp;middot;인가&lt;/td&gt;
&lt;td&gt;무인증 보호 API 401, USER의 관리자 API 접근 403, ADMIN 접근 성공을 Swagger로 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;관리자 역할 변경 실행 로그&lt;/td&gt;
&lt;td&gt;인가를 통과한 ADMIN의 API 호출 때 요청자 ID&amp;middot;시간&amp;middot;URL&amp;middot;메서드가 애플리케이션 로그에 남는지 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;N+1 개선&lt;/td&gt;
&lt;td&gt;Todo&amp;middot;댓글 조회 시 작성자까지 함께 조회되는 Hibernate SQL 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;독립 트랜잭션&lt;/td&gt;
&lt;td&gt;존재하지 않는 담당자 ID로 실패시키고 Manager 수와 log 행 수를 통합 테스트로 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QueryDSL 검색&lt;/td&gt;
&lt;td&gt;조건별 응답, 생성일 정렬, 집계 수, 잘못된 page&amp;middot;size&amp;middot;날짜 범위 400을 API 호출로 확인&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;USER 토큰의 /admin/** 요청이 403으로 반환되는 Swagger 또는 API 응답 화면&lt;/li&gt;
&lt;li&gt;담당자 등록 실패 뒤 Manager는 늘지 않고 log는 남은 통합 테스트 또는 DB 조회 결과&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 결과에는 요구된 제목과 집계만 반환하므로 Todo ID는 포함하지 않았다. 검색 결과에서 바로 상세 화면으로 이동하는 기능이 필요해진다면, 그때는 API 사용 흐름을 다시 검토한 뒤 식별자를 추가하는 것이 맞다.&lt;/p&gt;
&lt;h2 data-heading=&quot;정리&quot; 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;mipsasm&quot;&gt;&lt;code&gt;인증 정보
request attribute &amp;rarr; SecurityContext

URL별 권한 판단
JwtFilter &amp;rarr; SecurityConfig

요청 로그의 성공 조건
업무 트랜잭션과 동일 &amp;rarr; REQUIRES_NEW 독립 트랜잭션

로그의 기록 위치
관리자 역할 변경 실행 &amp;rarr; AOP 애플리케이션 로그
매니저 등록 요청 &amp;rarr; DB 영속 로그

조회 전략
모든 조회를 같은 방식으로 처리 &amp;rarr; 조회 목적별 JPQL&amp;middot;EntityGraph&amp;middot;QueryDSL 선택
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 필터는 &amp;ldquo;누구인지 확인하는 일&amp;rdquo;에 집중하고, SecurityConfig는 &amp;ldquo;무엇을 허용할지&amp;rdquo;를 결정한다. 관리자 역할 변경이 실제 실행되기 직전의 운영 로그는 AOP로, 업무 검증 실패에도 보존해야 하는 매니저 등록 요청 이력은 독립 트랜잭션의 DB 로그로 남겼다. 조회는 엔티티 전체를 항상 가져오기보다 필요한 연관 데이터와 결과 필드에 맞춰 선택했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;참고 자료&quot; 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;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html&quot; data-tooltip-position=&quot;top&quot;&gt;Spring Security Servlet Authentication Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html&quot; data-tooltip-position=&quot;top&quot;&gt;Spring Security Authorize HTTP Requests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/reference/data-access/transaction/declarative/tx-propagation.html&quot; data-tooltip-position=&quot;top&quot;&gt;Spring Framework Transaction Propagation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/support/PageableExecutionUtils.html&quot; data-tooltip-position=&quot;top&quot;&gt;Spring Data PageableExecutionUtils API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html&quot; data-tooltip-position=&quot;top&quot;&gt;Spring Data JPA Query Methods and EntityGraph&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/trex1004/spring-plus-refactoring/commits/main&quot; data-tooltip-position=&quot;top&quot;&gt;프로젝트 커밋 이력&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/12</guid>
      <comments>https://zeroto-dev.tistory.com/12#entry12comment</comments>
      <pubDate>Fri, 4 Sep 2026 13:28:50 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Security] JWT 인증 성공 로그가 있는데 관리자 API가 401이었던 이유</title>
      <link>https://zeroto-dev.tistory.com/11</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security로 JWT 인증&amp;middot;인가 구조를 전환한 뒤, USER 권한 토큰으로 관리자 API를 호출했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기대한 결과는 403 Forbidden이었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;인증 성공
하지만 ADMIN 권한 없음
&amp;rarr; 403 Forbidden
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 실제 응답은 401 Unauthorized였다. 더 혼란스러웠던 점은 애플리케이션 로그에 JWT 인증 성공까지 남았다는 것이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;JwtFilter 진입 - uri=/admin/users/1
JWT 인증 성공 - userId=1, role=USER
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &amp;ldquo;토큰 검증은 성공했는데 왜 최종 응답은 401이었는가&amp;rdquo;를 추적하며, JWT 필터와 Spring Security 인가 정책의 책임을 다시 나눈 기록이다.&lt;/p&gt;
&lt;h2 data-heading=&quot;먼저 구분할 것: 401과 403은 실패 지점이 다르다&quot; data-ke-size=&quot;size26&quot;&gt;먼저 구분할 것: 401과 403은 실패 지점이 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자 API의 응답은 다음 표처럼 해석해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 상태 의미 기대 응답&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;토큰 없음&lt;/td&gt;
&lt;td&gt;인증 주체가 없음&lt;/td&gt;
&lt;td&gt;401 Unauthorized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;토큰이 잘못됐거나 만료됨&lt;/td&gt;
&lt;td&gt;JWT 인증 실패&lt;/td&gt;
&lt;td&gt;401 Unauthorized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USER 토큰으로 관리자 API 접근&lt;/td&gt;
&lt;td&gt;인증은 성공했지만 권한 부족&lt;/td&gt;
&lt;td&gt;403 Forbidden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADMIN 토큰으로 관리자 API 접근&lt;/td&gt;
&lt;td&gt;인증&amp;middot;인가 성공&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;즉 위 로그처럼 role=USER까지 복원됐다면 JWT 파싱이 실패한 것이 아니다. 먼저 &amp;ldquo;권한 부족 403이 다른 dispatch에서 다시 처리됐는가&amp;rdquo;를 의심해야 했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;원래 구조: JwtFilter가 너무 많은 일을 했다&quot; data-ke-size=&quot;size26&quot;&gt;원래 구조: JwtFilter가 너무 많은 일을 했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전환 전 JwtFilter는 토큰을 검증하고 request attribute에 사용자 정보를 저장했다. /admin URL인지 확인하고 관리자 권한도 직접 검사했다. Controller의 @Auth AuthUser는 별도 ArgumentResolver가 request attribute를 읽어 만들었다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;JwtFilter
&amp;rarr; JWT 검증
&amp;rarr; request.setAttribute(...)
&amp;rarr; /admin URL과 ADMIN 권한 직접 검사
&amp;rarr; @Auth ArgumentResolver
&amp;rarr; Controller
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 인증에 성공했는지, URL 접근이 허용됐는지, Controller가 어떤 인증 정보를 받는지가 하나의 필터와 Servlet request attribute에 섞인다.&lt;/p&gt;
&lt;h2 data-heading=&quot;1. JWT 필터는 Authentication 생성만 하도록 바꿨다&quot; data-ke-size=&quot;size26&quot;&gt;1. JWT 필터는 Authentication 생성만 하도록 바꿨다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT Claims를 AuthUser로 바꾸고, 역할을 Spring Security authority 형식으로 변환해 SecurityContext에 저장했다.&lt;/p&gt;
&lt;pre class=&quot;verilog&quot;&gt;&lt;code&gt;Claims claims = jwtUtil.extractClaims(token);
AuthUser authUser = createAuthUser(claims);

Authentication authentication = new UsernamePasswordAuthenticationToken(
        authUser,
        null,
        List.of(new SimpleGrantedAuthority(&quot;ROLE_&quot; + authUser.getUserRole().name()))
);

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security는 SecurityContext에 현재 사용자의 Authentication을 보관하고, 인가 단계에서 principal과 authorities를 사용한다. 이때 hasRole(&quot;ADMIN&quot;)은 ROLE_ADMIN authority를 기준으로 판단한다.&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;JWT 서명&amp;middot;만료&amp;middot;형식&amp;middot;Claim이 잘못돼 인증 정보를 만들 수 없는 경우: 401&lt;/li&gt;
&lt;li&gt;JWT가 없거나 Bearer 형식이 아닌 경우: 아무 응답도 만들지 않고 다음 필터로 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 sendError() 뒤에는 반드시 return해야 한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;} catch (ExpiredJwtException e) {
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;만료된 JWT 토큰입니다.&quot;);
    return;
}

filterChain.doFilter(request, response);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sendError()는 오류 응답 생성을 컨테이너에 맡기고, 호출 뒤 response를 더 작성하지 않아야 하는 상태로 만든다. 따라서 JWT 오류를 응답한 뒤에는 return해 현재 필터의 실행을 끝낸다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bearer prefix 처리는 sendError()와는 별개의 책임이다. Authorization 헤더와 Bearer 접두사는 HTTP 요청 형식이므로 이를 읽는 JwtFilter가 해석한다. 반면 JwtUtil은 HTTP와 무관하게 순수 JWT 문자열의 생성&amp;middot;검증&amp;middot;Claims 파싱만 담당하도록 두었다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private static final String BEARER_PREFIX = &quot;Bearer &quot;;

private String resolveToken(HttpServletRequest request) {
    String authorizationHeader = request.getHeader(&quot;Authorization&quot;);
    if (!StringUtils.hasText(authorizationHeader)
            || !authorizationHeader.startsWith(BEARER_PREFIX)) {
        return null;
    }
    return authorizationHeader.substring(BEARER_PREFIX.length());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-heading=&quot;2. URL별 권한 판단은 SecurityConfig로 옮겼다&quot; data-ke-size=&quot;size26&quot;&gt;2. URL별 권한 판단은 SecurityConfig로 옮겼다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtFilter가 /admin 문자열을 검사하는 대신 SecurityFilterChain에 정책을 선언했다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;.authorizeHttpRequests(auth -&amp;gt; auth
        .requestMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
        .requestMatchers(&quot;/auth/**&quot;).permitAll()
        .requestMatchers(HttpMethod.GET, &quot;/todos&quot;, &quot;/todos/**&quot;, &quot;/users/**&quot;).permitAll()
        .anyRequest().authenticated()
)
.exceptionHandling(exception -&amp;gt; exception
        .authenticationEntryPoint((request, response, e) -&amp;gt;
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;인증이 필요합니다.&quot;))
        .accessDeniedHandler((request, response, e) -&amp;gt;
                response.sendError(HttpServletResponse.SC_FORBIDDEN, &quot;접근 권한이 없습니다.&quot;))
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 USER 토큰은 ROLE_USER authority를 가진 Authentication으로 SecurityContext에 들어간다. /admin/** 규칙은 ROLE_ADMIN을 요구하므로 Spring Security가 권한 부족으로 판단하고 AccessDeniedHandler가 403을 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security의 authorizeHttpRequests는 요청 경로와 권한 규칙을 연결하며, permitAll, authenticated, hasRole처럼 정책을 선언할 수 있다.&lt;/p&gt;
&lt;h2 data-heading=&quot;3. 403이 401로 바뀐 지점은 ERROR dispatch였다&quot; data-ke-size=&quot;size26&quot;&gt;3. 403이 401로 바뀐 지점은 ERROR dispatch였다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 첫 요청만 인가되는 것이 아니라는 데 있었다. Spring Security의 AuthorizationFilter는 일반 요청뿐 아니라 FORWARD, ERROR, INCLUDE 같은 dispatch도 인가할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;USER &amp;rarr; PATCH /admin/users/1
     &amp;rarr; JwtFilter: 인증 성공, ROLE_USER 저장
     &amp;rarr; /admin/** 인가 실패
     &amp;rarr; AccessDeniedHandler가 response.sendError(403, ...) 호출
     &amp;rarr; 컨테이너의 오류 처리 경로로 ERROR dispatch
     &amp;rarr; SecurityFilterChain과 URL 인가 규칙 재적용
     &amp;rarr; 최종 응답이 기대와 다르게 처리될 수 있음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서도 ERROR dispatch가 다시 인가될 수 있으며, 오류 처리를 위해 ERROR dispatch를 허용하는 구성을 고려할 수 있다고 설명한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 URL별 규칙보다 먼저 ERROR dispatch를 공개했다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;.authorizeHttpRequests(auth -&amp;gt; auth
        .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
        .requestMatchers(&quot;/admin/**&quot;).hasRole(&quot;ADMIN&quot;)
        // ...
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 ERROR dispatch를 permitAll()로 둔 것은 /admin/** 자체를 공개한다는 뜻이 아니다. 최초 /admin/** 요청은 여전히 hasRole(&quot;ADMIN&quot;) 규칙으로 인가한다. 이미 403으로 결정된 뒤의 ERROR dispatch가 URL 인가 규칙에 의해 다시 차단되는 것을 막는 설정이다.&lt;/p&gt;
&lt;h2 data-heading=&quot;4. Controller의 @Auth 사용 방식은 유지했다&quot; data-ke-size=&quot;size26&quot;&gt;4. Controller의 @Auth 사용 방식은 유지했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SecurityContext로 전환하면서 Controller마다 인증 정보를 꺼내는 코드를 새로 쓰지 않도록 @Auth를 @AuthenticationPrincipal의 메타 어노테이션으로 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(errorOnInvalidType = true)
public @interface Auth {
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public ResponseEntity&amp;lt;TodoSaveResponse&amp;gt; saveTodo(
        @Auth AuthUser authUser,
        @Valid @RequestBody TodoSaveRequest request
) {
    return ResponseEntity.ok(todoService.saveTodo(authUser, request));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller의 파라미터 형태는 유지하면서, 내부적으로는 request attribute가 아니라 Authentication.principal의 AuthUser를 받는다. 관리자 접근 AOP도 같은 이유로 request attribute 대신 SecurityContext에서 principal을 읽도록 변경했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;확인한 결과&quot; data-ke-size=&quot;size26&quot;&gt;확인한 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시나리오 JwtFilter 결과 최종 응답&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;토큰 없음 + 보호 API&lt;/td&gt;
&lt;td&gt;Authentication 미생성&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;잘못된&amp;middot;만료된 토큰&lt;/td&gt;
&lt;td&gt;필터에서 JWT 예외 처리&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USER 토큰 + /admin/**&lt;/td&gt;
&lt;td&gt;ROLE_USER Authentication 생성&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADMIN 토큰 + /admin/**&lt;/td&gt;
&lt;td&gt;ROLE_ADMIN Authentication 생성&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;게시 전에는 USER 토큰 요청에서 JWT 인증 성공 - role=USER 로그와 403 응답이 함께 보이는 Swagger 또는 curl 캡처를 추가할 예정이다.&lt;/p&gt;
&lt;h2 data-heading=&quot;정리&quot; data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 문제는 JWT 검증 실패가 아니라, 인증과 인가 그리고 ERROR dispatch의 책임이 섞여 최종 상태 코드까지 바뀐 경우였다.&lt;/p&gt;
&lt;pre class=&quot;subunit&quot;&gt;&lt;code&gt;JwtFilter
&amp;rarr; 토큰 검증과 Authentication 생성

SecurityConfig
&amp;rarr; 공개&amp;middot;인증&amp;middot;역할 기반 URL 정책

exceptionHandling
&amp;rarr; 인증 실패 401 / 권한 부족 403

ERROR dispatch 허용
&amp;rarr; ERROR dispatch가 URL 인가 규칙에 의해 다시 차단되는 것을 방지
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 인증 성공 로그가 남는다면 토큰 파싱만 다시 보지 않고, Authentication의 authority, URL 인가 규칙, ERROR dispatch 재진입까지 이어서 확인해야 한다.&lt;/p&gt;
&lt;h2 data-heading=&quot;참고 자료&quot; 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;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html&quot; data-tooltip-position=&quot;top&quot;&gt;Spring Security Servlet Authentication Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html&quot; data-tooltip-position=&quot;top&quot;&gt;Spring Security Authorize HTTP Requests&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://jakarta.ee/specifications/servlet/6.0/apidocs/jakarta.servlet/jakarta/servlet/http/httpservletresponse&quot; data-tooltip-position=&quot;top&quot;&gt;Jakarta Servlet HttpServletResponse.sendError API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/trex1004/spring-plus-refactoring/commit/8986e2db38ff6144c523814f1239ebb4e1740edb&quot; data-tooltip-position=&quot;top&quot;&gt;JWT 인증&amp;middot;인가 전환 커밋&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/trex1004/spring-plus-refactoring/commit/507da5e7c0fed4968633ffd9036edccf6b2c7296&quot; data-tooltip-position=&quot;top&quot;&gt;Bearer 처리 책임 정리 커밋&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/trex1004/spring-plus-refactoring/commit/fa662897f5b46d531d9708bd2180df19a1993514&quot; data-tooltip-position=&quot;top&quot;&gt;ERROR dispatch 수정 커밋&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/11</guid>
      <comments>https://zeroto-dev.tistory.com/11#entry11comment</comments>
      <pubDate>Fri, 4 Sep 2026 13:27:59 +0900</pubDate>
    </item>
    <item>
      <title>[AWS] GitHub Actions OIDC부터 ALB까지, Spring Boot 배포 경로 만들기</title>
      <link>https://zeroto-dev.tistory.com/10</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트의 Spring Boot 애플리케이션을 AWS에 배포했다. 목표는 단순히 EC2에서 애플리케이션을 실행하는 것이 아니었다. main 브랜치에 반영된 코드가 테스트를 거쳐 Docker 이미지로 만들어지고, HTTPS 도메인으로 접근 가능한 상태까지 이어지는 배포 경로를 만드는 것이 목표였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 환경은 기능 검증 후 비용 관리를 위해 삭제한 임시 배포 환경이다. 따라서 아래 내용은 운영 환경을 완성했다는 기록이 아니라, 배포에 필요한 구성 요소가 어떻게 연결되는지 직접 확인한 기록이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드에 꺾쇠로 표시한 계정 ID, 저장소, 프로젝트명, 커밋 SHA 같은 식별자는 실제 구조를 유지하면서 일반화한 예시다.&lt;/p&gt;
&lt;h2 data-heading=&quot;배포 경로&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;배포 경로&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 흐름은 아래와 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;GitHub Actions (main push)
  ├─ 테스트 실행
  ├─ OIDC로 AWS 역할 임시 권한 획득
  ├─ GHCR 미러와 ECR에 ARM64 커밋 SHA 태그 이미지 push
  └─ SSM Run Command로 EC2 배포 스크립트 실행
       ├─ ECR에서 같은 SHA 태그 이미지 pull
       ├─ EC2 Docker 컨테이너 실행 :8080
       └─ EC2 애플리케이션에서 RDS MySQL 연결 :3306

브라우저
  &amp;rarr; Route 53
  &amp;rarr; ALB :443 (ACM 인증서로 TLS 종료)
  &amp;rarr; 대상 그룹 HTTP :8080
  &amp;rarr; EC2 Docker 컨테이너

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 148px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;구간&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;역할&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;&lt;b&gt;구성&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9767%;&quot;&gt;GitHub Actions &amp;rarr; AWS&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 18.6047%;&quot;&gt;배포 권한 획득&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 49.3023%;&quot;&gt;OIDC와 IAM 역할&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9767%;&quot;&gt;GitHub Actions &amp;rarr; GHCR&amp;middot;ECR&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 18.6047%;&quot;&gt;이미지 저장&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 49.3023%;&quot;&gt;ARM64 커밋 SHA 태그, 실제 EC2 배포 원본은 ECR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9767%;&quot;&gt;GitHub Actions &amp;rarr; EC2&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 18.6047%;&quot;&gt;원격 배포 실행&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 49.3023%;&quot;&gt;SSM Run Command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9767%;&quot;&gt;외부 사용자 &amp;rarr; ALB&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 18.6047%;&quot;&gt;HTTPS 진입점&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 49.3023%;&quot;&gt;ACM 인증서, 443 리스너&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9767%;&quot;&gt;ALB &amp;rarr; EC2&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 18.6047%;&quot;&gt;애플리케이션 전달&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 49.3023%;&quot;&gt;대상 그룹, 8080, 상태 검사&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 31.9767%;&quot;&gt;EC2 &amp;rarr; RDS&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 18.6047%;&quot;&gt;데이터베이스 연결&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 49.3023%;&quot;&gt;보안 그룹 참조, 3306&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;1. GitHub Secrets 대신 OIDC로 AWS 권한 받기&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. GitHub Secrets 대신 OIDC로 AWS 권한 받기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions가 실행 시점에 OIDC 토큰을 받고, AWS IAM 역할이 그 토큰을 검증한 뒤 짧은 시간의 임시 권한을 발급하는 방식을 사용했다. 그래서 GitHub에 장기 AWS Access Key와 Secret Key를 저장하지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;워크플로에는 OIDC 토큰 발급 권한이 필요하다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;permissions:
  contents: read
  id-token: write

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IAM 역할의 신뢰 정책은 어떤 저장소와 브랜치의 워크플로만 역할을 맡을 수 있는지 제한한다. 중요한 것은 sub 조건이 실제 실행 주체와 정확히 일치해야 한다는 점이다. 아래 정책은 이번 과정에서 사용한 구조에서 계정과 저장소 식별자만 일반화한 예시다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: {
        &quot;Federated&quot;: &quot;arn:aws:iam::&amp;lt;AWS_ACCOUNT_ID&amp;gt;:oidc-provider/token.actions.githubusercontent.com&quot;
      },
      &quot;Action&quot;: &quot;sts:AssumeRoleWithWebIdentity&quot;,
      &quot;Condition&quot;: {
        &quot;StringEquals&quot;: {
          &quot;token.actions.githubusercontent.com:aud&quot;: &quot;sts.amazonaws.com&quot;,
          &quot;token.actions.githubusercontent.com:sub&quot;: &quot;repo:&amp;lt;ORGANIZATION&amp;gt;/&amp;lt;REPOSITORY&amp;gt;:ref:refs/heads/main&quot;
        }
      }
    }
  ]
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 저장소에서는 예시와 같은 기존 형식의 sub를 사용했다. GitHub 공식 문서에 따르면 2026년 7월 15일 이후 생성됐거나 immutable subject claim을 사용하도록 전환한 저장소는 sub에 변경 불가능한 소유자&amp;middot;저장소 ID가 포함될 수 있으므로, 예시를 그대로 복사하지 말고 실제 토큰 형식과 신뢰 정책을 맞춰야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IAM 역할에는 ECR 이미지 push와 SSM 명령 실행&amp;middot;결과 조회에 필요한 권한을 부여했다. 신뢰 정책은 누가 역할을 맡을 수 있는지를 제한하고, 권한 정책은 역할을 맡은 뒤 어떤 AWS 작업을 할 수 있는지를 정의한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-heading=&quot;2. 이미지는 SHA로 식별하고, 배포 명령은 SSM으로 전달하기&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 이미지는 SHA로 식별하고, 배포 명령은 SSM으로 전달하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;main push에서는 같은 커밋을 linux/arm64 이미지로 각각 빌드해 GHCR과 ECR에 push했다. GHCR은 같은 SHA의 미러로 사용했고, 실제 EC2 배포에는 ECR 이미지만 사용했다. 이후 GitHub Actions가 SSM Run Command로 EC2의 배포 스크립트를 실행하면, EC2가 ECR에서 해당 SHA의 이미지를 받아 컨테이너를 교체하도록 구성했다.&lt;/p&gt;
&lt;h3 data-heading=&quot;커밋 SHA를 이미지 태그로 사용하기&quot; data-ke-size=&quot;size23&quot;&gt;커밋 SHA를 이미지 태그로 사용하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지 태그를 latest 하나로 두면 현재 배포된 코드가 무엇인지 확인하기 어렵다. 그래서 GitHub 커밋 SHA를 이미지 태그로 사용했다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;&amp;lt;AWS_ACCOUNT_ID&amp;gt;.dkr.ecr.ap-northeast-2.amazonaws.com/&amp;lt;프로젝트명&amp;gt;:&amp;lt;GITHUB_SHA&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 현재 EC2에서 실행 중인 이미지가 어떤 커밋으로 만들어졌는지 확인할 수 있다. 배포에 문제가 생겼을 때 이전 SHA를 지정해 다시 배포할 수 있는 기준도 남는다.&lt;/p&gt;
&lt;h3 data-heading=&quot;SSM으로 EC2 배포 스크립트 실행하기&quot; data-ke-size=&quot;size23&quot;&gt;SSM으로 EC2 배포 스크립트 실행하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2에는 Docker와 SSM Agent를 실행하고 /opt/&amp;lt;배포 디렉터리&amp;gt;/deploy.sh를 미리 배치했다. GitHub Actions는 이미지를 직접 EC2에 전달하지 않고, SSM Run Command를 통해 해당 스크립트를 실행했다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;DEPLOY_DIR=&quot;/opt/&amp;lt;배포 디렉터리&amp;gt;&quot;
sudo &quot;${DEPLOY_DIR}/deploy.sh&quot; &quot;&amp;lt;GITHUB_SHA&amp;gt;&quot; normal
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions는 SSM 명령을 보낸 뒤 aws ssm wait command-executed로 명령이 성공할 때까지 기다리고, 실행 결과 JSON을 조회했다. 실제 deploy.sh는 다음 작업을 담당한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;bootstrap 또는 normal 모드에 맞는 DB 초기화 환경 변수 결정&lt;/li&gt;
&lt;li&gt;ECR 로그인&lt;/li&gt;
&lt;li&gt;전달받은 SHA 태그의 이미지 pull&lt;/li&gt;
&lt;li&gt;기존 컨테이너 제거&lt;/li&gt;
&lt;li&gt;prod 프로필과 모드별 환경 변수를 전달해 새 컨테이너 실행&lt;/li&gt;
&lt;li&gt;로컬 health check&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 이 가운데 이미지를 받고 컨테이너를 교체하는 부분만 줄인 예시다. 꺾쇠로 표시한 값은 실제 계정 ID, 프로젝트명, 배포할 커밋 SHA로 바꿔야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;PROJECT_NAME=&quot;&amp;lt;프로젝트명&amp;gt;&quot;
IMAGE_TAG=&quot;&amp;lt;GITHUB_SHA&amp;gt;&quot;
IMAGE_URI=&quot;&amp;lt;AWS_ACCOUNT_ID&amp;gt;.dkr.ecr.ap-northeast-2.amazonaws.com/${PROJECT_NAME}:${IMAGE_TAG}&quot;
CONTAINER_NAME=&quot;${PROJECT_NAME}-app&quot;

docker pull &quot;$IMAGE_URI&quot;
docker rm -f &quot;$CONTAINER_NAME&quot; 2&amp;gt;/dev/null || true

docker run -d \
  --name &quot;$CONTAINER_NAME&quot; \
  --restart unless-stopped \
  -p 8080:8080 \
  &quot;$IMAGE_URI&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식에서는 SSH 키를 GitHub에 보관하거나 EC2의 22번 포트를 배포용으로 열 필요가 없다. GitHub Actions는 SSM에 명령을 전달하고, 실제 이미지 pull과 컨테이너 실행은 EC2 내부에서 수행한다.&lt;/p&gt;
&lt;h3 data-heading=&quot;GitHub Actions 역할과 EC2 역할 분리하기&quot; data-ke-size=&quot;size23&quot;&gt;GitHub Actions 역할과 EC2 역할 분리하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions가 맡는 IAM 역할에는 ECR 이미지 push와 SSM 명령 실행&amp;middot;결과 조회에 필요한 권한을 부여했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2의 런타임 역할에는 SSM 관리형 노드로 동작하기 위한 AmazonSSMManagedInstanceCore와 다음 실행 권한을 부여했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ECR 이미지 읽기&lt;/li&gt;
&lt;li&gt;Parameter Store 설정 읽기&lt;/li&gt;
&lt;li&gt;CloudWatch Logs 쓰기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 비밀번호와 JWT 같은 값은 GitHub Secrets나 Docker 이미지에 포함하지 않았다. deploy.sh는 컨테이너에 prod 프로필과 DB 초기화 모드만 전달했고, Spring 애플리케이션이 시작될 때 spring.config.import를 통해 Parameter Store의 설정을 직접 읽었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-heading=&quot;3. HTTPS는 ALB에서 끝내고, 애플리케이션은 8080으로 유지하기&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. HTTPS는 ALB에서 끝내고, 애플리케이션은 8080으로 유지하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 컨테이너는 8080 포트로 실행했다. TLS 인증서와 HTTPS 처리는 애플리케이션이 아니라 ALB가 맡도록 했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;사용자 HTTPS 요청
  &amp;rarr; ALB 443 리스너
  &amp;rarr; ACM 인증서로 TLS 종료
  &amp;rarr; 대상 그룹 HTTP 8080
  &amp;rarr; Spring Boot 컨테이너

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ALB에는 두 리스너를 구성했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HTTP:80은 HTTPS:443으로 301 리다이렉트&lt;/li&gt;
&lt;li&gt;HTTPS:443은 대상 그룹으로 전달&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대상 그룹의 상태 검사 경로는 Spring Boot Actuator의 /actuator/health로 지정했다. ALB는 이 경로의 응답을 기준으로 등록된 대상을 healthy 또는 unhealthy로 판단한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;groups&quot;: [&quot;liveness&quot;, &quot;readiness&quot;],
  &quot;status&quot;: &quot;UP&quot;
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등록된 EC2 대상이 정상적으로 healthy 상태가 되려면 컨테이너가 실행 중이어야 하고, ALB에서 EC2의 8080 포트로 접근할 수 있어야 하며, health check 경로가 정상 응답해야 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-heading=&quot;4. **보안 그룹으로 서비스 간 접근 경로 제한하기**&quot; data-ke-size=&quot;size26&quot;&gt;4. &lt;b&gt;보안 그룹으로 서비스 간 접근 경로 제한하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션이 RDS에 접근하는 주체는 개발자 PC가 아니라 EC2이므로, 서비스 간 연결을 보안 그룹 참조로 제한했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대상 인바운드 허용 이유&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ALB 보안 그룹&lt;/td&gt;
&lt;td&gt;0.0.0.0/0의 80, 443&lt;/td&gt;
&lt;td&gt;공개 HTTP&amp;middot;HTTPS 진입점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EC2 보안 그룹&lt;/td&gt;
&lt;td&gt;ALB 보안 그룹의 8080&lt;/td&gt;
&lt;td&gt;ALB만 애플리케이션에 전달&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS 보안 그룹&lt;/td&gt;
&lt;td&gt;EC2 보안 그룹의 3306&lt;/td&gt;
&lt;td&gt;애플리케이션만 DB 접근&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 외부 사용자가 EC2의 8080이나 RDS의 3306에 직접 접근할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서는 단일 EC2를 퍼블릭 서브넷에 두었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-heading=&quot;5. 최초 DB 초기화와 일반 배포 모드 분리하기&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 최초 DB 초기화와 일반 배포 모드 분리하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빈 RDS에 스키마와 초기 데이터를 넣는 최초 배포와 기존 DB를 유지하는 이후 배포를 bootstrap, normal 모드로 분리했다. data.sql은 bootstrap에서만 실행하도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구분 용도 초기화 정책&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bootstrap&lt;/td&gt;
&lt;td&gt;빈 초기 DB를 처음 만들 때만 사용&lt;/td&gt;
&lt;td&gt;스키마 생성 후 초기 데이터 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;normal&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;pre class=&quot;routeros&quot;&gt;&lt;code&gt;bootstrap: ddl-auto=update,   sql.init.mode=always, defer-datasource-initialization=true
normal:    ddl-auto=validate, sql.init.mode=never,  defer-datasource-initialization=false

&lt;/code&gt;&lt;/pre&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;DEPLOY_TO_EC2=false 상태에서 첫 main 배포를 실행해 GHCR과 ECR에 SHA 태그 이미지를 올리고, EC2 배포는 건너뛴다.&lt;/li&gt;
&lt;li&gt;그 SHA로 bootstrap을 한 번 실행하고 초기 데이터가 들어갔는지 확인한다.&lt;/li&gt;
&lt;li&gt;같은 SHA를 normal로 다시 실행해 초기화 설정을 끈다.&lt;/li&gt;
&lt;li&gt;DEPLOY_TO_EC2=true로 바꾼 뒤부터 main 배포가 SSM을 통해 normal 모드만 실행하게 한다&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-heading=&quot;6. Route 53과 ACM으로 서비스 도메인 연결하기&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. Route 53과 ACM으로 서비스 도메인 연결하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;commerce.trex1004.click은 *.trex1004.click 와일드카드 인증서가 포함하는 한 단계 하위 도메인이다. ACM에서 Issued 상태인 이 인증서를 선택해 ALB의 HTTPS:443 리스너에 연결했다.&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;등록 도메인의 상태가 Active인지 확인한다.&lt;/li&gt;
&lt;li&gt;기존 와일드카드 인증서가 사용할 서비스 도메인을 포함하는지 확인한 뒤 ALB의 443 리스너에 연결한다.&lt;/li&gt;
&lt;li&gt;서비스 도메인의 Route 53 A 별칭 레코드를 ALB로 연결한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-heading=&quot;7. 상태 확인 경로와 사용자 진입 경로를 분리했다&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 상태 확인 경로와 사용자 진입 경로를 분리했다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ALB의 /actuator/health는 애플리케이션 상태를 확인하는 경로다. 사용자가 처음 접속하는 루트(/)는 별도의 진입점으로 준비했다. 처음에는 루트 요청에 아래 파일을 제공해 /demo/products.html로 이동시켰다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;!doctype html&amp;gt;
&amp;lt;html lang=&quot;ko&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;title&amp;gt;Commerce Payment System&amp;lt;/title&amp;gt;
  &amp;lt;script&amp;gt;
    window.location.replace(&quot;/demo/products.html&quot;);
  &amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;a href=&quot;/demo/products.html&quot;&amp;gt;상품 목록으로 이동&amp;lt;/a&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-heading=&quot;배포 완료 후 동작 확인하기&quot; data-ke-size=&quot;size26&quot;&gt;배포 완료 후 동작 확인하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;배포 후에는 브라우저에서 실제 서비스 도메인으로 접속해 &lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;과 프론트 화면이 정상적으로 열리는지 확인했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 배포과정 중 이상이 있을때 확인 할 수 있는 명령어들이다.&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;CONTAINER_NAME=&quot;&amp;lt;프로젝트명&amp;gt;-app&quot;
APP_DOMAIN=&quot;&amp;lt;서비스 도메인&amp;gt;&quot;

# 1. EC2가 실제로 어떤 이미지를 실행하는지 확인
sudo docker inspect -f '{{.Config.Image}}' &quot;$CONTAINER_NAME&quot;

# 2. 컨테이너 로그 확인
sudo docker logs --tail 200 &quot;$CONTAINER_NAME&quot;

# 3. EC2 내부의 애플리케이션 health 확인
curl -fsS http://localhost:8080/actuator/health

# 4. 외부 HTTPS 경로 확인
curl -i &quot;https://${APP_DOMAIN}/actuator/health&quot;

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대상 그룹 health와 Route 53 DNS 조회도 함께 확인하면 CI, 이미지, 컨테이너, 네트워크, 도메인 중 어느 구간에서 문제가 발생했는지 구분하는 데 도움이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-heading=&quot;배포 과정에서 확인한 문제와 한계&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;회고&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Auto Scaling Group과 다중 인스턴스, 프라이빗 애플리케이션 서버와 NAT Gateway, WAF까지 적용하는 방법도 생각했다. 하지만 이전 프로젝트에서 구성을 확장하면서 과금이 빠르게 늘어나는 것을 경험했기 때문에, 이번에는 구성을 단순화하고 배포 자동화에 집중했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 범위를 줄였음에도 실제 배포 과정에서는 예상보다 손이 많이 가는 지점들이 꽤 있었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;애플리케이션 배포와 DB 초기화 책임이 섞였다&quot; data-ke-size=&quot;size23&quot;&gt;애플리케이션 배포와 DB 초기화 책임이 섞였다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDS를 처음 연결했을 때 테이블은 생성됐지만 상품 데이터는 들어가지 않았다. 당시 bootstrap 모드가 ddl-auto=update, sql.init.mode=never, defer-datasource-initialization=false로 실행돼 data.sql이 동작하지 않은 것이 원인이었다.&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;설정을 나눠 초기 데이터를 넣을 수는 있었지만 DEPLOY_TO_EC2 값 변경, bootstrap 실행, 데이터 확인, normal 재실행 순서를 사람이 관리해야 했다. bootstrap을 다시 실행하면 기존 데이터를 지우는 현재 data.sql이 다시 동작할 수도 있었고, 애플리케이션 배포와 DB 초기화 책임도 하나의 deploy.sh에 섞였다.&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;Flyway는 스키마나 데이터 변경 SQL에 버전을 붙이고, 아직 적용되지 않은 변경을 순서대로 실행한 뒤 이력을 기록하는 도구다. 아직 이 프로젝트에는 적용하지 않았지만, 다음에는 이런 방식으로 DB 변경을 관리해 수동 초기화 단계를 없애 보고 싶다.&lt;/p&gt;
&lt;h3 data-heading=&quot;SSM 배포 경로의 초기 설정과 수동 검증이 번거로웠다&quot; data-ke-size=&quot;size23&quot;&gt;SSM 배포 경로의 초기 설정과 수동 검증이 번거로웠다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지 빌드와는 별개로, SSM Run Command를 통해 EC2의 배포 스크립트를 실행하는 경로를 처음 구성하는 과정도 번거로웠다. 처음에는 EC2가 SSM 관리형 노드로 잡히지 않아 대상 인스턴스를 선택할 수 없었고, IAM 역할과 SSM Agent 상태를 바로잡아야 했다. 이후에도 Run Command 화면에서 대상 인스턴스를 선택하고 Git SHA가 포함된 명령을 직접 실행하며 동작을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단한 배포환경을 위한 작업치고는 설정과 수동 확인이 매우 비생산적으로 느껴졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 GitHub Actions가 SSM에 normal 모드의 배포 명령을 보내도록 구성하면서 반복적인 콘솔 실행은 없어졌다.&lt;/p&gt;
&lt;h3 data-heading=&quot;인증서 문제가 아니라 도메인 등록 상태 문제였다&quot; data-ke-size=&quot;size23&quot;&gt;인증서 문제가 아니라 도메인 등록 상태 문제였다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 Issued 상태인 *.trex1004.click 인증서가 있었는데도 commerce.trex1004.click용 인증서를 다시 요청했다. 이 때문에 ACM 검증용 CNAME이 하나 더 생겼고 새 인증서는 Pending validation 상태로 남았지만, 실제 원인은 인증서가 아니었다.&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;당시 받은 이메일은 ACM 인증서 검증이 아니라 Route 53 도메인 등록자 연락처 검증을 위한 것이었다. 안내된 15일 안에 이메일을 확인하지 않아 도메인이 clientHold 상태가 됐고, 이 배포에서는 commerce.trex1004.click 조회가 NXDOMAIN으로 확인됐다. 이메일 검증 후 도메인이 Active로 바뀌자 DNS 조회도 정상화됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 와일드카드 인증서를 ALB에 연결하면 됐으므로 별도 인증서 발급은 불필요했다. NXDOMAIN은 HTTPS 인증서를 확인하기 전 단계에서 도메인 이름을 해석하지 못한 상태이므로, 도메인 등록 상태와 DNS 조회를 먼저 확인한 뒤 ALB와 인증서를 확인했어야 했다.&lt;/p&gt;
&lt;h3 data-heading=&quot;GHCR과 ECR을 함께 사용했지만 복구 전략은 부족했다&quot; data-ke-size=&quot;size23&quot;&gt;GHCR과 ECR을 함께 사용했지만 복구 전략은 부족했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한쪽 이미지 저장소에 문제가 생겼을 때를 대비해 GHCR과 ECR에 같은 이미지를 저장했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 배포 경로는 ECR을 기준으로만 구성했기 때문에 GHCR 이미지를 자동으로 사용하는 복구 절차까지는 만들지 않았다.&lt;br /&gt;돌이켜보면 이미지 저장소를 이중화하는 것과 배포 경로 전체를 이중화하는 것은 다른 문제였다.&lt;/p&gt;
&lt;h2 data-heading=&quot;참고 자료&quot; 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;a href=&quot;https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws&quot; data-tooltip-position=&quot;top&quot;&gt;GitHub Actions: OIDC in AWS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html&quot; data-tooltip-position=&quot;top&quot;&gt;AWS ALB 대상 그룹 상태 검사&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/acm/latest/userguide/acm-certificate-characteristics.html&quot; data-tooltip-position=&quot;top&quot;&gt;AWS ACM 공개 인증서 특성과 와일드카드 범위&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/troubleshooting-domain-suspended.html&quot; data-tooltip-position=&quot;top&quot;&gt;AWS Route 53: 도메인 clientHold 상태&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.spring.io/spring-boot/reference/actuator/endpoints.html&quot; data-tooltip-position=&quot;top&quot;&gt;Spring Boot Actuator 엔드포인트&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_VPC.WorkingWithRDSInstanceinaVPC.html&quot; data-tooltip-position=&quot;top&quot;&gt;Amazon RDS VPC 구성&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>AWS</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/10</guid>
      <comments>https://zeroto-dev.tistory.com/10#entry10comment</comments>
      <pubDate>Wed, 2 Sep 2026 00:15:38 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] GlobalExceptionHandler와 AOP 로깅 - 응답과 실패 기록의 책임 나누기</title>
      <link>https://zeroto-dev.tistory.com/9</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트에서 AOP와 로깅 작업을 맡았다. 처음에는 Controller의 반복 로그를 AOP로 묶고, 예외는&lt;br /&gt;BusinessException으로 던져 GlobalExceptionHandler에서 공통 응답으로 바꾸면 충분하다고 생각했다.&lt;br /&gt;하지만 결제&amp;middot;환불 흐름에 업무 로그를 추가하는 중, 두가지 의문이 생겼다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;이 실패를 클라이언트에 어떤 HTTP 응답으로 돌려줄 것인가?&lt;/li&gt;
&lt;li&gt;이 실패를 개발자가 나중에 어떤 정보로 추적할 것인가?&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘은 관련 있지만 같은 책임은 아니다. 이 글에서는 커머스 결제 시스템에 적용한&lt;br /&gt;GlobalExceptionHandler, Controller AOP, 결제&amp;middot;환불 업무 로그를 기준으로 응답과 기록의 책임을&lt;br /&gt;나눈 과정을 정리한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;&amp;#96;GlobalExceptionHandler&amp;#96;는 오류 응답을 통일한다&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;GlobalExceptionHandler는 오류 응답을 통일한다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring MVC에서는 @ControllerAdvice 안의 @ExceptionHandler를 이용해 여러 Controller에서 발생한&lt;br /&gt;예외를 공통으로 처리할 수 있다. Controller마다 같은 try-catch와 오류 응답 생성을 반복하지 않고,&lt;br /&gt;HTTP 상태와 응답 형식을 한곳에서 관리할 수 있다.&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;도메인별 오류는 ErrorCode enum으로 관리하고, 이를 담는 BusinessException을 사용했다.&lt;br /&gt;ErrorCode에는 HTTP 상태, 클라이언트용 코드, 기본 메시지가 들어 있다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@ExceptionHandler(BusinessException.class)
public ResponseEntity&amp;lt;ApiResponse&amp;lt;Void&amp;gt;&amp;gt; handleBusinessException(BusinessException e) {
    ErrorCode code = e.getErrorCode();

    return ResponseEntity.status(code.getStatus())
            .body(ApiResponse.error(code, e.getMessage()));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 포인트가 부족하면 409 Conflict와 &amp;lsquo;포인트가 부족합니다.&amp;rsquo;라는 메시지를 공통 응답 형식에 담아 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고 부족, 포인트 부족, 이미 처리된 환불처럼 서로 다른 도메인 오류도 같은 형식으로 응답할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 점은 예외가 발생한 계층이 아니라, ErrorCode가 나타내는 실패의 성격에 따라 HTTP 상태를 결정하는 것이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-heading=&quot;전역 Handler에서 다룬 예외와 방어 목적&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;전역 Handler에서 다룬 예외와 방어 목적&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전역 Handler가 모든 Java 예외를 하나씩 나열할 필요는 없다. HTTP 요청 경계에서 응답의 의미가 달라지는 대표 예외만 구분했다. @Valid 검증 실패, 깨진 JSON, 경로&amp;middot;쿼리 파라미터 타입 오류는 모두 클라이언트 요청 문제지만 Spring에서 발생시키는 예외가 서로 다르다.&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;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;&lt;i&gt;(도메인별 오류는 ErrorCode enum으로 관리하고, 이를 담기 위해 프로젝트에서 정의한 BusinessException을 사용했다.)&lt;/i&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 269px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;예외&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;발생 상황&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;HTTP Status&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;처리하지 않았을 때 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;BusinessException&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;포인트&amp;middot;재고 부족, 중복 환불 등&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;ErrorCode 기준 4xx&amp;middot;5xx&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;도메인 오류가 모두 500으로 처리되는 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;MethodArgumentNotValidException&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;@Valid DTO 검증 실패&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;400&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;검증되지 않은 요청값이 업무 로직으로 전달되는 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;HttpMessageNotReadableException&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;깨진 JSON, 요청 본문 타입 변환 실패&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;400&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;요청 형식 오류가 서버 장애로 분류되는 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;MethodArgumentTypeMismatchException&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;경로&amp;middot;쿼리 파라미터 타입 오류&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;400&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;/orders/abc 같은 요청이 500으로 처리되는 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;NoResourceFoundException (미구현)&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;존재하지 않는 API&amp;middot;정적 페이지 요청&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;404 예정&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;잘못된 URL이 500과 ERROR 로그로 기록되는 문제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;그 밖의 Exception&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;예상하지 못한 코드&amp;middot;DB 오류&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;500&lt;/td&gt;
&lt;td style=&quot;height: 42px;&quot;&gt;내부 예외 정보 노출과 형식 없는 오류 응답&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &amp;lsquo;방어한다&amp;rsquo;는 말은 예외 발생 자체를 막는다는 뜻이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 입력 오류가 500 서버 장애로 잘못 분류되는 것, 내부 예외 정보가 응답에 노출되는 것, Controller마다 다른 오류 형식을 만드는 것을 막는다는 의미다.&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;존재하지 않는 데모 페이지를 요청하면 NoResourceFoundException이 포괄적인 Exception Handler에 잡혀 서버 내부 오류를 뜻하는 500으로 응답되는 한계도 확인했다. 사용자 주소 오타를 서버 장애로 분류한 것이므로 404 처리가 필요하지만, 아직 구현하지 않은 개선 대상으로 남겼다.&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;반대로 모든 IllegalArgumentException을 일괄적으로 400으로 바꾸지는 않았다. 외부 요청을 변환하다 발생한 입력 오류인지, 이미 검증된 값을 내부 코드가 잘못 전달한 계약 위반인지에 따라 400과 500의 의미가 달라질 수 있기 때문이다. Spring Security 필터의 인증&amp;middot;인가 오류, DNS 오류, 프론트엔드 내부 오류도 MVC 전역 Handler의 처리 범위 밖에 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;응답을 통일해도 실패의 맥락까지 알 수 있는 것은 아니다&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;응답을 통일해도 실패의 맥락까지 알 수 있는 것은 아니다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 응답은 클라이언트가 실패 이유를 이해하는 데 필요하다. 그러나 운영 중 특정 결제나 환불을 추적하기에는 PG사 처리 중 오류라는 응답만으로 부족하다. 어떤 결제에서 PG 취소가 실패했는지, 포인트가 얼마나 사용&amp;middot;복구됐는지, 실패 뒤 상태를 변경했는지는 해당 업무를 수행하는 코드가 가장 잘 알고 있다.&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;pre class=&quot;properties&quot;&gt;&lt;code&gt;Controller AOP
  └─ Controller 메서드 실행 시작&amp;middot;완료&amp;middot;실패, URI, 처리 시간

Facade / Service의 결과를 아는 지점
  └─ 결제&amp;middot;환불 결과, 포인트 정산액, 보상 처리 같은 업무 맥락

GlobalExceptionHandler
  └─ 공통 오류 응답 생성, 예상하지 못한 서버 오류의 stack trace
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 업무 로그를 전부 Facade에 모으는 방식도 생각했다. 하지만 포인트 사용액, 적립액, 복구액과 처리 후 잔액은 실제 정산을 수행하는 PointService가 가장 정확히 알고 있었다. 로그를 특정 계층에 억지로 모으기보다, &lt;b&gt;해당 결과가 확정되는 지점에만 남기는 것&lt;/b&gt;이 더 명확했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인별 ExceptionHandler도 단순히 파일을 나누기 위한 목적으로 추가하지 않았다. 결제와 주문이 실제로 다른 오류 응답 형식이나 별도 변환 규칙을 가져야 할 때는 나눌 수 있지만, 여기서는 하나의 전역 Handler로 공통 응답을 만드는 편이 단순했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;AOP는 Controller 메서드의 반복 로그를 맡긴다&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;AOP는 Controller 메서드의 반복 로그를 맡긴다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller마다 요청 시작&amp;middot;완료 시각, URI, 처리 시간을 직접 기록하면 같은 코드가 반복된다. 이 정보는 개별 도메인의 규칙이 아니라 여러 Controller에 걸친 공통 관심사다.&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;Spring AOP의 @Around Advice는 대상 메서드 실행 전후를 감쌀 수 있다. 이때 원래 메서드를 실행하려면 ProceedingJoinPoint.proceed()를 호출해야 한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Around(&quot;execution(public * io.github.spartateam6.commercepaymentsystem.domain..controller..*.*(..))&quot;)
public Object logController(ProceedingJoinPoint joinPoint) throws Throwable {
    long startNanos = System.nanoTime();

    log.info(&quot;[요청 시작] httpMethod={} uri={} controller={}.{}()&quot;,
            httpMethod, requestUri, className, methodName);

    try {
        Object result = joinPoint.proceed();

        log.info(&quot;[요청 완료] httpMethod={} uri={} controller={}.{}() durationMs={}&quot;,
                httpMethod, requestUri, className, methodName,
                elapsedMillis(startNanos));
        return result;
    } catch (Throwable throwable) {
        log.warn(&quot;[요청 실패] httpMethod={} uri={} controller={}.{}() durationMs={} exceptionType={}&quot;,
                httpMethod, requestUri, className, methodName,
                elapsedMillis(startNanos),
                throwable.getClass().getSimpleName());
        throw throwable;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 요청 본문 파싱과 인자 변환은 Controller 메서드를 호출하기 전에 수행된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt; HttpMessageNotReadableException,&lt;/b&gt;&lt;br /&gt;&lt;b&gt;MethodArgumentTypeMismatchException,&lt;/b&gt;&lt;br /&gt;&lt;b&gt;MethodArgumentNotValidException&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처럼 이 단계에서 발생한 오류는 메서드 AOP가 기록하지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 예외들은 GlobalExceptionHandler가 400 응답으로 변환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller 실행 여부와 관계없이 모든 HTTP 요청을 기록해야 한다면 Filter나 Interceptor처럼 더 바깥 경계를 검토해야 한다.&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;같은 로그인 URI에 요청을 보냈을 때 첫 요청은 BusinessException과 함께 실패 WARN으로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 요청은 완료 INFO로 구분됐고 각 처리 시간도 기록됐다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2798&quot; data-origin-height=&quot;234&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/y3Vz5/dJMcajwGTcl/aBBEuP3XxBkHE8WWP9Yf8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/y3Vz5/dJMcajwGTcl/aBBEuP3XxBkHE8WWP9Yf8k/img.png&quot; data-alt=&quot;동일한 로그인 요청에서 실패 WARN과 완료 INFO가 구분된 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/y3Vz5/dJMcajwGTcl/aBBEuP3XxBkHE8WWP9Yf8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fy3Vz5%2FdJMcajwGTcl%2FaBBEuP3XxBkHE8WWP9Yf8k%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;2798&quot; height=&quot;234&quot; data-origin-width=&quot;2798&quot; data-origin-height=&quot;234&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동일한 로그인 요청에서 실패 WARN과 완료 INFO가 구분된 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 Aspect는 예외 원인을 분석하거나 응답을 만들지 않는다. 예외를 다시 던져 GlobalExceptionHandler가 응답을 만들게 하고, AOP는 &amp;ldquo;어떤 요청이 Controller 메서드에 도달해 얼마나 걸린 뒤 성공 또는 실패했는가&amp;rdquo;만 기록한다.&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;이 로그만으로도 로그인 요청이 실패한 사실은 알 수 있지만 비밀번호 오류인지 회원이 없는지는 알 수 없다. 이것이 AOP 로그와 업무&amp;middot;예외 로그를 따로 둔 이유다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;결제&amp;middot;환불에는 결과를 설명하는 업무 로그를 남긴다&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;결제&amp;middot;환불에는 결과를 설명하는 업무 로그를 남긴다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 AOP 로그만으로는 &amp;ldquo;환불 요청이 완료됐다&amp;rdquo;는 사실만 알 수 있다. PG 취소가 필요했는지, 사용 포인트가 얼마나 돌아왔는지까지 확인하려면 업무 결과를 별도로 기록해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트 정산은 PointService가 실제 증감과 처리 후 잔액을 알고 있으므로 그 자리에서 남겼다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;log.info(
        &quot;환불 포인트 정산 반영 paymentId={} memberId={} restored={} revoked={} balanceAfter={}&quot;,
        payment.getId(),
        memberId,
        restoreAmount,
        revokeAmount,
        member.getPointBalance()
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PG 호출 여부와 최종 환불 결과는 RefundFacade가 알고 있으므로 Facade에서 남겼다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;log.info(
        &quot;PG취소 후 환불 완료 refundId={} paymentId={} pgRefundAmount={} pointRefundAmount={} pgCancelRequired=true&quot;,
        result.response().refundId(),
        request.paymentId(),
        result.response().pgRefundAmount(),
        result.response().pointRefundAmount()
);&lt;/code&gt;&lt;/pre&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-origin-width=&quot;3122&quot; data-origin-height=&quot;464&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dFpG2f/dJMcadXvVKT/R6OZjy6kSnXNb33ZLCVfqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dFpG2f/dJMcadXvVKT/R6OZjy6kSnXNb33ZLCVfqK/img.png&quot; data-alt=&quot;포인트 전액 결제 후 전액 환불까지 이어진 요청&amp;amp;middot;업무 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dFpG2f/dJMcadXvVKT/R6OZjy6kSnXNb33ZLCVfqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdFpG2f%2FdJMcadXvVKT%2FR6OZjy6kSnXNb33ZLCVfqK%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;3122&quot; height=&quot;464&quot; data-origin-width=&quot;3122&quot; data-origin-height=&quot;464&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;포인트 전액 결제 후 전액 환불까지 이어진 요청&amp;middot;업무 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 화면에서는 다음 흐름을 확인할 수 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;주문 생성 요청이 완료된다.&lt;/li&gt;
&lt;li&gt;포인트 1,500원이 사용되고 PG 금액이 0원이므로 PortOne을 호출하지 않고 결제가 완료된다.&lt;/li&gt;
&lt;li&gt;환불 시 사용 포인트 1,500원이 복구된다.&lt;/li&gt;
&lt;li&gt;RefundFacade가 PG 호출 없는 포인트 전액 환불 결과를 기록한다.&lt;/li&gt;
&lt;li&gt;마지막으로 환불 Controller 요청 완료와 처리 시간이 남는다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB를 직접 조회하지 않아도 한 요청 안에서 포인트 정산과 환불 분기가 어떻게 이어졌는지 볼 수 있었다. 다만 모든 정상 서비스 메서드에 INFO를 추가한 것은 아니다. 결제&amp;middot;주문 취소&amp;middot;환불처럼 상태가 크게 바뀌거나 외부 연동 여부를 확인해야 하는 지점으로 범위를 제한했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;실패 자체보다 실패 후 처리를 기록해야 했다&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;실패 자체보다 실패 후 처리를 기록해야 했다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PG 취소가 실패하면 &amp;ldquo;외부 호출 실패&amp;rdquo;만 중요한 것이 아니다. 환불 상태를 실패로 변경했는지, 예외를&lt;br /&gt;호출자에게 다시 전달했는지도 함께 확인해야 한다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;try {
    paymentGateway.cancelPayment(
            result.portonePaymentId(),
            result.cancelReason(),
            result.pgRefundAmount().longValue()
    );
} catch (RuntimeException exception) {
    refundService.markFailed(result.response().refundId());

    log.warn(
            &quot;PG취소 실패 후 실패 처리 refundId={} paymentId={} pgRefundAmount={}&quot;,
            result.response().refundId(),
            request.paymentId(),
            result.pgRefundAmount(),
            exception
    );
    throw exception;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 실패 분기는 RefundFacade 단위 테스트의 실행 결과에서 확인했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2798&quot; data-origin-height=&quot;284&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/delWcx/dJMcajpK99e/Xg6ZWuMsmwzpkzGPIqB3O1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/delWcx/dJMcajpK99e/Xg6ZWuMsmwzpkzGPIqB3O1/img.png&quot; data-alt=&quot;PG 취소 실패 분기를 재현한 RefundFacade 단위 테스트 로그&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/delWcx/dJMcajpK99e/Xg6ZWuMsmwzpkzGPIqB3O1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdelWcx%2FdJMcajpK99e%2FXg6ZWuMsmwzpkzGPIqB3O1%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;2798&quot; height=&quot;284&quot; data-origin-width=&quot;2798&quot; data-origin-height=&quot;284&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;PG 취소 실패 분기를 재현한 RefundFacade 단위 테스트 로그&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[Test worker]라는 스레드 이름에서 알 수 있듯 위 로그는 실제 PortOne 장애가 아니라 단위 테스트에서 재현된 실패다. 실행 결과를 통해 PG 취소 실패 시 markFailed()를 호출하고 WARN을 남긴 뒤 예외를 다시 던지는 흐름을 확인했다. 실제 외부 통신 장애까지 검증하려면 PortOne 테스트 환경이나 별도의 통합 테스트가 필요하다.&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;테스트 코드에 로그를 추가한 것은 아니다. 테스트 대상인 RefundFacade의 실제 메서드가 실행되면서 해당 메서드에 작성한 로그가 콘솔에 출력됐다. 단위 테스트가 로그 문구 자체를 검증하지는 않지만, 실패 분기가 실행됐다는 점은 실행 결과에서 함께 확인할 수 있었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;남은 문제: 5xx의 진짜 원인을 보존하기&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;남은 문제: 5xx의 진짜 원인을 보존하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PortOneClient는 HTTP 오류나 연결 오류를 PAYMENT_GATEWAY_ERROR라는 BusinessException으로 변환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 원래 예외를 cause로 연결하지 않아 타임아웃, 4xx 응답, 5xx 응답 중 무엇이 원인이었는지 stack trace에서 구분하기 어렵다.&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;또한 GlobalExceptionHandler의 일반 Exception Handler는 ERROR와 stack trace를 남기지만, BusinessException은 별도 Handler가 먼저 처리한다. 따라서 502인 PAYMENT_GATEWAY_ERROR도 공통 응답만 반환한다. 다음 리팩터링에서는 아래처럼 역할을 정리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;i&gt;&lt;b&gt;아래 코드는 적용한 구현이 아니라 다음 작업에서 이렇게 구현하면 어떨까하는 개선안이다,,&lt;/b&gt;&lt;/i&gt;&lt;/blockquote&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// BusinessException
public BusinessException(ErrorCode errorCode, Throwable cause) {
    super(errorCode.getMessage(), cause);
    this.errorCode = errorCode;
}

// PortOneClient
catch (HttpClientErrorException |
       HttpServerErrorException |
       ResourceAccessException e) {
    throw new BusinessException(ErrorCode.PAYMENT_GATEWAY_ERROR, e);
}

// GlobalExceptionHandler
if (code.getStatus().is5xxServerError()) {
    log.error(
            &quot;서버 처리 중 예외가 발생했습니다. code={} status={}&quot;,
            code.getCode(),
            code.getStatus().value(),
            e
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정책을 적용한다면 Facade는 paymentId, refundId, 금액처럼 업무 식별 정보만 WARN으로 남기고, stack trace는 Handler에서 한 번만 기록하는 편이 낫다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트 부족&amp;middot;재고 부족&amp;middot;중복 환불 같은 4xx 업무 거절은 공통 응답과 요청 실패 로그만 남기고, 조사해야 할 5xx만 ERROR와 원인 예외를 남길 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;개발 환경과 배포 환경은 같은 로그량이 필요하지 않다&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;개발 환경과 배포 환경은 같은 로그량이 필요하지 않다&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서는 요청 시작&amp;middot;완료와 처리 시간을 모두 보는 것이 디버깅에 유리하다. 그러나 배포 환경에서 모든 정상 요청을 계속 INFO로 남기면 필요한 실패 로그를 찾기 어려워질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot는 logging.level.&amp;lt;logger-name&amp;gt;으로 logger별 로그 레벨을 설정할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application-prod.yaml에서는 Controller AOP logger를 WARN으로 설정했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;logging:
  level:
    io.github.spartateam6.commercepaymentsystem.global.logging.ControllerLoggingAspect: WARN&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 로컬에서는 [요청 시작], [요청 완료], [요청 실패]를 모두 확인하고, 배포 프로필에서는 Controller AOP의 정상 요청 INFO를 숨기고 실패 WARN은 유지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정은 해당 AOP logger에만 적용되므로 결제&amp;middot;환불&amp;middot;포인트 업무 로그의 운영 레벨은 서비스 성격과 로그 보관 정책에 따라 별도로 결정해야 한다.&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;이 방식에서는 포인트 부족이나 중복 요청처럼 정상적인 4xx 업무 거절도 WARN으로 남는다. 데모에서는 실패 흐름을 확인하는 데 유용하지만, 실제 운영에서는 발생량과 조사 필요성에 따라 INFO로 낮추거나 별도로 분류할 필요가 있다..&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;정리&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서 GlobalExceptionHandler, AOP, 업무 로그의 책임을 다음과 같이 나눴다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GlobalExceptionHandler: 예외를 공통 HTTP 오류 응답으로 변환한다.&lt;/li&gt;
&lt;li&gt;Controller AOP: 실제로 호출된 Controller 메서드의 시작&amp;middot;완료&amp;middot;실패와 처리 시간을 기록한다.&lt;/li&gt;
&lt;li&gt;Facade와 Service: 결제&amp;middot;환불&amp;middot;포인트 결과를 가장 정확히 아는 지점에서 필요한 업무 맥락만 기록한다.&lt;/li&gt;
&lt;li&gt;환경 설정: 로컬과 배포 프로필의 로그량을 분리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 AOP 하나로 원하는 서비스 메서드를 묶으면 로깅이 끝날 것이라 생각했다. 그러나 AOP는 반복되는 Controller 실행 흐름을 기록하는 데 적합했고, 결제&amp;middot;환불의 세부 원인은 해당 흐름 안에서 직접 기록해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외를 던지는 것에서 끝나는 것이 아니라, 실패 뒤에 상태를 어떻게 바꿨고 어떤 정보가 남아야 다시 추적할 수 있는지까지가 로깅 설계의 범위였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/9</guid>
      <comments>https://zeroto-dev.tistory.com/9#entry9comment</comments>
      <pubDate>Tue, 25 Aug 2026 01:55:09 +0900</pubDate>
    </item>
    <item>
      <title>[Docker Compose]Docker를 도입했는데, 왜 개발 환경은 똑같아지지 않을까?</title>
      <link>https://zeroto-dev.tistory.com/8</link>
      <description>&lt;h2 data-heading=&quot;문제&quot; data-ke-size=&quot;size26&quot;&gt;문제&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2092&quot; data-origin-height=&quot;1510&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AJz3b/dJMcajpB1BP/PkFJD3ahNXthK8xeky0y7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AJz3b/dJMcajpB1BP/PkFJD3ahNXthK8xeky0y7K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AJz3b/dJMcajpB1BP/PkFJD3ahNXthK8xeky0y7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAJz3b%2FdJMcajpB1BP%2FPkFJD3ahNXthK8xeky0y7K%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;453&quot; height=&quot;327&quot; data-origin-width=&quot;2092&quot; data-origin-height=&quot;1510&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;mbti 프로젝트에서 Docker를 도입하고 CI/CD부터 AWS 인프라까지 직접 구성을 했다.&lt;br /&gt;과제 완료 후에 CI/CD 흐름을 이해하기 위해&amp;nbsp; 그리면서 의문이 생긴것이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;Docker를 사용하면 개발자마다 동일한 환경을 만들 수 있다고 하지 않았나?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 막상 내가 구성한 환경을 돌아보니 조금 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 Docker Image를 만들고, 이를 Docker Hub에 Push한 뒤 EC2에서 Pull해서 컨테이너를 실행하고 있었다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Docker Image
     &amp;darr;
 Docker Hub
     &amp;darr;
    EC2
     &amp;darr;
Docker Container
&lt;/code&gt;&lt;/pre&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;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-heading=&quot;내가 처음 생각했던 Docker&quot; data-ke-size=&quot;size26&quot;&gt;내가 처음 생각했던 Docker&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Docker Image 자체가 개발 환경을 통째로 공유하는 것이라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Spring Boot 애플리케이션을 Docker Image로 만들면 그 안에는 다음과 같은 것들이 들어간다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;Docker Image

├── Java
├── Spring Boot Application
├── Dependency
└── Application 설정
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 이미지를 다른 개발자가 Pull해서 실행하면 같은 환경에서 실행할 수 있다고 생각했다.&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;Docker Image를 사용하면 애플리케이션이 실행되는 환경을 일정하게 만들 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여기에는 중요한 전제가 빠져 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;애플리케이션 하나만 실행하면 되는가?&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;Docker Image 하나로는 개발 환경이 완성되지 않는다&quot; data-ke-size=&quot;size26&quot;&gt;Docker Image 하나로는 개발 환경이 완성되지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker를 사용하고 있지만 협업이었다면&amp;nbsp;&lt;b&gt;개발 환경 전체가 동일하다고는 보장할 수 없다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 A &amp;mdash; 내 코드가 도는 환경 자체가 다름&lt;/b&gt; (Java 버전 등)&lt;br /&gt;Docker 이미지 하나로 끝난다. jar를 이미지로 말면 FROM amazoncorretto:17이 고정되니 누가 실행해도 같은 런타임이 보장된다. 내가 만든 Dockerfile이 정확히 이 범위만 해결하고 있었다.&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;문제 B &amp;mdash; 내 코드가 의존하는 다른 서비스(DB 등)까지 사람마다 준비 상태가 다름&lt;/b&gt;&lt;br /&gt;이미지 하나로는 안 풀린다.&lt;br /&gt;MySQL은 내 코드 안에 포함된 것이 아니라, 애플리케이션이 실행되기 위해 필요한 &lt;b&gt;외부 의존 서비스&lt;/b&gt;이기 때문에 사람마다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설치 버전&amp;middot;포트&amp;middot;DB 이름이 다르면 같은 Dockerfile을 써도 안 잡힌다.&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;여기서 내가 놓치고 있던 것이 Docker Compose였다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;Docker Compose는 무엇을 해결해주는가?&quot; data-ke-size=&quot;size26&quot;&gt;Docker Compose는 무엇을 해결해주는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Compose는 Docker Image를 대체하는 기술이라기보다,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여러 컨테이너를 하나의 애플리케이션 환경으로 묶어서 관리하기 위한 도구&lt;/b&gt;에 가깝다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;services:
  app:
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mysql:8.4
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런식으로 작성하면 &amp;nbsp;필요한 서비스에 대한 설정을 함께 관리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 새로운 개발자는 프로젝트를 받은 뒤&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;docker compose up
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;명령 하나로 Spring Boot와 MySQL을 함께 실행할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한 줄이 정확히 뭘 대체하는지는 아래 비교가 명확하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;손으로 할 때 Compose가 해주는 일&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;docker network create app-net&lt;/td&gt;
&lt;td&gt;프로젝트 전용 네트워크 자동 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;--name my-mysql로 이름 관리&lt;/td&gt;
&lt;td&gt;서비스 이름이 곧 주소가 된다 (db:3306)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mysqladmin ping으로 수동 확인&lt;/td&gt;
&lt;td&gt;healthcheck + depends_on으로 자동 대기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-e, -p, -v 매번 입력&lt;/td&gt;
&lt;td&gt;파일 하나로 선언, Git으로 버전 관리&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;이렇게 보면, Docker Image는 앞서 말한 &lt;b&gt;문제 A&lt;/b&gt;(실행 환경 고정)를 풀고, Docker Compose는 &lt;b&gt;문제 B&lt;/b&gt;(주변 서비스까지 포함한 구성)를 푼다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;정작 mbti 프로젝트엔 Compose가 없었다&quot; data-ke-size=&quot;size26&quot;&gt;정작 mbti 프로젝트엔 Compose가 없었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 정리하고 나니 의문이 하나 생겼다. &lt;b&gt;그럼 mbti 프로젝트는 문제 B를 어떻게 피해간 거지?&lt;/b&gt; mbti에는 docker-compose.yml이 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌아보니 내 프로젝트에 Compose가 없었던 것 자체가 문제는 아니었다. &lt;b&gt;애초에 Compose가 풀어야 할 협업 문제가 발생하지 않았다.&lt;/b&gt; mbti는 나 혼자 처음부터 끝까지 만든 프로젝트다. 문제 B의 핵심은 &quot;여러 개발자의 준비 상태가 서로 다를 수 있다&quot;는 것인데, 비교 대상이 될 다른 개발자가 없으니 애초에 이 문제가 성립할 상황 자체가 아니었던 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(DB 구성도 참고할 만하다. 로컬은 H2, prod는 RDS라서 개발자가 로컬에서 MySQL을 직접 설치하고 버전이나 포트 등을 맞춰야 하는 상황도 없었다.&lt;br /&gt;&lt;b&gt;만약 로컬에서도 MySQL을 사용해야 했다면, Docker Compose를 통해 동일한 MySQL 환경을 제공할 수 있었을 것이다.&lt;/b&gt;&lt;br /&gt;결국 Compose가 필요해지는 조건은 명확하다. &lt;b&gt;여러 사람이 같은 환경을 재현해야 하거나, 여러 컨테이너가 서로를 알아야 할 때다.&lt;/b&gt; mbti는 둘 다 해당하지 않았다.)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;배포 구조와 개발 환경 구성은 다른 문제였다&quot; data-ke-size=&quot;size26&quot;&gt;배포 구조와 개발 환경 구성은 다른 문제였다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Docker Compose를 알고나서&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;&quot;그럼 내가 지금까지 구성한 Docker &amp;rarr; Docker Hub &amp;rarr; EC2 구조는 잘못된 건가?&quot;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 다시 생각해보니 해결하려는 문제가 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 구축한 구조는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;GitHub
   &amp;darr;
CI/CD
   &amp;darr;
Docker Image
   &amp;darr;
Docker Hub
   &amp;darr;
  EC2
   &amp;darr;
Docker Container
   &amp;darr;
  ALB
   &amp;darr;
  ASG
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 &lt;b&gt;빌드된 애플리케이션을 운영 환경에 배포하고, AWS 인프라를 통해 트래픽을 분산하고 확장하는 문제&lt;/b&gt;를 해결한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Docker Compose는 주로 다음과 같은 문제를 해결한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;738&quot; data-origin-height=&quot;390&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cnON6i/dJMcacxnSuN/hlDygv1aU2oZmKHLVl7Iu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cnON6i/dJMcacxnSuN/hlDygv1aU2oZmKHLVl7Iu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cnON6i/dJMcacxnSuN/hlDygv1aU2oZmKHLVl7Iu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcnON6i%2FdJMcacxnSuN%2FhlDygv1aU2oZmKHLVl7Iu1%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;341&quot; height=&quot;180&quot; data-origin-width=&quot;738&quot; data-origin-height=&quot;390&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;결국 Docker Image 와 AWS인프라는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;서로 다른 문제를 해결하고 있었다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;Docker 구성 요소의 역할을 구분해보자&quot; data-ke-size=&quot;size26&quot;&gt;Docker 구성 요소의 역할을 구분해보자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 경험을 통해 Docker를 단순히&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;&quot;애플리케이션을 컨테이너로 만들어서 서버에 배포하는 기술&quot;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로만 생각하면 부족하다는 것을 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 나누어 보면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Dockerfile
    &amp;darr;
애플리케이션을 어떻게 이미지로 만들 것인가?

Docker Image
    &amp;darr;
어디서 실행하더라도 동일한 애플리케이션 환경 제공

Docker Compose
    &amp;darr;
애플리케이션 + DB 등
여러 서비스를 어떻게 함께 실행할 것인가?

Docker Hub
    &amp;darr;
Docker Image를 어디에 저장하고 공유할 것인가?

EC2 / ALB / ASG
    &amp;darr;
이 애플리케이션을 운영 환경에서
어떻게 배포하고 확장할 것인가?
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나누고 나니 각각의 기술이 왜 필요한지 조금 더 명확해졌다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;이번에 가장 크게 배운 것&quot; data-ke-size=&quot;size26&quot;&gt;이번에 가장 크게 배운 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이번에 배운 것은 Docker Compose의 사용법 하나가 아니었다.&lt;br /&gt;처음에는 기술을 하나씩 추가하면서&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;Docker
&amp;rarr; CI/CD
&amp;rarr; Docker Hub
&amp;rarr; EC2
&amp;rarr; ALB
&amp;rarr; ASG
&lt;/code&gt;&lt;/pre&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;각 기술들이 해결하려는 문제가 무엇인가?&quot;&lt;/b&gt; 를 정확히 아는 것이 중요하다는 것을 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker Image는 &lt;b&gt;문제 A&lt;/b&gt;(내 애플리케이션의 실행 환경 고정)를 풀고,&lt;br /&gt;Docker Compose는 &lt;b&gt;문제 B&lt;/b&gt;(의존 서비스까지 포함한 환경 전체의 구성)를 푼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘은 비슷해 보이지만 해결하려는 문제가 다르다.&lt;br /&gt;이번에 직접 인프라를 구축해보지 않았다면 아마 Docker Compose를 단순히&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;Docker를 편하게 실행하는 도구&quot;&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정도로 생각했을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제로 Docker Image를 Docker Hub에 Push하고 EC2에서 Pull해서 운영해보면서,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;애플리케이션을 동일하게 만드는 것&quot;과 &quot;애플리케이션이 동작하는 전체 환경을 동일하게 만드는 것&quot;은 다르다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라는 것을 체감할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로는 기술을 공부할 때 단순히 &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&quot;이 기술은 무엇인가?&quot;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에서 끝내기보다,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;&quot;이 기술은 어떤 문제를 해결하기 위해 존재하는가?&quot;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 같이 생각해보려 한다면 좋을 것 같다,,&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;GitHub 링크&quot; data-ke-size=&quot;size26&quot;&gt;GitHub 링크&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/trex1004/mbti&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/trex1004/mbti&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1786456898888&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - trex1004/mbti&quot; data-og-description=&quot;Contribute to trex1004/mbti development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/trex1004/mbti&quot; data-og-url=&quot;https://github.com/trex1004/mbti&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bdJfCo/dJMb87gmtaQ/m15xYUL1iTnwbYPFLJy8Uk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/d1pyza/dJMb9kUiFJt/P43a5XTYwBF8XiH6A5UcPk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bgVhiO/dJMb9kUiFJu/2kKeHlKwGqUkuXGp8CkrCk/img.png?width=3286&amp;amp;height=1618&amp;amp;face=0_0_3286_1618&quot;&gt;&lt;a href=&quot;https://github.com/trex1004/mbti&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/trex1004/mbti&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bdJfCo/dJMb87gmtaQ/m15xYUL1iTnwbYPFLJy8Uk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/d1pyza/dJMb9kUiFJt/P43a5XTYwBF8XiH6A5UcPk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bgVhiO/dJMb9kUiFJu/2kKeHlKwGqUkuXGp8CkrCk/img.png?width=3286&amp;amp;height=1618&amp;amp;face=0_0_3286_1618');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - trex1004/mbti&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to trex1004/mbti development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Docker</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/8</guid>
      <comments>https://zeroto-dev.tistory.com/8#entry8comment</comments>
      <pubDate>Tue, 11 Aug 2026 23:10:48 +0900</pubDate>
    </item>
    <item>
      <title>[AWS S3] IAM Role로 만든 Presigned URL이 7일을 못 버티는 이유</title>
      <link>https://zeroto-dev.tistory.com/7</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제: 코드에 적은 기간과 실제 유효기간이 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에서 회원 프로필 이미지를 S3에 저장하고, 다운로드는 Presigned URL로 처리하도록 구현했다.&lt;br /&gt;과제 요구사항은 &quot;Presigned URL의 유효기간을 7일로 설정&quot;하는 것이었고 코드에도 그대로 반영했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private static final Duration PRESIGNED_URL_EXPIRATION = Duration.ofDays(7);

public String createPresignedUrl(String key) {
    GetObjectRequest getObjectRequest = GetObjectRequest.builder()
            .bucket(bucket)
            .key(key)
            .build();
    GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
            // 여기서 7일을 요청하지만, 실제로는 아래에서 보듯 이 값이 그대로 지켜지지 않는다
            .signatureDuration(PRESIGNED_URL_EXPIRATION) 
            .getObjectRequest(getObjectRequest)
            .build();
    return s3Presigner.presignGetObject(presignRequest).url().toString();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;signatureDuration에 7일을 넣었으니 당연히 7일짜리 URL이 나올 거라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 실제로는 그렇지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서는 설정한 7일이 정상적으로 적용됐지만, IAM Role을 쓰는 EC2 환경에서는 같은 코드인데도 다른 결과가 나왔다. 강의를 들으며 이 사실을 알게 됐고, 코드는 동일한데 왜 실행 환경에 따라 결과가 달라지는지 직접 확인해보았다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;직접 확인하는 법&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;492&quot; data-origin-height=&quot;254&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HPCY3/dJMcag7vbxT/MfPU9PwNZSh1rkrzjLqMA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HPCY3/dJMcag7vbxT/MfPU9PwNZSh1rkrzjLqMA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HPCY3/dJMcag7vbxT/MfPU9PwNZSh1rkrzjLqMA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHPCY3%2FdJMcag7vbxT%2FMfPU9PwNZSh1rkrzjLqMA0%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;261&quot; height=&quot;135&quot; data-origin-width=&quot;492&quot; data-origin-height=&quot;254&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IAM 콘솔에서 Role의 최대 세션 지속 시간을 확인할 수 있지만, 이 값이 현재 EC2가 사용 중인 자격증명의 실제 만료 시간을 의미하지는 않는다 .&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS IAM 공식 문서에 &quot;Amazon EC2 IAM rolecredentials are exempt from the maximum session duration configured for the role&quot;(EC2 IAM 역할 자격증명은 이 설정에서 아예 제외된다)라고 명시돼 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 수명은 EC2가 지금 들고 있는 자격증명을 인스턴스 메타데이터 서비스(IMDS)에서 직접 조회해야 알 수 있다(IMDSv2라 토큰을 먼저 발급받아야 한다):&lt;/p&gt;
&lt;pre class=&quot;elixir&quot;&gt;&lt;code&gt;$ TOKEN=$(curl -s -X PUT &quot;http://169.254.169.254/latest/api/token&quot; \
  -H &quot;X-aws-ec2-metadata-token-ttl-seconds: 21600&quot;)
$ curl -H &quot;X-aws-ec2-metadata-token: $TOKEN&quot; \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/mbti-ec2-role
{
  &quot;Code&quot; : &quot;Success&quot;,
  &quot;LastUpdated&quot; : &quot;2026-08-05T12:54:43Z&quot;,
  &quot;Type&quot; : &quot;AWS-HMAC&quot;,
  &quot;AccessKeyId&quot; : &quot;ASIA******&quot;,  # ASIA로 시작 = 임시 자격증명
  &quot;SecretAccessKey&quot; : &quot;[REDACTED]&quot;,
  &quot;Token&quot; : &quot;[REDACTED]&quot;,
  &quot;Expiration&quot; : &quot;2026-08-05T19:05:40Z&quot;    # &amp;lt;- 이 시각에 만료
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회 결과 EC2가 사용하는 자격증명은 IAM User의 장기 Access Key가 아니라, Expiration이 약 6시간 뒤로 찍힌 임시 자격증명이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콘솔의 &quot;1시간&quot;과도 다른 숫자였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 여기서 의문이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Presigned URL의 만료 시간은 URL에 설정한 7일을 기준으로 할까, 아니면 이 임시 자격증명의 만료 시간에도 영향을 받을까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 확인하기 위해 AWS 공식 문서를 찾아보았다&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;AWS 공식 문서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS S3 User Guide의 &quot;Using Presigned URLs&quot; 문서에 정확히 이 내용이 나온다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Presigned URLs created with temporary security credentials cannot exceed the lifetime of&lt;br /&gt;the credentials themselves... A URL will expire at its configured expiration time or when&lt;br /&gt;the associated credentials expire, whichever happens first. For example, AWS STS AssumeRole&lt;br /&gt;sessions typically default to one hour, while EC2 instance profile credentials rotate&lt;br /&gt;periodically.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(번역: 임시 보안 자격증명으로 만든 Presigned URL은 그 자격증명의 수명을 넘어설 수 없다. URL은 설정된 만료 시간 또는 연결된 자격증명이 만료되는 시점 중 먼저 도래하는 쪽에서 만료된다. 예를 들어 AWS STS AssumeRole 세션은 기본적으로 1시간, EC2 인스턴스 프로파일 자격증명은 주기적으로 갱신된다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 헷갈릴 수 있는 점은 URL 자체에는 7일이라는 정보가 정상적으로 들어간다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Presigned URL을 보면 X-Amz-Expires=604800(7일)이 포함되어 있어 애플리케이션은 분명 7일짜리 URL 생성을 요청한 상태다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 URL은 서명에 사용된 임시 자격증명에도 의존한다. 따라서 설정한 만료 시간이 남아 있더라도, 자격증명이 먼저 만료되면 사용할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 식으로 쓰면:&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;실제 유효기간 = min(코드의 signatureDuration, 서명에 쓴 자격증명의 남은 유효 기간)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 IMDS 조회로 확인했듯 EC2 Instance Profile의 자격증명은 AWS가 자동으로 관리하며 수 시간 단위로 만료된다. 즉 아무리 signatureDuration을 7일로 설정하더라도 실제 유효기간은 그 자격증명의 만료 시각을 넘을 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 로컬 개발 환경에서 IAM User의 장기 Access Key(AKIA...)로 서명하는 경우에는 해당 자격증명 자체의 세션 만료 시간이 없기 때문에 signatureDuration으로 설정한 만료 시간이 그대로 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;왜 로컬에서는 되는데 EC2에서는 안 되지?&quot;라는 질문의 답이 여기에 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과: IAM Role은 그대로 유지, 제출 방식만 다르게&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Access Key를 코드에 넣지 않는 게 원칙이라, IAM Role 방식 자체를 바꾸지는 않았다. 대신 과제&lt;br /&gt;원문을 다시 보니 이 상황을 위한 조항이 이미 있었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;IAM Role로 진행하신 수강생의 경우는 발제에서 요구하는 Presigned URL 대신, 접근 성공&lt;br /&gt;스크린샷을 확보하여 README.md에 첨부 부탁드리겠습니다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;대체 제출 옵션이 있구나&quot; 정도로만 생각했는데, 임시 자격증명의 동작 원리를 알고 나니 왜 이런 예외 조항이 필요한지 이해가 됐다. EC2 Instance Profile이 제공하는 임시 자격증명을 그대로 사용하는 구조에서는, 해당 자격증명의 남은 유효 기간을 넘어서는 Presigned URL을 만들 수 없기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;진짜 7일로 설정 하고 싶다면?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 문제는 IAM Role 구조 자체를 바꾸지 않는 방향으로 해결했다. 하지만 실제 서비스에서 사용자가 며칠 뒤에도 접근해야 하는 다운로드 링크처럼 긴 유효기간이 필요한 경우에는 S3 Presigned URL만으로 해결하지 않을 수도 있다. 대표적으로:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다운로드 API에서 자체 인증 토큰을 발급하는 방식&lt;/li&gt;
&lt;li&gt;CloudFront Signed URL을 사용하는 방식&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CloudFront Signed URL이 되는 이유도 앞서와 같은 구조다. IAM 세션이 아니라 별도의 키페어로 서명하기 때문에, 애초에 세션 만료라는 개념 자체가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과제의 LV6이 마침 CloudFront다. 지금 마주친 &quot;7일을 못 채우는&quot; 문제의 실무 해법이 이미&lt;br /&gt;다음 단계 로드맵에 있었던 셈이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그럼 왜 만료시간이 필요한가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 &quot;다운로드 API냐 CloudFront냐&quot; 같은 방식 선택이 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;링크 하나가 며칠씩 살아있어야 하는가&quot;라는 전제 자체가 상황마다 다르다&lt;/b&gt;는 게 진짜 이유다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각해보면 프로필 이미지는 조금 다르다.&lt;br /&gt;프로필 이미지는 페이지 요청 시 서버가 새로운 URL을 발급하면 된다. 사용자가 URL 자체를 장기간 보관해야 하는 요구사항이 아니기 때문에, 긴 만료 시간을 설정하는 것은 보안 측면에서 오히려 불필요할 수 있다. 만료 시간을 길게 가져가면 URL이 유출됐을 때 접근 가능한 기간만 늘어나기 때문이다.&lt;br /&gt;반대로 이메일로 보낸 다운로드 링크처럼 &quot;사용자가 며칠 뒤에 열어볼 수도 있는&quot; 경우에는 이 전제가 안 통한다. 링크 발급 이후에도 일정 기간 동안 접근 가능해야 한다는 요구사항이 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 CloudFront든 다운로드 API 토큰이든, 링크가 얼마나 오래 살아있어야 하는지부터 정하고 그에 맞는 방식을 고르면 된다.&lt;/p&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;[AWS S3 User Guide - Using presigned URLs] &lt;a href=&quot;https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html&quot;&gt;https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;[AWS IAM User Guide - Update the maximum session duration for a role] &lt;a href=&quot;https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_update-role-settings.html&quot;&gt;https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_update-role-settings.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;[Amazon EC2 User Guide - Retrieve security credentials from instance metadata] &lt;a href=&quot;https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-metadata-security-credentials.html&quot;&gt;https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-metadata-security-credentials.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GitHub 링크&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/trex1004/mbti&quot;&gt;https://github.com/trex1004/mbti&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1786367974116&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - trex1004/mbti&quot; data-og-description=&quot;Contribute to trex1004/mbti development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/trex1004/mbti&quot; data-og-url=&quot;https://github.com/trex1004/mbti&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/BQvqC/dJMb9eftEzO/6SrVPASReLpOXk5RHaBhPK/img.png?width=2840&amp;amp;height=1124&amp;amp;face=0_0_2840_1124&quot;&gt;&lt;a href=&quot;https://github.com/trex1004/mbti&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/trex1004/mbti&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/BQvqC/dJMb9eftEzO/6SrVPASReLpOXk5RHaBhPK/img.png?width=2840&amp;amp;height=1124&amp;amp;face=0_0_2840_1124');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - trex1004/mbti&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to trex1004/mbti development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>AWS</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/7</guid>
      <comments>https://zeroto-dev.tistory.com/7#entry7comment</comments>
      <pubDate>Thu, 6 Aug 2026 14:28:28 +0900</pubDate>
    </item>
    <item>
      <title>GitHub Actions와 AWS OIDC 기반 CI/CD 구축 및 인증 오류 해결</title>
      <link>https://zeroto-dev.tistory.com/6</link>
      <description>&lt;h2 data-heading=&quot;배경&quot; data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 프로젝트에 GitHub Actions로 CI 파이프라인을 구축하고, AWS OIDC 인증으로 ECR에 Docker 이미지를 Push하는 자동화를 실습했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Workflow 파일을 작성하는 것 자체보다, 실제로 파이프라인을 돌리면서 마주친 인증&amp;middot;권한 문제를 해결하는 과정이 이번 실습의 핵심이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구축한 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Git Push
  &amp;darr;
GitHub Actions 실행
  &amp;darr;
Gradle Test
  &amp;darr;
Spring Boot JAR Build
  &amp;darr;
AWS OIDC 인증
  &amp;darr;
ECR Login
  &amp;darr;
Docker Image Build &amp;amp; Push
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;1. GitHub Actions Workflow 파일 Push 실패&quot; data-ke-size=&quot;size26&quot;&gt;1. GitHub Actions Workflow 파일 Push 실패&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.github/workflows/ci.yml을 작성하고 Push하는데 다음 오류가 발생했다.&lt;/p&gt;
&lt;pre class=&quot;basic&quot;&gt;&lt;code&gt;remote: refusing to allow a Personal Access Token to create or update workflow
'.github/workflows/ci.yml' without 'workflow' scope
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 YAML 문법 문제인 줄 알았지만, 원인은 Git 인증 방식이었다. HTTPS로 Push하면서 Mac Keychain에 저장된 Personal Access Token(PAT)을 쓰고 있었는데, 이 PAT에는 .github/workflows 경로를 수정할 수 있는 workflow scope가 없었다. GitHub는 보안상 워크플로 파일 변경 시 이 scope를 별도로 요구한다.&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;: HTTPS 대신 SSH로 전환했다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;# SSH 인증 상태 확인
ssh -T git@github.com
# &amp;rarr; Hi trex1004! You've successfully authenticated

# remote를 SSH 방식으로 변경
git remote set-url origin git@github.com:trex1004/ci-demo.git

git push origin main  # 성공
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Git Push 오류가 나면 코드나 파일 문제만 의심하기 쉬운데, 인증 방식(HTTPS/SSH)이나 Token scope도 원인이 될 수 있다는 걸 확인했다. (섬세한 GitHub 개발자들&amp;hellip;;;)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;2. AWS OIDC AssumeRole 인증 실패&quot; data-ke-size=&quot;size26&quot;&gt;2. AWS OIDC AssumeRole 인증 실패&lt;/h2&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;Could not assume role with OIDC:
Not authorized to perform sts:AssumeRoleWithWebIdentity
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;OIDC 인증 구조&quot; data-ke-size=&quot;size23&quot;&gt;OIDC 인증 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트는 처음부터 Access Key 대신 OIDC 방식을 쓰고 있었다. GitHub Actions가 발급받은 OIDC Token으로 AWS STS의 AssumeRoleWithWebIdentity를 호출해 IAM Role을 임시로 획득하는 구조라, Access Key를 GitHub Secrets에 저장할 필요가 없다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;GitHub Actions &amp;rarr; OIDC Token 발급 &amp;rarr; AWS STS AssumeRoleWithWebIdentity &amp;rarr; IAM Role 획득 &amp;rarr; AWS 서비스 접근
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;원인&quot; data-ke-size=&quot;size23&quot;&gt;원인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IAM Role의 Trust Policy를 확인했다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;&quot;StringLike&quot;: {
  &quot;token.actions.githubusercontent.com:sub&quot;: [
    &quot;repo:trex1004/ci-demo:*&quot;
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Trust Policy는 repo:trex1004/ci-demo:* 형식의 subject만 허용하고 있었다.&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;그런데 GitHub OIDC 정책 변경으로, 새로 만든 저장소는 owner ID와 repository ID를 포함한 immutable subject claim 형식을 쓰고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 GitHub가 전달하는 subject 값과 Trust Policy 조건이 일치하지 않아 AssumeRole이 실패한 것이었다.&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;: Trust Policy 조건에 새 형식을 추가했다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;&quot;StringLike&quot;: {
  &quot;token.actions.githubusercontent.com:sub&quot;: [
    &quot;repo:trex1004/ci-demo:*&quot;,
    &quot;repo:trex1004@*/ci-demo@*:*&quot;
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 후 재실행하니 AWS OIDC 인증에 성공했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-heading=&quot;회고 &amp;mdash; 다른 개발자의 트러블슈팅 기록으로 문제를 푼 경험&quot; data-ke-size=&quot;size26&quot;&gt;회고 &amp;mdash; 다른 개발자의 트러블슈팅 기록으로 문제를 푼 경험&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;p data-ke-size=&quot;size16&quot;&gt;이번에도 처음엔 같은 방식으로 접근했는데, OIDC&amp;middot;Trust Policy&amp;middot;Subject Claim 같은 개념이 아직 낯설어서 정확히 뭘 고쳐야 하는지 바로 감이 오지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Trust Policy(신뢰 관계)를 들여다보던 중&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;예전에 팀원 mo가 공유해준 &lt;b&gt;&quot;뭔가&quot;&lt;/b&gt; 떠올랐다.(짧은 내용이지만 정독해도 이해조차 안 되었었다,,;;)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1382&quot; data-origin-height=&quot;740&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kR8MJ/dJMcaiqGA0F/ERV1lsNXDCB1kfCKs5vYX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kR8MJ/dJMcaiqGA0F/ERV1lsNXDCB1kfCKs5vYX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kR8MJ/dJMcaiqGA0F/ERV1lsNXDCB1kfCKs5vYX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkR8MJ%2FdJMcaiqGA0F%2FERV1lsNXDCB1kfCKs5vYX0%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;425&quot; height=&quot;740&quot; data-origin-width=&quot;1382&quot; data-origin-height=&quot;740&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시엔 &quot;&lt;b&gt;뭔가&lt;/b&gt; 공유해 주셨구나&quot; 정도로만 받아들였다. 그런데 막상 같은 문제를 마주하니,,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;span style=&quot;color: #8a3db6;&quot;&gt;&quot;혹시 이게 그때 mo가 공유해준 그 &lt;b&gt;&amp;ldquo;뭔가&amp;rdquo;&lt;/b&gt; 때문인가?&quot; &lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;라는 생각이,,,&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 찾아본 끝에 기존 Trust Policy가 바뀐 subject 형식을 허용하지 않고 있다는 걸 확인할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mo님의 공유가 없었다면 IAM Role, OIDC Provider, Trust Policy를 하나씩 다시 확인하며 훨씬 오래 걸렸을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 이해하지 못했던 정보가 실제 문제 상황과 만나면서 쓸모 있는 지식으로 연결된 경험이기도 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로 나도 문제를 해결하면 개인 기록으로만 남기지 않고, 다른 사람이 같은 문제를 만났을 때 도움이 될 수 있도록 원인과 해결 과정을 정리해서 공유하는 개발자가 되고 싶다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 이번 오류 해결에 도움을 준&amp;nbsp;&lt;b&gt;mo님에게 감사드립니데이,,&lt;/b&gt;&lt;/p&gt;</description>
      <category>TIL</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/6</guid>
      <comments>https://zeroto-dev.tistory.com/6#entry6comment</comments>
      <pubDate>Sat, 1 Aug 2026 23:45:14 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 엔티티와 DTO는 왜 타입이 달라도 되는가</title>
      <link>https://zeroto-dev.tistory.com/4</link>
      <description>&lt;h2 data-heading=&quot;배경&quot; data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;엔티티 필드와 DTO 필드는 이름과 타입이 같아야 한다&quot;고 배웠던 것 같은데, 실제 실습 코드를 보면 그렇지 않았다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 엔티티
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
private User user;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// 응답 DTO
public class PostDto {
    private long Id;
    private String username; // user 객체가 아니라 username 문자열
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름도 다르고(user &amp;rarr; username), 타입도 다르다(Long &amp;rarr; long, 객체 참조 &amp;rarr; 문자열). 그런데도 이 코드는 실제로 잘 동작한다. 왜 그런지, 그리고 이게 아무렇게나 해도 괜찮다는 뜻인지 두 가지 사례로 파봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기준은&amp;nbsp; &quot;&lt;b&gt;반대로 왜 다른 방식(Enum 타입으로 바로 받는 것 등)을 원했냐고 되물었을 때 답할 수 있는가&lt;/b&gt;&quot;로 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 정답이 정해진 사례(id 타입)뿐 아니라 정답이 없는 설계 선택(role 타입)까지, 실제로 반대 케이스를 코드로 만들어보고 비교했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;타입은 &amp;quot;그 시점에 보장할 수 있는 것&amp;quot;을 표현한다&quot; data-ke-size=&quot;size26&quot;&gt;타입은 &quot;그 시점에 보장할 수 있는 것&quot;을 표현한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티, 요청 DTO, 응답 DTO는 같은 도메인을 다루더라도 서로 다른 시점, 다른 신뢰 수준의 데이터를 대상으로 한다.&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;는 DB에 저장된(또는 저장될) 상태를 대표한다. @GeneratedValue로 자동 생성되는 PK는 저장 전엔 값이 없으므로, 그 &quot;없음&quot;을 표현하려면 Long(래퍼)이 필요하다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;요청 DTO&lt;/b&gt;는 클라이언트가 보낸, 아직 아무것도 검증되지 않은 원시 데이터다. 이 시점엔 값이 뭐가 올지 서버가 통제할 수 없다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;응답 DTO&lt;/b&gt;는 검증과 저장이 다 끝난, 확정된 사실만 담는다. 이 시점엔 더 이상 불확실성이 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 시점이 서로 다르기 때문에, 같은 개념(id, role)이라도 계층마다 다른 타입을 쓰는 데는 이유가 있다. 다만 그 이유가 항상 유일한 정답을 뜻하지는 않는다. 아래 두 사례로 구체적으로 확인했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;사례 1: id (엔티티 &amp;#96;Long&amp;#96; / 응답 &amp;#96;long&amp;#96; / 요청 &amp;#96;Long&amp;#96;+&amp;#96;@NotNull&amp;#96;)&quot; data-ke-size=&quot;size26&quot;&gt;사례 1: id (엔티티 Long / 응답 long / 요청 Long+@NotNull)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티가 Long을 쓰는 이유는 앞서 봤듯 저장 전 null 상태를 표현하기 위해서다. 나머지 두 계층은 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostDto.from(post)는 이미 저장이 끝난 Post를 변환하는 메서드다. 이 시점엔 post.getId()가 항상 실제 값을 반환한다는 전제가 있어서 long(primitive)을 써도 안전하다.&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;여기서 primitive를 쓴 건 단순히 &quot;null일 리 없어서 편하게 썼다&quot;가 아니다. Long(null 가능) 값을 primitive 매개변수에 넘기면 컴파일러가 Long.longValue() 호출로 변환하는데, 만약 전제가 깨져서 null이 들어오면 그 자리에서 바로 NullPointerException이 터진다. 즉 long은 전제가 깨졌을 때 그 즉시 요란하게 실패하게 만드는 &lt;b&gt;fail-fast&lt;/b&gt; 장치이기도 하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 쪽은 반대다. 코드를 보면:&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public class ManagerSaveRequest {
    @NotNull
    private Long managerUserId;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 managerUserId가 long(primitive)이었다면 어떻게 됐을까. long은 애초에 null이라는 상태를 가질 수 없다. 그래서 @NotNull을 붙여도 검증할 대상 자체가 없어 의미가 없고, 클라이언트가 필드를 아예 안 보내면 자바 기본값인 0이 조용히 채워져서 정상 요청처럼 통과해버린다. 실제 코드처럼 Long + @NotNull로 받으면 필드가 없을 때 null로 남고, Bean Validation이 컨트롤러 로직 실행 전에 명확하게 걸러낸다.&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;managerUserId&quot;: null로 명시하면 400과 함께 &quot;null이어서는 안 된다&quot;는 명확한 메시지가 오는데, 바디가 비어있거나 형식이 깨진 상태로 보내면 &lt;b&gt;500 Internal Server Error&lt;/b&gt;가 떴다. 원인은 GlobalExceptionHandler에 있었다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@ExceptionHandler(MethodArgumentNotValidException.class) // 400
@ExceptionHandler(Exception.class) // 500, catch-all
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 바디 자체를 객체로 만드는 데 실패하면(JSON 파싱 단계) HttpMessageNotReadableException이 발생하는데, 이 프로젝트엔 이 예외용 핸들러가 없다. 그래서 MethodArgumentNotValidException(검증 실패)은 400으로 깔끔하게 처리되지만, 파싱 자체가 실패하는 경우는 catch-all로 떨어져 500이 나가고 있었다.&lt;/p&gt;
&lt;h2 data-heading=&quot;사례 2: role (엔티티 &amp;#96;UserRole&amp;#96; / 요청 &amp;#96;String&amp;#96; + 직접 검증)&quot; data-ke-size=&quot;size26&quot;&gt;사례 2: role (엔티티 UserRole / 요청 String )&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserRoleChangeRequest는 권한 값을 String으로 받는다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public void changeUserRole(long userId, UserRoleChangeRequest userRoleChangeRequest) {
    User user = userRepository.findById(userId).orElseThrow(...);
    user.updateRole(UserRole.of(userRoleChangeRequest.getRole()));
}

public enum UserRole {
    ADMIN, USER;
    public static UserRole of(String role) {
        return Arrays.stream(UserRole.values())
                .filter(r -&amp;gt; r.name().equalsIgnoreCase(role))
                .findFirst()
                .orElseThrow(() -&amp;gt; new CustomException(ErrorCode.INVALID_USER_ROLE));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 필드 자체를 UserRole role로 선언했다면 어떻게 될까. 실제로 바꿔서 테스트해보니, Jackson이 파싱 단계에서 자기 방식대로 변환을 시도하면서 UserRole.of()는 호출될 기회조차 없어졌다. 그리고 Jackson의 기본 Enum 매칭은 대소문자를 구분해서, &quot;admin&quot;(소문자)을 보내면 실패했다. 반면 지금 구조는 equalsIgnoreCase로 직접 검증하므로 소문자도 받아준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 사례로 정리되는 차이는 두 가지다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;필드별로 다른 에러 메시지/코드가 필요한가&lt;/b&gt;: String + 직접 검증이면 ErrorCode.INVALID_USER_ROLE처럼 이 필드만의 응답을 만들 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프레임워크 기본 동작과 다른 매칭 규칙이 필요한가&lt;/b&gt;: 대소문자 무시 같은 규칙은 String + 직접 검증 쪽이 자유롭다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;Enum으로 바로 받으면 파싱 실패로 500이 새니까 String이 안전하다&quot;고 생각했는데, 이건 근거가 약했다. 그 500은 HttpMessageNotReadableException 핸들러가 빠진 이 프로젝트의 문제지, Enum 타입 자체의 문제가 아니다. 그 핸들러 하나만 추가하면 Enum으로 바로 받아도 똑같이 400이 나온다.&lt;/p&gt;
&lt;h2 data-heading=&quot;결론&quot; data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티와 DTO가 이름&amp;middot;타입이 달라도 되는 이유는 &quot;아무렇게나 해도 상관없어서&quot;가 아니라, &lt;b&gt;각 계층이 책임지는 범위가 다르기 때문&lt;/b&gt;이다. 그 책임을 코드가 정확히 지키고 있는 한(응답 DTO는 저장 후에만 변환, 요청 DTO는 검증을 거쳐야만 엔티티로 감) 문제가 안 생기고, 오히려 타입 자체가 그 책임을 강제하는 장치로 쓰일 수 있다(primitive의 fail-fast, @NotNull의 검증 강제).&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;다만 id 사례와 달리 role 사례(Enum vs String)는 정답이 있는 문제가 아니었다. 이 실습 자체가 &quot;정답 없이 의도를 가지고 리팩토링해보라&quot;는 조건이었던 만큼, 실제로 남는 차이(에러 메시지 제어, 매칭 규칙)가 지금 이 프로젝트에 필요한 정도인지는 트레이드오프로 판단할 문제로 남는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 이 검증 로직 자체가 왜 필요한지도 다시 생각해볼 만하다. UserRole.of()가 없었다면 String으로 받든 Enum으로 바로 받든 결과는 같았을 것이다. 정의되지 않은 값이 오면 그냥 500이 났을 것이다. 500은 &quot;서버가 알아서 처리해야 할 문제&quot;라는 신호고, 400과 함께 명확한 메시지를 주는 건 &quot;요청을 보낸 쪽이 무엇을 고치면 되는지&quot; 알려주는 신호다. 이 API를 실제로 쓰는 클라이언트 입장에서 둘의 차이는 크다. 검증 로직은 타입 설계의 정교함을 보여주기 위한 것이 아니라, 잘못된 요청을 보냈을 때 그 사실을 정확히 알려주기 위해 존재한다.&lt;/p&gt;</description>
      <category>Spring</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/4</guid>
      <comments>https://zeroto-dev.tistory.com/4#entry4comment</comments>
      <pubDate>Tue, 28 Jul 2026 23:58:30 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] 프로젝트 실행 실패 원인 분석 - JWT 시크릿 키 누락과 MySQL 포트 충돌</title>
      <link>https://zeroto-dev.tistory.com/3</link>
      <description>&lt;div class=&quot;toc&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;h3 data-heading=&quot;배경&quot; data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 0 요구사항은 &quot;프로젝트를 실행했으나 특정 에러로 인해 실행에 실패했다, 원인을 분석해서 실행 가능하게 만들어라(에러는 여러 개일 수 있다)&quot;였다. 실제로 처음 받은 프로젝트를 그대로 실행하면 최소 두 가지 지점에서 막혔다.&lt;/p&gt;
&lt;h3 data-heading=&quot;문제 1. JWT 시크릿 키 미설정&quot; data-ke-size=&quot;size23&quot;&gt;문제 1. JWT 시크릿 키 미설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtUtil에서 시크릿 키를 다음과 같이 외부 설정값으로 받고 있었다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Value(&quot;${jwt.secret.key}&quot;)
private String secretKey;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에는 application.yml 자체가 없어서 jwt.secret.key 값을 어디서도 읽을 수 없었고, 애플리케이션이 뜨는 시점에 실패했다.&lt;/p&gt;
&lt;h3 data-heading=&quot;문제 2. MySQL 포트 충돌&quot; data-ke-size=&quot;size23&quot;&gt;문제 2. MySQL 포트 충돌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml을 작성하면서 MySQL 연결을 시도했는데, 3306 포트를 다른 프로그램이 이미 사용하고 있어서 연결이 되지 않았다. 해당 포트를 점유하고 있던 프로세스를 종료한 뒤 정상적으로 연결됐다.&lt;/p&gt;
&lt;h3 data-heading=&quot;해결&quot; data-ke-size=&quot;size23&quot;&gt;해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml을 새로 작성해서 두 문제를 함께 해결했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;spring:
  datasource:
    url: jdbc:mysql://localhost:3306/advanced
    username: root
    password: 12345678
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true

jwt:
  secret:
    key: abcdefghijklmnopqrstuvwxyz12345678901234567890
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;검증&quot; data-ke-size=&quot;size23&quot;&gt;검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Value(&quot;${jwt.secret.key}&quot;)처럼 기본값이 없는 플레이스홀더는 Spring Boot가 자동 등록하는 PropertySourcesPlaceholderConfigurer가 해석한다.&lt;br /&gt;공식 문서 기준으로 이 컴포넌트는 기본적으로 해석되지 않는 플레이스홀더가 있으면 실패하도록 되어 있고&lt;br /&gt;(setIgnoreUnresolvablePlaceholders(true)를 명시적으로 켜야 실패 없이 넘어간다),&lt;br /&gt;@Value가 참조하는 값에 기본값(${key:default})이 없으면 반드시 Environment에 그 값이 존재해야 한다.&lt;br /&gt;즉 jwt.secret.key처럼 기본값 없는 필수값이 비어 있으면 컨텍스트 초기화 시점에 바로 실패하는 게 맞는 동작이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(참고: 이 과정에서 src/main/resources 폴더가 IntelliJ에서 리소스 루트로 인식되지 않고 application.yml도 제대로 인식되지 않는 현상이 있었다. build.gradle에 spring-boot-devtools를 추가한 직후 해결됐지만, devtools 자체가 원인은 아닌 것으로 보인다 &amp;mdash; build.gradle을 수정하면 IntelliJ가 Gradle 프로젝트를 다시 동기화하는데, 그 타이밍에 우연히 같이 인식된 것으로 추정된다.)&lt;/p&gt;
&lt;h3 data-heading=&quot;결과&quot; data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml 작성(DB 접속 정보, JPA 설정, JWT 시크릿 키)과 포트 정리로 프로젝트가 정상적으로 실행되는 상태를 만들었다. build.gradle에는 spring-boot-devtools도 함께 추가했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;[Spring MVC] AuthUserArgumentResolver가 동작하지 않던 이유 - ArgumentResolver 등록&quot; data-ke-size=&quot;size26&quot;&gt;[Spring MVC] AuthUserArgumentResolver가 동작하지 않던 이유 - ArgumentResolver 등록&lt;/h2&gt;
&lt;h3 data-heading=&quot;문제&quot; data-ke-size=&quot;size23&quot;&gt;문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AuthUserArgumentResolver의 supportsParameter()/resolveArgument() 로직 자체는 정상이었지만, @Auth AuthUser 파라미터를 쓰는 모든 컨트롤러가 동작하지 않고 있었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;검증&quot; data-ke-size=&quot;size23&quot;&gt;검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring MVC는 HandlerMethodArgumentResolver를 구현한 클래스가 있다고 자동으로 인식해서 써주지 않는다. RequestMappingHandlerAdapter는 기본으로 정해진 리졸버 목록만 갖고 있고, 커스텀 리졸버를 그 목록에 추가하려면 WebMvcConfigurer.addArgumentResolvers()를 통해 명시적으로 등록해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Spring 공식 문서: &quot;Custom argument resolvers can be implemented to support unique argument types not covered by the default set&quot; &amp;mdash; WebMvcConfigurer가 이 등록을 위한 확장 지점이다). 즉 리졸버 로직 자체가 맞아도, 등록 코드가 없으면 그 리졸버는 존재하지 않는 것과 같다.&lt;/p&gt;
&lt;h3 data-heading=&quot;해결&quot; data-ke-size=&quot;size23&quot;&gt;해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebConfig implements WebMvcConfigurer를 새로 만들고 addArgumentResolvers()에서 등록했다. AuthUserArgumentResolver엔 @Component @RequiredArgsConstructor를 붙여 스프링 빈으로 만들고, WebConfig에서 생성자 주입으로 받아 등록했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { ... }

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final AuthUserArgumentResolver argumentResolver;

    @Override
    public void addArgumentResolvers(List&amp;lt;HandlerMethodArgumentResolver&amp;gt; resolvers) {
        resolvers.add(argumentResolver);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;다른 방식은 없었는지&quot; data-ke-size=&quot;size23&quot;&gt;다른 방식은 없었는지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리졸버를 빈으로 만들지 않고 WebConfig 안에서 new AuthUserArgumentResolver()로 직접 생성해서 등록하는 방법도 있다. 지금은 AuthUserArgumentResolver가 다른 빈에 의존하지 않아서 두 방식의 동작 차이는 없다.&lt;br /&gt;다만 나중에 이 리졸버가 다른 빈(예: 별도 인증 유틸)을 주입받아야 하는 상황이 오면 new 방식은 코드를 고쳐야 하고, 빈으로 등록해둔 쪽은 그대로 확장된다 &amp;mdash; 그래서 빈 등록 방식을 선택했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-heading=&quot;[Spring] 코드 개선 3종 - Early Return, if-else 제거, Validation 위치 이동&quot; data-ke-size=&quot;size26&quot;&gt;[Spring] 코드 개선 3종 - Early Return, if-else 제거, Validation 위치 이동&lt;/h2&gt;
&lt;h3 data-heading=&quot;2-1. Early Return&quot; data-ke-size=&quot;size23&quot;&gt;2-1. Early Return&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;: signup()에서 이메일 중복 여부(existsByEmail)를 확인하기도 전에 passwordEncoder.encode()부터 호출하고 있었다. encode()는 BCrypt 기반이라 비교적 비용이 큰 연산인데, 이미 존재하는 이메일이라 어차피 실패할 요청에도 이 연산이 먼저 실행되고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: 이메일 중복 체크를 encode() 호출보다 앞으로 옮겨서, 중복이면 그 자리에서 바로 예외를 던지고 리턴하도록 순서를 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;if (userRepository.existsByEmail(signupRequest.getEmail())) {
    throw new InvalidRequestException(&quot;이미 존재하는 이메일입니다.&quot;);
}
String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;2-2. 불필요한 if-else 제거&quot; data-ke-size=&quot;size23&quot;&gt;2-2. 불필요한 if-else 제거&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;: getTodayWeather()에서 상태 코드가 OK가 아니면 예외를 던지고 메서드 흐름을 끝내는 if 블록인데도, 그 다음 null/length 체크가 else 블록 안에 중첩되어 있었다. if 쪽에서 이미 흐름이 끝나므로 else는 로직상 불필요한 중첩이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: 상태 코드가 OK가 아니면 그 자리에서 예외를 던지고 리턴하므로, 이어지는 null/length 체크를 else 없이 나란히 작성해도 동일하게 동작한다는 점을 이용해 중첩을 제거했다.&lt;/p&gt;
&lt;h3 data-heading=&quot;2-3. 비밀번호 형식 검증을 서비스에서 DTO로 이동&quot; data-ke-size=&quot;size23&quot;&gt;2-3. 비밀번호 형식 검증을 서비스에서 DTO로 이동&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;: changePassword() 안에서 새 비밀번호 형식(8자 이상, 숫자&amp;middot;대문자 포함)을 length()/matches() 조합으로 서비스 로직 한가운데서 직접 검증하고 있었다. 입력 형식 검증은 요청이 들어오는 시점(DTO)에서 걸러지는 게 자연스러운데, 서비스 로직에 섞여 있어 책임이 분리되어 있지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: 검증 로직을 @Pattern(regexp = &quot;^(?=.*\\d)(?=.*[A-Z]).{8,}$&quot;)로 옮기고 컨트롤러에 @Valid를 추가했다. 같은 비밀번호 정책을 SignupRequest.password에도 적용해서, 회원가입 시점부터 동일한 정책이 걸리도록 범위를 넓혔다(요구사항은 changePassword()만 명시했지만, 비밀번호 정책이라면 회원가입에도 같이 적용되는 게 맞다고 판단).&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@NotBlank(message = &quot;새 비밀번호를 입력하세요&quot;)
@Pattern(
        regexp = &quot;^(?=.*\\d)(?=.*[A-Z]).{8,}$&quot;,
        message = &quot;새 비밀번호는 8자 이상이며, 숫자와 대문자를 포함해야 합니다.&quot;
)
private String newPassword;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;검증 중 발견한 보완 필요 지점&lt;/b&gt;: @Valid 검증에 실패하면 스프링은 MethodArgumentNotValidException을 던진다. 그런데 지금 GlobalExceptionHandler엔 이 예외 전용 핸들러가 없고, 레벨 1에서 추가한 @ExceptionHandler(Exception.class) catch-all 핸들러만 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MethodArgumentNotValidException은 InvalidRequestException도 AuthException도 아니라서 이 catch-all에 걸리게 되고, 그러면 의도한 400 + 구체적 검증 메시지 대신 500 &quot;서버 오류&quot;로 응답될 가능성이 높다. MethodArgumentNotValidException 전용 핸들러를 추가해서 400 응답과 필드 메시지를 유지하는 걸 권장한다 &amp;mdash; 실제 요청을 보내서 응답을 확인해보는 게 좋을 것 같다.&lt;/p&gt;
&lt;h2 data-heading=&quot;[Spring Data JPA] N+1 문제 해결 - JPQL fetch join을 @EntityGraph로 전환&quot; data-ke-size=&quot;size26&quot;&gt;[Spring Data JPA] N+1 문제 해결 - JPQL fetch join을 @EntityGraph로 전환&lt;/h2&gt;
&lt;h3 data-heading=&quot;문제&quot; data-ke-size=&quot;size23&quot;&gt;문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TodoRepository.findAllByOrderByModifiedAtDesc()는 JPQL &quot;LEFT JOIN FETCH t.user u&quot;로 N+1 문제를 이미 해결하고 있었다. 요구사항은 이 방식을 메서드 이름 기반 쿼리 + @EntityGraph로 바꾸는 것이었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;해결&quot; data-ke-size=&quot;size23&quot;&gt;해결&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Query JPQL을 제거하고, 메서드 이름만으로 쿼리가 생성되는 findAllByOrderByModifiedAtDesc(Pageable pageable)에 @EntityGraph(attributePaths = {&quot;user&quot;})를 붙였다. 여기서 더 나아가, 같은 파일에 있던 또 다른 fetch join 메서드 findByIdWithUser()도 없애고, JpaRepository가 기본 제공하는 findById()를 @Override + @EntityGraph로 재정의해서 TodoService.getTodo()가 표준 findById()를 쓰도록 통일했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@EntityGraph(attributePaths = {&quot;user&quot;})
Page&amp;lt;Todo&amp;gt; findAllByOrderByModifiedAtDesc(Pageable pageable);

@Override
@EntityGraph(attributePaths = {&quot;user&quot;})
Optional&amp;lt;Todo&amp;gt; findById(@NonNull Long id);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;검증&quot; data-ke-size=&quot;size23&quot;&gt;검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA 공식 문서에 따르면 @EntityGraph(attributePaths = {...})는 리포지토리가 기본 제공하는 메서드를 오버라이드할 때도 동일하게 적용된다 &amp;mdash; 공식 예시로 findAll(Specification, Pageable)을 오버라이드하며 @EntityGraph를 붙이는 패턴이 문서에 그대로 나온다. 내부적으로 SimpleJpaRepository.applyQueryHints()를 통해 JPA fetch 힌트로 적용되므로, findById()를 오버라이드하는 것도 같은 방식으로 정상 동작한다.&lt;/p&gt;
&lt;h3 data-heading=&quot;다른 방식은 없었는지&quot; data-ke-size=&quot;size23&quot;&gt;다른 방식은 없었는지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요구사항 문장이 &quot;N+1 문제가 발생할 수 있는 시나리오는 getTodos 메서드에서&amp;hellip;&quot;라고 특정 시나리오만 예로 들었기 때문에, findAllByOrderByModifiedAtDesc()만 @EntityGraph로 바꾸고 findByIdWithUser()는 그대로 둬도 요구사항 자체는 충족된다(실제로 그렇게만 처리해도 동작하는 걸 확인함). 리포지토리 전체를 하나의 방식으로 통일할지, 요구사항이 콕 집어 설명한 부분만 바꿀지는 범위를 얼마나 넓게 해석하느냐의 문제였고, 여기서는 일관성을 위해 전체를 통일하는 쪽을 선택했다.&lt;/p&gt;
&lt;h3 data-heading=&quot;다시 학습 - findById까지 바꾼 건 잘못된 선택이었다&quot; data-ke-size=&quot;size23&quot;&gt;다시 학습 - findById까지 바꾼 건 잘못된 선택이었다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;findById()는 한 건만 조회하는 거라 애초에 &quot;N+1&quot;이 아니다 &amp;mdash; N이 없다. @EntityGraph를 안 붙이면 user를 쓸 때 지연로딩으로 쿼리가 딱 1번 더 나가는 정도지, 목록 조회처럼 N번 반복되는 문제가 아니다. &quot;엔티티그래프를 붙이면 무조건 더 빠르다&quot;고 막연히 생각해서 findById()까지 확장했는데, 실제로 이 메서드를 쓰는 곳을 전부 확인해보니 그렇지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;findById()를 쓰는 곳은 5군데였고, 그중 user를 실제로 쓰는 곳은 3곳(TodoService.getTodo(), ManagerService.saveManager(), deleteManager())뿐이었다. 나머지 2곳(ManagerService.getManagers(), CommentService)은 user를 아예 안 쓴다. Todo.user는 원래 LAZY라서, @EntityGraph가 없었다면 이 2곳은 애초에 추가 쿼리 자체가 안 나갔을 것이다(지연로딩은 실제로 쓸 때만 쿼리가 나가니까). 그런데 findById()에 @EntityGraph를 걸어버리면 user가 필요 없는 요청에도 매번 조인이 따라붙는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 이 선택은 &quot;3곳에서 쿼리 1번씩 아끼려고, 2곳에서는 매번 안 써도 될 조인 비용을 지불하는&quot; 트레이드오프였고, 애초에 findById() 하나당 아끼는 게 &quot;쿼리 1번&quot;에 불과하다는 걸 생각하면 이득보다 손해가 더 명확한 선택이었다. 더 나은 방법은 findById()는 원래대로(LAZY, @EntityGraph 없이) 두고, user가 필요한 3곳만 별도 메서드에 @EntityGraph를 걸어서 쓰는 것이었다.&lt;/p&gt;
&lt;h2 data-heading=&quot;[테스트] 테스트코드 연습 - 잘못된 테스트/버그 수정&quot; data-ke-size=&quot;size26&quot;&gt;[테스트] 테스트코드 연습 - 잘못된 테스트/버그 수정&lt;/h2&gt;
&lt;h3 data-heading=&quot;4-1. PasswordEncoderTest&quot; data-ke-size=&quot;size23&quot;&gt;4-1. PasswordEncoderTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;: PasswordEncoder.matches(rawPassword, encodedPassword) 순서로 정의되어 있는데, 테스트에서는 matches(encodedPassword, rawPassword)로 인자 순서가 뒤바뀐 채 호출되고 있었다. 실제 구현이 맞더라도 테스트는 항상 실패하는 상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: 인자 순서를 실제 메서드 시그니처에 맞게 고쳤다. 여기서 더 나아가 @InjectMocks와 SpringExtension 기반 목킹도 걷어내고 new PasswordEncoder()로 직접 생성하도록 바꿨다 &amp;mdash; PasswordEncoder는 목으로 대체할 의존성이 없는 클래스라서, 목킹 프레임워크를 쓸 이유가 없었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;4-2. ManagerServiceTest&quot; data-ke-size=&quot;size23&quot;&gt;4-2. ManagerServiceTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 1&lt;/b&gt;: manager_목록_조회_시_Todo가_없다면_NPE_에러를_던진다() 테스트가 InvalidRequestException의 메시지로 &quot;Manager not found&quot;를 기대하고 있었는데, 실제 getManagers()가 Todo를 못 찾을 때 던지는 메시지는 &quot;Todo not found&quot;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: 메시지를 &quot;Todo not found&quot;로 수정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 2&lt;/b&gt;: todo의_user가_null인_경우_예외가_발생한다() 테스트가 원래 기대한 대로, saveManager()는 todo.getUser()가 null일 때 getId() 호출에서 실제로 NPE를 던지는 버그가 있었다. deleteManager()에는 이미 있던 null 체크가 saveManager()에는 빠져 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: deleteManager()와 동일한 null 체크(todo.getUser() == null || ...)를 saveManager()에도 추가했다.&lt;/p&gt;
&lt;h3 data-heading=&quot;4-3. CommentServiceTest&quot; data-ke-size=&quot;size23&quot;&gt;4-3. CommentServiceTest&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;: 테스트는 Todo가 없을 때 ServerException이 던져지길 기대했지만, 실제 saveComment()는 InvalidRequestException(&quot;Todo not found&quot;)를 던지고 있어서 테스트가 실패했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;: 테스트가 검증하는 예외 타입을 실제 동작에 맞게 수정했다.&lt;/p&gt;
&lt;h2 data-heading=&quot;[Spring] 검증 실패 메시지, 클라이언트에 어떻게 전달할 것인가&quot; data-ke-size=&quot;size26&quot;&gt;[Spring] 검증 실패 메시지, 클라이언트에 어떻게 전달할 것인가&lt;/h2&gt;
&lt;h3 data-heading=&quot;배경&quot; data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 2-3에서 @Valid/@Pattern 검증을 추가한 뒤, 검증에 실패하면 클라이언트에게 정확히 어떤 정보를 어떤 형태로 돌려줄지 고민이 생겼다. @Valid가 걸린 DTO는 필드가 여러 개라, 한 번에 여러 필드가 동시에 실패할 수도 있다.&lt;/p&gt;
&lt;h3 data-heading=&quot;시도 1: 예외 메시지를 그대로 사용&quot; data-ke-size=&quot;size23&quot;&gt;시도 1: 예외 메시지를 그대로 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MethodArgumentNotValidException.getMessage()를 그대로 쓰면 사람이 읽을 메시지가 아니라 스프링 내부 디버깅용 텍스트가 나온다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Validation failed for argument [0] in public ... : [Field error in object 'signupRequest' on field 'password': rejected value [A1]; codes [Pattern.signupRequest.password, ...]; ... default message [새 비밀번호는 8자 이상이며, 숫자와 대문자를 포함해야 합니다.]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Pattern(message = &quot;...&quot;)에 직접 적어둔 메시지는 이 안에 파묻혀 있어서, 그대로 응답에 쓰기엔 부적합하다.&lt;/p&gt;
&lt;h3 data-heading=&quot;시도 2: 필드 에러 하나만 꺼내기 (단수)&quot; data-ke-size=&quot;size23&quot;&gt;시도 2: 필드 에러 하나만 꺼내기 (단수)&lt;/h3&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;FieldError fieldError = ex.getBindingResult().getFieldError();
String message = fieldError != null ? fieldError.getDefaultMessage() : &quot;잘못된 요청입니다.&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필드 하나만 실패하면 문제없이 그 메시지가 나간다. 하지만 이메일 형식과 비밀번호 정책을 동시에 어겨서 두 필드가 같이 실패하는 요청으로 테스트해보니, 매번 같은 필드가 아니라 그때그때 다른 필드의 메시지가 나왔다.&lt;/p&gt;
&lt;h3 data-heading=&quot;검증: 왜 결과가 매번 다르게 나오는가&quot; data-ke-size=&quot;size23&quot;&gt;검증: 왜 결과가 매번 다르게 나오는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jakarta Bean Validation 표준의 Validator.validate()는 검증 결과를 List가 아니라 &lt;b&gt;Set&amp;lt;ConstraintViolation&amp;lt;T&amp;gt;&amp;gt;&lt;/b&gt;로 반환한다(Hibernate Validator 공식 문서의 예제들이 전부 이 타입을 쓴다).&lt;br /&gt;Set은 순서를 보장하지 않는 자료구조라서, 여러 필드가 동시에 실패했을 때 그중 뭐가 먼저 나올지는 애초에 정해진 규칙이 없다. 필드 선언 순서도 아니고 검증 애노테이션을 적은 순서도 아니다 &amp;mdash; &quot;그때그때 다르게 나온다&quot;는 관찰이 실제로 근거가 있는 현상이었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;시도 3: 필드별로 구조화한 배열&quot; data-ke-size=&quot;size23&quot;&gt;시도 3: 필드별로 구조화한 배열&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;List&amp;lt;Map&amp;lt;String, String&amp;gt;&amp;gt; errors = ex.getBindingResult().getFieldErrors().stream()
        .map(error -&amp;gt; Map.of(
                &quot;field&quot;, error.getField(),
                &quot;message&quot;, error.getDefaultMessage()
        ))
        .toList();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답을 &quot;errors&quot;: [{&quot;field&quot;: &quot;email&quot;, &quot;message&quot;: &quot;...&quot;}, {&quot;field&quot;: &quot;password&quot;, &quot;message&quot;: &quot;...&quot;}] 형태로 만든다. 문자열을 합치는 대신 필드-메시지 쌍의 배열로 응답하는 것이라, 이 응답을 소비하는 쪽(프론트엔드, 다른 서비스, 테스트 코드 등)이 특정 필드의 에러를 바로 꺼내 쓸 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서 검증 에러 응답에 이런 구조화된 형태를 더 흔히 쓰는데, 프론트가 입력창마다 에러를 표시하기 편하다는 것 외에도 API 문서화(정확한 스키마 명시)와 테스트 코드(errors.get(0).getField()처럼 값으로 직접 검증) 양쪽에서 이점이 있다.&lt;/p&gt;
&lt;h3 data-heading=&quot;결과&quot; data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 시도 2(단수) 방식으로 커밋했고, 이후 시도 3 방식도 별도로 작성해봤다. 지금 과제 범위에서는 어느 쪽이든 요구사항(비밀번호 정책 위반 시 적절한 메시지 반환)은 충족하지만, 여러 필드가 동시에 실패하는 경우까지 고려하면 시도 3이 더 안전하다.&lt;/p&gt;
&lt;h2 data-heading=&quot;[Spring] 예외 메시지를 ErrorCode enum으로 옮기며 깨달은 것&quot; data-ke-size=&quot;size26&quot;&gt;[Spring] 예외 메시지를 ErrorCode enum으로 옮기며 깨달은 것&lt;/h2&gt;
&lt;h3 data-heading=&quot;배경&quot; data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;InvalidRequestException/AuthException/ServerException 3개 클래스에 문자열 메시지를 그때그때 넘겨서 예외를 던지고 있었는데, 같은 의미의 에러(&quot;Todo not found&quot; 등)가 여러 파일에 중복 하드코딩돼 있는 걸 발견했다. ErrorCode enum과 CustomException 하나로 통합하는 리팩토링을 진행했다 &amp;mdash; 상태코드 구분 역할을 예외 클래스가 아니라 ErrorCode가 담당하도록 바꾼 것이다.&lt;/p&gt;
&lt;h3 data-heading=&quot;과정에서 겪은 것&quot; data-ke-size=&quot;size23&quot;&gt;과정에서 겪은 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;throw 지점을 하나씩 옮기는 과정에서 실수가 여러 번 났다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;getErrorResponse()에 오버로드를 추가하는 대신 기존 메서드를 교체해버려서, 그걸 쓰던 다른 핸들러들이 전부 컴파일 에러가 남&lt;/li&gt;
&lt;li&gt;캐치올 핸들러(Exception.class)를 수정하다가 메서드 자체가 통째로 사라짐 &amp;mdash; 트레이스 노출을 막으려고 처음에 만든 안전장치가 리팩토링 도중 없어져서 당황함&lt;/li&gt;
&lt;li&gt;new 키워드를 빠뜨려서 생성자 호출이 (존재하지 않는) 메서드 호출로 오인됨&lt;/li&gt;
&lt;li&gt;예외 3개를 하나로 합치는 과정에서, 원래 서로 다른 상태코드였던 두 곳(로그인 비밀번호 실패 401, 비밀번호 변경 실패 400)이 같은 ErrorCode로 합쳐지면서 상태코드가 의도치 않게 바뀜&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;깨달은 점&quot; data-ke-size=&quot;size23&quot;&gt;깨달은 점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 짜여진 로직에 나중에 enum 설계를 끼워 넣으려니 번거로웠다. 처음부터 enum으로 설계했다면 이런 시행착오는 없었을 것이다. 그래서 &quot;자주 재사용되는 메시지만 enum으로 만들고, 한 번만 쓰이는 메시지는 그냥 문자열로 두는 게 낫지 않았을까&quot;라는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 판단에는 반론도 있다 &amp;mdash; 예외를 던지는 방식이 enum 기반과 문자열 직접 던지기 두 갈래로 나뉘면, 새 예외를 추가할 때마다 &quot;이게 enum으로 만들 만큼 자주 쓰일까&quot;를 매번 판단해야 하고, 지금 한 번만 쓰이는 메시지도 나중에 재사용되면 결국 다시 지금과 같은 리팩토링을 반복해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;절충안으로, CustomException에 ErrorCode를 받는 생성자 말고 (HttpStatus, String message)를 즉석으로 받는 생성자를 하나 더 두는 방법도 있다. 예외를 던지는 방식(CustomException) 자체는 통일하면서, 정말 한 번만 쓰이는 메시지는 enum 상수로 안 만들고 그 자리에서 바로 넘길 수 있다.&lt;/p&gt;
&lt;h2 data-heading=&quot;[Spring] Interceptor와 AOP, 역할을 안 겹치게 나누기&quot; data-ke-size=&quot;size26&quot;&gt;[Spring] Interceptor와 AOP, 역할을 안 겹치게 나누기&lt;/h2&gt;
&lt;h3 data-heading=&quot;배경&quot; data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨5(선택) 요구사항은 어드민 전용 API(CommentAdminController.deleteComment(), UserAdminController.changeUserRole()) 접근 시 Interceptor로 권한을 확인하고, AOP로 요청/응답을 상세히 로깅하는 것이었다.&lt;/p&gt;
&lt;h3 data-heading=&quot;Interceptor - 처음 구현과 그 이후&quot; data-ke-size=&quot;size23&quot;&gt;Interceptor - 처음 구현과 그 이후&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요구사항대로 Interceptor에 &quot;어드민 아니면 막기 + 접근 로깅&quot;을 그대로 구현했는데, 코드를 보다가 JwtFilter가 이미 /admin으로 시작하는 요청에 대해 어드민 권한을 검사하고 있는 걸 발견했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;if (url.startsWith(&quot;/admin&quot;) &amp;amp;&amp;amp; !UserRole.ADMIN.equals(userRole)) {
    sendErrorResponse(httpResponse, HttpStatus.FORBIDDEN, &quot;접근 권한이 없습니다.&quot;);
    return;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 Interceptor의 권한 체크는 실제로는 도달할 일이 없는 중복 코드였다. 다만 JwtFilter를 다시 보니, &lt;b&gt;거부했을 때만&lt;/b&gt; 로그를 남기고 &lt;b&gt;통과시킬 때는 아무 기록도 안 남기고&lt;/b&gt; 있었다 .&lt;br /&gt;chain.doFilter()만 호출하고 끝난다. 그래서 Interceptor의 역할을 &quot;권한 재확인&quot;에서 &quot;어드민이 실제로 어떤 API를 건드렸는지 기록하는 감사 로그&quot;로 좁혔다. 권한 검사와 무관하게, 통과된 이후의 행동 자체를 기록에 남긴다는 점에서 여전히 의미가 있었다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Slf4j
@Component
public class AdminAccessInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        log.info(&quot;[관리자 접근] userId={}, URI={}, 시각={}&quot;,
                request.getAttribute(&quot;userId&quot;), request.getRequestURI(), LocalDateTime.now());
        return true;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-heading=&quot;AOP - 성능 측정으로 방향 전환&quot; data-ke-size=&quot;size23&quot;&gt;AOP - 성능 측정으로 방향 전환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨5는 &quot;정해진 정답은 없습니다, 자신만의 생각을 코드에 담아보세요&quot;라는 항목이라, AOP는 요구사항이 말한 &quot;어드민 API 요청/응답 바디 로깅&quot; 대신 레벨3에서 다룬 N+1 문제를 실제로 증명하는 데 쓰기로 했다. TodoService.getTodos()(N+1이 있었던 그 메서드)를 @Around로 감싸서 실행시간을 측정한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Around(&quot;execution(* org.example.expert.domain.todo.service.TodoService.getTodos(..))&quot;)
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
    long start = System.currentTimeMillis();
    Object result = joinPoint.proceed();
    long elapsed = System.currentTimeMillis() - start;
    log.info(&quot;[성능 측정] {} 실행시간: {}ms&quot;, joinPoint.getSignature().toShortString(), elapsed);
    return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@EntityGraph를 껐다 켰다 하면서 같은 요청을 두 번 호출해보면, N+1이 있을 때와 없을 때의 실행시간을 직접 비교할 수 있다.&lt;/p&gt;
&lt;h3 data-heading=&quot;결과&quot; data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 세 가지가 역할을 안 겹치게 나뉘었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JwtFilter: 권한 차단(거부만 기록)&lt;/li&gt;
&lt;li&gt;AdminAccessInterceptor: 통과된 어드민 접근의 감사 로그(누가/어떤 URI/언제)&lt;/li&gt;
&lt;li&gt;LoggingAspect: TodoService.getTodos()의 실행시간 측정(N+1 성능 비교용)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-heading=&quot;[회고] 테스트 코드와 Mockito에 대한 생각&quot; data-ke-size=&quot;size26&quot;&gt;[회고] 테스트 코드와 Mockito에 대한 생각&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨4에서 테스트 코드를 고치다 보니, 테스트 코드 자체에 대한 실무 관행도 같이 정리해본다.&lt;/p&gt;
&lt;h3 data-heading=&quot;테스트 코드가 실제로 주는 이점&quot; 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;b&gt;예상치 못한 부작용 발견&lt;/b&gt;: 어떤 비즈니스 로직을 수정했을 때, 그 변경이 전혀 다른 곳에 영향을 미치는 경우가 있다. 기존 테스트 코드가 있으면 이런 부작용이 있는지 실행해보는 즉시 알 수 있다 &amp;mdash; 사람이 코드를 읽으면서 &quot;이거 건드리면 저기도 영향 있겠다&quot;를 매번 다 예측하는 건 현실적으로 어렵다.&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;: 잘 짜인 테스트는 &quot;이 코드가 이런 입력에서 이렇게 동작해야 한다&quot;를 코드로 보여준다. 주석이나 문서는 시간이 지나면 실제 코드와 어긋나도 아무도 모르지만, 테스트는 실제 동작과 어긋나면 바로 실패하기 때문에 계속 최신 상태로 유지된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;버그를 더 일찍, 더 싸게 발견&lt;/b&gt;: 개발 중에 테스트로 잡히는 버그는 고치는 비용이 작지만, 같은 버그를 배포 후 사용자가 발견하면 훨씬 큰 비용(장애 대응, 신뢰 하락 등)이 든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;회사마다 다른 테스트 문화&quot; 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;/li&gt;
&lt;li&gt;어떤 로직을 수정했는데 테스트 코드가 통과 안 되면 PR을 올려도 리뷰조차 하지 않는 문화가 있다 &amp;mdash; 테스트도 통과 못 한 코드를 리뷰하는 건 시간 낭비이기 때문이다.&lt;/li&gt;
&lt;li&gt;기획자의 요구대로 로직을 수정했는데 기존 테스트 코드에 걸린다면, 그냥 테스트를 고치기 전에 기획자와 의논해야 한다. 예상치 못한 영향이 생긴 것일 수 있고, 이런 상황을 애초에 고려하지 않은 기획이었을 수도 있기 때문이다.&lt;/li&gt;
&lt;li&gt;실무에서는 PR을 올리면 GitHub Actions 같은 CI 설정으로 테스트가 자동 실행되고, 통과해야 머지되는 방식을 많이 쓴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-heading=&quot;Mockito를 지양하는 문화도 있다 - 근데 이유를 정확히 알아야 한다&quot; data-ke-size=&quot;size23&quot;&gt;Mockito를 지양하는 문화도 있다 - 근데 이유를 정확히 알아야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mockito 같은 목킹 프레임워크를 과하게 쓰는 걸 지양하는 문화가 실제로 있다고 한다. 처음엔 &quot;Mockito가 가짜 데이터를 지멋대로 넣어줘서 못 믿는다&quot;는 식으로 이해했는데, 이건 정확한 이해가 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Mockito 자체는 완전히 결정적(deterministic)이다&lt;/b&gt; &amp;mdash; when(...).thenReturn(...)으로 정해준 대로만 동작하지, 랜덤하게 움직이지 않는다. 실제 문제는 다른 데 있다.&lt;br /&gt;&lt;b&gt;목(mock)이 반환하는 값은 &quot;진짜 의존성이 이렇게 동작할 것이다&quot;라고 개발자가 가정해서 만든 가짜값&lt;/b&gt;인데, 그 가정이 처음부터 틀렸거나, 나중에 실제 코드가 바뀌었는데 목은 그대로 남아있으면, 테스트는 계속 통과하는데 실제 서비스는 안 되는 상황이 생긴다.&lt;br /&gt;즉 문제는 &quot;Mockito가 지멋대로&quot;가 아니라 &quot;목이 실제 동작과 점점 어긋나도 테스트만 보고는 아무도 눈치채지 못한다&quot;는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 논쟁은 테스트 커뮤니티에서 &quot;Mockist vs Classicist&quot;(또는 London school vs Chicago/Detroit school) 논쟁으로 불리고, Martin Fowler의 &quot;Mocks Aren't Stubs&quot; 글이 자주 인용된다. 목을 최소화하고 실제 의존성(또는 Testcontainers 같은 걸로 띄운 진짜 DB)을 쓰는 걸 선호하는 진영과, 단위를 확실히 격리하기 위해 목을 적극적으로 쓰는 진영이 나뉘어 있다.&lt;/p&gt;</description>
      <category>TIL</category>
      <author>디버거러너</author>
      <guid isPermaLink="true">https://zeroto-dev.tistory.com/3</guid>
      <comments>https://zeroto-dev.tistory.com/3#entry3comment</comments>
      <pubDate>Mon, 27 Jul 2026 14:46:09 +0900</pubDate>
    </item>
  </channel>
</rss>