<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Coding-Su</title>
    <link>https://coding-su.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 25 Aug 2026 11:54:35 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Coding-Su</managingEditor>
    <item>
      <title>[Design Doc] Transactional Outbox Pattern &amp;mdash; Kafka 직접 발행의 한계와 설계 (7주차 &amp;middot; 6팀 &amp;middot; 조수현)</title>
      <link>https://coding-su.tistory.com/206</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;TL;DR&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka에 직접 발행하면 트랜잭션 커밋과 Kafka 발행 사이의 원자성을 보장할 수 없다. Outbox 테이블을 경유하는 방식으로 At Least Once 발행을 보장했고, partition-offset 기반 멱등성 키의 한계를 발견해 outboxId 기반으로 진화시켰다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Introduction &amp;amp; Goals&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Context / Background&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요, 조회, 주문 이벤트를 다른 시스템(commerce-streamer)으로 전파해야 했다. 처음엔 서비스 로직 안에서 직접 &lt;code&gt;kafkaTemplate.send()&lt;/code&gt;를 호출하는 방식을 고려했는데, 트랜잭션 커밋 직후 Kafka 발행 직전에 애플리케이션이 죽으면 이벤트가 유실된다는 문제가 있었다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// 문제: 커밋 후 send 전에 죽으면 이벤트 유실
@Transactional
public void like(Long memberId, Long productId) {
    likeRepository.save(new LikeModel(memberId, productId));
    // &amp;larr; 여기서 죽으면?
    kafkaTemplate.send(&quot;catalog-events&quot;, event); // 발행 안 됨
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Goals&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이벤트 유실 없이 At Least Once 발행 보장&lt;/li&gt;
&lt;li&gt;Kafka 장애가 핵심 비즈니스 로직(좋아요 등록)에 영향을 주지 않아야 함&lt;/li&gt;
&lt;li&gt;중복 발행이 발생해도 Consumer가 멱등하게 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Detailed Design&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;System Architecture&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;[commerce-api]
핵심 로직 실행 (좋아요 저장)
  └─ 같은 트랜잭션에서 outbox 테이블에 PENDING으로 저장

OutboxRelayScheduler (1초마다)
  └─ PENDING 최대 100개 조회
  └─ payload에 outboxId 추가 후 Kafka 발행
  └─ 성공 &amp;rarr; PUBLISHED, 실패 &amp;rarr; PENDING 유지 (자동 재시도)

[commerce-streamer]
CatalogEventsConsumer / OrderEventsConsumer
  └─ outboxId로 event_handled 체크 (중복 skip)
  └─ eventType switch &amp;rarr; metrics upsert
  └─ manual Ack&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Data Models&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;outbox 테이블&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컬럼&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;id&lt;/td&gt;
&lt;td&gt;PK (outboxId로 활용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;topic&lt;/td&gt;
&lt;td&gt;발행 대상 토픽&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;partition_key&lt;/td&gt;
&lt;td&gt;Kafka 파티션 키&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;event_type&lt;/td&gt;
&lt;td&gt;이벤트 종류 식별자&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;payload&lt;/td&gt;
&lt;td&gt;JSON 직렬화된 이벤트&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;status&lt;/td&gt;
&lt;td&gt;PENDING / PUBLISHED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;created_at / published_at&lt;/td&gt;
&lt;td&gt;발행 시각 추적&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;event_handled 테이블&lt;/b&gt;&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컬럼&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;event_id (PK)&lt;/td&gt;
&lt;td&gt;outboxId &amp;mdash; 중복 처리 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;handled_at&lt;/td&gt;
&lt;td&gt;처리 시각&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Alternatives Considered&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멱등성 키 전략&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;partition-offset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Kafka 메타데이터 기반, payload 파싱 불필요&lt;/td&gt;
&lt;td&gt;Outbox 재발행 시 다른 offset으로 도착 &amp;rarr; event_handled 미스 &amp;rarr; 중복 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;&lt;code&gt;outboxId&lt;/code&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;같은 비즈니스 이벤트는 항상 동일한 ID &amp;rarr; 재발행 횟수에 무관하게 차단&lt;/td&gt;
&lt;td&gt;Producer/Consumer 간 payload 구조 의존성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선택 근거:&lt;/b&gt; Outbox 크래시 시나리오를 분석했더니 partition-offset 방식의 허점이 있었다.&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;code&gt;partition-offset&lt;/code&gt;을 멱등성 키로 썼다. Kafka 메시지는 파티션 번호와 offset이라는 고유 위치를 가지는데, 이걸 조합해 &lt;code&gt;event_handled&lt;/code&gt;에 저장하면 같은 메시지가 두 번 와도 차단할 수 있다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &quot;같은 메시지가 다른 offset으로 올 수 있다&quot;는 점이었다. 아래 시나리오에서 발생한다:&lt;/p&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;1. OutboxRelay &amp;rarr; Kafka 발행 성공 &amp;rarr; ack 수신
2. outbox 레코드를 PUBLISHED로 업데이트 시도
3. DB 커밋 전 장애 발생  
4. 재시작 &amp;rarr; outbox 레코드가 여전히 PENDING
5. &quot;아직 안 보낸 것&quot;으로 인식 &amp;rarr; 재발행
6. 새 offset으로 Consumer에 도착
7. event_handled에 이전 offset은 있지만 새 offset은 없음 &amp;rarr; 중복 처리&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;Kafka가 이미 메시지를 받았는데, Outbox DB는 모르는 상황이 만들어진 것이다. &lt;code&gt;enable.idempotence=true&lt;/code&gt;도 이 경우엔 막을 수 없다 &amp;mdash; 재시작하면 새 Producer 인스턴스가 생기고, 새 시퀀스 번호를 써서 Kafka가 새 메시지로 인식한다.&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;outboxId를 사용하면 재발행 횟수나 offset에 관계없이 같은 비즈니스 이벤트는 항상 동일한 ID를 가지므로 &lt;code&gt;event_handled&lt;/code&gt;에서 차단된다. outboxId는 닭-달걀 문제(payload 생성 시 ID가 없음)가 있어, RelayScheduler가 Kafka 발행 직전에 이미 저장된 outbox의 ID를 payload에 주입하는 방식으로 해결했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Cross-cutting Concerns&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Observability&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PUBLISHED 전환 실패 시 &lt;code&gt;[OutboxRelay] 발행 실패: outboxId={}&lt;/code&gt; 로그로 추적 가능&lt;/li&gt;
&lt;li&gt;PENDING 레코드가 누적되면 Kafka 장애 또는 직렬화 오류 신호&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Known Limitations&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DLQ 미구현: 기술적 실패(DB 오류 등)가 반복되면 같은 메시지가 무한 재시도되어 파티션 블로킹 가능&lt;/li&gt;
&lt;li&gt;발행 재시도 횟수 제한 없음: &lt;code&gt;retry_count&lt;/code&gt; + Exponential Backoff 미적용 (Nice-to-Have)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>tech-note</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/206</guid>
      <comments>https://coding-su.tistory.com/206#entry206comment</comments>
      <pubDate>Fri, 3 Jul 2026 17:06:57 +0900</pubDate>
    </item>
    <item>
      <title>[Design Doc] PG 외부 연동 장애 대응 설계 (6주차 &amp;middot; 6팀 &amp;middot; 조수현)</title>
      <link>https://coding-su.tistory.com/205</link>
      <description>&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;FeignClient로 PG를 호출하는 결제 시스템에서 Timeout + Retry + CircuitBreaker 조합을 적용해 PG 장애가 전체 서비스로 번지는 Cascading Failure를 차단했다. 타임아웃/CB Open 시 PENDING 유지 + 30초 배치 복구로 상태 정합성을 보장한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Introduction &amp;amp; Goals&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Context / Background&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;결제 요청 시 우리 서버가 PG(Payment Gateway)를 FeignClient로 동기 호출한다. PG는 외부 시스템이므로 응답 지연이나 장애를 제어할 수 없다. Tomcat 스레드 풀(기본 200개)은 PG 응답을 기다리는 동안 블로킹 상태로 묶이며, 스레드가 고갈되면 결제와 무관한 상품 조회나 주문 생성까지 503을 반환하게 된다. 이것이 Cascading Failure다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Goals&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PG 장애 시 결제 외 기능(상품 조회, 주문 등)은 정상 동작한다&lt;/li&gt;
&lt;li&gt;타임아웃/CircuitBreaker Open 시 Payment는 즉시 FAILED가 아닌 PENDING을 유지하고, 30초 배치가 PG 상태를 확인해 최종 상태로 수렴한다&lt;/li&gt;
&lt;li&gt;PG 500 에러는 Retry 3회 소진 후 즉시 FAILED 처리하고 주문을 취소한다&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Detailed Design&lt;/h2&gt;
&lt;h3&gt;System Architecture&lt;/h3&gt;
&lt;p&gt;PG 호출 흐름에 Resilience4j를 통해 3단 방어선을 적용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Client
  └─ PaymentController
       └─ PaymentFacade  (@Transactional 없음 — TX 분리 핵심)
            ├─ [TX 1] PaymentService.create()  → Payment(PENDING) 저장 &amp;amp; DB 커넥션 반납
            └─ [TX 외부] PgClient.requestPayment()
                  └─ CircuitBreaker
                       └─ Retry (500 에러만, 최대 3회)
                            └─ TimeLimiter (readTimeout 400ms)
                                 └─ FeignClient → PG&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;트랜잭션 분리가 핵심이다.&lt;/strong&gt; &lt;code&gt;PaymentFacade&lt;/code&gt;에 &lt;code&gt;@Transactional&lt;/code&gt;을 두지 않아 Payment 저장(TX 1) 커밋 직후 DB 커넥션을 반납한다. 이후 PG 호출이 느려지거나 CB가 열려도 DB 커넥션 풀에 영향을 주지 않는다.&lt;/p&gt;
&lt;h3&gt;장애 유형별 처리 흐름&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;장애 유형&lt;/th&gt;
&lt;th&gt;처리 방식&lt;/th&gt;
&lt;th&gt;최종 상태&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;PG 500 에러&lt;/td&gt;
&lt;td&gt;Retry 3회 → 소진 시 즉시 FAILED + 주문 취소&lt;/td&gt;
&lt;td&gt;FAILED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeout (400ms 초과)&lt;/td&gt;
&lt;td&gt;Retry 없음, PENDING 유지 → 배치 복구&lt;/td&gt;
&lt;td&gt;PENDING → 배치가 수렴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CircuitBreaker Open&lt;/td&gt;
&lt;td&gt;PG 호출 차단, PENDING 유지 → 배치 복구&lt;/td&gt;
&lt;td&gt;PENDING → 배치가 수렴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PG 정상 응답&lt;/td&gt;
&lt;td&gt;transactionKey 저장, PENDING 유지&lt;/td&gt;
&lt;td&gt;PENDING → 콜백으로 수렴&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;500은 Retry하고 Timeout은 Retry하지 않는 이유:&lt;/strong&gt; PG 500은 &lt;code&gt;createTransaction&lt;/code&gt; 이전 실패이므로 PG에 기록이 없다. 중복 결제 위험 없이 재시도 가능하다. 반면 Timeout은 PG가 처리 중일 수 있어 재시도하면 이중 결제가 발생한다.&lt;/p&gt;
&lt;h3&gt;Resilience4j 설정&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;feign:
  client:
    config:
      pg-client:
        connectTimeout: 1000
        readTimeout: 400   # PG 최대 지연 500ms 기준 — 타임아웃 시나리오 재현 의도

resilience4j:
  circuitbreaker:
    instances:
      pgCircuit:
        sliding-window-size: 10
        failure-rate-threshold: 50
        slow-call-duration-threshold: 2s
        slow-call-rate-threshold: 50
        wait-duration-in-open-state: 10s
        permitted-number-of-calls-in-half-open-state: 2
  retry:
    instances:
      pgRetry:
        max-attempts: 3
        retry-exceptions:
          - feign.RetryableException       # 503, 네트워크 오류
          - java.net.SocketTimeoutException
        ignore-exceptions:
          - SocketTimeoutException         # Timeout은 재시도 안 함&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;slow-call&lt;/code&gt; 설정이 중요하다. PG가 죽지는 않았지만 모든 요청이 3~4초씩 걸리는 상황에서 실패율은 0%이지만 스레드는 이미 고갈되고 있다. 이 설정 없이는 CB가 열리지 않는다.&lt;/p&gt;
&lt;h3&gt;PENDING 복구 배치&lt;/h3&gt;
&lt;p&gt;콜백이 메인 경로, 배치는 안전망이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@Scheduled(fixedDelay = 30000)
대상: status = PENDING AND created_at &amp;lt; now() - 30s

PG 조회 결과:
  SUCCESS + 주문 PENDING   → Payment SUCCESS + Order CONFIRMED
  SUCCESS + 주문 CANCELLED → 로그 기록 + 수동 처리 대상 표시
  FAILED                   → Payment FAILED + cancelBySystem + 재고 복구
  기록 없음                → Payment FAILED + cancelBySystem + 재고 복구
  PENDING                  → 건드리지 않음, 다음 사이클에 재시도&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;트레이드오프: split-commit&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;recoverAsFailure()&lt;/code&gt;에서 &lt;code&gt;@Transactional&lt;/code&gt;을 제거해 &lt;code&gt;failByOrderId()&lt;/code&gt;가 먼저 독립 커밋된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;왜 하나의 트랜잭션으로 묶으면 안 되는가?&lt;/strong&gt; &lt;code&gt;cancelBySystem()&lt;/code&gt; 예외 시 &lt;code&gt;failByOrderId()&lt;/code&gt;도 함께 롤백돼 결제가 PENDING으로 남는다. 배치가 30초 후 같은 건을 다시 처리하고 또 같은 예외가 발생하는 무한 재시도 루프가 된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;허용하는 불일치:&lt;/strong&gt; &lt;code&gt;failByOrderId()&lt;/code&gt; 커밋 후 &lt;code&gt;cancelBySystem()&lt;/code&gt;이 실패하면 결제는 FAILED지만 주문이 CANCELLED 되지 않은 상태가 일시적으로 발생할 수 있다. 결제가 FAILED로 확정되면 배치 재시도는 없으므로 운영팀 수동 보정 또는 향후 Outbox Pattern으로 해결한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Alternatives Considered&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;A. Timeout만 적용&lt;/td&gt;
&lt;td&gt;구현 단순&lt;/td&gt;
&lt;td&gt;PG 지속 장애 시 매 요청마다 Timeout까지 스레드 점유, Cascading Failure 완전 차단 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B. Timeout + Retry만 적용&lt;/td&gt;
&lt;td&gt;A보다 회복력 향상&lt;/td&gt;
&lt;td&gt;반복 실패 시 Retry Storm 위험, CB 없으면 이미 죽은 PG에 계속 호출&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C. Timeout + Retry + CircuitBreaker 조합&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;장애 확산 차단 + 일시 장애 회복 + 빠른 Fallback&lt;/td&gt;
&lt;td&gt;설정값 튜닝 필요, 구현 복잡도 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;선택 근거:&lt;/strong&gt; C안. CB가 열리면 스레드가 즉시 반납되고 PG에 트래픽이 끊겨 회복 시간을 준다. PENDING + 배치 조합으로 Timeout/CB Open 케이스의 정합성을 보장한다. Retry는 일시적 500 에러에만 적용해 Retry Storm을 방지한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Cross-cutting Concerns&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Observability&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;K6로 30 TPS 부하를 주면서 PG 지연 시나리오를 돌리면 CB 동작을 p99로 관찰할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[CB Closed — PG 지연 발생]
  http_req_duration p99 → 급등 (Timeout 400ms 대기)
  http_req_failed rate  → 증가

[CB Open 전환 시점]
  p99 → 갑자기 낮아짐  ← 핵심 관찰 포인트
  fallback 비율        → 증가

[CB Open 구간]
  p99 → 회복 (Fallback은 즉시 반환)
  PG 트래픽 → 0&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;CB가 없다면 p99가 내내 높은 상태를 유지한다. &amp;quot;PG 장애 → 일정 시간 후 p99 급락&amp;quot; 패턴이 보이면 CB가 제대로 동작한다는 증거다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scalability&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;PENDING 건수가 많아지면 배치 실행 시간이 길어질 수 있다. 건수 제한(페이징) 처리로 대응 가능하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Known Limitations&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;handleCallback()&lt;/code&gt; FAILED 경로에서 &lt;code&gt;getById()&lt;/code&gt;(잠금 없음) → &lt;code&gt;cancelBySystem()&lt;/code&gt;(FOR UPDATE) 사이에 사용자 수동 취소가 끼어드는 TOCTOU 레이스 컨디션이 존재한다. 30초 배치가 PENDING을 재확인해 최종 상태로 수렴되므로 허용한다.&lt;/li&gt;
&lt;li&gt;PG SUCCESS + 주문 CANCELLED 케이스는 pg-simulator에 취소 API가 없어 자동화 불가다. 실무에서는 PG 취소 API 호출로 자동화 가능하다.&lt;/li&gt;
&lt;li&gt;split-commit의 완전한 해결은 Outbox Pattern 도입이 필요하다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>tech-note</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/205</guid>
      <comments>https://coding-su.tistory.com/205#entry205comment</comments>
      <pubDate>Fri, 26 Jun 2026 17:10:50 +0900</pubDate>
    </item>
    <item>
      <title>[Benchmark] MySQL 인덱스 전략 비교 &amp;mdash; 좋아요 순 상품 목록 최적화 (5주차 &amp;middot; 6팀 &amp;middot; 조수현)</title>
      <link>https://coding-su.tistory.com/204</link>
      <description>&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;상품 목록 API의 좋아요 순 정렬 성능을 확보하기 위해 13개 시나리오에서 인덱스 전략을 실측 비교했다.&lt;br&gt;&lt;code&gt;brand_id&lt;/code&gt; 단일 인덱스만으로 풀스캔 대비 &lt;strong&gt;100ms → 0.1ms(약 1,000배)&lt;/strong&gt; 를 달성했으며,&lt;br&gt;읽기 성능이 사실상 동일한 반정규화 vs 분리 테이블 중&lt;br&gt;&lt;strong&gt;동시 쓰기의 락 경합 격리&lt;/strong&gt;를 이유로 &lt;code&gt;product_like_view&lt;/code&gt; 분리 테이블을 선택했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Question — 무엇을 비교했는가&lt;/h2&gt;
&lt;h3&gt;결정해야 했던 것&lt;/h3&gt;
&lt;p&gt;브랜드별 상품 목록을 &lt;strong&gt;좋아요 순으로 정렬&lt;/strong&gt;하는 API에서 두 가지 질문을 동시에 풀어야 했다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;인덱스 전략&lt;/strong&gt;: 인덱스 없음 / 단일(&lt;code&gt;brand_id&lt;/code&gt;) / 복합(&lt;code&gt;brand_id, like_count DESC&lt;/code&gt;) / 커버링 중 어느 수준까지 필요한가?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;like_count 저장 구조&lt;/strong&gt;: &lt;code&gt;products&lt;/code&gt; 테이블에 컬럼으로 두는 &lt;strong&gt;반정규화&lt;/strong&gt; vs 별도 &lt;code&gt;product_like_view&lt;/code&gt; 테이블로 분리하는 &lt;strong&gt;분리 테이블&lt;/strong&gt; 중 무엇을 선택할 것인가?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;두 결정은 독립적이지 않다. like_count 저장 위치가 달라지면 인덱스 설계도 달라지고, 쓰기 경로의 락 경합 구조도 달라진다.&lt;/p&gt;
&lt;h3&gt;비교 대상&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;축&lt;/th&gt;
&lt;th&gt;후보&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;인덱스 전략&lt;/td&gt;
&lt;td&gt;없음 → &lt;code&gt;(brand_id)&lt;/code&gt; → &lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt; → 커버링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;like_count 구조&lt;/td&gt;
&lt;td&gt;반정규화 &lt;code&gt;products.like_count&lt;/code&gt; vs 분리 테이블 &lt;code&gt;product_like_view&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;정렬 조건&lt;/td&gt;
&lt;td&gt;좋아요 순 / 최신순 / 가격순 / 복합 필터(브랜드 + 가격 범위 + 좋아요)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;연관 쿼리&lt;/td&gt;
&lt;td&gt;좋아요 목록, 구매 목록, 재고 JOIN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;Setup — 측정 환경&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;DB&lt;/td&gt;
&lt;td&gt;MySQL 9.7 (로컬, macOS arm64)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;products&lt;/td&gt;
&lt;td&gt;996,327건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;likes&lt;/td&gt;
&lt;td&gt;2,490,470건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;brands&lt;/td&gt;
&lt;td&gt;7,500개 (brand당 평균 133건)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;like_count 분포&lt;/td&gt;
&lt;td&gt;0개(164K), 1~10개(834K), 11~100개(82건), 100초과(1K건)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;측정 도구&lt;/td&gt;
&lt;td&gt;자체 SQL 스크립트 — &lt;code&gt;EXPLAIN FORMAT=TRADITIONAL&lt;/code&gt; + &lt;code&gt;NOW(6)&lt;/code&gt; 기반 마이크로초 측정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;공통 조건&lt;/td&gt;
&lt;td&gt;&lt;code&gt;brand_id = 1&lt;/code&gt;, &lt;code&gt;LIMIT 20&lt;/code&gt;, OFFSET 0(1페이지) vs OFFSET 500,000(25,000페이지)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;데이터는 seed 스크립트로 직접 생성했다. 브랜드 카디널리티(7,500개)와 brand당 상품 수(평균 133건)를 의도적으로 설계해서, 인덱스가 실제로 필터를 얼마나 줄여주는지 검증할 수 있도록 했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Results — 측정 결과&lt;/h2&gt;
&lt;h3&gt;Round 1~4: 브랜드별 좋아요 순 (핵심 시나리오)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;라운드&lt;/th&gt;
&lt;th&gt;인덱스 전략&lt;/th&gt;
&lt;th&gt;구조&lt;/th&gt;
&lt;th&gt;OFFSET&lt;/th&gt;
&lt;th&gt;Extra&lt;/th&gt;
&lt;th&gt;실행시간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;반정규화&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;td&gt;103ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;반정규화&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;td&gt;102ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;(brand_id)&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;반정규화&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Using where; Using filesort&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.10ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;(brand_id)&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;반정규화&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Using where; Using filesort&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.09ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;반정규화&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Using where&lt;/strong&gt; (filesort 제거)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.09ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;반정규화&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Using where&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.08ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;커버링 &lt;code&gt;(brand_id, like_count DESC, id, name, price)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;반정규화&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;Using where&lt;/td&gt;
&lt;td&gt;0.12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;커버링&lt;/td&gt;
&lt;td&gt;반정규화&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;Using where&lt;/td&gt;
&lt;td&gt;0.11ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;첫 번째 발견&lt;/strong&gt;: 인덱스 없음 → &lt;code&gt;(brand_id)&lt;/code&gt; 단일 인덱스 한 줄로 &lt;strong&gt;1,000배&lt;/strong&gt; 개선된다.&lt;br&gt;이유는 카디널리티에 있다. &lt;code&gt;brand_id = 1&lt;/code&gt; 조건으로 996K 행이 즉시 133건으로 줄어든다. 이후 filesort는 133건만 대상으로 하므로 메모리 정렬 마이크로초 수준이 된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;두 번째 발견&lt;/strong&gt;: 복합 인덱스 &lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt; 는 filesort 자체를 제거하지만, 실행시간은 0.09ms로 단일 인덱스(0.10ms)와 차이가 거의 없다.&lt;br&gt;이유도 동일하다 — 어차피 133건을 정렬하는 것은 인덱스 순서를 타든 filesort를 하든 체감 차이가 없는 수준이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;세 번째 발견&lt;/strong&gt;: 커버링 인덱스는 기대와 달리 복합 인덱스보다 오히려 미세하게 느렸다(0.12ms).&lt;br&gt;&lt;code&gt;deleted_at IS NULL&lt;/code&gt; 조건이 인덱스에 없어서 테이블 접근이 필요하고, 인덱스 크기 자체가 커져서 B-tree 탐색 비용이 소폭 증가한 것으로 판단된다. &lt;code&gt;deleted_at&lt;/code&gt;까지 포함하면 커버링 조건이 깨지고, 포함시키면 인덱스 크기 문제가 더 심해진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Round 5: 분리 테이블 &lt;code&gt;product_like_view&lt;/code&gt; JOIN&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;인덱스&lt;/th&gt;
&lt;th&gt;OFFSET&lt;/th&gt;
&lt;th&gt;실행시간&lt;/th&gt;
&lt;th&gt;Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;111ms&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(brand_id)&lt;/code&gt; 단일&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.12ms&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt; 복합&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.12ms&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커버링&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.15ms&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;분리 테이블은 JOIN으로 인해 &lt;strong&gt;인덱스 종류와 무관하게 항상 Using filesort&lt;/strong&gt; 가 발생한다.&lt;br&gt;하지만 brand_id 필터 후 133건만 대상으로 하므로 실행시간은 0.12~0.15ms로 반정규화와 실질적으로 동일하다.&lt;br&gt;분리 테이블 구조에서 추가 인덱스를 더 걸어도 성능 개선이 없다는 의미이기도 하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Round 6: 정렬 조건별 비교&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;정렬&lt;/th&gt;
&lt;th&gt;인덱스 없음&lt;/th&gt;
&lt;th&gt;복합 인덱스&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;최신순 &lt;code&gt;created_at DESC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;123ms&lt;/td&gt;
&lt;td&gt;0.10ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;가격순 &lt;code&gt;price ASC&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;108ms&lt;/td&gt;
&lt;td&gt;0.10ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;정렬 컬럼이 바뀌어도 패턴은 동일하다. 각 정렬마다 &lt;code&gt;(brand_id, 정렬컬럼 방향)&lt;/code&gt; 복합 인덱스가 필요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Round 9: 전체 좋아요 순 — OFFSET의 함정&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;인덱스&lt;/th&gt;
&lt;th&gt;OFFSET&lt;/th&gt;
&lt;th&gt;실행시간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;128ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;242ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(like_count DESC)&lt;/code&gt; 단일&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.13ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(like_count DESC)&lt;/code&gt; 단일&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;253ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커버링&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.25ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;커버링&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;264ms (풀스캔으로 전환)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;OFFSET 500,000에서 인덱스 스캔이 253ms로 올라가며 풀스캔(242ms)과 비슷해졌다.&lt;br&gt;옵티마이저가 인덱스를 통해 500,020번째 행을 읽는 비용이 풀스캔보다 비싸다고 판단해 인덱스를 포기했다.&lt;br&gt;OFFSET 기반 페이지네이션의 구조적 한계이며, 실서비스에서는 커서 기반 페이지네이션이 필요함을 시사한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Round 10: 복합 필터 (브랜드 + 가격 범위 + 좋아요 순)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;인덱스&lt;/th&gt;
&lt;th&gt;Extra&lt;/th&gt;
&lt;th&gt;실행시간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;td&gt;103ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(brand_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;td&gt;0.12ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Using where&lt;/strong&gt; (filesort 제거)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.10ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(brand_id, price ASC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Using index condition; Using filesort&lt;/td&gt;
&lt;td&gt;0.10ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;price BETWEEN&lt;/code&gt; 같은 range 조건이 있어도 &lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt; 인덱스는 filesort를 제거한다.&lt;br&gt;MySQL이 &lt;code&gt;brand_id = 1&lt;/code&gt; 구간을 &lt;code&gt;like_count&lt;/code&gt; 내림차순으로 읽으면서 &lt;code&gt;price&lt;/code&gt; 조건을 행별로 필터링하기 때문이다.&lt;br&gt;반면 &lt;code&gt;(brand_id, price ASC)&lt;/code&gt; 인덱스는 range 이후 정렬 컬럼이 달라 filesort가 불가피하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Round 11: stocks JOIN — hash join의 함정&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;인덱스&lt;/th&gt;
&lt;th&gt;type(p)&lt;/th&gt;
&lt;th&gt;type(s)&lt;/th&gt;
&lt;th&gt;Extra&lt;/th&gt;
&lt;th&gt;실행시간&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;eq_ref&lt;/td&gt;
&lt;td&gt;ALL&lt;/td&gt;
&lt;td&gt;Using filesort&lt;/td&gt;
&lt;td&gt;541ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;products: &lt;code&gt;(brand_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ref&lt;/td&gt;
&lt;td&gt;ALL&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;hash join&lt;/strong&gt; + Using filesort&lt;/td&gt;
&lt;td&gt;0.16ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;products: &lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ref&lt;/td&gt;
&lt;td&gt;ALL&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;hash join&lt;/strong&gt; + Using filesort&lt;/td&gt;
&lt;td&gt;0.15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ stocks: &lt;code&gt;(product_id, quantity)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ref&lt;/td&gt;
&lt;td&gt;ref&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;nested loop&lt;/strong&gt; + &lt;strong&gt;filesort 제거&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.16ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code&gt;(brand_id, like_count DESC)&lt;/code&gt; 인덱스 단독으로는 filesort가 제거되지 않았다.&lt;br&gt;hash join이 행 처리 순서를 파괴하므로 인덱스 정렬 순서를 보장할 수 없기 때문이다.&lt;br&gt;&lt;code&gt;stocks&lt;/code&gt;에 &lt;code&gt;(product_id, quantity)&lt;/code&gt; 인덱스를 추가하면 hash join → nested loop join으로 전환되어 비로소 filesort가 제거된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;반정규화 vs 분리 테이블 — 읽기/쓰기 종합&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;기준&lt;/th&gt;
&lt;th&gt;반정규화&lt;/th&gt;
&lt;th&gt;분리 테이블&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;브랜드별 좋아요 순 읽기&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.08ms&lt;/strong&gt; (filesort 제거)&lt;/td&gt;
&lt;td&gt;0.12ms (filesort 있음)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실질적 차이&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.04ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;좋아요 단건 쓰기&lt;/td&gt;
&lt;td&gt;~1.5ms&lt;/td&gt;
&lt;td&gt;~1.0ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동시 10명&lt;/td&gt;
&lt;td&gt;~2ms&lt;/td&gt;
&lt;td&gt;~1ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동시 100명&lt;/td&gt;
&lt;td&gt;10~30ms (스파이크)&lt;/td&gt;
&lt;td&gt;~2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동시 1,000명&lt;/td&gt;
&lt;td&gt;100ms+&lt;/td&gt;
&lt;td&gt;5~10ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;동시 쓰기 성능 차이의 원인은 &lt;strong&gt;락 경합 범위&lt;/strong&gt;다.&lt;br&gt;&lt;code&gt;products&lt;/code&gt;는 상품 목록/상세 등 모든 API가 읽는 핫 테이블이다.&lt;br&gt;반정규화 구조에서는 좋아요 등록/취소마다 &lt;code&gt;products.like_count&lt;/code&gt; UPDATE가 일어나고, 동시 사용자가 늘수록 조회 API와의 락 경합이 심화된다.&lt;br&gt;&lt;code&gt;product_like_view&lt;/code&gt;는 좋아요 연산만 접근하는 테이블이므로 락 범위가 격리된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Decision&lt;/h2&gt;
&lt;h3&gt;선택&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;product_like_view&lt;/code&gt; 분리 테이블 + &lt;code&gt;products(brand_id)&lt;/code&gt; 단일 인덱스&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;선택 이유&lt;/h3&gt;
&lt;p&gt;읽기 성능 차이 0.04ms는 실서비스에서 의미 없는 수준이다.&lt;br&gt;brand_id 인덱스가 먼저 ~133건으로 행 수를 줄이면, 이후 정렬은 메모리 내 마이크로초 연산이기 때문에 구조 차이가 희석된다.&lt;/p&gt;
&lt;p&gt;반면 쓰기 경로에서의 차이는 실질적이다.&lt;br&gt;좋아요는 사용자가 실시간으로 발생시키는 이벤트다. 동시 요청이 늘어날수록 &lt;code&gt;products&lt;/code&gt; 테이블의 락 경합이 상품 목록/상세 조회까지 영향을 미친다.&lt;br&gt;분리 테이블은 이 문제를 구조적으로 차단한다.&lt;/p&gt;
&lt;h3&gt;포기한 것&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;반정규화 + 복합 인덱스의 filesort 제거&lt;/strong&gt;: 0.04ms 이득. 읽기 최고 성능을 포기했다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;단순한 스키마&lt;/strong&gt;: 테이블이 하나 늘고, 좋아요 등록/취소 시 &lt;code&gt;product_like_view&lt;/code&gt; 동기화 로직을 애플리케이션에서 관리해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;최종 인덱스 목록&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;테이블&lt;/th&gt;
&lt;th&gt;인덱스&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;products&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(brand_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;브랜드별 상품 필터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;products&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(brand_id, created_at DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;최신순 정렬&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;products&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(brand_id, price ASC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;가격순 정렬&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;product_like_view&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(like_count DESC, product_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;전체 좋아요 순 커버링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;likes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(member_id, created_at DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;좋아요 목록 시간순&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;orders&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(member_id, created_at DESC)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;구매 목록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;order_items&lt;/td&gt;
&lt;td&gt;&lt;code&gt;(order_id, product_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;주문 상세 JOIN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;Future Work&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;커서 기반 페이지네이션&lt;/strong&gt;: OFFSET 500K 시나리오에서 인덱스 스캔도 253ms로 올라가는 것을 확인했다. 실서비스에서는 &lt;code&gt;WHERE like_count &amp;lt; :cursor&lt;/code&gt; 형태의 커서 방식이 필요하다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;product_like_view 정합성&lt;/strong&gt;: 좋아요 등록/취소 트랜잭션 실패 시 &lt;code&gt;like_count&lt;/code&gt; 불일치 가능성이 있다. 정렬 참고용 수치이므로 소폭 오차는 허용하지만, 모니터링 기준값 설정이 필요하다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;재평가 시점&lt;/strong&gt;: 브랜드당 평균 상품 수가 현재 133건을 크게 넘어서거나, 좋아요 동시 요청이 수백 RPS 이상으로 증가하면 반정규화 재검토 여지가 있다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>teck-note</category>
      <category>복합인덱스</category>
      <category>인덱스</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/204</guid>
      <comments>https://coding-su.tistory.com/204#entry204comment</comments>
      <pubDate>Mon, 22 Jun 2026 17:22:27 +0900</pubDate>
    </item>
    <item>
      <title>[Design Doc] 쿠폰 테이블 설계 (6팀, 4주차, 조수현)</title>
      <link>https://coding-su.tistory.com/203</link>
      <description>&lt;h3&gt;TL;DR&lt;/h3&gt;
&lt;p&gt;CouponTemplate과 UserCoupon을 분리하고, isActive(신규 발급 차단)와 isBlocked(기존 쿠폰 전량 차단)를 별도 책임으로 설계했다. CouponStatus는 DB에 저장하지 않고 조회 시점에 계산한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Introduction &amp;amp; Goals&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Context&lt;/strong&gt;: 쿠폰 정책(할인율, 유효기간)과 발급본(소유자, 사용 여부)을 하나의 테이블로 관리하면 정책 변경 시 발급된 전체 row가 영향을 받는다. 100만 명이 발급받은 쿠폰의 할인율이 잘못됐으면 100만 row를 UPDATE해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Goals&lt;/strong&gt;: 정책 변경이 기존 발급 쿠폰에 소급 적용되지 않는 구조. 운영자가 신규 발급만 막거나, 기존 쿠폰 사용까지 막거나, 두 가지를 독립적으로 제어할 수 있을 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3&gt;Detailed Design&lt;/h3&gt;
&lt;h4&gt;Data Models&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;CouponTemplate
  id, name (수정 가능)
  type, value, minOrderAmount, expiredAt → 불변
  isActive   → 신규 발급 차단 스위치
  isBlocked  → DELETE API 호출 시 true, 기존 발급 쿠폰 전량 차단

UserCoupon
  id, memberId, templateId
  usedAt     → null: AVAILABLE, not null: USED
  @Version version  → 낙관적 락
  UNIQUE(memberId, templateId)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;불변 필드를 둔 이유가 있다. &lt;code&gt;expiredAt&lt;/code&gt;을 운영자가 당길 수 있으면 &amp;quot;7일 유효&amp;quot;로 받은 쿠폰이 갑자기 &amp;quot;3일 유효&amp;quot;가 된다. 발급 시점의 조건이 소급 변경되면 안 되기 때문에 type, value, minOrderAmount, expiredAt은 생성 후 수정 불가다.&lt;/p&gt;
&lt;p&gt;수정이 필요하면 기존 템플릿을 &lt;code&gt;isActive=false&lt;/code&gt;로 내리고 새 템플릿을 만든다. 번거롭지만 그게 맞는 방향이다.&lt;/p&gt;
&lt;h4&gt;CouponStatus 동적 계산&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;status&lt;/code&gt; 컬럼은 DB에 저장하지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;CouponStatus getStatus(LocalDateTime templateExpiredAt, boolean templateBlocked) {
    if (templateBlocked) return BLOCKED;
    if (templateExpiredAt.isBefore(LocalDateTime.now())) return EXPIRED;
    if (usedAt != null) return USED;
    return AVAILABLE;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;저장하면 두 가지 문제가 생긴다. &lt;code&gt;expiredAt&lt;/code&gt;이 이미 있는데 &lt;code&gt;status=EXPIRED&lt;/code&gt;를 따로 저장하면 중복 데이터다. 그리고 배치가 돌기 전 짧은 시간 동안 expiredAt은 지났는데 status는 AVAILABLE인 row가 존재할 수 있다. 어느 쪽이 진실인지 모호해진다.&lt;/p&gt;
&lt;h4&gt;중복 발급 방지&lt;/h4&gt;
&lt;p&gt;서비스 레이어의 exists 체크는 의미 없다. 두 요청이 동시에 exists를 통과하면 둘 다 INSERT를 시도한다. TOCTOU 문제다.&lt;/p&gt;
&lt;p&gt;실제 보호는 &lt;code&gt;UNIQUE(memberId, templateId)&lt;/code&gt; 제약이 한다. 충돌 시 &lt;code&gt;DataIntegrityViolationException&lt;/code&gt; → ApiControllerAdvice에서 409로 변환.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Alternatives Considered&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;A. 단일 테이블&lt;/td&gt;
&lt;td&gt;구조 단순&lt;/td&gt;
&lt;td&gt;정책 변경 시 발급 전체 row UPDATE, 소급 적용 위험&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B. isActive 하나로 발급 차단 + 사용 차단 통합&lt;/td&gt;
&lt;td&gt;필드 추가 없음&lt;/td&gt;
&lt;td&gt;isActive 의미 두 가지 혼용 — 코드 읽는 사람이 어느 쪽인지 모름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C. UserCoupon에 isBlocked 추가&lt;/td&gt;
&lt;td&gt;개별 차단 가능&lt;/td&gt;
&lt;td&gt;100만 건 발급이면 100만 row UPDATE 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D. CouponTemplate에 isBlocked 추가 (채택)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;row 1개 변경으로 전량 차단, 책임 분리 명확&lt;/td&gt;
&lt;td&gt;필드 하나 추가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;선택 근거:&lt;/strong&gt; 운영 시나리오는 &amp;quot;잘못 발급된 쿠폰 전량 차단&amp;quot;이다. UserCoupon에 넣으면 발급 건수에 비례한 UPDATE가 필요하다. CouponTemplate에 넣으면 row 1개만 바꾸면 된다. isActive는 신규 발급 차단 단일 책임을 유지하고, isBlocked는 기존 쿠폰 사용 차단 책임을 분리했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Cross-cutting Concerns&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Scalability&lt;/strong&gt;: UserCoupon 목록 조회는 CouponTemplate JOIN이 필수다. 목록 조회마다 template를 개별 조회하면 N+1이 생긴다. JOIN 쿼리로 한 번에 가져온다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observability&lt;/strong&gt;: CouponTemplate.isBlocked를 true로 바꾸는 DELETE API는 되돌리기 어렵다. 운영자 실수 방지를 위해 감사 로그가 필요한 시점이다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>tech note</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/203</guid>
      <comments>https://coding-su.tistory.com/203#entry203comment</comments>
      <pubDate>Fri, 12 Jun 2026 17:25:17 +0900</pubDate>
    </item>
    <item>
      <title>[Design Doc] 레이어드 아키텍처 설계 &amp;mdash; 4개 레이어, 컴포넌트, 실제 구현 (3주차 &amp;middot; 6팀 &amp;middot; 조수현)</title>
      <link>https://coding-su.tistory.com/202</link>
      <description>&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;4개 레이어(&lt;code&gt;interfaces&lt;/code&gt;, &lt;code&gt;application&lt;/code&gt;, &lt;code&gt;domain&lt;/code&gt;, &lt;code&gt;infrastructure&lt;/code&gt;)로 구성된 레이어드 아키텍처를 적용했다.&lt;br&gt;의존 방향은 &lt;code&gt;interfaces → application → domain ← infrastructure&lt;/code&gt; 단방향으로 강제하고,&lt;br&gt;DIP(의존성 역전 원칙)로 &lt;code&gt;domain&lt;/code&gt;이 Spring/JPA/BCrypt 같은 인프라 기술을 모르는 구조를 만들었다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Introduction &amp;amp; Goals&lt;/h2&gt;
&lt;p&gt;커머스 백엔드 API를 설계할 때 두 가지 문제를 피하고 싶었다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;기술 의존 전파&lt;/strong&gt;: Spring Security, JPA 같은 인프라 기술이 비즈니스 로직에 직접 침투하는 것&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;책임 불명확&lt;/strong&gt;: &amp;quot;이 코드는 어느 레이어에 있어야 하나?&amp;quot;라는 질문에 명확히 답할 수 없는 구조&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;레이어드 아키텍처 + DIP로 두 문제를 해결하고, MSA 이행 시에도 레이어 구조 변경 없이 호출 방식만 교체할 수 있는 설계를 목표로 했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Goals:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;interfaces → application → domain ← infrastructure&lt;/code&gt; 의존 방향 단방향 강제&lt;/li&gt;
&lt;li&gt;&lt;code&gt;domain&lt;/code&gt;이 Spring, JPA, BCrypt를 import하지 않도록 유지&lt;/li&gt;
&lt;li&gt;경계 위반 경로를 코드 구조 자체로 없앰 (문서 규칙이 아닌 컴파일 에러로 강제)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;System Architecture&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;클라이언트
     ↕ HTTP (JSON)
┌─────────────────────────────────────────┐
│  interfaces / api                       │
│  Controller · DTO · ApiControllerAdvice │
│  LoginInterceptor · WebMvcConfig        │
└─────────────────────────────────────────┘
     ↕ Info (record)
┌─────────────────────────────────────────┐
│  application                            │
│  Facade · Service · Info                │
└─────────────────────────────────────────┘
     ↕ Model
┌─────────────────────────────────────────┐
│  domain                                 │
│  Model · VO · Repository 인터페이스     │
│  PasswordHasher 인터페이스              │
└──────────────↑──────────────────────────┘
               │ 구현 (DIP)
┌─────────────────────────────────────────┐
│  infrastructure                         │
│  XJpaRepository · XRepositoryImpl       │
│  BCryptPasswordHasher                   │
└─────────────────────────────────────────┘
     ↕ DB (MySQL)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;의존 규칙:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;① interfaces → application  : Controller는 Facade만 호출. Model import 금지.
② application → domain      : Facade·Service는 Model·Repository 인터페이스 사용.
③ domain ← infrastructure   : domain이 정의한 인터페이스를 infrastructure가 구현(DIP).&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;패키지 구조&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;src/main/java/com/loopers/
├── interfaces/api/
│   ├── {domain}/     XController.java, XDto.java
│   ├── common/       ApiControllerAdvice.java, ApiResponse.java
│   └── config/       LoginInterceptor.java, WebMvcConfig.java
├── application/
│   └── {domain}/     XFacade.java, XService.java, XInfo.java
├── domain/
│   ├── {domain}/     XModel.java, XRepository.java
│   │   └── vo/       LoginId.java, Email.java, PlainPassword.java ...
│   └── common/       CoreException.java, ErrorType.java, BaseEntity.java
└── infrastructure/
    └── {domain}/     XJpaRepository.java, XRepositoryImpl.java&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;Layer 1. interfaces / api&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;책임&lt;/strong&gt;: HTTP 요청을 파싱하고 application에 위임한 뒤, 결과를 HTTP 응답으로 변환한다.&lt;br&gt;비즈니스 로직이 없다. 도메인 객체(&lt;code&gt;XModel&lt;/code&gt;)를 직접 생성하거나 반환하지 않는다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컴포넌트&lt;/th&gt;
&lt;th&gt;책임&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XController&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP 엔드포인트 정의. Facade 호출, Info → Response 변환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XDto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP 입출력 형태 정의. 로직 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ApiControllerAdvice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CoreException&lt;/code&gt; → HTTP 상태코드 변환 (전역 예외 처리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LoginInterceptor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;헤더에서 인증 정보 파싱, 인증 검증, &lt;code&gt;userId&lt;/code&gt;를 request attribute에 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WebMvcConfig&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;인터셉터 등록. blacklist 방식 — 전체 적용 후 공개 경로만 제외&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;설계 포인트:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Controller는 &lt;code&gt;XModel&lt;/code&gt;을 import하지 않는다. &lt;code&gt;BrandInfo info = brandFacade.create(...)&lt;/code&gt; 처럼 Info만 받아 DTO로 변환한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;LoginInterceptor&lt;/code&gt;의 책임은 인증만이다. 데이터를 Controller에 전달하면 인증 + 데이터 전달 두 책임이 생기고 Controller가 Interceptor 구조에 결합된다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;catch(CoreException e)&lt;/code&gt; — &lt;code&gt;Exception&lt;/code&gt;이 아닌 &lt;code&gt;CoreException&lt;/code&gt;만 잡아 401로 변환한다. DB 장애 같은 시스템 오류는 그대로 전파해 500이 된다.&lt;/li&gt;
&lt;li&gt;WebMvcConfig는 &lt;code&gt;new LoginInterceptor(...)&lt;/code&gt; 아닌 Bean 주입으로 받는다. &lt;code&gt;new&lt;/code&gt;로 생성하면 Spring이 관리하는 Bean과 별개 인스턴스가 두 개 생긴다.&lt;/li&gt;
&lt;li&gt;whitelist(인증 경로를 하나씩 추가) 대신 blacklist를 채택했다. 새 API 추가 시 인증 누락 위험이 없다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 2. application&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;책임&lt;/strong&gt;: 도메인 Service를 조합해 유스케이스를 완성한다. &lt;code&gt;Model → Info&lt;/code&gt; 변환으로 도메인 객체가 Controller까지 나가지 못하게 막는다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컴포넌트&lt;/th&gt;
&lt;th&gt;책임&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;Repository 의존&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XFacade&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;여러 Service 조합, 유스케이스 진입점, Model → Info 변환&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XService&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;단일 도메인 유스케이스, 트랜잭션 관리&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;✅ &lt;strong&gt;하나만&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XInfo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Facade 출력 DTO. 서버 내부 계약. Record로 구현&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;설계 포인트:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Service가 Repository 하나에만 의존하는 이유: 여러 도메인을 조율하는 로직은 Facade가 담당한다. Service가 여러 Repository를 의존하면 Facade의 존재 이유가 없어진다.&lt;/li&gt;
&lt;li&gt;Facade가 얇아 보여도 자리를 잡아두는 이유: &lt;code&gt;BrandFacade&lt;/code&gt;는 지금 위임 + Info 변환뿐이지만, &lt;code&gt;ProductFacade&lt;/code&gt;처럼 Brand 존재 확인 → Product 저장 → Stock 생성을 한 흐름에서 조율하는 Facade가 있어야 Controller가 여러 Service를 직접 건드리지 않아도 된다.&lt;/li&gt;
&lt;li&gt;DTO(외부 계약) vs Info(내부 계약) 분리: 지금은 &lt;code&gt;BrandInfo(id, name)&lt;/code&gt;와 &lt;code&gt;BrandResponse(id, name)&lt;/code&gt;이 같아 보여도 나중에 갈라진다. Info에 &lt;code&gt;productCount&lt;/code&gt;가 추가돼도 DTO는 그대로라 클라이언트에 영향이 없다.&lt;/li&gt;
&lt;li&gt;OrderFacade의 핵심 흐름: 확인을 먼저 전체 통과시킨 후 차감해야 부분 차감 없이 All-or-Nothing이 보장된다.&lt;pre&gt;&lt;code&gt;1. 상품 목록 조회
2. productId 오름차순 정렬     ← 모든 트랜잭션이 같은 순서로 락 → 데드락 방지
3. 재고 SELECT FOR UPDATE      ← 비관적 락
4. isAvailable() 전체 선검사  ← 하나라도 부족하면 여기서 중단, DB 변경 없음
5. Order + OrderItem 저장      ← 가격·상품명 스냅샷
6. 재고 일괄 차감              ← 더티 체킹으로 save() 없이 반영&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;LikeFacade: &lt;code&gt;likeService.create()&lt;/code&gt;와 &lt;code&gt;productService.increaseLikeCount()&lt;/code&gt;를 같은 &lt;code&gt;@Transactional&lt;/code&gt; 안에서 호출한다. 분리하면 Like는 있는데 likeCount가 안 오르는 데이터 불일치가 생긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 3. domain&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;책임&lt;/strong&gt;: 비즈니스 규칙을 캡슐화한다. Spring/JPA에 의존하지 않는다.&lt;br&gt;단, &lt;code&gt;@Entity&lt;/code&gt;, &lt;code&gt;@Embeddable&lt;/code&gt; 같은 JPA 매핑 어노테이션은 사용한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컴포넌트&lt;/th&gt;
&lt;th&gt;책임&lt;/th&gt;
&lt;th align=&quot;center&quot;&gt;Spring 의존&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XModel&lt;/code&gt; (Entity)&lt;/td&gt;
&lt;td&gt;도메인 상태 보유, 비즈니스 불변식 보호, 상태 변경 메서드&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;code&gt;@Entity&lt;/code&gt; (매핑만)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;값 타입, 불변, 생성자에서 즉시 검증&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;code&gt;@Embeddable&lt;/code&gt; (매핑만)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XRepository&lt;/code&gt; (인터페이스)&lt;/td&gt;
&lt;td&gt;영속성 인터페이스 정의. JPA를 import하지 않음&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PasswordHasher&lt;/code&gt; (인터페이스)&lt;/td&gt;
&lt;td&gt;비밀번호 해싱/검증 포트. BCrypt를 import하지 않음&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CoreException&lt;/code&gt; / &lt;code&gt;ErrorType&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;도메인 예외. &lt;code&gt;ErrorType&lt;/code&gt;이 HTTP 상태코드와 묶임&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BaseEntity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;updatedAt&lt;/code&gt;, &lt;code&gt;deletedAt&lt;/code&gt; 공통 필드&lt;/td&gt;
&lt;td align=&quot;center&quot;&gt;&lt;code&gt;@MappedSuperclass&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;설계 포인트:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Model 생성자에서 VO 즉시 생성: &lt;code&gt;this.loginId = new LoginId(loginId)&lt;/code&gt; — VO 생성자가 검증을 담당하므로 Model 생성자는 유효성 검사를 직접 하지 않는다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;VO는 Record 불가: &lt;code&gt;@Embeddable&lt;/code&gt; + Record 조합은 QueryDSL APT가 컴파일 타임에 &lt;code&gt;ElementKind.RECORD&lt;/code&gt;를 처리하지 못해 컴파일 에러가 난다. JPA 명세도 no-arg 생성자를 요구하는데 Record는 이를 지원하지 않는다. 일반 클래스 + &lt;code&gt;@NoArgsConstructor(PROTECTED)&lt;/code&gt; + &lt;code&gt;@EqualsAndHashCode&lt;/code&gt;로 구현했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Embeddable
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA 명세: no-arg 생성자 필요
public class LoginId {
    private String value;
    public LoginId(String value) {
        if (!value.matches(&amp;quot;^[a-zA-Z0-9]+$&amp;quot;))
            throw new CoreException(ErrorType.BAD_REQUEST, &amp;quot;아이디는 영문과 숫자만 사용 가능합니다.&amp;quot;);
        this.value = value;
    }
    public String value() { return value; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;VO 배치 기준: 값 하나의 유효성 → VO 생성자. 객체 자신의 상태 변경 → Entity 메서드. 여러 객체 사이의 정책 → Domain Service (현재 미도입). DB 조회 필요 → Application Service.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PlainPassword 생성자 오버로딩: 비밀번호 변경 시 형식만 검증하는 생성자와, 회원가입 시 형식 + 생년월일 포함 여부를 검증하는 생성자를 분리했다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ProductModel에 &lt;code&gt;@ManyToOne BrandModel&lt;/code&gt; 대신 &lt;code&gt;Long brandId&lt;/code&gt;: &lt;code&gt;@ManyToOne&lt;/code&gt;이면 &lt;code&gt;product.getBrand().update()&lt;/code&gt;가 컴파일된다 — 코드 구조 자체로 타 도메인 상태 변경 경로를 막는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// ❌ @ManyToOne → 타 도메인 상태 변경 경로가 열림
product.getBrand().update(&amp;quot;새 브랜드명&amp;quot;);

// ✅ Long brandId → 이 코드 자체가 컴파일 안 됨
private Long brandId;&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;OrderItemModel에 스냅샷 저장: &lt;code&gt;productId&lt;/code&gt;만 저장하면 이후 상품 가격이 바뀔 때 과거 주문 금액이 소급 변경된다. 주문 시점 상품명·가격을 함께 저장해 상품 수정·삭제와 무관하게 주문 데이터를 보존한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;PasswordHasher&lt;/code&gt; 인터페이스 (DIP 포트): &lt;code&gt;UserModel&lt;/code&gt;과 &lt;code&gt;UserService&lt;/code&gt;는 이 인터페이스에 의존한다. BCrypt가 뭔지 모른다. domain 레이어에 &lt;code&gt;spring-security&lt;/code&gt; import가 없다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// domain — spring-security import 없음
public interface PasswordHasher {
    String hash(String raw);
    boolean matches(String raw, String encoded);
}
// infrastructure — BCryptPasswordEncoder는 여기서만 등장
@Component
public class BCryptPasswordHasher implements PasswordHasher { ... }&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Repository 인터페이스: JPA를 import하지 않는 순수 인터페이스. 파라미터와 반환 타입만 정의한다. &lt;code&gt;findByProductIdForUpdate&lt;/code&gt;처럼 비관적 락 의도를 메서드 이름에 명시한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;CoreException → HTTP 변환: &lt;code&gt;domain&lt;/code&gt;은 &lt;code&gt;CoreException(ErrorType.BAD_REQUEST, &amp;quot;...&amp;quot;)&lt;/code&gt; 만 던진다. &lt;code&gt;ApiControllerAdvice&lt;/code&gt;가 &lt;code&gt;ErrorType&lt;/code&gt;을 보고 HTTP 상태코드로 변환한다. domain은 HTTP를 모른다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 4. infrastructure&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;책임&lt;/strong&gt;: &lt;code&gt;domain&lt;/code&gt;이 정의한 Repository 인터페이스와 PasswordHasher를 기술로 구현한다. 기술 선택(JPA, Redis, BCrypt 등)은 이 레이어에서만 이루어진다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;컴포넌트&lt;/th&gt;
&lt;th&gt;책임&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XJpaRepository&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Spring Data JPA 인터페이스. &lt;code&gt;@Lock&lt;/code&gt;, &lt;code&gt;@Query&lt;/code&gt; 등 JPA 어노테이션 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;XRepositoryImpl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domain&lt;/code&gt;의 &lt;code&gt;XRepository&lt;/code&gt; 구현체. &lt;code&gt;XJpaRepository&lt;/code&gt;에 위임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BCryptPasswordHasher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PasswordHasher&lt;/code&gt; 구현체. &lt;code&gt;BCryptPasswordEncoder&lt;/code&gt;에 위임&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;설계 포인트:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;비관적 락은 JpaRepository에서: &amp;quot;어떻게 DB에서 읽어올 것인가&amp;quot;는 인프라 관심사다. StockModel은 DB를 모르는 순수 도메인 객체여야 한다.&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&amp;quot;SELECT s FROM StockModel s WHERE s.productId = :productId AND s.deletedAt IS NULL&amp;quot;)
Optional&amp;lt;StockModel&amp;gt; findByProductIdForUpdate(@Param(&amp;quot;productId&amp;quot;) Long productId);&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Embedded&lt;/code&gt; 필드 경로 탐색: &lt;code&gt;findByLoginIdValue(String)&lt;/code&gt; — &lt;code&gt;loginId&lt;/code&gt;가 &lt;code&gt;LoginId&lt;/code&gt; VO이고 내부 필드가 &lt;code&gt;value&lt;/code&gt;이므로, Spring Data JPA가 &lt;code&gt;loginId.value&lt;/code&gt;를 파싱해 &lt;code&gt;WHERE login_id = ?&lt;/code&gt; 쿼리를 생성한다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BCryptPasswordHasher&lt;/code&gt;: &lt;code&gt;PasswordHasher&lt;/code&gt; 인터페이스를 구현해 DIP를 완성한다. Spring Security 의존이 이 클래스에서만 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Cross-cutting Concerns&lt;/h2&gt;
&lt;h3&gt;Soft Delete&lt;/h3&gt;
&lt;p&gt;모든 엔티티는 &lt;code&gt;BaseEntity&lt;/code&gt;를 상속해 &lt;code&gt;deletedAt&lt;/code&gt; 컬럼을 갖는다.&lt;br&gt;삭제 시 &lt;code&gt;deletedAt&lt;/code&gt;을 현재 시각으로 설정하고, &lt;code&gt;@SQLRestriction(&amp;quot;deleted_at IS NULL&amp;quot;)&lt;/code&gt;이 조회 쿼리에 자동으로 필터를 추가한다.&lt;/p&gt;
&lt;h3&gt;Soft Delete + Unique 제약 (MySQL)&lt;/h3&gt;
&lt;p&gt;활성 계정 간 &lt;code&gt;loginId&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt; 중복 불허 + 탈퇴 후 재가입 허용을 충족해야 했다.&lt;br&gt;MySQL은 &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt; 조건의 Filtered Index를 지원하지 않아 Generated Column으로 우회했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ALTER TABLE users
  ADD COLUMN login_id_unique_key VARCHAR(20)
  GENERATED ALWAYS AS (IF(deleted_at IS NULL, login_id, NULL)) VIRTUAL;
CREATE UNIQUE INDEX uk_users_login_id_active ON users (login_id_unique_key);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;활성 계정은 &lt;code&gt;login_id&lt;/code&gt; 값을, 탈퇴 계정은 &lt;code&gt;NULL&lt;/code&gt;을 갖는다. MySQL Unique 제약은 &lt;code&gt;NULL&lt;/code&gt;을 중복으로 보지 않으므로 탈퇴 계정이 아무리 많아도 unique 위반이 없다.&lt;/p&gt;
&lt;h3&gt;애그리거트 경계&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;같은 애그리거트  → @OneToMany 직접 참조 허용
  Order + OrderItem: 항상 함께 생성·삭제, 같은 트랜잭션

다른 애그리거트  → Long ID로만 참조, FK 제약 없음
  Product → Brand: Long brandId
  Stock → Product: Long productId
  Like, Order → Member: Long memberId

읽기(Query)  → JOIN 허용
쓰기(Command) → 경계 엄격히 유지&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;FK 없이 애플리케이션에서 검증하는 이유: MSA 전환 시 다른 서비스 DB에 FK를 걸 수 없다. 모놀리스부터 같은 구조를 써두면 코드 변경 없이 Service 간 호출 방식(직접 → HTTP API)만 교체하면 된다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Alternatives Considered&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;대안&lt;/th&gt;
&lt;th&gt;선택&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Service 위치&lt;/td&gt;
&lt;td&gt;&lt;code&gt;domain&lt;/code&gt; 레이어&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application&lt;/code&gt; 레이어&lt;/td&gt;
&lt;td&gt;Repository 의존 Service는 인프라를 간접 필요로 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand-Product 참조&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@ManyToOne BrandModel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Long brandId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;컴파일 레벨에서 타 도메인 상태 변경 경로 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비밀번호 해싱&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UserModel&lt;/code&gt;에서 직접 BCrypt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PasswordHasher&lt;/code&gt; 포트&lt;/td&gt;
&lt;td&gt;DIP — domain이 인프라 기술을 import하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facade 유무&lt;/td&gt;
&lt;td&gt;Controller에서 Service 직접 조합&lt;/td&gt;
&lt;td&gt;Facade 경유&lt;/td&gt;
&lt;td&gt;Controller가 여러 Service를 조율하면 레이어 위반&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VO 구현&lt;/td&gt;
&lt;td&gt;Java Record + &lt;code&gt;@Embeddable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;일반 클래스 + Lombok&lt;/td&gt;
&lt;td&gt;QueryDSL APT 컴파일 에러, JPA no-arg 생성자 스펙 위반&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebMvcConfig 경로&lt;/td&gt;
&lt;td&gt;whitelist&lt;/td&gt;
&lt;td&gt;blacklist&lt;/td&gt;
&lt;td&gt;새 API 추가 시 인증 누락 위험 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. Repository 인터페이스가 domain에 있다고 Service도 domain에 있는 게 아니다&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Repository 인터페이스가 domain에 있는 이유는 DIP다. Service가 그 인터페이스를 사용한다는 사실은 변하지 않고, Service는 infrastructure를 간접 필요로 한다. 그 책임은 application 레이어에 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 컴파일 레벨에서 경계를 강제하라&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Long brandId&lt;/code&gt;를 쓰면 &lt;code&gt;product.getBrand().update()&lt;/code&gt;가 컴파일 자체가 안 된다. 경계를 문서나 팀 규약으로 강제하는 것보다 코드 구조로 위반 경로를 없애는 게 더 강력하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Service가 Repository 하나에만 의존하는 것이 Facade를 의미 있게 만든다&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이 원칙이 없으면 Facade는 그냥 위임 클래스일 뿐이다. Service의 단일 의존 제약이 있어야 Facade가 &amp;quot;여러 도메인을 조율하는 레이어&amp;quot;라는 의미를 갖는다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. VO는 &amp;quot;이 값이 존재하기 위한 조건&amp;quot;을, Entity 메서드는 &amp;quot;이 객체의 상태 변경&amp;quot;을 담당한다&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;경계가 애매할 때 판단 기준: &amp;quot;이 로직이 없어도 이 값/객체는 존재할 수 있는가?&amp;quot; 없으면 안 된다 → 그 안에 넣는다. 외부 조건이다 → 바깥으로 꺼낸다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. VO를 Record로 구현하면 QueryDSL APT와 충돌한다&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Hibernate 6이 런타임에서 Record를 우회 처리해줘도, QueryDSL APT는 컴파일 타임에 &lt;code&gt;ElementKind.CLASS&lt;/code&gt;만 처리한다. &amp;quot;런타임에서 동작하는가&amp;quot;와 &amp;quot;생태계 도구 전체가 지원하는가&amp;quot;는 별개의 질문이다.&lt;/p&gt;</description>
      <category>architecture</category>
      <category>tech note</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/202</guid>
      <comments>https://coding-su.tistory.com/202#entry202comment</comments>
      <pubDate>Fri, 29 May 2026 16:26:54 +0900</pubDate>
    </item>
    <item>
      <title>[Challenge Story] 주문&amp;middot;결제 동시성 제어 &amp;mdash; 6개 문제를 연쇄 발견한 설계 여정 (2주차 &amp;middot; 6팀 &amp;middot; 조수현)</title>
      <link>https://coding-su.tistory.com/201</link>
      <description>&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;주문/결제 시스템에서 동시성 문제를 하나씩 해결할 때마다 새로운 문제가 연쇄적으로 드러났다.&lt;br&gt;SAGA 패턴 → 취소 시 Lost Update → 데드락 → 이중 취소까지 6개 문제를 차례로 발견하고,&lt;br&gt;&lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;와 정렬 기반 락 획득 순서로 모두 해결했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;문제 1: 결제 실패 중 새 주문 → 트랜잭션 분리로 보상 범위 명확화 (보상 자동 실행은 미구현)
    ↓
문제 2: 주문 취소 시 재고 Lost Update → 취소에도 SELECT FOR UPDATE 추가
    ↓
문제 3: 여러 상품 주문 시 데드락 → productId 오름차순 정렬로 해결
    ↓
문제 4: 이중 취소 요청 → Order에 SELECT FOR UPDATE 추가
    ↓
문제 5: PENDING 방치 → 배치 자동 취소 (잠재 리스크)
    ↓
문제 6: 결제 확정 주체 → 클라이언트 confirm 호출 (B방식, Webhook 예정)&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;Context (배경 및 목표)&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;어떤 시스템을 만드는가?&lt;/strong&gt;: 외부 PG(결제대행사)와 연동하는 주문/결제 API. 재고 차감 → PG 결제 → 주문 확정의 흐름을 여러 사용자가 동시에 요청해도 안전하게 처리해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;가장 큰 기술적 도전 과제는?&lt;/strong&gt;: PG 호출이 트랜잭션 경계 밖에 있어 기존 단일 트랜잭션 방식으로는 대응이 불가능하고, 주문·취소·재시도가 동시에 쏟아질 때 재고 정합성을 유지하는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;Design &amp;amp; Implementation (설계 및 구현)&lt;/h2&gt;
&lt;h3&gt;핵심 기술 선택: 비관적 락 (&lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;낙관적 락(&lt;code&gt;@Version&lt;/code&gt;)과 비교했을 때 재고 도메인에서는 비관적 락이 필수였다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;낙관적 락&lt;/th&gt;
&lt;th&gt;비관적 락&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;DB 음수 저장&lt;/td&gt;
&lt;td&gt;발생 안 함&lt;/td&gt;
&lt;td&gt;발생 안 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재고 있는데 주문 실패&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;발생 가능&lt;/strong&gt; (충돌 시 재시도 반복 → 결국 실패)&lt;/td&gt;
&lt;td&gt;발생 안 함 (대기 후 처리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;낮음 (대기 발생)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;적합한 상황&lt;/td&gt;
&lt;td&gt;충돌 빈도 낮은 데이터&lt;/td&gt;
&lt;td&gt;충돌 빈도 높은 데이터&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;선택 근거&lt;/strong&gt;: 재고는 충돌 빈도가 높고, 재고가 있으면 반드시 주문이 성공해야 한다는 요구사항이 핵심이다. 낙관적 락의 &amp;quot;충돌 → 재시도 → 또 충돌&amp;quot; 패턴은 재고가 남아 있는데 고객이 실패를 보는 상황을 만들어낸다.&lt;/p&gt;
&lt;h3&gt;Logic Flow&lt;/h3&gt;
&lt;p&gt;트랜잭션을 세 구간으로 분리한 SAGA 패턴을 기반으로 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[TX 1] 재고 차감 + 주문 생성 (PENDING) → 커밋
    ↓ 트랜잭션 종료 후
PG 결제 요청 (외부 시스템, 트랜잭션 외부)
    ↓
결제 성공 → 클라이언트 confirm 호출 → [TX 2] PENDING → CONFIRMED
결제 실패 → 클라이언트 취소 호출 → [TX 3] 재고 복구 + CANCELLED&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2&gt;Engineering Challenges (트러블슈팅 및 최적화)&lt;/h2&gt;
&lt;h3&gt;  문제 1. 보상 트랜잭션이 다른 주문의 차감량을 덮어쓴다&lt;/h3&gt;
&lt;p&gt;처음 설계는 재고 차감 + PG 결제 + 주문 생성을 하나의 트랜잭션에 묶는 구조였다. 두 가지 문제가 있었다. PG 응답이 느린 동안 DB 락이 그대로 유지되고, 보상 트랜잭션 실행 중에 새 주문의 차감량까지 덮어쓸 수 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1번 주문: 재고 차감 → 결제 실패 → 보상 트랜잭션 실행 중
                                    ↓ (동시에)
                    2번 주문: 같은 상품 재고 차감
                                    ↓
1번 보상 완료 → 2번의 차감량까지 복구해버림&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;트랜잭션이 길수록 보상 범위가 불명확해진다. 외부 시스템(PG)을 트랜잭션 안에 넣으면 트랜잭션 길이를 통제할 수 없다.&lt;/p&gt;
&lt;p&gt;트랜잭션 경계를 분리해 각 TX를 독립적으로 만들었고, 보상 범위가 명확해졌다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;적용 여부&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;로컬 트랜잭션 분리&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;TX1 / TX2 / TX3 분리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;보상 트랜잭션 자동 실행&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;TX3(취소)는 클라이언트가 수동 호출해야 실행됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;완전한 SAGA가 되려면 실패를 감지하고 보상을 자동으로 트리거하는 메커니즘(이벤트 or 오케스트레이터)이 필요하다. 현재는 클라이언트가 호출하지 않으면 보상이 일어나지 않으며, 이것이 문제 3(PENDING 방치)의 근본 원인이기도 하다. Webhook 전환 시 PG가 서버에 직접 결과를 전달하므로 보상 자동화가 가능해진다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;  문제 2. 재고 락 밖에서 생긴 두 가지 문제&lt;/h3&gt;
&lt;p&gt;재고 경로에는 락이 있었다. 문제는 그 바깥이었다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;데드락 — 여러 상품을 동시에 주문할 때&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;정렬 없이 items 순서대로 락을 잡으면 두 요청이 서로를 기다리는 순환 대기가 생긴다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;주문 A: [상품 1 락] 획득 → [상품 2 락] 대기
주문 B: [상품 2 락] 획득 → [상품 1 락] 대기 → 데드락&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;productId&lt;/code&gt; 오름차순 정렬로 해결했다. 주문 생성·취소 양쪽 모두 적용했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;이중 취소 — Order 행에도 락이 필요했다&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;재고에는 락을 걸었는데 Order 상태 확인은 락 없이 읽고 있었다. 클라이언트 재시도 또는 추후 도입될 배치 자동 취소가 동시에 실행되면 두 요청이 모두 &lt;code&gt;PENDING&lt;/code&gt;을 보고 재고를 두 번 복구한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;취소 A: Order 읽기 (PENDING) → 재고 복구 중...
취소 B: Order 읽기 (PENDING) → 재고 복구 시작 → 중복 복구!&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Order 조회에도 &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;를 추가했다. 두 번째 요청은 반드시 &lt;code&gt;CANCELLED&lt;/code&gt;를 보고 400을 반환한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;  문제 3. PENDING 방치 (클라이언트 미응답)&lt;/h3&gt;
&lt;p&gt;클라이언트가 confirm도 취소도 안 하면 재고가 차감된 채 PENDING이 영구 유지된다. B 방식(클라이언트 confirm)의 구조적 한계다. 배치 자동 취소 도입이 필요하다. 실제 PG 연동 시 Webhook으로 교체할 예정이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;배치 스케줄러: PENDING 상태이고 생성 후 N분 경과한 주문 조회
→ 자동 취소 처리 (재고 복구 + CANCELLED)&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h3&gt;  문제 4. 결제 확정 주체 선택&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;흐름&lt;/th&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;A (Admin 수동)&lt;/td&gt;
&lt;td&gt;Admin이 PG 대시보드 확인 후 confirm&lt;/td&gt;
&lt;td&gt;주문량 많으면 현실적으로 불가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B (Client 호출)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;클라이언트가 PG 성공 후 confirm 호출&lt;/td&gt;
&lt;td&gt;클라이언트 신뢰에 의존, 결제 없이 confirm 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C (Webhook)&lt;/td&gt;
&lt;td&gt;PG가 서버에 자동 notify&lt;/td&gt;
&lt;td&gt;가장 안전하나 현재 구현 범위 초과&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;현재 PG 연동이 없으므로 서버가 결제 여부를 검증할 방법이 없다. B 방식으로 흐름을 구현하고, 실제 PG 연동 시 Webhook으로 교체한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;변경된 엔드포인트&lt;/strong&gt;: &lt;code&gt;PATCH /api/v1/orders/{orderId}/confirm&lt;/code&gt; (USER 권한)&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Verification &amp;amp; Insight (검증)&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;th&gt;해결 방법&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;보상 트랜잭션 범위 불명확&lt;/td&gt;
&lt;td&gt;SAGA 패턴 (TX 분리)&lt;/td&gt;
&lt;td&gt;보상 범위 명확화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;취소 시 Lost Update&lt;/td&gt;
&lt;td&gt;취소 경로에 &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; 추가&lt;/td&gt;
&lt;td&gt;Lost Update 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다중 상품 데드락&lt;/td&gt;
&lt;td&gt;&lt;code&gt;productId&lt;/code&gt; 오름차순 정렬&lt;/td&gt;
&lt;td&gt;순환 대기 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이중 취소 중복 복구&lt;/td&gt;
&lt;td&gt;Order 행 &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;직렬화로 중복 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;처리량(Throughput) 영향&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;hr&gt;
&lt;h2&gt;잠재 리스크&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;리스크&lt;/th&gt;
&lt;th&gt;현재 대응&lt;/th&gt;
&lt;th&gt;추후 개선&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;PENDING 방치 (클라이언트 미응답)&lt;/td&gt;
&lt;td&gt;배치 자동 취소 도입 필요 (미구현)&lt;/td&gt;
&lt;td&gt;Webhook으로 교체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;클라이언트 신뢰 의존 (결제 없이 confirm 호출)&lt;/td&gt;
&lt;td&gt;현재 허용&lt;/td&gt;
&lt;td&gt;PG Webhook 수신 후 서버 검증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;상품 수 많을수록 락 보유 시간 증가&lt;/td&gt;
&lt;td&gt;productId 정렬로 데드락만 방지&lt;/td&gt;
&lt;td&gt;배치/비동기 이벤트 방식 검토&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;락은 쓰는 경로뿐 아니라 읽는 경로도 확인해야 한다.&lt;/strong&gt; 주문 생성에만 락이 있어도 취소 경로에 락이 없으면 Lost Update가 생긴다. read-modify-write 패턴이 있는 모든 경로를 빠짐없이 점검해야 한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;트랜잭션 경계가 락 전략보다 먼저다.&lt;/strong&gt; 외부 시스템(PG)을 트랜잭션 안에 넣으면 락 전략이 아무리 정교해도 락 보유 시간이 제어 불가능해진다. SAGA처럼 트랜잭션을 짧게 유지하는 것이 먼저다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;동시성 설계는 전체 흐름을 시나리오 단위로 반복 검토해야 허점이 없다.&lt;/strong&gt; 주문 생성 직렬화 → 취소 Lost Update → 데드락 → 이중 취소의 순서처럼, 해결하고 나면 숨어 있던 다음 문제가 나온다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SAGA의 본질은 트랜잭션 분리가 아니라 보상 자동화다.&lt;/strong&gt; 트랜잭션을 쪼개는 것만으로는 SAGA가 아니다. 실패를 감지하고 보상을 자동으로 트리거하는 메커니즘이 없으면, 보상이 클라이언트 몫이 되고 PENDING 방치 같은 구조적 허점이 생긴다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>tech-note</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/201</guid>
      <comments>https://coding-su.tistory.com/201#entry201comment</comments>
      <pubDate>Fri, 22 May 2026 16:12:52 +0900</pubDate>
    </item>
    <item>
      <title>TDD와 DDD로 처음 개발해보며 얻은 것들</title>
      <link>https://coding-su.tistory.com/200</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이론으로만 알던 TDD와 DDD를 loopers에서 직접 적용하면서 &quot;왜 이렇게 해야 하는가&quot;를 몸으로 깨달은 기록.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TDD 방법론은 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TDD란 Test Driven Development의 약자로 '테스트 주도 개발'이다.&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;소프트웨어 방법론으로 작은 단위 테스트 케이스를 작성하고 이를 통과하는 코드를 추가하는 단계를 반복하여 구현한다. TDD의 개발 절차에는 Red -&amp;gt; Green -&amp;gt; Refactor를 반복하면서 Red 단계에서는 테스트 케이스에 맞는 실패하는 테스트 코드를 먼저 작성하고, Green 단계에서는 테스트를 성공시키기 위한 최소한의 코드를 작성한다. 마지막으로 Refactor 단계에서 중복 코드 제거, 일반화 등 리팩토링을 한다.&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;h2 data-ke-size=&quot;size26&quot;&gt;TDD를 직접 써보고 나서&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;TDD로 테스트 코드를 먼저 작성을 하고 로직을 작성하니 상황이 바뀌었다. 아직 로직이 없으니 테스트 케이스를 먼저 작성해야 했고, 테스트 케이스에 따라 테스트 코드를 먼저 작성하면서 어떤 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;TDD를 하면서 DDD에 대해서도 알게 되면서 Service가 아닌 Domain에 로직을 넣고, Domain을 설계해야 한다는 것을 알게 되었다.&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-ke-size=&quot;size26&quot;&gt;DDD란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DDD는 &amp;ldquo;데이터를 저장하는 객체&amp;rdquo;가 아니라&lt;br /&gt;&amp;ldquo;업무 규칙을 수행하는 객체&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;DDD를 이용하면 비즈니스 도메인에 집중하기 때문에 요구사항을 더 잘 이해하고 모델링 할 수 있고, 도메인 모델링을 통해 각 요소가 높은 응집도를 갖게 되어 코드의 가독성과 유지보수성이 향상된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;DDD: Entity가 없어도 가능하다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DDD를 처음 접했을 때 &quot;서비스를 최대한 단순하게 만들고, 도메인에 넣는다&quot;는 말을 들었다. 하지만 나는 MyBatis 환경에서 개발을 하면서 '@Entity' 클래스를 쓰지 않으니 DDD는 불가능하다고 생각했었다.&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;하지만 DDD는 ORM 기술이 아니라 설계 방식이다. '@Entity'의 유무가 핵심이 아니었다. DDD에서의 Entity는 식별자와 생명주기를 가지는 도메인 객체이고, DDD의 핵심은 비즈니스 규칙, 상태 변화 규칙, 불변성, 행위를 객체 안에 넣는 것을 의미한다. DTO에서 도메인 객체를 분리하면 MyBatis 환경에서도 DDD를 적용할 수 있다는 것을 알게 되었다.&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;MyBatis를 사용하면 자연스럽게 'DB -&amp;gt; DTO -&amp;gt; Service' 흐름이 강해진다. 비즈니스 로직이 모두 Service에 쌓이는 Transaction Script 패턴이 된다. 단순 CRUD라면 괜찮지만, 주문/결제/쿠폰처럼 규칙이 복잡해질수록 Service가 길어질 수 밖에 없는 구조로 이어진다.&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-ke-size=&quot;size26&quot;&gt;핵심 개념 정리: Entity, VO, DTO&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 알고있던 Entity가 아니라는 것을 알고 Entity와 VO, DTO에 대해 정리를 해보았다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Entity (Model)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고유 ID가 있고, 상태가 변할 수 있으며, 비즈니스 로직을 직접 가진다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;public class User {
    private UserId id;
    private String loginId;
    private String password;
    private String name;

    public void changePassword(String newPassword) {
        validatePassword(newPassword);
        this.password = newPassword;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;VO (Value Object)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값 자체가 의미인 객체. ID가 없고, 불변이며, 생성 시점에 스스로 유효성을 검증한다. JPA의 &lt;code&gt;@Embedded&lt;/code&gt;로 별도 테이블 없이 컬럼으로 저장되는 경우가 일반적이다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;public class Email {
    private final String value;

    public Email(String value) {
        if (!value.contains(&quot;@&quot;)) {
            throw new CoreException(ErrorType.BAD_REQUEST,
                &quot;이메일 형식이 올바르지 않습니다.&quot;);
        }
        this.value = value;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DTO (Data Transfer Object)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이어 간 데이터를 전달만 하는 객체. 비즈니스 로직이 없고, Controller &amp;harr; Facade &amp;harr; Service 경계에서 사용한다.&lt;/p&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;// 요청 DTO
public record SignUpRequest(
    String loginId, String password, String name
) {}

// 응답 DTO (비밀번호는 포함하지 않음)
public record UserResponse(
    String loginId, String name, String email
) {}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;한눈에 비교&lt;/h3&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&amp;nbsp;&lt;/th&gt;
&lt;th&gt;Entity&lt;/th&gt;
&lt;th&gt;VO&lt;/th&gt;
&lt;th&gt;DTO&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DB 저장&lt;/td&gt;
&lt;td&gt;O 별도 테이블&lt;/td&gt;
&lt;td&gt;컬럼(Embedded)&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;고유 ID&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;불변&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;보통 O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;비즈니스 로직&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;O (자기검증)&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용 위치&lt;/td&gt;
&lt;td&gt;domain&lt;/td&gt;
&lt;td&gt;domain&lt;/td&gt;
&lt;td&gt;레이어 경계&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-ke-size=&quot;size26&quot;&gt;DB 중심 사고 &amp;rarr; 비즈니스 중심 사고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그동안 테이블 구조를 먼저 생각하고 코드를 짰다. DDD를 공부하면서 이 사고방식이 얼마나 Service를 망가뜨리는지 알게 됐다.&lt;br /&gt;멘토님이 &amp;ldquo;기계적으로 코딩하지 말고 도메인 설계와 비즈니스 로직을 고민해야 한다&amp;rdquo;라고 말씀하셨을 때는 그 의미를 잘 이해하지 못했다. 그런데 지금 돌아보면, 단순히 DB 구조를 설계하라는 뜻이 아니었다.&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 테이블 구조를 그대로 옮겨놓은 객체가 아니라, 실제 비즈니스 개념과 업무 규칙을 표현하는 객체라는 의미였다. 즉, 객체 안에 데이터만 담는 것이 아니라 업무 규칙과 행위를 함께 담아야 한다는 뜻이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테이블 = 객체 X&lt;/li&gt;
&lt;li&gt;비즈니스 개념 = 객체 O, DB는 단순 저장소&lt;/li&gt;
&lt;li&gt;객체는 &lt;b&gt;상태 + 행동 + 규칙&lt;/b&gt;을 함께 가진다&lt;br /&gt;프로젝트 규모가 커지면 정책이 늘고, 예외 케이스가 늘고, Service가 자연스럽게 비대해진다. 도메인 객체에 책임을 나눠주면 Service는 흐름 조율(orchestration)과 트랜잭션 관리에만 집중할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론&lt;/b&gt; &amp;mdash; DDD의 핵심은 비즈니스 개념을 객체로 모델링하고, 관련된 데이터와 행위를 하나의 책임으로 묶어 복잡한 비즈니스 로직을 이해하기 쉽고 유지보수하기 좋은 구조로 만드는 것이다. 결국 DDD는 객체지향 설계를 비즈니스 복잡성에 체계적으로 적용하는 방법론이라고 생각한다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>CS &amp;amp; 공부한 내용</category>
      <category>ddd</category>
      <category>TDD</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/200</guid>
      <comments>https://coding-su.tistory.com/200#entry200comment</comments>
      <pubDate>Fri, 15 May 2026 17:58:40 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] JWT를 이용한 인증/인가 구현하기(Spring Security X)</title>
      <link>https://coding-su.tistory.com/197</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT를 사용하기 위한 파일&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;FilterConfig.java&lt;/h4&gt;
&lt;pre id=&quot;code_1731655228688&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@RequiredArgsConstructor
public class FilterConfig {

    private final JwtUtil jwtUtil;

    @Bean
    public FilterRegistrationBean&amp;lt;JwtFilter&amp;gt; jwtFilter() {
        FilterRegistrationBean&amp;lt;JwtFilter&amp;gt; registrationBean = new FilterRegistrationBean&amp;lt;&amp;gt;();
        registrationBean.setFilter(new JwtFilter(jwtUtil));
        registrationBean.addUrlPatterns(&quot;/*&quot;); // 필터를 적용할 URL 패턴을 지정합니다.

        return registrationBean;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 jwt를 필터에서 config 파일을 등록해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JwtFilter.java&lt;/h4&gt;
&lt;pre id=&quot;code_1731655287430&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;
    private final Pattern authPattern = Pattern.compile(&quot;^/v\\d+/auth.*&quot;);

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String url = httpRequest.getRequestURI();

        // `/v{숫자}/auth`로 시작하는 URL은 필터를 통과하지 않도록 설정
        if (authPattern.matcher(url).matches()) {
            chain.doFilter(request, response);
            return;
        }

        // NOTE: 위의 방법이 이해가 어려운 분은 이런 방법을 사용하셔도 좋습니다.
//        if (url.startsWith(&quot;/v1/auth&quot;) || url.startsWith(&quot;/v2/auth&quot;)) {
//            chain.doFilter(request, response);
//            return;
//        }

        String bearerJwt = httpRequest.getHeader(&quot;Authorization&quot;);

        if (bearerJwt == null || !bearerJwt.startsWith(&quot;Bearer &quot;)) {
            // 토큰이 없는 경우 400을 반환합니다.
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, &quot;JWT 토큰이 필요합니다.&quot;);
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt);

        try {
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);

            // 사용자 정보를 ArgumentResolver 로 넘기기 위해 HttpServletRequest 에 세팅
            httpRequest.setAttribute(&quot;userId&quot;, Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute(&quot;email&quot;, claims.get(&quot;email&quot;, String.class));

            chain.doFilter(request, response);
        } catch (SecurityException | MalformedJwtException e) {
            log.error(&quot;Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.&quot;, e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;유효하지 않는 JWT 서명입니다.&quot;);
        } catch (ExpiredJwtException e) {
            log.error(&quot;Expired JWT token, 만료된 JWT token 입니다.&quot;, e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;만료된 JWT 토큰입니다.&quot;);
        } catch (UnsupportedJwtException e) {
            log.error(&quot;Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.&quot;, e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, &quot;지원되지 않는 JWT 토큰입니다.&quot;);
        } catch (IllegalArgumentException e) {
            log.error(&quot;JWT claims is empty, 잘못된 JWT 토큰 입니다.&quot;, e);
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, &quot;잘못된 JWT 토큰입니다.&quot;);
        } catch (Exception e) {
            log.error(&quot;JWT 토큰 검증 중 오류가 발생했습니다.&quot;, e);
            httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;JWT 토큰 검증 중 오류가 발생했습니다.&quot;);
        }
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT 필터를 등록합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1731655364909&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;	if (authPattern.matcher(url).matches()) {
            chain.doFilter(request, response);
            return;
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드 부분은 doFilter에서 검증을 하지 않고 넘어가기 위해 등록을 합니다.&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;만약 하지 않는다면 로그인과 회원가입을 하기 위해 JWT토큰이 필요하다는 에러가 나타나게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JwtUtil.java&lt;/h4&gt;
&lt;pre id=&quot;code_1731655491005&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j(topic = &quot;JwtUtil&quot;)
@Component
public class JwtUtil {

    private static final String BEARER_PREFIX = &quot;Bearer &quot;;
    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value(&quot;${jwt.secret.key}&quot;)
    private String secretKey;

    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    public String createToken(Long userId, String username, String email, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim(&quot;username&quot;, username)
                        .claim(&quot;email&quot;, email)
                        .claim(&quot;userRole&quot;, userRole.name())
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) &amp;amp;&amp;amp; tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        log.error(&quot;Not Found Token&quot;);
        throw new NullPointerException(&quot;Not Found Token&quot;);
    }

    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서는 JWT 토큰을 생성해서 발급해주는 코드입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;${jwt.secret.key}&quot;는 application.yaml이나 application.properties에서 설정해주시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3개의 파일을 모두 작성하였다면 기본적으로 JWT를 사용할 준비가 끝났습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 저희는 로그인에서 사용할 예정임으로 몇가지 더 코드를 추가하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 로그인에서는 @Auth 어노테이션 방식으로 사용할 예정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;추가적인 코드&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;비밀번호 암호화&lt;/h4&gt;
&lt;pre id=&quot;code_1731655737353&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class PasswordEncoder {

    /**
     * 원문 비밀번호를 BCrypt 알고리즘을 사용하여 암호화합니다.
     * @param rawPassword 원문 비밀번호
     * @return 암호화된 비밀번호 문자열
     */
    public String encode(String rawPassword) {
        // BCrypt의 기본 설정을 사용하여 비밀번호를 해시화
        return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
    }

    /**
     * 원문 비밀번호와 암호화된 비밀번호를 비교하여 일치 여부를 확인합니다.
     * @param rawPassword 원문 비밀번호
     * @param encodedPassword 암호화된 비밀번호
     * @return 비밀번호 일치 여부 (true: 일치, false: 불일치)
     */
    public boolean matches(String rawPassword, String encodedPassword) {
        // BCrypt를 사용하여 원문 비밀번호와 암호화된 비밀번호를 비교
        BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword);
        return result.verified; // 비교 결과 반환
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회원가입 할때 비밀번호를 바로 DB에 저장하면 보안성에 취약해지기 때문에 원문 비밀번호를 암호화 해야합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Auth.java&lt;/h3&gt;
&lt;pre id=&quot;code_1731655850263&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
}&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AuthUserArgumentResolver.java&lt;/h3&gt;
&lt;pre id=&quot;code_1731655907661&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {

    // @Auth 어노테이션이 있는지 확인
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(Auth.class) != null;
    }

    // AuthUser 객체를 생성하여 반환
    @Override
    public Object resolveArgument(
            @Nullable MethodParameter parameter,
            @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            @Nullable WebDataBinderFactory binderFactory
    ) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        // JwtFilter 에서 set 한 userId, email 값을 가져옴
        Long userId = (Long) request.getAttribute(&quot;userId&quot;);
        String email = (String) request.getAttribute(&quot;email&quot;);

        return new AuthUser(userId, email); // userid 와 email를 담기 위한 객체를 생성함
    }
}&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;ArgumentResolver를 생성해줍니다. ArgumentResolver를 통해 유저의 정보를 가져올 수 있습니다. 즉, 로그인 할때 유저의 정보를 가져오기 위해 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WebConfig.java&lt;/h3&gt;
&lt;pre id=&quot;code_1731655988975&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    // ArgumentResolver 등록
    @Override
    public void addArgumentResolvers(List&amp;lt;HandlerMethodArgumentResolver&amp;gt; resolvers) {
        resolvers.add(new AuthUserArgumentResolver());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 ArgumentResolver를 사용하기 위해 WebConfig에 등록을 해줍니다.&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;h3 data-ke-size=&quot;size23&quot;&gt;로그인, 회원가입 구현하기&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;AuthController.java&lt;/h4&gt;
&lt;pre id=&quot;code_1731656063141&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/v1/auth&quot;)
public class AuthController {

    private final AuthService authService;

    @PostMapping(&quot;/signup&quot;)
    public ResponseEntity&amp;lt;SiginupResponseDto&amp;gt; signup(@RequestBody SiginupRequestDto siginupRequestDto) {
        return ResponseEntity.ok(authService.signup(siginupRequestDto));
    }

    @PostMapping(&quot;/signin&quot;)
    public ResponseEntity&amp;lt;SigininResponseDto&amp;gt; siginin(@RequestBody SigininRequestDto sigininRequestDto) {
        String token = authService.signin(sigininRequestDto).getBearerToken();
        return ResponseEntity.ok().header(&quot;Authorization&quot;,token).body(authService.signin(sigininRequestDto));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AuthService.java&lt;/h3&gt;
&lt;pre id=&quot;code_1731656124769&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    @Transactional
    public SiginupResponseDto signup(SiginupRequestDto siginupRequestDto) {

        if(userRepository.existsByEmail(siginupRequestDto.getEmail())) {
            throw new IllegalArgumentException(&quot;이미 존재하는 이메일 입니다.&quot;);
        }

        String encodedPassword = passwordEncoder.encode(siginupRequestDto.getPassword());

        User newUser = new User(siginupRequestDto.getName(), siginupRequestDto.getEmail(), encodedPassword);

        User savedUser = userRepository.save(newUser);

        String bearerToken = jwtUtil.createToken(
                savedUser.getId(),
                savedUser.getName(),
                savedUser.getEmail(),
                savedUser.getRole()
        );

        return new SiginupResponseDto(bearerToken);
    }

    public SigininResponseDto signin(SigininRequestDto sigininRequestDto) {
        User user = userRepository.findByEmail(sigininRequestDto.getEmail()).orElseThrow(
                () -&amp;gt; new IllegalArgumentException(&quot;이메일을 찾을 수 없습니다.&quot;)
        );

        if (!passwordEncoder.matches(sigininRequestDto.getPassword(), user.getPassword())) {
            throw new IllegalArgumentException(&quot;잘못된 비밀번호 입니다.&quot;);
        }

        String bearerToken = jwtUtil.createToken(
                user.getId(),
                user.getName(),
                user.getEmail(),
                user.getRole()
        );

        return new SigininResponseDto(bearerToken);
    }
}&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;AuthController와 AuthService에서 로그인과 회원가입을 구현하였습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Controller에서 RequestMapping을 보면 &quot;/v1/auth&quot;로 시작하는 것을 알 수 있습니다. 따라서 Filter를 넘어가기 때문에 JWT토큰이 없이 바로 로그인이나 회원가입을 할 수 있습니다.&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;로그인을 하게 되면 JWT토큰을 반환합니다. 현재 방법은 body와 header 모두에서 반환을 하는데 만약 header에서만 반환을 하고 싶다면 body부분을 지우고 사용할 수 있습니다.&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;AuthService에서는 우선 회원가입 할 때 Id중복을 확인하고 비밀번호를 인코딩 한 다음 저장합니다.&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;h3 data-ke-size=&quot;size23&quot;&gt;@Auth 사용하기&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;MemoController.java&lt;/h4&gt;
&lt;pre id=&quot;code_1731656408252&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/memos&quot;)
public class MemoController {

    private final MemoService memoService;

    @PostMapping
    public ResponseEntity&amp;lt;MemoResponseDto&amp;gt; createMemo(@Auth AuthUser authUser, @RequestBody MemoRequsetDto memoRequsetDto) {
        MemoResponseDto memoResponseDto = memoService.createMemo(authUser, memoRequsetDto);
        return ResponseEntity.status(HttpStatus.OK).body(memoResponseDto);
    }

    @GetMapping
    public ResponseEntity&amp;lt;List&amp;lt;MemoResponseDto&amp;gt;&amp;gt; getMemo() {
        List&amp;lt;MemoResponseDto&amp;gt; memoResponseDtoList = memoService.getMemo();
        return ResponseEntity.status(HttpStatus.OK).body(memoResponseDtoList);
    }

    @PatchMapping(&quot;/{memoId}&quot;)
    public ResponseEntity&amp;lt;MemoResponseDto&amp;gt; patchMemo(@PathVariable(&quot;memoId&quot;) Long memoId, @RequestBody MemoRequsetDto memoRequsetDto) {
        MemoResponseDto memoResponseDto = memoService.patchMemo(memoId, memoRequsetDto);
        return ResponseEntity.status(HttpStatus.OK).body(memoResponseDto);
    }

    @DeleteMapping(&quot;/{memoId}&quot;)
    public void deleteMemo(@PathVariable(&quot;memoId&quot;) Long memoId) {
        memoService.deleteMemo(memoId);
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;MemoService.java&lt;/h3&gt;
&lt;pre id=&quot;code_1731656460493&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class MemoService {

    private final MemoRepository memoRepository;
    private final UserRepository userRepository;

    public MemoResponseDto createMemo(AuthUser authUser, MemoRequsetDto memoRequsetDto) {
        User findUser = userRepository.findById(authUser.getId()).orElseThrow(
                ()-&amp;gt; new IllegalArgumentException(&quot;유저를 찾을 수 없습니다.&quot;));

        Memo memo = new Memo(findUser.getName(), memoRequsetDto);
        return new MemoResponseDto(memoRepository.save(memo));
    }

    public List&amp;lt;MemoResponseDto&amp;gt; getMemo() {
        return memoRepository.findAll().stream().map(MemoResponseDto::new).toList();
    }

    @Transactional
    public MemoResponseDto patchMemo(Long memoId, MemoRequsetDto memoRequsetDto) {
        Memo findMemo = memoRepository.findById(memoId).orElseThrow();
        return new MemoResponseDto(findMemo.patchMemo(memoRequsetDto));
    }

    public void deleteMemo(Long memoId) {
        memoRepository.delete(memoRepository.findById(memoId).orElseThrow());
    }
}&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;메모 CRUD를 간단하게 만들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Auth 어노테이션은 위와 같이 유저의 정보를 가져올 수 있습니다.&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;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT를 이용한 로그인 구현, 메모 구현에 관한 코드입니다.&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/SuHyun-git/spring-basecode/tree/study/jwt&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/SuHyun-git/spring-basecode/tree/study/jwt&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1731656640960&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 - SuHyun-git/spring-basecode&quot; data-og-description=&quot;Contribute to SuHyun-git/spring-basecode development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/SuHyun-git/spring-basecode/tree/study/jwt&quot; data-og-url=&quot;https://github.com/SuHyun-git/spring-basecode&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Locwq/hyXzRcm42Z/sShYSBd3dfE3R0FlSVcxCk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/ewsTor/hyXzMhOa5f/RFjvi6eEgh4e9cbI4ofHy1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/SuHyun-git/spring-basecode/tree/study/jwt&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/SuHyun-git/spring-basecode/tree/study/jwt&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Locwq/hyXzRcm42Z/sShYSBd3dfE3R0FlSVcxCk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/ewsTor/hyXzMhOa5f/RFjvi6eEgh4e9cbI4ofHy1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&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 - SuHyun-git/spring-basecode&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to SuHyun-git/spring-basecode 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>CS &amp;amp; 공부한 내용</category>
      <category>JWT</category>
      <category>springboot</category>
      <category>로그인</category>
      <category>오블완</category>
      <category>인가</category>
      <category>인증</category>
      <category>티스토리챌린지</category>
      <category>회원가입</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/197</guid>
      <comments>https://coding-su.tistory.com/197#entry197comment</comments>
      <pubDate>Fri, 15 Nov 2024 20:05:51 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] 스레드(Thread)</title>
      <link>https://coding-su.tistory.com/199</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;스레드란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드란 프로그램에서 실행되는 작업의 최소 단위입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 API를 호출하면 Spring에서 스레드가 요청을 처리합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;여기 한 식당이 있습니다. 그 식당에는 &lt;b&gt;종업원(Thread)&lt;/b&gt; 이 있습니다. 각각의 종업원은 &lt;b&gt;손님(Client)&lt;/b&gt;&lt;br /&gt;들에게 &lt;b&gt;주문(request)&lt;/b&gt;을 받고 &lt;b&gt;주방(Spring MVC)&lt;/b&gt; 에서 &lt;b&gt;음식(response)&lt;/b&gt; 이 완성이되면 음식을 손님에게 &lt;br /&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능이 좋은 서버란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 하나의 스레드를 극한의 효율로 사용하기(싱글 스레드), 비동기 방식과 non-blocking방식을 사용 -&amp;gt; Node.js&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 스레드를 여러개 사용하기(멀티 스레드), 비공기 방식과 non-blocking 방식을 사용 -&amp;gt; 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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동기와 비동기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동기: 동기 방식에서는 작업이 순차적으로 진행됩니다. 즉, 하나의 작업이 끝나야 다음 작업이 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시: 함수 A가 함수B를 호출하고, 함수 B의 실행이 완료될 때까지 함수 A가 기다리는 경우, 이는 동기 호출입니다.&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;h3 data-ke-size=&quot;size23&quot;&gt;Blocking과 Non-Blocking&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Blocking: 블로킹은 특정 작업이 완료될 때까지 현재 스레드의 실행을 멈추는 것을 의미합니다. 블로킹 호출에서는 현재 작업이 완료될 때까지 프로그램의 다른 작업이나 이벤트가 실행되지 않습니다.&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;Non-Blocking: 논블로킹 방식에서는 작업을 수행하는 동안 해당 작업이 완료될 때까지 기다리지 않고, 즉시 제어권을 반환합니다. 논블로킹 호출은 결과가 즉시 반환하거나 작업이 아직 완료되지 않았음을 의미합니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;스레드 풀이란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;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;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;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;내용 정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API요청이 들어오면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 가장 머너저 서블릿 컨테이너에 들어옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 스레드 풀에서 스레드가 할당됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 할당된 스레드는 필터 페인에 등록된 필터를 순서대로 통과합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 필터를 통화한 스레드는 DispatcherServlet을 호출하여 어떤 Servlet을 사용할 것인지 결정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. Spring 3 Layer 계층을 통과합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1360&quot; data-origin-height=&quot;765&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDBCmM/btsKLuaImmo/UnBWO5HVwfs3f7T4RtlEyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDBCmM/btsKLuaImmo/UnBWO5HVwfs3f7T4RtlEyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDBCmM/btsKLuaImmo/UnBWO5HVwfs3f7T4RtlEyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDBCmM%2FbtsKLuaImmo%2FUnBWO5HVwfs3f7T4RtlEyk%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;1360&quot; height=&quot;765&quot; data-origin-width=&quot;1360&quot; data-origin-height=&quot;765&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>CS &amp;amp; 공부한 내용</category>
      <category>springboot</category>
      <category>스레드</category>
      <category>스레드풀</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/199</guid>
      <comments>https://coding-su.tistory.com/199#entry199comment</comments>
      <pubDate>Fri, 15 Nov 2024 17:24:34 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] Spring Security를 이용하여 인증인가 구현하기</title>
      <link>https://coding-su.tistory.com/198</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Security란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security는 인증, 인가를 구현하는 데 도움을 주는 Spring의 프레임워크입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security를 이용하면 전에 구현했던 어노테이션을 만드는 과정도 매우 쉽게 구현할 수 있습니다.&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://coding-su.tistory.com/197&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전에 JWT를 이용한 인증인가&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1731656976967&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[Spring Boot] JWT를 이용한 인증/인가 구현하기(Spring Security X)&quot; data-og-description=&quot;JWT를 사용하기 위한 파일FilterConfig.java@Configuration@RequiredArgsConstructorpublic class FilterConfig { private final JwtUtil jwtUtil; @Bean public FilterRegistrationBean jwtFilter() { FilterRegistrationBean registrationBean = new FilterRegistr&quot; data-og-host=&quot;coding-su.tistory.com&quot; data-og-source-url=&quot;https://coding-su.tistory.com/197&quot; data-og-url=&quot;https://coding-su.tistory.com/197&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cixpeZ/hyXzVlwhon/Ewynofrccks3CaXemVYhSk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/g95a6/hyXzImaLAO/Q2YAZZ0TMhCkbfR3XZNwlK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/MfLd2/hyXwllUbjC/hFCFK0xsaqzE8oKsKWEVRk/img.png?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400&quot;&gt;&lt;a href=&quot;https://coding-su.tistory.com/197&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://coding-su.tistory.com/197&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cixpeZ/hyXzVlwhon/Ewynofrccks3CaXemVYhSk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/g95a6/hyXzImaLAO/Q2YAZZ0TMhCkbfR3XZNwlK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/MfLd2/hyXwllUbjC/hFCFK0xsaqzE8oKsKWEVRk/img.png?width=400&amp;amp;height=400&amp;amp;face=0_0_400_400');&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;[Spring Boot] JWT를 이용한 인증/인가 구현하기(Spring Security X)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;JWT를 사용하기 위한 파일FilterConfig.java@Configuration@RequiredArgsConstructorpublic class FilterConfig { private final JwtUtil jwtUtil; @Bean public FilterRegistrationBean jwtFilter() { FilterRegistrationBean registrationBean = new FilterRegistr&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;coding-su.tistory.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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Security를 이용한 인증/인가 구현하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에 있는 전에 사용한 코드를 변경하여 구현하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JwtUtil.java&lt;/h4&gt;
&lt;pre id=&quot;code_1731657193076&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j(topic = &quot;JwtUtil&quot;)
@Component
public class JwtUtil {

    private static final String BEARER_PREFIX = &quot;Bearer &quot;;
    private static final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value(&quot;${jwt.secret.key}&quot;)
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    public String createToken(Long userId, String email, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim(&quot;email&quot;, email)
                        .claim(&quot;userRole&quot;, userRole.getUserRole())
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) &amp;amp;&amp;amp; tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        log.error(&quot;Not Found Token&quot;);
        throw new NullPointerException(&quot;Not Found Token&quot;);
    }

    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}&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;SecurityConfig.java&lt;/p&gt;
&lt;pre id=&quot;code_1731657221817&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfig {

    private final JwtSecurityFilter jwtSecurityFilter;
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -&amp;gt; session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // SessionManagementFilter, SecurityContextPersistenceFilter
                )
                .addFilterBefore(jwtSecurityFilter, SecurityContextHolderAwareRequestFilter.class)
                .formLogin(AbstractHttpConfigurer::disable) // UsernamePasswordAuthenticationFilter, DefaultLoginPageGeneratingFilter 비활성화
                .anonymous(AbstractHttpConfigurer::disable) // AnonymousAuthenticationFilter 비활성화
                .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화
                .logout(AbstractHttpConfigurer::disable) // LogoutFilter 비활성화
                .authorizeHttpRequests(auth -&amp;gt; auth
                        .requestMatchers(&quot;/auth/signin&quot;, &quot;/auth/signup&quot;).permitAll()
                        .requestMatchers(&quot;/test&quot;).hasAuthority(UserRole.Authority.ADMIN)
                        .anyRequest().authenticated()
                )
                .build();
    }
}&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;Filter를 등록해줍니다. 여기서 여러가지 내용을 비활성화 하는 이유는 Spring Security는 JWT 토큰 방식보다는 세션 방식에 조금 더 치중되어 있어 세션기능에서 필요한 기능은 JWT 토큰 방식에서는 필요 없기 때문에 비활성화 하였습니다.&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;permitAll은 전에 Spring Security 없이 구현했을 때와 마찬가지로 로그인과 회원가입은 Filter의 검증을 통과해야 하기 때문에 permitAll로 통과시켜주어야 합니다.&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 Security를 적용하시는 분들이 permitAll 설정을 하지 않아 로그인과 회원가입도 막히는 경우가 있기 때문에 꼭 설정을 해야합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JwtSecurityFilter.java&lt;/h4&gt;
&lt;pre id=&quot;code_1731657434294&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Component
@RequiredArgsConstructor
public class JwtSecurityFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(
            HttpServletRequest httpRequest,
            @NonNull HttpServletResponse httpResponse,
            @NonNull FilterChain chain
    ) throws ServletException, IOException {
        String authorizationHeader = httpRequest.getHeader(&quot;Authorization&quot;);

        if (authorizationHeader != null &amp;amp;&amp;amp; authorizationHeader.startsWith(&quot;Bearer &quot;)) {
            String jwt = jwtUtil.substringToken(authorizationHeader);
            try {
                Claims claims = jwtUtil.extractClaims(jwt);
                Long userId = Long.valueOf(claims.getSubject());
                String email = claims.get(&quot;email&quot;, String.class);
                UserRole userRole = UserRole.of(claims.get(&quot;userRole&quot;, String.class));

                if (SecurityContextHolder.getContext().getAuthentication() == null) {
                    AuthUser authUser = new AuthUser(userId, email, userRole);

                    JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authUser);
                    authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            } catch (SecurityException | MalformedJwtException e) {
                log.error(&quot;Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.&quot;, e);
                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;유효하지 않는 JWT 서명입니다.&quot;);
            } catch (ExpiredJwtException e) {
                log.error(&quot;Expired JWT token, 만료된 JWT token 입니다.&quot;, e);
                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;만료된 JWT 토큰입니다.&quot;);
            } catch (UnsupportedJwtException e) {
                log.error(&quot;Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.&quot;, e);
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, &quot;지원되지 않는 JWT 토큰입니다.&quot;);
            } catch (Exception e) {
                log.error(&quot;Internal server error&quot;, e);
                httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            }
        }
        chain.doFilter(httpRequest, httpResponse);
    }
}&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;Filter를 만들어 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;JwtAuthenticationToken.java&lt;/h4&gt;
&lt;pre id=&quot;code_1731657481600&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final AuthUser authUser;

    public JwtAuthenticationToken(AuthUser authUser) {
        super(authUser.getAuthorities());
        this.authUser = authUser;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return authUser;
    }
}&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;Argument Resolver입니다. Spring Security를 사용하지 않을 때는 많은 설정이 필요했지만 Spring Security를 사용하면 위 파일 하나로 해결할 수 있습니다.&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 Security를 사용하지 않았을 때는 PasswordEncoder를 사용했지만 Spring Security에서는 기본적으로 제공하기 때문에 구현할 필요가 없습니다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Security를 사용하여 인증/인가, 메모를 구현한 코드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/SuHyun-git/spring-basecode/tree/study/springsecurity&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/SuHyun-git/spring-basecode/tree/study/springsecurity&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1731657640283&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 - SuHyun-git/spring-basecode&quot; data-og-description=&quot;Contribute to SuHyun-git/spring-basecode development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/SuHyun-git/spring-basecode/tree/study/springsecurity&quot; data-og-url=&quot;https://github.com/SuHyun-git/spring-basecode&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cU3BLb/hyXwuwmXhV/jkKggjhIw08MuSgthxNRAk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/eqgzb5/hyXzIzJjYc/lSA3N174hwAIVPlMOM2jV0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/SuHyun-git/spring-basecode/tree/study/springsecurity&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/SuHyun-git/spring-basecode/tree/study/springsecurity&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cU3BLb/hyXwuwmXhV/jkKggjhIw08MuSgthxNRAk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/eqgzb5/hyXzIzJjYc/lSA3N174hwAIVPlMOM2jV0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&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 - SuHyun-git/spring-basecode&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to SuHyun-git/spring-basecode 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>CS &amp;amp; 공부한 내용</category>
      <category>springboot</category>
      <category>springsecutiry</category>
      <category>로그인</category>
      <category>인가</category>
      <category>인증</category>
      <category>회원가입</category>
      <author>Coding-Su</author>
      <guid isPermaLink="true">https://coding-su.tistory.com/198</guid>
      <comments>https://coding-su.tistory.com/198#entry198comment</comments>
      <pubDate>Fri, 15 Nov 2024 17:01:25 +0900</pubDate>
    </item>
  </channel>
</rss>