<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>E.NO.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 04 May 2026 08:55:46 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>Copyright (C) 2019. E.NO.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/eno_lj" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Kafka 기반 비동기 알림 시스템 설계와 구현]]></title>
            <link>https://velog.io/@eno_lj/Kafka-%EA%B8%B0%EB%B0%98-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%95%8C%EB%A6%BC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84%EC%99%80-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@eno_lj/Kafka-%EA%B8%B0%EB%B0%98-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%95%8C%EB%A6%BC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84%EC%99%80-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 04 May 2026 08:55:46 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-이걸-만들었나">왜 이걸 만들었나</h2>
<p>  결제 완료, 멘토링 수락, 구독 활성화 같은 비즈니스 이벤트가 발생할 때마다<br>  사용자에게 즉시 알림을 전달해야 했다.
  초기에는 서비스 로직 내부에서 바로 알림을 생성하는 방식을 생각했지만,<br>  이 방식에는 구조적인 문제가 있었다.                                                                                                                                                                                     </p>
<hr>
<h2 id="1-기존-방식의-문제--동기-처리의-한계">1. 기존 방식의 문제 — 동기 처리의 한계</h2>
<pre><code class="language-java">  // ❌ 서비스 내부에서 직접 알림 생성                                                                                                                                                                                  e
  mentorshipRepository.save(mentorship);
  notificationService.create(...); // 알림 실패 → 멘토링 신청 트랜잭션도 롤백                                                                                                                                             </code></pre>
<p>  이 방식의 문제는 세 가지다.                                                                                                                                                                                             </p>
<ul>
<li><p><strong>강결합</strong>: 비즈니스 로직과 알림 로직이 하나의 트랜잭션으로 묶인다                                                                                                                                                    </p>
</li>
<li><p><strong>트랜잭션 오염</strong>: 알림 저장 실패 시 멘토링 신청 자체가 롤백된다                                                                                                                                                    </p>
</li>
<li><p><strong>성능 저하</strong>: 알림 처리 시간이 응답 시간에 직접 영향을 준다                                                                                                                                                          </p>
<p>알림은 비즈니스 처리가 <strong>완료된 이후</strong>에 <strong>독립적으로</strong> 처리되어야 한다.                                                                                                                                                </p>
</li>
</ul>
<hr>
<h2 id="2-메시지-브로커-선택--redis-vs-rabbitmq-vs-kafka">2. 메시지 브로커 선택 — Redis vs RabbitMQ vs Kafka</h2>
<table>
<thead>
<tr>
<th></th>
<th>Redis Pub/Sub</th>
<th>RabbitMQ</th>
<th>Kafka</th>
</tr>
</thead>
<tbody><tr>
<td>메시지 저장</td>
<td>❌ (fire-and-forget)</td>
<td>✅ (큐 기반)</td>
<td>✅ (디스크 영속)</td>
</tr>
<tr>
<td>재처리</td>
<td>❌</td>
<td>△ (재큐 가능)</td>
<td>✅ (offset 기반)</td>
</tr>
<tr>
<td>처리량</td>
<td>중간</td>
<td>중간</td>
<td>높음</td>
</tr>
<tr>
<td>순서 보장</td>
<td>❌</td>
<td>△ (큐 단위)</td>
<td>✅ (파티션 키 기반)</td>
</tr>
<tr>
<td>확장성</td>
<td>❌ (fan-out 부하)</td>
<td>△</td>
<td>✅ (파티션 수평 확장)</td>
</tr>
</tbody></table>
<h3 id="redis-pubsub을-선택하지-않은-이유">Redis Pub/Sub을 선택하지 않은 이유</h3>
<p>  채팅 시스템에서 이미 Redis Pub/Sub을 사용하고 있다.<br>  Redis Pub/Sub은 구독 중이 아닌 서버가 메시지를 놓치면 영원히 복구할 수 없다.<br>  알림은 메시지 유실이 곧 사용자 경험 손상이기 때문에 부적합하다.                                                                                                                                                         </p>
<h3 id="rabbitmq를-선택하지-않은-이유">RabbitMQ를 선택하지 않은 이유</h3>
<p>  RabbitMQ는 큐 기반으로 메시지를 보관하고 ACK 처리도 지원한다.<br>  하지만 파티션 개념이 없어 사용자별 순서 보장이 어렵고,<br>  대규모 트래픽에서 수평 확장이 Kafka보다 불리하다.                                                                                                                                                                       </p>
<h3 id="kafka를-선택한-이유">Kafka를 선택한 이유</h3>
<ul>
<li><strong>메시지 영속성</strong>: 디스크에 저장하므로 서버가 다운돼도 복구 가능                                                                                                                                                      </li>
<li><strong>offset 기반 재처리</strong>: 컨슈머가 실패해도 오프셋부터 다시 읽을 수 있다</li>
<li><strong>파티션 키로 순서 보장</strong>: <code>key = userId</code>로 같은 사용자의 알림은 항상 같은 파티션에 쌓인다                                                                                                                            </li>
<li><strong>수평 확장</strong>: 파티션 수를 늘리면 컨슈머도 함께 늘려 처리량을 선형으로 확장할 수 있다                                                                                                                                 </li>
</ul>
<hr>
<h2 id="3-전체-구조">3. 전체 구조</h2>
<pre><code>  [비즈니스 서비스]                                                                                                                                                                                                     
      ↓  ApplicationEventPublisher.publishEvent()
  [Spring Application Event]                                                                                                                                                                                              
      ↓  트랜잭션 커밋 이후 (@TransactionalEventListener)
  [NotificationEventRelay]                                                                                                                                                                                                
      ↓  KafkaTemplate.send()                                                                                                                                                                                             
  [Kafka Broker (partition 3, replica 3)]                                                                                                                                                                                 
      ↓                                                                                                                                                                                                                   
  [NotificationService @KafkaListener]                                                                                                                                                                                  
      ↓                                                                                                                                                                                                                   
  [DB 저장 (eventId unique 제약으로 멱등성 보장)]                                                                                                                                                                       
      ↓                                                                                                                                                                                                                   
  [WebSocket 브로드캐스트 → 클라이언트]                                                                                                                                                                                 </code></pre><hr>
<h2 id="4-핵심-설계-1--트랜잭션-이후-이벤트-발행">4. 핵심 설계 1 — 트랜잭션 이후 이벤트 발행</h2>
<h3 id="문제">문제</h3>
<p>  DB 저장이 실패했는데 알림이 먼저 발송되면 데이터 불일치가 생긴다.<br>  반대로 알림 발송 로직을 같은 트랜잭션에 넣으면 알림 실패가 비즈니스 로직을 롤백시킨다.</p>
<h3 id="해결-applicationeventpublisher--transactionaleventlistener">해결: ApplicationEventPublisher + @TransactionalEventListener</h3>
<p>  비즈니스 서비스에서는 이벤트만 등록하고, 실제 Kafka 발행은 트랜잭션 커밋 이후에 실행한다.                                                                                                                               </p>
<p>  <strong>이벤트 등록 (서비스 레이어)</strong>                                                                                                                                                                                         </p>
<pre><code class="language-java">  // MentorshipService.java - 멘토링 신청                                                                                                                                                                               
  @Transactional                                                                                                                                                                                                          
  public MentorshipCreateResponse applyMentorship(...) {
      Mentorship saved = mentorshipRepository.save(mentorship);                                                                                                                                                           

      // 트랜잭션 내부에서는 이벤트만 등록                                                                                                                                                                                
      eventPublisher.publishEvent(                                                                                                                                                                                        
          NotificationEvent.mentorshipRequest(mentor.getUserId(), mentee.getNickname(), saved.getId())
      );                                                                                                                                                                                                                  

      return MentorshipCreateResponse.from(saved.getId());                                                                                                                                                                
  }                                                                                                                                                                                                                     </code></pre>
<pre><code class="language-java">  // MentoringService.java - 멘토링 수락
  @Transactional                                                                                                                                                                                                          
  public void approveMentee(...) {                                                                                                                                                                                      
      mentorship.approve();

      eventPublisher.publishEvent(
          NotificationEvent.mentorshipAccepted(menteeId, mentorNickname, mentoringId)                                                                                                                                     
      );                                                                                                                                                                                                                  
  }</code></pre>
<p>  <strong>트랜잭션 커밋 후 Kafka 발행</strong>                                                                                                                                                                                         </p>
<pre><code class="language-java">  // NotificationEventRelay.java                                                                                                                                                                                        
  @Slf4j
  @Component
  @RequiredArgsConstructor
  public class NotificationEventRelay {                                                                                                                                                                                   

      private final NotificationProducer notificationProducer;                                                                                                                                                            

      @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
      public void relay(NotificationEvent event) {                                                                                                                                                                        
          log.debug(&quot;[알림 릴레이] 트랜잭션 커밋 후 Kafka 발행 userId={} type={}&quot;, event.userId(), event.type());
          notificationProducer.publish(event);                                                                                                                                                                            
      }                                                                                                                                                                                                                 
  }                                                                                                                                                                                                                       </code></pre>
<p>  <code>AFTER_COMMIT</code>이 핵심이다.<br>  DB 커밋이 완료된 시점에만 Kafka에 발행하므로 데이터 정합성이 보장된다.
  비즈니스 트랜잭션이 롤백되면 이 메서드 자체가 실행되지 않는다.                                                                                                                                                          </p>
<hr>
<h2 id="5-이벤트-dto--notificationevent">5. 이벤트 DTO — NotificationEvent</h2>
<p>  알림의 종류마다 팩토리 메서드를 만들어 호출부 코드를 선언적으로 유지했다.                                                                                                                                               </p>
<pre><code class="language-java">  // NotificationEvent.java                                                                                                                                                                                             
  public record NotificationEvent(
          String eventId,       // UUID — 멱등성 키                                                                                                                                                                       
          Long userId,                                                                                                                                                                                                    
          NotificationType type,                                                                                                                                                                                          
          String title,                                                                                                                                                                                                   
          String message,                                                                                                                                                                                               
          Long referenceId,
          String referenceType                                                                                                                                                                                            
  ) {
      private static String newId() {                                                                                                                                                                                     
          return UUID.randomUUID().toString();                                                                                                                                                                            
      }

      public static NotificationEvent mentorshipRequest(Long mentorUserId, String applicantName, Long mentorshipId) {                                                                                                   
          return new NotificationEvent(newId(), mentorUserId, NotificationType.MENTORSHIP_REQUEST,                                                                                                                        
                  &quot;멘토링 신청 도착&quot;,                                                                                                                                                                                     
                  String.format(&quot;%s님이 멘토링을 신청했습니다.&quot;, applicantName),                                                                                                                                          
                  mentorshipId, &quot;MENTORSHIP&quot;);                                                                                                                                                                            
      }                                                                                                                                                                                                                   

      public static NotificationEvent paymentSuccess(Long userId, String orderName, long amount, Long paymentId) {                                                                                                        
          return new NotificationEvent(newId(), userId, NotificationType.PAYMENT_SUCCESS,                                                                                                                                 
                  &quot;결제 성공&quot;,
                  String.format(&quot;&#39;%s&#39; 결제가 완료되었습니다. (%d원)&quot;, orderName, amount),                                                                                                                                 
                  paymentId, &quot;PAYMENT&quot;);                                                                                                                                                                                  
      }                                                                                                                                                                                                                   

      public static NotificationEvent episodePublished(Long followerId, String authorName, String novelTitle, Long episodeId) {                                                                                           
          return new NotificationEvent(newId(), followerId, NotificationType.EPISODE_PUBLISHED,                                                                                                                           
                  &quot;구독 작가 신작 발행&quot;,
                  String.format(&quot;%s 작가님의 &#39;%s&#39; 신규 회차가 발행되었습니다.&quot;, authorName, novelTitle),                                                                                                                  
                  episodeId, &quot;EPISODE&quot;);                                                                                                                                                                                  
      }                                                                                                                                                                                                                   

      // 결제 실패, 구독 활성화, 포인트 충전, 환불, 이벤트 생성 등 동일한 패턴으로 확장                                                                                                                                   
  }                                                                                                                                                                                                                       </code></pre>
<p>  <code>eventId</code>에 UUID를 부여하는 것이 핵심이다.<br>  Kafka는 at-least-once 전달을 보장하므로 같은 메시지가 두 번 이상 올 수 있다.
  이 <code>eventId</code>가 나중에 멱등성 처리의 키가 된다.                                                                                                                                                                          </p>
<hr>
<h2 id="6-kafka-설정--producer-consumer-topic">6. Kafka 설정 — Producer, Consumer, Topic</h2>
<pre><code class="language-java">  // KafkaConfig.java
  @Configuration
  @EnableKafka
  public class KafkaConfig {

      @Bean
      public NewTopic notificationTopic() {
          return TopicBuilder.name(notificationTopic)
                  .partitions(3)   // 병렬 처리 단위
                  .replicas(3)     // 브로커 장애 대비 복제본
                  .build();
      }

      @Bean
      public ProducerFactory&lt;String, NotificationEvent&gt; notificationProducerFactory() {
          Map&lt;String, Object&gt; config = new HashMap&lt;&gt;();
          config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
          config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
          config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
          config.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, RoundRobinPartitioner.class.getName());
          return new DefaultKafkaProducerFactory&lt;&gt;(config);
      }

      @Bean
      public ConcurrentKafkaListenerContainerFactory&lt;String, NotificationEvent&gt; notificationKafkaListenerContainerFactory() {
          ConcurrentKafkaListenerContainerFactory&lt;String, NotificationEvent&gt; factory =
                  new ConcurrentKafkaListenerContainerFactory&lt;&gt;();
          factory.setConsumerFactory(notificationConsumerFactory());
          factory.setConcurrency(3); // 파티션 수와 동일 → 컨슈머 1개당 파티션 1개 담당
          return factory;                                                                                                                                                                                                 
      }
  }                                                                                                                                                                                                                       </code></pre>
<ul>
<li><code>partitions(3)</code> — 3개의 파티션으로 병렬 처리                                                                                                                                                                          </li>
<li><code>replicas(3)</code> — 브로커 1대가 다운돼도 메시지 유실 없음</li>
<li><code>setConcurrency(3)</code> — 파티션 수와 동일하게 맞춰 컨슈머 스레드가 파티션을 1:1로 담당                                                                                                                                   </li>
</ul>
<hr>
<h2 id="7-kafka-producer--비동기-발행">7. Kafka Producer — 비동기 발행</h2>
<pre><code class="language-java">  // NotificationProducer.java
  @Slf4j
  @Component                                                                                                                                                                                                              
  @RequiredArgsConstructor
  public class NotificationProducer {                                                                                                                                                                                     

      private final KafkaTemplate&lt;String, NotificationEvent&gt; notificationKafkaTemplate;

      @Value(&quot;${notification.kafka.topic}&quot;)
      private String topic;                                                                                                                                                                                               

      public void publish(NotificationEvent event) {                                                                                                                                                                      
          try {                                                                                                                                                                                                         
              notificationKafkaTemplate.send(topic, String.valueOf(event.userId()), event)
                      .whenComplete((result, ex) -&gt; {                                                                                                                                                                     
                          if (ex != null) {                                                                                                                                                                               
                              log.error(&quot;[Kafka] 알림 발행 실패 userId={} type={}&quot;, event.userId(), event.type(), ex);                                                                                                    
                          } else {                                                                                                                                                                                        
                              log.debug(&quot;[Kafka] 알림 발행 성공 userId={} type={} partition={}&quot;,                                                                                                                        
                                      event.userId(), event.type(), result.getRecordMetadata().partition());                                                                                                              
                          }                                                                                                                                                                                             
                      });                                                                                                                                                                                                 
          } catch (Exception e) {                                                                                                                                                                                       
              log.error(&quot;[Kafka] 알림 발행 요청 실패 userId={} type={}&quot;, event.userId(), event.type(), e);                                                                                                                
          }
      }                                                                                                                                                                                                                   
  }                                                                                                                                                                                                                     </code></pre>
<p>  <code>key = String.valueOf(event.userId())</code>가 핵심이다.<br>  Kafka는 같은 key를 가진 메시지를 항상 같은 파티션으로 라우팅한다.
  즉, 같은 사용자의 알림은 항상 같은 파티션에 순서대로 쌓인다.                                                                                                                                                            </p>
<hr>
<h2 id="8-핵심-설계-2--멱등성-보장-중복-처리-방지">8. 핵심 설계 2 — 멱등성 보장 (중복 처리 방지)</h2>
<p>  Kafka는 <strong>at-least-once</strong> 전달을 보장한다.<br>  네트워크 장애나 컨슈머 재시작 시 같은 메시지가 두 번 이상 전달될 수 있다.<br>  이를 그냥 두면 사용자가 같은 알림을 여러 번 받게 된다.                                                                                                                                                                  </p>
<h3 id="해결-db-unique-제약--중복-감지">해결: DB unique 제약 + 중복 감지</h3>
<pre><code class="language-java">  // NotificationService.java                                                                                                                                                                                           
  @KafkaListener(
          topics = &quot;${notification.kafka.topic}&quot;,
          containerFactory = &quot;notificationKafkaListenerContainerFactory&quot;
  )                                                                                                                                                                                                                       
  public void consume(NotificationEvent event) {
      Notification notification = Notification.create(                                                                                                                                                                    
              event.eventId(), // UUID — unique 제약 컬럼                                                                                                                                                                 
              event.userId(),                                                                                                                                                                                             
              event.type(),                                                                                                                                                                                               
              event.title(),                                                                                                                                                                                              
              event.message(),                                                                                                                                                                                          
              event.referenceId(),
              event.referenceType()
      );                                                                                                                                                                                                                  

      boolean isDuplicate = false;                                                                                                                                                                                        
      try {                                                                                                                                                                                                             
          notificationRepository.save(notification);
      } catch (DataIntegrityViolationException e) {                                                                                                                                                                       
          if (!isDuplicateEventId(e)) {
              // eventId 외 다른 제약 위반 (null, 길이 초과 등) → 재던져서 Kafka가 재처리하도록                                                                                                                           
              throw e;                                                                                                                                                                                                    
          }                                                                                                                                                                                                               
          // eventId 중복: 이미 DB 저장은 됐지만 WebSocket 전송이 누락된 케이스 → 재전송 시도                                                                                                                             
          notification = notificationRepository.findByEventId(event.eventId()).orElse(null);                                                                                                                              
          if (notification == null) return;                                                                                                                                                                               
          isDuplicate = true;                                                                                                                                                                                             
      }                                                                                                                                                                                                                   

      // WebSocket 전송: 실패해도 Kafka 재소비가 발생하지 않도록 예외를 삼킨다
      // (DB 저장은 이미 완료됐으므로 클라이언트는 API로 조회 가능)                                                                                                                                                       
      try {                                                                                                                                                                                                               
          NotificationResponse response = NotificationResponse.from(notification);                                                                                                                                        
          messagingTemplate.convertAndSend(&quot;/topic/notifications/&quot; + event.userId(), response);                                                                                                                           
      } catch (Exception e) {                                                                                                                                                                                             
          log.warn(&quot;[알림] WebSocket 전송 실패 (DB 저장은 완료) userId={} type={}&quot;, event.userId(), event.type(), e);                                                                                                     
      }                                                                                                                                                                                                                   
  }                                                                                                                                                                                                                       

  private boolean isDuplicateEventId(DataIntegrityViolationException e) {                                                                                                                                               
      Throwable cause = e.getCause();                                                                                                                                                                                     
      String message = (cause instanceof SQLIntegrityConstraintViolationException)
              ? cause.getMessage()                                                                                                                                                                                        
              : e.getMessage();                                                                                                                                                                                         
      return message != null &amp;&amp; message.contains(&quot;event_id&quot;);                                                                                                                                                             
  }                                                                                                                                                                                                                     </code></pre>
<p>  <strong>중복 처리 로직의 핵심:</strong></p>
<ol>
<li><code>eventId</code>에 DB unique 제약을 걸어 중복 삽입을 DB 레벨에서 막는다                                                                                                                                                     </li>
<li><code>DataIntegrityViolationException</code> 발생 시 <code>eventId</code> 중복인지 먼저 확인한다</li>
<li><code>eventId</code> 중복이면 이미 DB에 저장된 알림을 재조회해서 WebSocket 재전송만 시도한다                                                                                                                                    </li>
<li>그 외 제약 위반(null, 길이 초과 등)이면 예외를 다시 던져 Kafka가 재처리하도록 한다                                                                                                                                   </li>
</ol>
<p>  <strong>실패 처리 전략:</strong>                                                                                                                                                                                                     </p>
<table>
<thead>
<tr>
<th>실패 지점</th>
<th>처리 방식</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>DB 저장 실패</td>
<td>예외 던짐 → Kafka 재소비</td>
<td>저장이 안 됐으므로 재처리 필요</td>
</tr>
<tr>
<td>WebSocket 전송 실패</td>
<td>예외 삼킴, 경고 로그만</td>
<td>DB는 저장됨, 클라이언트가 API로 조회 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="9-notification-엔티티--eventid-unique-제약">9. Notification 엔티티 — eventId unique 제약</h2>
<pre><code class="language-java">  // Notification.java                                                                                                                                                                                                  
  @Entity
  @Table(name = &quot;notifications&quot;)
  public class Notification extends BaseEntity {                                                                                                                                                                          

      @Column(nullable = false, unique = true, length = 36)                                                                                                                                                               
      private String eventId;  // UUID — 멱등성 보장의 핵심                                                                                                                                                               

      @Column(nullable = false)                                                                                                                                                                                           
      private Long userId;                                                                                                                                                                                              

      @Enumerated(EnumType.STRING)
      private NotificationType type;                                                                                                                                                                                      

      private String title;
      private String content;
      private Long referenceId;
      private String referenceType;
      private boolean isRead;                                                                                                                                                                                             

      public static Notification create(String eventId, Long userId, NotificationType type,                                                                                                                               
                                        String title, String message, Long referenceId, String referenceType) {                                                                                                         
          Notification notification = new Notification();                                                                                                                                                                 
          notification.eventId = eventId;
          notification.userId = userId;                                                                                                                                                                                   
          // ...                                                                                                                                                                                                        
          notification.isRead = false;
          return notification;                                                                                                                                                                                            
      }

      public void markAsRead() {                                                                                                                                                                                        
          this.isRead = true;
      }                                                                                                                                                                                                                   
  }</code></pre>
<p>  <code>unique = true</code>가 걸린 <code>eventId</code> 컬럼이 멱등성의 핵심이다.<br>  같은 UUID로 두 번 저장을 시도하면 DB가 막아주고, 코드에서 이를 감지해 중복 처리 분기로 들어간다.</p>
<hr>
<h2 id="10-전체-메시지-처리-흐름">10. 전체 메시지 처리 흐름</h2>
<pre><code>  ① 비즈니스 서비스 @Transactional 메서드 실행                                                                                                                                                                          
     ex) 멘토링 수락 → mentorship.approve()

  ② ApplicationEventPublisher.publishEvent(NotificationEvent)
     → 트랜잭션 내부에서 이벤트 등록 (발행은 아직 안 함)                                                                                                                                                                  

  ③ 트랜잭션 커밋 완료

  ④ @TransactionalEventListener(AFTER_COMMIT) 실행                                                                                                                                                                      
     → NotificationEventRelay.relay()                                                                                                                                                                                     
     → KafkaTemplate.send(topic, userId, event)

  ⑤ Kafka Broker가 파티션에 메시지 저장                                                                                                                                                                                 
     → 같은 userId는 항상 같은 파티션 (key 기반 라우팅)                                                                                                                                                                   

  ⑥ @KafkaListener NotificationService.consume() 실행                                                                                                                                                                     
     → DB 저장 (eventId unique 제약으로 중복 방지)                                                                                                                                                                        
     → WebSocket 브로드캐스트 (/topic/notifications/{userId})                                                                                                                                                             

  ⑦ 클라이언트 실시간 수신                                                                                                                                                                                                
     → 실패 시 GET /api/notifications로 조회 가능                                                                                                                                                                         </code></pre><hr>
<h2 id="11-한계점과-개선-방향">11. 한계점과 개선 방향</h2>
<h3 id="❗-kafka-publish-자체가-실패하면-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">❗ Kafka publish 자체가 실패하면?                                                                                                                                                                                   ─</h3>
<p>  <code>@TransactionalEventListener</code>는 트랜잭션 커밋 후에 실행되므로,<br>  이 시점에서 Kafka 브로커가 다운되면 이벤트가 유실된다.</p>
<blockquote>
<p><strong>개선 방향:</strong> Transactional Outbox 패턴 도입<br>→ 이벤트를 별도 <code>outbox</code> 테이블에 같은 트랜잭션으로 저장하고, 스케줄러가 발행하도록 분리                                                                                                                              </p>
</blockquote>
<h3 id="❗-at-least-once이므로-중복-처리-필수-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">❗ at-least-once이므로 중복 처리 필수                                                                                                                                                                               ─</h3>
<p>  현재 <code>eventId</code> unique 제약으로 DB 레벨 멱등성은 보장하지만,<br>  WebSocket 전송은 중복 전송될 수 있다.<br>  클라이언트에서 <code>eventId</code> 기반 중복 제거가 필요하다.                                                                                                                                                                     </p>
<h3 id="❗-비동기-처리로-인한-약간의-지연-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">❗ 비동기 처리로 인한 약간의 지연                                                                                                                                                                                   ─</h3>
<p>  동기 처리 대비 Kafka 브로커를 거치는 latency가 추가된다.<br>  알림의 특성상 수백ms 지연은 허용 범위이지만, 실시간성이 중요한 이벤트라면 별도 고려가 필요하다.                                                                                                                       </p>
<h3 id="❗-kafka-클러스터-운영-복잡성">❗ Kafka 클러스터 운영 복잡성</h3>
<p>  파티션, 레플리카, 컨슈머 그룹 설정을 직접 관리해야 한다.<br>  작은 규모에서는 오히려 RabbitMQ가 운영이 단순할 수 있다.</p>
<blockquote>
<p><strong>개선 방향:</strong>                                                                                                                                                                                                      </p>
<ul>
<li>Dead Letter Queue(DLQ) 도입 — 반복 실패 메시지 별도 격리                                                                                                                                                            </li>
<li>retry/backoff 정책 강화                                                                                                                                                                                             </li>
<li>알림 타입별 topic 분리 (우선순위 차별화)                                                                                                                                                                            </li>
<li>Firebase Cloud Messaging 연동 (앱 푸시 알림)                                                                                                                                                                        </li>
</ul>
</blockquote>
<hr>
<h2 id="12-회고">12. 회고</h2>
<p>  단순히 &quot;Kafka가 좋다더라&quot;가 아니라,<br>  Redis Pub/Sub의 비영속성과 RabbitMQ의 확장성 한계를 짚고 나서<br>  Kafka를 선택한 것이 이 설계의 출발점이었다.                                                                                                                                                                             </p>
<p>  <code>@TransactionalEventListener</code>로 트랜잭션 정합성을 지키고,<br>  <code>eventId</code> unique 제약으로 멱등성을 확보하고,<br>  <code>key = userId</code>로 파티션 기반 순서를 보장한 것이 이 시스템의 핵심 세 축이다.                                                                                                                                             </p>
<p>  WebSocket 전송 실패를 조용히 삼키고 REST API로 보완하는 결정도,<br>  &quot;Kafka 재소비를 막는 것&quot;과 &quot;실시간 전송을 보장하는 것&quot; 사이의 트레이드오프를 의식한 선택이었다.                                                                                                                         </p>
<hr>
<blockquote>
<p><strong>한 줄 요약</strong><br>트랜잭션 커밋 이후 Kafka로 이벤트를 발행하고, 컨슈머에서 eventId unique 제약으로 멱등성을 보장하면서 DB 저장과 WebSocket 브로드캐스트를 처리하는 비동기 알림 시스템을 설계했다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[WebSocket + STOMP + Redis Pub/Sub 기반 채팅 시스템 설계와 구현]]></title>
            <link>https://velog.io/@eno_lj/WebSocket-STOMP-Redis-PubSub-%EA%B8%B0%EB%B0%98-%EC%B1%84%ED%8C%85-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84%EC%99%80-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@eno_lj/WebSocket-STOMP-Redis-PubSub-%EA%B8%B0%EB%B0%98-%EC%B1%84%ED%8C%85-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84%EC%99%80-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Mon, 04 May 2026 08:15:28 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-이걸-만들었나">왜 이걸 만들었나</h2>
<p>  멘토-멘티 간 채팅 기능을 구현하면서 단순히 &quot;기술 스택을 선택&quot;하는 게 아니라,<br>  각 선택에 트레이드오프가 있음을 직접 체감했다.<br>  기술 선택의 이유, 실제 코드 구현, 그리고 남아 있는 한계까지 정리한다.                                                                                                                                                   </p>
<hr>
<h2 id="1-실시간-통신-방식-비교--왜-websocket인가">1. 실시간 통신 방식 비교 — 왜 WebSocket인가</h2>
<h3 id="polling">Polling</h3>
<pre><code>  클라이언트 → 서버 (1초마다) → &quot;새 메시지 있어요?&quot;
  서버 → &quot;없어요&quot; (대부분의 경우)</code></pre><p>  메시지가 없어도 HTTP 요청이 계속 발생한다.<br>  동시 접속자 수 × 요청 주기만큼 서버 부하가 쌓이고, 1초 폴링이면 최대 1초 지연이 생긴다.<br>  채팅에서 1초 지연은 치명적이다.                                                                                                                                                                                         </p>
<h3 id="sse-server-sent-events">SSE (Server-Sent Events)</h3>
<ul>
<li><p><strong>서버 → 클라이언트 단방향</strong>만 가능                                                                                                                                                                                   </p>
</li>
<li><p>클라이언트가 메시지를 보내려면 별도 REST 요청이 필요                                                                                                                                                                </p>
</li>
<li><p>채팅은 양방향이라 구조 자체가 맞지 않는다                                                                                                                                                                             </p>
<h3 id="websocket-✅--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">WebSocket ✅                                                                                                                                                                                                        ─</h3>
<p>HTTP Upgrade 핸드셰이크로 연결을 한 번 맺으면, 이후는 TCP 위에서 프레임 단위로 통신한다.<br>불필요한 헤더 오버헤드 없이 저지연 양방향 통신이 가능해 채팅에 최적이다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>방향</th>
<th>연결 비용</th>
<th>실시간성</th>
<th>채팅 적합성</th>
</tr>
</thead>
<tbody><tr>
<td>Polling</td>
<td>단방향</td>
<td>매 요청마다 HTTP</td>
<td>낮음</td>
<td>❌</td>
</tr>
<tr>
<td>SSE</td>
<td>서버→클라이언트</td>
<td>1회</td>
<td>높음</td>
<td>❌</td>
</tr>
<tr>
<td>WebSocket</td>
<td>양방향</td>
<td>1회</td>
<td>높음</td>
<td>✅</td>
</tr>
</tbody></table>
</li>
</ul>
<hr>
<h2 id="2-stomp--raw-websocket-위에-프로토콜을-얹은-이유">2. STOMP — raw WebSocket 위에 프로토콜을 얹은 이유</h2>
<p>  WebSocket은 그 자체로는 &quot;바이트를 주고받는 통로&quot;일 뿐이다.<br>  메시지 라우팅, 구독, 브로드캐스트 같은 개념은 직접 구현해야 한다.                                                                                                                                                     </p>
<p>  STOMP(Simple Text Oriented Messaging Protocol)를 사용하면 이 부분을 프레임워크에 위임할 수 있다.                                                                                                                        </p>
<h3 id="websocket--stomp-설정">WebSocket + STOMP 설정</h3>
<pre><code class="language-java">  @Configuration
  @EnableWebSocketMessageBroker                                                                                                                                                                                           
  @RequiredArgsConstructor
  public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {                                                                                                                                              

      private final StompChannelInterceptor stompChannelInterceptor;

      @Override
      public void registerStompEndpoints(StompEndpointRegistry registry) {                                                                                                                                                
          registry.addEndpoint(&quot;/ws-chat&quot;)  // 클라이언트 연결 엔드포인트                                                                                                                                               
                  .setAllowedOriginPatterns(&quot;*&quot;);                                                                                                                                                                         
      }                                                                                                                                                                                                                   

      @Override                                                                                                                                                                                                           
      public void configureMessageBroker(MessageBrokerRegistry registry) {                                                                                                                                              
          registry.enableSimpleBroker(&quot;/topic&quot;, &quot;/queue&quot;); // 구독 prefix
          registry.setApplicationDestinationPrefixes(&quot;/app&quot;); // 서버 처리 prefix                                                                                                                                         
      }                                                                                                                                                                                                                   

      @Override                                                                                                                                                                                                           
      public void configureClientInboundChannel(ChannelRegistration registration) {                                                                                                                                     
          registration.interceptors(stompChannelInterceptor); // JWT 인증 인터셉터 등록
      }                                                                                                                                                                                                                   
  }</code></pre>
<ul>
<li><code>/app/chat/{roomId}</code> → 서버 컨트롤러(@MessageMapping)로 라우팅                                                                                                                                                        </li>
<li><code>/topic/chat/{roomId}</code> → 해당 채팅방 구독자 전체에게 브로드캐스트</li>
<li><code>/queue/errors</code> → 특정 사용자에게 에러 메시지 개별 전달                                                                                                                                                               </li>
</ul>
<hr>
<h2 id="3-websocket-인증--channelinterceptor로-connect-시점에-jwt-검증">3. WebSocket 인증 — ChannelInterceptor로 CONNECT 시점에 JWT 검증</h2>
<p>  WebSocket은 HTTP와 달리 연결 이후 인증 메커니즘이 없다.<br>  Spring Security 필터 체인도 WebSocket 프레임에는 적용되지 않는다.                                                                                                                                                     </p>
<h3 id="해결-stomp-channelinterceptor">해결: STOMP ChannelInterceptor</h3>
<pre><code class="language-java">  @Slf4j                                                                                                                                                                                                                
  @Component
  @RequiredArgsConstructor
  public class StompChannelInterceptor implements ChannelInterceptor {

      private final JwtUtil jwtUtil;
      private final UserDetailsService userDetailsService;                                                                                                                                                                

      @Override
      public Message&lt;?&gt; preSend(Message&lt;?&gt; message, MessageChannel channel) {                                                                                                                                             
          StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);                                                                                                         
          if (accessor == null) return message;                                                                                                                                                                           

          if (StompCommand.CONNECT.equals(accessor.getCommand())) {                                                                                                                                                       
              String authHeader = accessor.getFirstNativeHeader(&quot;Authorization&quot;);                                                                                                                                         

              if (authHeader == null || !authHeader.startsWith(JwtUtil.BEARER_PREFIX)) {                                                                                                                                  
                  throw new ServiceErrorException(ChatExceptionEnum.ERR_WEBSOCKET_UNAUTHORIZED);                                                                                                                          
              }                                                                                                                                                                                                           

              String token = jwtUtil.substringToken(authHeader);                                                                                                                                                          
              if (!jwtUtil.validateToken(token)) {                                                                                                                                                                        
                  throw new ServiceErrorException(ChatExceptionEnum.ERR_WEBSOCKET_UNAUTHORIZED);
              }                                                                                                                                                                                                           

              String email = jwtUtil.extractEmail(token);
              UserDetails userDetails = userDetailsService.loadUserByUsername(email);                                                                                                                                     
              Long userId = ((UserDetailsImpl) userDetails).getUser().getId();

              // userId를 Principal로 등록 → 컨트롤러에서 principal.getName()으로 접근                                                                                                                                  
              accessor.setUser(new UsernamePasswordAuthenticationToken(                                                                                                                                                   
                      userId.toString(), null, userDetails.getAuthorities()                                                                                                                                             
              ));                                                                                                                                                                                                         

          } else if (StompCommand.SEND.equals(accessor.getCommand())                                                                                                                                                      
                  || StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {                                                                                                                                              
              // CONNECT 없이 직접 SEND/SUBSCRIBE를 보내는 비인증 세션 차단
              if (accessor.getUser() == null) {                                                                                                                                                                           
                  throw new ServiceErrorException(ChatExceptionEnum.ERR_WEBSOCKET_UNAUTHORIZED);                                                                                                                        
              }                                                                                                                                                                                                           
          }                                                                                                                                                                                                             

          return message;                                                                                                                                                                                               
      }
  }</code></pre>
<p>  <strong>핵심 흐름:</strong></p>
<ol>
<li>CONNECT 시점에 <code>Authorization</code> 헤더에서 JWT를 꺼내 검증</li>
<li>검증 성공 시 userId를 <code>Principal</code>로 세션에 저장                                                                                                                                                                      </li>
<li>이후 SEND/SUBSCRIBE 시점에 <code>accessor.getUser() == null</code> 체크로 비정상 접근 차단                                                                                                                                      </li>
<li>컨트롤러에서는 <code>principal.getName()</code>으로 userId를 꺼내 사용                                                                                                                                                          </li>
</ol>
<hr>
<h2 id="4-메시지-송수신--controller와-service">4. 메시지 송수신 — Controller와 Service</h2>
<h3 id="websocket-컨트롤러">WebSocket 컨트롤러</h3>
<pre><code class="language-java">  @Slf4j                                                                                                                                                                                                                
  @Controller
  @RequiredArgsConstructor
  public class ChatController {

      private final ChatService chatService;
      private final ChatRedisPublisher chatRedisPublisher;

      @MessageMapping(&quot;/chat/{roomId}&quot;)
      public void sendMessage(                                                                                                                                                                                            
              @DestinationVariable Long roomId,                                                                                                                                                                         
              @Valid @Payload ChatMessageRequest request,                                                                                                                                                                 
              Principal principal) {
          Long senderId = Long.parseLong(principal.getName()); // CONNECT 시 설정된 userId                                                                                                                                

          ChatMessageResponse response = chatService.saveMessage(roomId, senderId, request);                                                                                                                              
          chatRedisPublisher.publish(roomId, ChatEventResponse.message(response));                                                                                                                                        
      }                                                                                                                                                                                                                   

      @MessageExceptionHandler                                                                                                                                                                                            
      @SendToUser(&quot;/queue/errors&quot;)                                                                                                                                                                                      
      public String handleException(ServiceErrorException e) {                                                                                                                                                            
          return e.getMessage(); // 에러는 해당 유저의 /queue/errors로만 전달
      }                                                                                                                                                                                                                   
  }                                                                                                                                                                                                                       </code></pre>
<p>  <code>@MessageExceptionHandler</code> + <code>@SendToUser(&quot;/queue/errors&quot;)</code>를 조합해<br>  에러 메시지가 다른 사용자에게 브로드캐스트되지 않도록 격리했다.</p>
<h3 id="메시지-저장-서비스">메시지 저장 서비스</h3>
<pre><code class="language-java">  @Transactional                                                                                                                                                                                                        
  public ChatMessageResponse saveMessage(Long roomId, Long senderId, ChatMessageRequest request) {
      if (request.messageType() == null || request.content() == null || request.content().isBlank()) {
          throw new ServiceErrorException(ChatExceptionEnum.ERR_INVALID_MESSAGE);                                                                                                                                         
      }

      // FILE 타입이면 S3 버킷 URL 출처 검증 (링크 위·변조 방지)                                                                                                                                                        
      if (request.messageType() == MessageType.FILE) {                                                                                                                                                                    
          if (!isValidS3Url(request.fileUrl())) {
              throw new ServiceErrorException(ChatExceptionEnum.ERR_INVALID_MESSAGE);                                                                                                                                     
          }                                                                                                                                                                                                             
      }                                                                                                                                                                                                                   

      ChatRoom room = getChatRoomOrThrow(roomId);
      validateActiveParticipant(room, senderId);                                                                                                                                                                          

      ChatMessage message = (request.messageType() == MessageType.FILE)                                                                                                                                                   
              ? chatMessageRepository.save(                                                                                                                                                                               
                  ChatMessage.createWithFile(roomId, senderId, request.content(), request.messageType(), request.fileUrl()))                                                                                              
              : chatMessageRepository.save(                                                                                                                                                                               
                  ChatMessage.create(roomId, senderId, request.content(), request.messageType()));                                                                                                                        

      return ChatMessageResponse.from(message);                                                                                                                                                                         
  }                                                                                                                                                                                                                       

  private boolean isValidS3Url(String fileUrl) {                                                                                                                                                                          
      String expectedPrefix = String.format(&quot;https://%s.s3.%s.amazonaws.com/chat/&quot;,                                                                                                                                     
              s3BucketName, s3Region);                                                                                                                                                                                    
      return fileUrl != null &amp;&amp; fileUrl.startsWith(expectedPrefix);                                                                                                                                                       
  }                                                                                                                                                                                                                       </code></pre>
<p>  <strong>DB 저장 → publish 순서를 반드시 지킨다.</strong><br>  publish가 실패해도 DB에 메시지가 남아 있어 REST API로 재조회 및 복구가 가능하다.</p>
<p>  파일 메시지의 경우 S3 버킷 URL 패턴을 검증해서 외부 URL 삽입을 통한 위·변조를 방지했다.                                                                                                                                 </p>
<hr>
<h2 id="5-redis-pubsub--멀티-서버-브로드캐스트">5. Redis Pub/Sub — 멀티 서버 브로드캐스트</h2>
<p>  단일 서버라면 <code>SimpMessagingTemplate.convertAndSend()</code>만으로 충분하다.<br>  하지만 서버가 여러 대면 문제가 생긴다.                                                                                                                                                                                </p>
<pre><code>  [서버 A] 유저 1 접속 (WebSocket 연결)                                                                                                                                                                                   
  [서버 B] 유저 2 접속 (WebSocket 연결)                                                                                                                                                                                   

  유저 1 → 서버 A에 메시지 전송                                                                                                                                                                                           
  서버 A → 자신에게 연결된 클라이언트에게만 브로드캐스트                                                                                                                                                                  
  유저 2 (서버 B에 연결) → 메시지 못 받음 ❌                                                                                                                                                                               </code></pre><p>  <strong>해결: Redis Pub/Sub으로 서버 간 메시지 중계</strong>                                                                                                                                                                         </p>
<pre><code>  서버 A → Redis publish(&quot;chat:room:1&quot;, message)                                                                                                                                                                        
  Redis  → 구독 중인 서버 A, B, C 모두에게 전달                                                                                                                                                                           
  각 서버 → /topic/chat/{roomId} 로 WebSocket 브로드캐스트                                                                                                                                                                
  클라이언트 수신 ✅                                                                                                                                                                                                      ─</code></pre><h3 id="publisher--redis에-이벤트-발행">Publisher — Redis에 이벤트 발행</h3>
<pre><code class="language-java">  @Slf4j                                                                                                                                                                                                                
  @Component
  @RequiredArgsConstructor
  public class ChatRedisPublisher {

      private static final int MAX_RETRY_ATTEMPTS = 3;
      private static final long RETRY_DELAY_MS = 100;                                                                                                                                                                     

      private final RedisTemplate&lt;String, Object&gt; redisTemplate;                                                                                                                                                          
      private final ObjectMapper objectMapper;                                                                                                                                                                            

      public void publish(Long roomId, ChatEventResponse event) {                                                                                                                                                         
          int attempt = 0;                                                                                                                                                                                              
          Exception lastException = null;

          while (attempt &lt; MAX_RETRY_ATTEMPTS) {
              try {                                                                                                                                                                                                       
                  byte[] channel = (&quot;chat:room:&quot; + roomId).getBytes(StandardCharsets.UTF_8);                                                                                                                            
                  byte[] message = objectMapper.writeValueAsBytes(event);                                                                                                                                                 
                  redisTemplate.execute((RedisCallback&lt;Long&gt;) conn -&gt; conn.publish(channel, message));

                  if (attempt &gt; 0) {                                                                                                                                                                                    
                      log.info(&quot;[Redis] 이벤트 발행 성공 (재시도 {}회) roomId={}&quot;, attempt, roomId);                                                                                                                      
                  }                                                                                                                                                                                                       
                  return;

              } catch (Exception e) {                                                                                                                                                                                   
                  lastException = e;
                  attempt++;
                  if (attempt &lt; MAX_RETRY_ATTEMPTS) {
                      Thread.sleep(RETRY_DELAY_MS);                                                                                                                                                                       
                  }
              }                                                                                                                                                                                                           
          }                                                                                                                                                                                                             

          log.error(&quot;[Redis] 이벤트 발행 최종 실패 ({}회 재시도) roomId={}&quot;,
                  MAX_RETRY_ATTEMPTS, roomId, lastException);                                                                                                                                                             
      }
  }                                                                                                                                                                                                                       </code></pre>
<ul>
<li><p>채널명 패턴: <code>chat:room:{roomId}</code>                                                                                                                                                                                     </p>
</li>
<li><p>일시적인 Redis 장애에 대응해 <strong>3회, 100ms 간격 재시도</strong> 구현</p>
</li>
<li><p>최종 실패 시 에러 로그 기록 (메시지는 DB에 이미 저장된 상태)                                                                                                                                                          </p>
<h3 id="subscriber--redis-수신-후-websocket-브로드캐스트">Subscriber — Redis 수신 후 WebSocket 브로드캐스트</h3>
<pre><code class="language-java">@Slf4j                                                                                                                                                                                                                
@Component
@RequiredArgsConstructor
public class ChatRedisSubscriber implements MessageListener {

  private final RedisMessageListenerContainer listenerContainer;
  private final SimpMessagingTemplate messagingTemplate;                                                                                                                                                              
  private final ObjectMapper objectMapper;

  @PostConstruct                                                                                                                                                                                                    
  public void subscribe() {                                                                                                                                                                                           
      // 모든 채팅방 채널 구독 (패턴 매칭)
      listenerContainer.addMessageListener(this, new PatternTopic(&quot;chat:room:*&quot;));                                                                                                                                    
  }                                                                                                                                                                                                                   

  @Override                                                                                                                                                                                                           
  public void onMessage(Message message, byte[] pattern) {                                                                                                                                                          
      try {
          ChatEventResponse event = objectMapper.readValue(message.getBody(), ChatEventResponse.class);
          // 해당 채팅방을 구독 중인 모든 클라이언트에게 전달                                                                                                                                                         
          messagingTemplate.convertAndSend(&quot;/topic/chat/&quot; + event.roomId(), event);
      } catch (Exception e) {                                                                                                                                                                                         
          log.error(&quot;[Redis] 메시지 처리 실패: {}&quot;, e.getMessage());                                                                                                                                                
      }                                                                                                                                                                                                               
  }                                                                                                                                                                                                                 
}                                                                                                                                                                                                                       </code></pre>
<p><code>@PostConstruct</code>로 애플리케이션 시작 시 <code>chat:room:*</code> 패턴을 구독하고,<br>Redis에서 메시지를 받으면 즉시 해당 WebSocket 토픽으로 브로드캐스트한다.</p>
</li>
</ul>
<hr>
<h2 id="6-이벤트-타입-통합--chateventresponse">6. 이벤트 타입 통합 — ChatEventResponse</h2>
<p>  클라이언트가 WebSocket으로 받는 이벤트는 단일 DTO로 통일했다.                                                                                                                                                           </p>
<pre><code class="language-java">  public record ChatEventResponse(                                                                                                                                                                                      
          String eventType,  // &quot;MESSAGE&quot; | &quot;READ&quot; | &quot;LEAVE&quot;
          Long userId,                                                                                                                                                                                                    
          Long roomId,
          ChatMessageResponse message  // MESSAGE 타입일 때만 존재, 나머지는 null                                                                                                                                         
  ) {                                                                                                                                                                                                                     
      public static ChatEventResponse message(ChatMessageResponse msg) {
          return new ChatEventResponse(&quot;MESSAGE&quot;, msg.senderId(), msg.roomId(), msg);                                                                                                                                     
      }                                                                                                                                                                                                                   

      public static ChatEventResponse read(Long roomId, Long readerId) {                                                                                                                                                  
          return new ChatEventResponse(&quot;READ&quot;, readerId, roomId, null);                                                                                                                                                   
      }

      public static ChatEventResponse leave(Long roomId, Long userId) {                                                                                                                                                 
          return new ChatEventResponse(&quot;LEAVE&quot;, userId, roomId, null);
      }                                                                                                                                                                                                                   
  }</code></pre>
<p>  메시지 수신, 읽음 처리, 나가기 이벤트를 하나의 DTO로 관리해서 클라이언트 처리 로직을 단순화했다.                                                                                                                        </p>
<hr>
<h2 id="7-동시성-처리--채팅방-중복-생성-방지">7. 동시성 처리 — 채팅방 중복 생성 방지</h2>
<p>  같은 멘토십에 대해 동시에 채팅방 생성 요청이 오면 중복이 생길 수 있다.                                                                                                                                                  </p>
<pre><code class="language-java">  return chatRoomRepository.findByMentorshipId(mentorshipId)                                                                                                                                                            
          .orElseGet(() -&gt; {                                                                                                                                                                                              
              try {                                                                                                                                                                                                       
                  // saveAndFlush로 즉시 DB 반영 → 동시 요청이 충돌을 감지할 수 있도록                                                                                                                                    
                  ChatRoom room = chatRoomRepository.saveAndFlush(                                                                                                                                                        
                          ChatRoom.create(mentorshipId, mentorUserId, menteeUserId)                                                                                                                                     
                  );                                                                                                                                                                                                      
                  return ChatRoomResponse.from(room);                                                                                                                                                                   
              } catch (DataIntegrityViolationException e) {                                                                                                                                                               
                  // 동시 요청으로 unique 제약 위반 시 이미 생성된 방 반환                                                                                                                                              
                  return ChatRoomResponse.from(                                                                                                                                                                           
                          chatRoomRepository.findByMentorshipId(mentorshipId)                                                                                                                                           
                                  .orElseThrow(...)                                                                                                                                                                       
                  );                                                                                                                                                                                                      
              }
          });                                                                                                                                                                                                             </code></pre>
<p>  <code>mentorshipId</code>에 DB unique 제약을 걸어두고, 충돌 시 <code>DataIntegrityViolationException</code>을 잡아<br>  이미 생성된 방을 반환하는 방식으로 처리했다.</p>
<hr>
<h2 id="8-읽지-않은-메시지-수--n1-방지">8. 읽지 않은 메시지 수 — N+1 방지</h2>
<p>  채팅방 목록 조회 시 각 방의 읽지 않은 메시지 수를 함께 보여줘야 한다.<br>  방마다 별도 쿼리를 날리면 N+1 문제가 생긴다.                                                                                                                                                                          </p>
<pre><code class="language-java">  @Query(&quot;&quot;&quot;                                                                                                                                                                                                              
      SELECT m.roomId, COUNT(m)                                                                                                                                                                                         
      FROM ChatMessage m
      WHERE m.roomId IN :roomIds
        AND m.senderId != :userId                                                                                                                                                                                         
        AND m.isRead = false
      GROUP BY m.roomId                                                                                                                                                                                                   
      &quot;&quot;&quot;)                                                                                                                                                                                                              
  List&lt;Object[]&gt; countUnreadByRoomIds(@Param(&quot;roomIds&quot;) List&lt;Long&gt; roomIds, @Param(&quot;userId&quot;) Long userId);                                                                                                                </code></pre>
<p>  방 ID 목록을 한 번에 넘겨서 IN 쿼리 하나로 모든 방의 읽지 않은 메시지 수를 가져온다.                                                                                                                                    </p>
<hr>
<h2 id="9-전체-메시지-처리-흐름">9. 전체 메시지 처리 흐름</h2>
<pre><code>  ① 클라이언트 STOMP SEND → /app/chat/{roomId}
  ② ChatController.sendMessage() 호출                                                                                                                                                                                     
  ③ ChatService.saveMessage() → DB 저장  ← 여기서 실패해도 유실 없음                                                                                                                                                      
  ④ ChatRedisPublisher.publish() → Redis에 이벤트 발행                                                                                                                                                                    
      └─ 실패 시 최대 3회 재시도 (100ms 간격)                                                                                                                                                                             
  ⑤ 모든 서버의 ChatRedisSubscriber.onMessage() 트리거                                                                                                                                                                    
  ⑥ SimpMessagingTemplate → /topic/chat/{roomId} 브로드캐스트                                                                                                                                                             
  ⑦ 클라이언트 수신                                                                                                                                                                                                       </code></pre><hr>
<h2 id="10-한계점과-개선-방향">10. 한계점과 개선 방향</h2>
<h3 id="❗-redis-pubsub은-메시지를-보관하지-않는다-------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">❗ Redis Pub/Sub은 메시지를 보관하지 않는다                                                                                                                                                                         ─</h3>
<p>  Redis Pub/Sub은 <strong>fire-and-forget</strong> 구조다.<br>  구독 중이 아닌 서버나 클라이언트는 메시지를 받을 수 없다.<br>  서버가 잠깐 다운됐다가 올라오면 그 사이 발행된 메시지는 영원히 사라진다.                                                                                                                                                </p>
<blockquote>
<p><strong>개선 방향:</strong> Kafka 도입 → offset 기반으로 재처리 가능, 메시지를 디스크에 영속 보관                                                                                                                                  </p>
</blockquote>
<h3 id="❗-메시지-유실-가능성-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">❗ 메시지 유실 가능성                                                                                                                                                                                               ─</h3>
<p>  Redis publish 직전에 서버가 다운되면 DB에는 저장됐지만 다른 사용자는 실시간으로 못 받는다.<br>  재시도와 DB 재조회 API로 보완하지만 <strong>완전한 at-least-once 보장은 아니다</strong>.                                                                                                                                           </p>
<blockquote>
<p><strong>개선 방향:</strong> Kafka의 acks + consumer group offset commit으로 정확한 전달 보장                                                                                                                                       </p>
</blockquote>
<h3 id="❗-메시지-순서-보장이-어렵다----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">❗ 메시지 순서 보장이 어렵다                                                                                                                                                                                        ─</h3>
<p>  멀티 서버 환경에서 네트워크 지연 차이로 메시지 순서가 뒤바뀔 수 있다.                                                                                                                                                   </p>
<blockquote>
<p><strong>개선 방향:</strong> 클라이언트에서 createdAt 기준 정렬 / Kafka partition key로 순서 보장                                                                                                                                   </p>
</blockquote>
<h3 id="❗-websocket-연결-끊김----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">❗ WebSocket 연결 끊김                                                                                                                                                                                              ─</h3>
<p>  모바일 환경 등 네트워크가 불안정한 경우 재연결 사이에 메시지를 놓친다.<br>  <code>GET /{roomId}/messages</code> REST API로 재조회해서 복구할 수 있지만, 클라이언트가 이를 직접 핸들링해야 한다.                                                                                                              </p>
<h3 id="❗-redis-pubsub의-확장성-한계--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------─">❗ Redis Pub/Sub의 확장성 한계                                                                                                                                                                                      ─</h3>
<p>  fan-out 구조라 채널 수와 구독자 수가 늘어날수록 Redis 부하가 증가한다.                                                                                                                                                  </p>
<blockquote>
<p><strong>개선 방향:</strong> Redis Stream 또는 Kafka로 전환                                                                                                                                                                         </p>
</blockquote>
<hr>
<h2 id="11-회고">11. 회고</h2>
<p>  기술을 선택할 때 &quot;이게 더 좋으니까&quot;가 아니라<br>  <strong>&quot;이 문제를 해결하기 위해 이 트레이드오프를 감수한다&quot;</strong> 는 관점이 중요하다는 걸 느꼈다.</p>
<p>  WebSocket + STOMP는 실시간 양방향 통신 문제를 풀었고,<br>  Redis Pub/Sub은 멀티 서버 브로드캐스트 문제를 풀었다.<br>  DB 저장 선행 구조와 재시도 로직은 신뢰성을 보완했다.                                                                                                                                                                    </p>
<p>  하지만 Redis Pub/Sub의 비영속성과 순서 보장 문제는 여전히 남아 있다.<br>  이걸 인식하고 어떻게 개선할 수 있는지 알고 있다는 것,<br>  그게 지금 단계에서 가장 중요한 성과라고 생각한다.                                                                                                                                                                       </p>
<hr>
<blockquote>
<p><strong>한 줄 요약</strong><br>WebSocket + STOMP으로 실시간 양방향 채팅을 구현하고, Redis Pub/Sub으로 멀티 서버 브로드캐스트를 해결했다.
DB 저장 선행 + publish 재시도 + REST 재조회 API로 메시지 유실을 보완했지만, 완전한 보장을 위해선 Kafka 전환이 필요하다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[구독 시스템 설계: 빌링키, 스케줄러, 분산 락, 결제 실패 정책, 트랜잭션 분리]]></title>
            <link>https://velog.io/@eno_lj/%EA%B5%AC%EB%8F%85-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-%EB%B9%8C%EB%A7%81%ED%82%A4-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EB%9D%BD-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC</link>
            <guid>https://velog.io/@eno_lj/%EA%B5%AC%EB%8F%85-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84-%EB%B9%8C%EB%A7%81%ED%82%A4-%EC%8A%A4%EC%BC%80%EC%A4%84%EB%9F%AC-%EB%9D%BD-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC</guid>
            <pubDate>Sun, 03 May 2026 13:15:03 GMT</pubDate>
            <description><![CDATA[<h2 id="1-왜-구독-결제는-일반-결제와-다른가">1. 왜 구독 결제는 일반 결제와 다른가</h2>
<p>  일반 포인트 충전은 사용자가 직접 결제창을 열고 카드 정보를 입력하는 <strong>1회성 흐름</strong>이다.<br>  구독은 달라진다.                                                                                                                                                                                                      </p>
<ul>
<li>최초 1회만 카드 정보를 입력하고 <strong>빌링키</strong>를 발급받는다                                                                                                                                                             </li>
<li>이후 서버가 빌링키를 보관하다가 <strong>매달 자동으로 PG에 결제를 요청</strong>한다</li>
<li>사용자 요청 없이 서버가 실행하므로, <strong>중복 실행 방지 / 외부 API 장애 대응 / 트랜잭션 정합성</strong> 문제를 모두 서버가 책임져야 한다                                                                                        </li>
</ul>
<hr>
<h2 id="2-빌링키-기반-정기결제">2. 빌링키 기반 정기결제</h2>
<h3 id="2-1-전체-흐름">2-1. 전체 흐름</h3>
<pre><code>  [사용자] /prepare  → subscriptionKey 발급 (PENDING Subscription 생성)
  [사용자] /complete → 빌링키 입력 → 첫 결제 실행 → ACTIVE 전환
  [스케줄러]         → 매달 빌링키로 자동 결제 → nextBillingAt 갱신                                                                                                                                                       </code></pre><h3 id="2-2-paymentkey-설계--웹훅-식별을-위한-컨벤션">2-2. paymentKey 설계 — 웹훅 식별을 위한 컨벤션</h3>
<p>  빌링키 결제에서 <code>paymentKey</code>는 서버가 직접 생성한다.<br>  이 키에 <code>subscriptionId</code>를 포함시켜 <strong>웹훅이 도착했을 때 구독 결제인지 일반 결제인지 구분</strong>한다.                                                                                                                      </p>
<pre><code class="language-java">  // SubscriptionService.java                                                                                                                                                                                             
  String paymentKey = &quot;subscription-&quot; + subscription.getId() + &quot;-&quot; + UUID.randomUUID();                                                                                                                                 </code></pre>
<p>  웹훅 처리 측에서는 prefix로 판별한다.                                                                                                                                                                                   </p>
<pre><code class="language-java">  // WebhookService.java
  private boolean isSubscriptionPayment(String paymentKey) {
      return paymentKey != null &amp;&amp; paymentKey.startsWith(&quot;subscription-&quot;);                                                                                                                                                
  }

  private Long extractSubscriptionId(String paymentKey) {                                                                                                                                                               
      String[] parts = paymentKey.split(&quot;-&quot;);
      if (parts.length &gt;= 2) {
          return Long.parseLong(parts[1]);
      }                                                                                                                                                                                                                   
      return null;
  }                                                                                                                                                                                                                       </code></pre>
<p>  UUID만 쓰면 웹훅에서 어떤 구독의 결제인지 알 수 없다. <code>subscription-{id}-{uuid}</code> 형식이 <strong>추적성 + 멱등성 + 라우팅</strong>을 동시에 해결한다.                                                                                 </p>
<h3 id="2-3-pg-응답을-그대로-믿지-않는다--get-재검증">2-3. PG 응답을 그대로 믿지 않는다 — GET 재검증</h3>
<p>  PortOne REST API에서 빌링키 결제(<code>POST /payments/{id}/billing-key</code>) 응답에는 일부 PG 제공사(TossPayments 등)에서 최종 상태 없이 트랜잭션 ID만 반환하는 경우가 있다.<br>  POST 응답의 <code>status</code>를 그대로 믿으면 실제로는 결제가 성공했는데 실패로 판단하거나, 반대로 처리 중인 것을 완료로 오판할 수 있다.                                                                                       </p>
<pre><code class="language-java">  // POST 후 GET으로 최종 상태 재검증                                                                                                                                                                                     
  String getUrl = &quot;https://api.portone.io/payments/&quot; + paymentKey;                                                                                                                                                      
  ResponseEntity&lt;String&gt; getResponse = restTemplate.exchange(                                                                                                                                                             
          getUrl, HttpMethod.GET, new HttpEntity&lt;&gt;(headers), String.class
  );                                                                                                                                                                                                                      

  JsonNode fetchedPayment = objectMapper.readTree(getResponse.getBody());
  String paymentStatus = fetchedPayment.path(&quot;status&quot;).asText();                                                                                                                                                          

  if (!&quot;PAID&quot;.equals(paymentStatus)) {                                                                                                                                                                                    
      String failureReason = fetchedPayment.path(&quot;failure&quot;).path(&quot;reason&quot;).asText(&quot;알 수 없음&quot;);                                                                                                                        
      log.error(&quot;[빌링키 결제 실패] status={}, reason={}&quot;, paymentStatus, failureReason);                                                                                                                                 
      throw new ServiceErrorException(SubscriptionExceptionEnum.ERR_PORTONE_API_ERROR);
  }                                                                                                                                                                                                                       </code></pre>
<h3 id="2-4-payment--purchase-원자성-보장">2-4. Payment + Purchase 원자성 보장</h3>
<p>  구독료는 포인트 충전이 아니라 프리미엄 혜택 구매다.<br>  두 레코드를 <strong>하나의 트랜잭션</strong>으로 묶어 둘 중 하나만 저장되는 불일치를 막는다.</p>
<pre><code class="language-java">  // SubscriptionTransactionService.java
  @Transactional                                                                                                                                                                                                          
  public Payment createPaymentAndPurchase(Long userId, String paymentKey, Long amount) {
      Payment payment = Payment.create(userId, paymentKey, amount, null);                                                                                                                                                 
      payment.complete(null);                                                                                                                                                                                           
      Payment savedPayment = paymentRepository.save(payment);

      Purchase purchase = Purchase.create(userId, PurchaseType.SUBSCRIPTION, amount, savedPayment.getId());
      purchaseRepository.save(purchase);                                                                                                                                                                                  

      return savedPayment;                                                                                                                                                                                                
  }                                                                                                                                                                                                                     </code></pre>
<hr>
<h2 id="3-스케줄러-기반-정기-청구">3. 스케줄러 기반 정기 청구</h2>
<h3 id="3-1-스케줄러-역할-분리">3-1. 스케줄러 역할 분리</h3>
<p>  스케줄러는 <strong>&quot;언제 실행할지&quot;만 담당</strong>하고, 실제 결제 로직은 <code>SubscriptionService.processBillingForSubscription()</code>에 위임한다.<br>  책임 분리를 통해 스케줄러 코드가 단순해지고 단위 테스트도 쉬워진다.</p>
<pre><code class="language-java">  // SubscriptionSchedulerService.java                                                                                                                                                                                    
  @Scheduled(cron = &quot;0 0 0 * * *&quot;, zone = &quot;Asia/Seoul&quot;)                                                                                                                                                                 
  public void processMonthlyBilling() {                                                                                                                                                                                   
      LocalDateTime now = LocalDateTime.now();

      List&lt;Subscription&gt; dueSubscriptions = subscriptionRepository                                                                                                                                                      
              .findAllBySubscriptionStatusAndNextBillingAtBefore(                                                                                                                                                         
                      SubscriptionStatus.ACTIVE, now.plusDays(1)                                                                                                                                                          
              );

      for (Subscription subscription : dueSubscriptions) {                                                                                                                                                              
          try {
              subscriptionService.processBillingForSubscription(subscription);
          } catch (Exception e) {                                                                                                                                                                                         
              log.error(&quot;[구독 스케줄러] 청구 실패 subscriptionId={}&quot;, subscription.getId(), e);
          }                                                                                                                                                                                                               
      }                                                                                                                                                                                                                 
  }                                                                                                                                                                                                                       </code></pre>
<p>  <code>now.plusDays(1)</code>을 사용하는 이유: <code>nextBillingAt</code>이 오늘 자정 정각인 경우 <code>isBefore(now)</code>로는 걸리지 않을 수 있어서, 오늘 포함 이전 것을 모두 포함시킨다.                                                              </p>
<h3 id="3-2-pending-구독-정리-스케줄러">3-2. PENDING 구독 정리 스케줄러</h3>
<p>  <code>/prepare</code>까지만 하고 <code>/complete</code>를 하지 않은 PENDING 구독이 24시간 이상 방치되면 정리한다.                                                                                                                             </p>
<pre><code class="language-java">  @Scheduled(cron = &quot;0 0 2 * * *&quot;, zone = &quot;Asia/Seoul&quot;)                                                                                                                                                                 
  public void cleanUpPendingSubscriptions() {
      LocalDateTime cutoffTime = LocalDateTime.now().minusDays(1);                                                                                                                                                        

      List&lt;Subscription&gt; expiredSubscriptions = subscriptionRepository                                                                                                                                                    
              .findAllBySubscriptionStatusAndCreatedAtBefore(                                                                                                                                                             
                      SubscriptionStatus.PENDING, cutoffTime
              );                                                                                                                                                                                                          
      expiredSubscriptions.forEach(subscriptionRepository::delete);                                                                                                                                                     
  }</code></pre>
<hr>
<h2 id="4-redis-분산-락-설계">4. Redis 분산 락 설계</h2>
<h3 id="4-1-왜-락이-필요한가">4-1. 왜 락이 필요한가</h3>
<p>  다음 세 경로가 <strong>동일 구독의 결제를 동시에 완료</strong>하려 할 수 있다.                                                                                                                                                       </p>
<table>
<thead>
<tr>
<th>경로</th>
<th>시나리오</th>
</tr>
</thead>
<tbody><tr>
<td>사용자 <code>/complete</code> 중복 클릭</td>
<td>첫 결제 2회 실행</td>
</tr>
<tr>
<td>스케줄러 + 웹훅 동시</td>
<td>정기 청구 중 이전 결제 웹훅 재전송</td>
</tr>
<tr>
<td>다중 서버 인스턴스</td>
<td>스케줄러가 여러 서버에서 동시 실행</td>
</tr>
</tbody></table>
<h3 id="4-2-락-키-네임스페이스-전략">4-2. 락 키 네임스페이스 전략</h3>
<p>  목적별로 락 키를 분리하여 <strong>서로 다른 목적의 잠금이 충돌하지 않게</strong> 한다.                                                                                                                                               </p>
<pre><code>  subscription:complete:lock:{subscriptionId}   // 첫 결제 완료 (/complete + 웹훅 Paid)                                                                                                                                 
  subscription:billing:lock:{subscriptionId}    // 정기 청구 (스케줄러 중복 실행)                                                                                                                                         
  subscription:cancel:lock:{subscriptionId}     // 구독 취소                                                                                                                                                              
  payment:confirm:lock:{paymentId}              // 일반 결제 확인 (/confirm + 웹훅 Paid)                                                                                                                                  
  payment:cancel:lock:{paymentId}               // 환불 처리 (/cancel + 웹훅 Cancelled)                                                                                                                                   </code></pre><p>  분리하지 않으면 정기 청구 중 취소 요청이 들어왔을 때 동일 키로 충돌이 발생해 취소가 블로킹되거나, 청구가 무시되는 문제가 생긴다.                                                                                        </p>
<h3 id="4-3-lock-after-acquire-패턴-이중-검증">4-3. Lock-after-Acquire 패턴 (이중 검증)</h3>
<p>  Lock을 얻기 직전에 다른 인스턴스가 이미 처리를 완료했을 수 있으므로, <strong>Lock 획득 후 상태를 재검증</strong>한다.                                                                                                                </p>
<pre><code class="language-java">  // SubscriptionService.java — completeSubscription()                                                                                                                                                                  
  if (!redisUtil.acquireLock(lockKey)) {                                                                                                                                                                                  
      throw new ServiceErrorException(SubscriptionExceptionEnum.ERR_SUBSCRIPTION_PROCESSING);                                                                                                                             
  }                                                                                                                                                                                                                       

  try {                                                                                                                                                                                                                 
      // Lock 획득 전~후 사이에 다른 요청이 처리했을 가능성 방어
      subscriptionTransactionService.validateSubscriptionStillPending(subscription.getId());                                                                                                                              

      Long paymentDbId = executeBillingPayment(...);                                                                                                                                                                      
      subscriptionTransactionService.completeSubscription(...);                                                                                                                                                           

  } finally {                                                                                                                                                                                                             
      redisUtil.releaseLock(lockKey);  // 예외 발생 시에도 반드시 해제                                                                                                                                                  
  }                                                                                                                                                                                                                       </code></pre>
<p>  <code>finally</code>로 락을 해제하는 것은 선택이 아니다. 해제하지 않으면 TTL이 만료될 때까지 다른 요청이 전부 차단된다.                                                                                                            </p>
<hr>
<h2 id="5-정기-청구-실패-정책">5. 정기 청구 실패 정책</h2>
<h3 id="5-1-고민한-선택지">5-1. 고민한 선택지</h3>
<table>
<thead>
<tr>
<th>정책</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>N회 재시도 후 취소</td>
<td>구현 복잡도 높음, 유예 기간 중 상태 관리 필요</td>
</tr>
<tr>
<td>유예 기간(3일 등) 후 취소</td>
<td>상태 추가 필요(<code>GRACE_PERIOD</code> 등), 알림 로직 복잡</td>
</tr>
<tr>
<td><strong>1회 실패 즉시 취소</strong></td>
<td>단순, 예측 가능, 상태 관리 용이</td>
</tr>
</tbody></table>
<p>  MVP 단계에서는 <strong>1회 실패 즉시 취소</strong> 정책을 선택했다.                                                                                                                                                                  </p>
<h3 id="5-2-구현">5-2. 구현</h3>
<pre><code class="language-java">  // SubscriptionService.java — processBillingForSubscription()
  try {                                                                                                                                                                                                                   
      Long paymentDbId = executeBillingPayment(...);
      subscriptionTransactionService.updateSubscriptionAfterPayment(...);                                                                                                                                                 

  } catch (Exception e) {
      log.error(&quot;[정기 청구 실패] subscriptionId={} - 즉시 취소&quot;, subscription.getId(), e);                                                                                                                               
      subscriptionTransactionService.cancelSubscriptionDueToPaymentFailure(                                                                                                                                               
              subscription.getId(),                                                                                                                                                                                       
              &quot;정기 결제 실패: &quot; + e.getMessage()                                                                                                                                                                         
      );                                                                                                                                                                                                                  
  } finally {                                                                                                                                                                                                           
      redisUtil.releaseLock(lockKey);
  }                                                                                                                                                                                                                       </code></pre>
<pre><code class="language-java">  // Subscription.java
  public void markPaymentFailed(String reason) {
      this.failReason = reason;                                                                                                                                                                                           
      this.subscriptionStatus = SubscriptionStatus.CANCELLED;
      this.endedAt = LocalDateTime.now();                                                                                                                                                                                 
  }                                                                                                                                                                                                                       </code></pre>
<p>  실패 이유(<code>failReason</code>)를 저장하는 게 중요하다. 운영자가 취소 원인을 확인하거나 사용자에게 안내할 때 사용된다.                                                                                                          </p>
<h3 id="5-3-멱등성-보장--updateafterpayment">5-3. 멱등성 보장 — updateAfterPayment</h3>
<p>  같은 결제 ID로 웹훅이 두 번 도착해도 <code>nextBillingAt</code>이 두 번 밀리는 문제를 방지한다.                                                                                                                                    </p>
<pre><code class="language-java">  // Subscription.java                                                                                                                                                                                                  
  public void updateAfterPayment(Long paymentId) {
      if (this.lastPaymentId != null &amp;&amp; this.lastPaymentId.equals(paymentId)) {
          return;  // 이미 처리된 결제 — 웹훅 재전송이므로 스킵                                                                                                                                                           
      }                                                                                                                                                                                                                   
      this.lastPaymentId = paymentId;                                                                                                                                                                                     
      this.nextBillingAt = LocalDateTime.now().plusMonths(1);                                                                                                                                                             
  }                                                                                                                                                                                                                     </code></pre>
<hr>
<h2 id="6-외부-api와-db-트랜잭션-분리">6. 외부 API와 DB 트랜잭션 분리</h2>
<h3 id="6-1-문제">6-1. 문제</h3>
<p>  외부 API 호출을 <code>@Transactional</code> 안에 두면 다음 문제가 발생한다.                                                                                                                                                        </p>
<ul>
<li><p>DB 커넥션이 PG API 응답을 기다리는 동안 점유된다 (외부 API 타임아웃 10초 → DB 커넥션 10초 낭비)                                                                                                                       </p>
</li>
<li><p>PG API 지연 시 커넥션 풀 고갈로 서비스 전체가 느려진다                                                                                                                                                              </p>
</li>
<li><p>트랜잭션 안에서 예외 발생 시 롤백 범위가 의도치 않게 넓어진다                                                                                                                                                         </p>
<h3 id="6-2-2계층-서비스-패턴">6-2. 2계층 서비스 패턴</h3>
<pre><code>[오케스트레이터]              [트랜잭션 전담 서비스]                                                                                                                                                                  
SubscriptionService           SubscriptionTransactionService                                                                                                                                                            
└─ @Transactional 없음        └─ @Transactional만 존재
  외부 API 호출 담당              DB 작업만 담당                                                                                                                                                                      

WebhookService                WebhookTransactionService                                                                                                                                                                 
└─ @Transactional 없음        └─ @Transactional만 존재                                                                                                                                                                 </code></pre><pre><code class="language-java">// SubscriptionService.java — @Transactional 없음                                                                                                                                                                     
public void processBillingForSubscription(Subscription subscription) {                                                                                                                                                  

  // 1. 외부 API 호출 (트랜잭션 밖)                                                                                                                                                                                   
  Long paymentDbId = executeBillingPayment(                                                                                                                                                                         
          subscription.getUserId(), paymentKey,                                                                                                                                                                       
          subscription.getAmount(), subscription.getBillingKey(), subscription.getId()
  );                                                                                                                                                                                                                  

  // 2. DB 작업만 (짧은 독립 트랜잭션)                                                                                                                                                                                
  subscriptionTransactionService.updateSubscriptionAfterPayment(                                                                                                                                                    
          subscription.getBillingKey(), paymentDbId                                                                                                                                                                   
  );
}                                                                                                                                                                                                                       </code></pre>
<p>웹훅 처리도 동일한 구조다.                                                                                                                                                                                              </p>
<pre><code class="language-java">// WebhookService.java — @Transactional 없음                                                                                                                                                                          
public void handleWebhook(WebhookRequest request) {
  // 1. 멱등성 체크 (짧은 트랜잭션)
  WebhookEvent webhookEvent = webhookTransactionService.prepareWebhookEvent(...);                                                                                                                                     

  // 2. 포트원 SDK 조회 (외부 API, 트랜잭션 밖)                                                                                                                                                                       
  io.portone.sdk.server.payment.Payment portOnePayment =                                                                                                                                                              
          paymentClient.getPayment(paymentId).get(10, TimeUnit.SECONDS);                                                                                                                                              

  // 3. 상태 분기 + DB 반영 (짧은 트랜잭션)                                                                                                                                                                           
  webhookTransactionService.completePendingPayment(...);                                                                                                                                                              
}                                                                                                                                                                                                                       </code></pre>
<h3 id="6-3-트랜잭션을-짧게-쪼개는-원칙">6-3. 트랜잭션을 짧게 쪼개는 원칙</h3>
<p>트랜잭션 전담 서비스의 각 메서드는 <strong>한 가지 DB 작업만</strong> 담당한다.                                                                                                                                                      </p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>책임</th>
</tr>
</thead>
<tbody><tr>
<td><code>prepareWebhookEvent()</code></td>
<td>WebhookEvent 멱등성 체크 + 생성</td>
</tr>
<tr>
<td><code>getPaymentByKey()</code></td>
<td>Payment 조회 (readOnly)</td>
</tr>
<tr>
<td><code>completePendingPayment()</code></td>
<td>Payment COMPLETED 전환 + Purchase 저장</td>
</tr>
<tr>
<td><code>markEventComplete()</code></td>
<td>WebhookEvent COMPLETE 전환</td>
</tr>
</tbody></table>
<p>외부 API 대기 시간이 DB 커넥션을 잡지 않으므로, <strong>커넥션 풀 자원을 훨씬 효율적으로</strong> 사용할 수 있다.                                                                                                                    </p>
</li>
</ul>
<hr>
<h2 id="7-웹훅-보정-흐름--락과-트랜잭션-분리가-함께-작동하는-예시">7. 웹훅 보정 흐름 — 락과 트랜잭션 분리가 함께 작동하는 예시</h2>
<p>  <code>/confirm</code> 타임아웃으로 실패 처리됐으나 실제 결제는 된 케이스 (<code>FAILED → COMPLETED</code> 보정):                                                                                                                              </p>
<pre><code>  [클라이언트] /confirm 호출                                                                                                                                                                                            
  [서버]       포트원 API 타임아웃 → Payment를 FAILED로 저장                                                                                                                                                              
                                      ↓ (실제로는 결제 완료됨)                                                                                                                                                            
  [포트원]     Transaction.Paid 웹훅 전송                                                                                                                                                                                 

  [WebhookService]                                                                                                                                                                                                        
    1. prepareWebhookEvent()          — 짧은 트랜잭션 (WebhookEvent 생성)                                                                                                                                                 
    2. paymentClient.getPayment()     — 외부 API (PAID 확인)                                                                                                                                                              
    3. acquireLock(&quot;payment:confirm:lock:{paymentId}&quot;)
    4. completePendingPayment()       — 짧은 트랜잭션 (FAILED→COMPLETED, Purchase 저장)                                                                                                                                   
    5. releaseLock()                                                                                                                                                                                                      </code></pre><p>  락 없이 4번을 실행하면 재검증 스케줄러(<code>PaymentReconciliationService</code>)가 동시에 동일 Payment를 완료 처리할 수 있다.<br>  락이 두 경로를 상호 배제한다.</p>
<hr>
<h2 id="8-핵심-정리">8. 핵심 정리</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>해결책</th>
</tr>
</thead>
<tbody><tr>
<td>서버 중복 결제</td>
<td>Redis 분산 락 + Lock-after-Acquire 이중 검증</td>
</tr>
<tr>
<td>PG 응답 신뢰 불가</td>
<td>GET 재검증으로 최종 상태 확인</td>
</tr>
<tr>
<td>DB 커넥션 낭비</td>
<td>오케스트레이터 / 트랜잭션 서비스 2계층 분리</td>
</tr>
<tr>
<td>웹훅 중복 수신</td>
<td>WebhookEvent 멱등성 키로 COMPLETE 체크</td>
</tr>
<tr>
<td>정기 청구 중복 실행</td>
<td><code>subscription:billing:lock</code> 분리</td>
</tr>
<tr>
<td>결제 실패 상태 관리</td>
<td>1회 실패 즉시 취소 + <code>failReason</code> 저장</td>
</tr>
<tr>
<td>웹훅과 API 경로 충돌</td>
<td>락 키 네임스페이스 목적별 분리</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[웹훅 기반 결제 정합성 보장 설계]]></title>
            <link>https://velog.io/@eno_lj/%EC%9B%B9%ED%9B%85-%EA%B8%B0%EB%B0%98-%EA%B2%B0%EC%A0%9C-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%B3%B4%EC%9E%A5-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@eno_lj/%EC%9B%B9%ED%9B%85-%EA%B8%B0%EB%B0%98-%EA%B2%B0%EC%A0%9C-%EC%A0%95%ED%95%A9%EC%84%B1-%EB%B3%B4%EC%9E%A5-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Sun, 03 May 2026 12:01:48 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-정의">문제 정의</h2>
<p>  결제 시스템에서 웹훅은 다음과 같은 특성을 가진다.                                                                                                                                                                       </p>
<ul>
<li><p>동일 이벤트를 <strong>중복 전송</strong>한다 (at-least-once delivery)                                                                                                                                                              </p>
</li>
<li><p><strong>순서를 보장하지 않는다</strong>                                                                                                                                                                                          </p>
</li>
<li><p><strong>지연 도착</strong>이 발생할 수 있다                                                                                                                                                                                        </p>
</li>
<li><p>네트워크 문제로 <strong>유실</strong>될 수 있다                                                                                                                                                                                  </p>
<p>이로 인해 다음 문제가 생긴다.                                                                                                                                                                                           </p>
</li>
</ul>
<pre><code>  - 동일 결제가 중복 처리됨                                                                                                                                                                                             
  - /confirm API와 웹훅이 동시에 실행되어 포인트 이중 충전                                                                                                                                                                
  - 순서가 뒤바뀐 상태 전이로 데이터 불일치                                                                                                                                                                               </code></pre><hr>
<h2 id="설계-목표">설계 목표</h2>
<blockquote>
<p>웹훅이 몇 번 오든, 언제 오든, 어떤 순서로 오든 결과는 항상 동일해야 한다                                                                                                                                                                                                                                          </p>
</blockquote>
<hr>
<h2 id="핵심-해결-전략">핵심 해결 전략</h2>
<ol>
<li><strong>멱등키 기반 중복 처리 방지</strong>                                                                                                                                                                                       </li>
<li><strong>Redis Lock으로 API ↔ 웹훅 동시성 제어</strong></li>
<li><strong>상태 기반 처리 (State Machine)</strong>                                                                                                                                                                                   </li>
<li><strong>트랜잭션 분리 (외부 API vs DB)</strong>                                                                                                                                                                                 </li>
<li><strong>웹훅 폭주 대응 구조</strong>                                                                                                                                                                                              </li>
</ol>
<hr>
<h2 id="1-멱등성-보장">1. 멱등성 보장</h2>
<h3 id="문제">문제</h3>
<p>  포트원은 웹훅을 <strong>at-least-once</strong>로 전송한다. 네트워크 지연이나 서버 재시작이 있으면 동일 이벤트를 여러 번 보낸다.<br>  이때 매번 처리하면 포인트가 여러 번 충전되는 치명적인 문제가 생긴다.</p>
<h3 id="해결-webhookevent-테이블-기반-멱등-처리">해결: WebhookEvent 테이블 기반 멱등 처리</h3>
<p>  웹훅이 도착하면 가장 먼저 <strong>이미 처리한 이벤트인지 체크</strong>한다.                                                                                                                                                          </p>
<pre><code class="language-java">  // WebhookTransactionService.java                                                                                                                                                                                     
  @Transactional                                                                                                                                                                                                          
  public WebhookEvent prepareWebhookEvent(String transactionId, WebhookEventType eventType,
                                          String paymentId, String rawPayload) {                                                                                                                                          

      WebhookEvent existing = webhookEventRepository.findByWebhookId(transactionId).orElse(null);

      // COMPLETE 상태 → 이미 처리 완료, 무시                                                                                                                                                                           
      if (existing != null &amp;&amp; existing.getStatus() == WebhookEventStatus.COMPLETE) {                                                                                                                                      
          log.info(&quot;웹훅 중복 수신 무시 transactionId={}&quot;, transactionId);                                                                                                                                              
          return null;                                                                                                                                                                                                    
      }                                                                                                                                                                                                                 

      // PENDING or FAIL 상태 → 재시도 이벤트, 이어서 처리                                                                                                                                                              
      if (existing != null) {                                                                                                                                                                                             
          return existing;                                                                                                                                                                                                
      }                                                                                                                                                                                                                   

      // 신규 이벤트 → 저장 후 처리 시작                                                                                                                                                                                
      WebhookEvent event = WebhookEvent.create(transactionId, eventType, paymentId, rawPayload);                                                                                                                          
      webhookEventRepository.save(event);
      log.info(&quot;웹훅 이벤트 생성 transactionId={} type={}&quot;, transactionId, eventType);                                                                                                                                    
      return event;                                                                                                                                                                                                       
  }</code></pre>
<p>  처리가 완료되면 COMPLETE로 전환한다.                                                                                                                                                                                    </p>
<pre><code class="language-java">  // WebhookEvent.java                                                                                                                                                                                                  
  public void complete() {
      this.status = WebhookEventStatus.COMPLETE;
      this.completeAt = LocalDateTime.now();
  }                                                                                                                                                                                                                       

  public void fail(String errorMessage) {                                                                                                                                                                                 
      this.status = WebhookEventStatus.FAIL;                                                                                                                                                                            
      this.errorMessage = errorMessage;
      this.retryCount++;  // 재시도 횟수 추적
  }                                                                                                                                                                                                                       </code></pre>
<h3 id="멱등성-키-설계--취소-웹훅은-별도-키를-써야-한다">멱등성 키 설계 — 취소 웹훅은 별도 키를 써야 한다</h3>
<p>  포트원 V2에서 취소(<code>Transaction.Cancelled</code>) 웹훅의 <code>transactionId</code>는 <strong>원래 결제의 transactionId와 동일</strong>하다.<br>  결제 완료 웹훅이 이미 COMPLETE 처리됐다면, 취소 웹훅도 중복으로 판단하여 무시해버린다.                                                                                                                                </p>
<pre><code class="language-java">  // WebhookService.java                                                                                                                                                                                                  
  String idempotencyKey = &quot;Transaction.Cancelled&quot;.equals(request.type())                                                                                                                                                  
          ? request.data().cancellationId()   // 취소는 cancellationId를 키로 사용                                                                                                                                        
          : transactionId;                     // 결제/실패는 transactionId를 키로 사용                                                                                                                                   </code></pre>
<table>
<thead>
<tr>
<th>이벤트 타입</th>
<th>멱등키</th>
</tr>
</thead>
<tbody><tr>
<td><code>Transaction.Paid</code></td>
<td><code>transactionId</code></td>
</tr>
<tr>
<td><code>Transaction.Failed</code></td>
<td><code>transactionId</code></td>
</tr>
<tr>
<td><code>Transaction.Cancelled</code></td>
<td><code>cancellationId</code> (별도 키)</td>
</tr>
</tbody></table>
<p>  이 설계 덕분에 결제 → 취소 순서로 웹훅이 와도 각각 독립적으로 멱등성이 보장된다.                                                                                                                                        </p>
<h3 id="결과">결과</h3>
<p>  웹훅이 10번 와도 <strong>1번만 처리</strong>되며, FAIL 상태인 이벤트는 재시도 시 이어서 처리한다.                                                                                                                                    </p>
<hr>
<h2 id="2-confirm-api-↔-웹훅-동시성-제어">2. /confirm API ↔ 웹훅 동시성 제어</h2>
<h3 id="문제-1">문제</h3>
<p>  결제 완료 직후 다음 두 경로가 <strong>동시에 실행</strong>될 수 있다.                                                                                                                                                                </p>
<pre><code>  [클라이언트] /confirm 호출 ─────────────────────────────┐                                                                                                                                                             
                                                           ↓  포인트 2회 충전 위험                                                                                                                                        
  [포트원]     Transaction.Paid 웹훅 전송 ─────────────────┘                                                                                                                                                              </code></pre><p>  두 경로가 모두 포인트 충전을 시도하면 이중 충전이 발생한다.                                                                                                                                                             </p>
<h3 id="해결-동일-lock-키로-상호-배제">해결: 동일 Lock 키로 상호 배제</h3>
<p>  <code>/confirm</code> API와 웹훅이 <strong>동일한 Lock 키</strong>를 사용하여 둘 중 하나만 실행되도록 강제한다.                                                                                                                                 </p>
<pre><code class="language-java">  // PaymentService.java — /confirm API                                                                                                                                                                                 
  public PaymentResponse confirmPayment(Long userId, PaymentConfirmRequest request) {                                                                                                                                     

      String lockKey = &quot;payment:confirm:lock:&quot; + request.paymentId();                                                                                                                                                     

      if (!redisUtil.acquireLock(lockKey)) {
          // 웹훅이 먼저 처리 중 → 에러 반환                                                                                                                                                                              
          log.warn(&quot;[결제] Lock 획득 실패 (이미 처리 중) paymentKey={}&quot;, request.paymentId());                                                                                                                            
          throw new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_PROCESSING);                                                                                                                                   
      }                                                                                                                                                                                                                   

      try {                                                                                                                                                                                                             
          // 포트원 SDK 검증 + 포인트 충전 + COMPLETED 전환                                                                                                                                                               
          ...                                                                                                                                                                                                             
      } finally {                                                                                                                                                                                                         
          redisUtil.releaseLock(lockKey);  // 예외 발생 시에도 반드시 해제                                                                                                                                                
      }                                                                                                                                                                                                                   
  }</code></pre>
<pre><code class="language-java">  // WebhookService.java — Transaction.Paid 웹훅
  private void completePaymentFromWebhook(...) {

      String lockKey = &quot;payment:confirm:lock:&quot; + paymentId;  // /confirm과 동일한 키

      if (!redisUtil.acquireLock(lockKey)) {                                                                                                                                                                            
          // /confirm이 처리 중 → PENDING 상태로 두어 포트원 재시도 시 재처리                                                                                                                                             
          log.warn(&quot;웹훅: Lock 획득 실패 (처리 중) paymentId={} → 포트원이 재시도 예정&quot;, paymentId);                                                                                                                      
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                   

      try {                                                                                                                                                                                                             
          webhookTransactionService.completePendingPayment(
                  webhookEvent.getId(), payment.getId(), resolvedMethod);                                                                                                                                                 
      } finally {                                                                                                                                                                                                         
          redisUtil.releaseLock(lockKey);                                                                                                                                                                                 
      }                                                                                                                                                                                                                   
  }                                                                                                                                                                                                                     </code></pre>
<h3 id="lock-키-네임스페이스-전략">Lock 키 네임스페이스 전략</h3>
<p>  목적에 따라 Lock 키를 분리하여 <strong>서로 다른 처리 경로가 충돌하지 않도록</strong> 한다.                                                                                                                                          </p>
<pre><code>  payment:confirm:lock:{paymentId}    // 결제 완료 (/confirm + 웹훅 Paid)                                                                                                                                               
  payment:cancel:lock:{paymentId}     // 환불 처리 (/cancel + 웹훅 Cancelled)                                                                                                                                             
  subscription:complete:lock:{id}     // 구독 첫 결제 완료                                                                                                                                                                
  subscription:billing:lock:{id}      // 정기 청구 (스케줄러)                                                                                                                                                             
  subscription:cancel:lock:{id}       // 구독 취소                                                                                                                                                                        </code></pre><p>  같은 키를 공유하는 경로는 <strong>반드시 상호 배제</strong>가 필요한 경로다.<br>  예를 들어 <code>/confirm</code>과 웹훅 Paid는 둘 다 포인트를 충전하므로 동일 키를 쓴다.
  반면 환불은 별도 <code>cancel:lock</code> 키를 사용하여 독립적으로 처리한다.                                                                                                                                                       </p>
<h3 id="결과-1">결과</h3>
<p>  <code>/confirm</code> 처리 중 → 웹훅이 Lock 획득 실패 → PENDING 상태 유지 → 포트원이 재전송 → 이번엔 웹훅이 먼저 Lock 획득 → 정상 처리.<br>  어느 경로가 먼저 실행되든 <strong>포인트는 정확히 1번만 충전</strong>된다.</p>
<hr>
<h2 id="3-상태-기반-설계-state-machine">3. 상태 기반 설계 (State Machine)</h2>
<h3 id="문제-2">문제</h3>
<p>  웹훅은 순서를 보장하지 않는다. <code>Transaction.Cancelled</code>가 <code>Transaction.Paid</code>보다 먼저 도착할 수도 있다.<br>  무조건 처리하면 이미 REFUNDED인 결제를 다시 COMPLETED로 되돌리는 사고가 생긴다.</p>
<h3 id="상태-정의">상태 정의</h3>
<pre><code>  PENDING  ──(결제완료)──▶  COMPLETED
           ──(결제실패)──▶  FAILED                                                                                                                                                                                        
  COMPLETED──(환불완료)──▶  REFUNDED                                                                                                                                                                                      </code></pre><p>  상태가 이미 최종 상태라면 처리를 스킵한다.                                                                                                                                                                              </p>
<h3 id="상태-기반-웹훅-처리">상태 기반 웹훅 처리</h3>
<pre><code class="language-java">  // WebhookService.java                                                                                                                                                                                                
  if (portOnePayment instanceof PaidPayment paidPayment) {

      // 이미 최종 상태 → 스킵
      if (payment.getStatus() == PaymentStatus.COMPLETED                                                                                                                                                                  
              || payment.getStatus() == PaymentStatus.REFUNDED) {                                                                                                                                                         
          log.info(&quot;웹훅 보정 불필요 - 이미 최종 상태 paymentId={} status={}&quot;, paymentId, payment.getStatus());
          webhookTransactionService.markEventComplete(webhookEvent.getId());                                                                                                                                              
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                   

      // PENDING 또는 FAILED(/confirm 타임아웃으로 잘못 처리된 케이스) → 보정                                                                                                                                           
      completePaymentFromWebhook(webhookEvent, payment, paidPayment, paymentId);                                                                                                                                          

  } else if (portOnePayment instanceof FailedPayment) {                                                                                                                                                                   

      // PENDING만 FAILED로 전환 가능                                                                                                                                                                                   
      if (payment.getStatus() != PaymentStatus.PENDING) {                                                                                                                                                                 
          webhookTransactionService.markEventComplete(webhookEvent.getId());                                                                                                                                              
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                   
      webhookTransactionService.failPendingPayment(webhookEvent.getId(), payment.getId());                                                                                                                              

  } else if (portOnePayment instanceof CancelledPayment) {

      // 이미 최종 상태 → 스킵                                                                                                                                                                                          
      if (payment.getStatus() == PaymentStatus.REFUNDED                                                                                                                                                                   
              || payment.getStatus() == PaymentStatus.FAILED) {
          webhookTransactionService.markEventComplete(webhookEvent.getId());                                                                                                                                              
          return;                                                                                                                                                                                                       
      }                                                                                                                                                                                                                   
      // COMPLETED → 환불 타임아웃 보정                                                                                                                                                                                 
      if (payment.getStatus() == PaymentStatus.COMPLETED) {                                                                                                                                                               
          webhookTransactionService.finalizeRefundFromWebhook(webhookEvent.getId(), payment.getId());
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                 
      // PENDING → 결제창 열린 후 완료 전 취소                                                                                                                                                                            
      webhookTransactionService.failPendingPayment(webhookEvent.getId(), payment.getId());                                                                                                                                
  }</code></pre>
<h3 id="confirm-타임아웃-시나리오--failed-→-completed-보정">/confirm 타임아웃 시나리오 — FAILED → COMPLETED 보정</h3>
<pre><code>  [클라이언트] /confirm 호출                                                                                                                                                                                            
  [서버]       포트원 API 타임아웃 → Payment를 FAILED로 저장                                                                                                                                                              
                                      ↓ (실제로는 결제 완료됨)                                                                                                                                                            
  [포트원]     Transaction.Paid 웹훅 전송                                                                                                                                                                                 
  [웹훅]       포트원 상태 조회 → PAID 확인                                                                                                                                                                               
               → FAILED 상태도 COMPLETED로 보정 허용                                                                                                                                                                      </code></pre><pre><code class="language-java">  // WebhookTransactionService.java                                                                                                                                                                                     
  @Transactional
  public void completePendingPayment(Long webhookEventId, Long paymentDbId, PaymentMethod method) {                                                                                                                       
      Payment payment = paymentRepository.findById(paymentDbId)
              .orElseThrow(() -&gt; new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_NOT_FOUND));                                                                                                                  

      if (payment.getStatus() == PaymentStatus.COMPLETED) {
          // 멱등성 — 이미 처리됨, 스킵                                                                                                                                                                                   
          webhookEventRepository.findById(webhookEventId).ifPresent(WebhookEvent::complete);                                                                                                                              
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                   

      // PENDING + FAILED 모두 허용 (confirm 타임아웃 보정)                                                                                                                                                             
      if (payment.getStatus() != PaymentStatus.PENDING                                                                                                                                                                    
              &amp;&amp; payment.getStatus() != PaymentStatus.FAILED) {
          log.warn(&quot;웹훅 보정 스킵 - 처리 불가 상태 paymentDbId={} status={}&quot;, paymentDbId, payment.getStatus());                                                                                                         
          webhookEventRepository.findById(webhookEventId).ifPresent(WebhookEvent::complete);                                                                                                                              
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                   

      payment.complete(method);                                                                                                                                                                                         
      pointService.charge(payment.getUserId(), payment.getAmount());
      purchaseRepository.save(                                                                                                                                                                                            
              Purchase.create(payment.getUserId(), PurchaseType.POINT, payment.getAmount(), paymentDbId)
      );                                                                                                                                                                                                                  
      webhookEventRepository.findById(webhookEventId).ifPresent(WebhookEvent::complete);                                                                                                                                
  }                                                                                                                                                                                                                       </code></pre>
<h3 id="결과-2">결과</h3>
<p>  순서가 뒤바뀌어도, 이미 처리된 상태면 스킵하여 <strong>항상 올바른 최종 상태를 유지</strong>한다.                                                                                                                                    </p>
<hr>
<h2 id="4-트랜잭션-분리-외부-api-vs-db">4. 트랜잭션 분리 (외부 API vs DB)</h2>
<h3 id="문제-3">문제</h3>
<p>  외부 API 호출을 <code>@Transactional</code> 안에 두면:                                                                                                                                                                             </p>
<ul>
<li><p>포트원 SDK 응답 대기(최대 10~20초) 동안 DB 커넥션이 점유된다                                                                                                                                                          </p>
</li>
<li><p>웹훅이 동시에 몰려오면 <strong>커넥션 풀 고갈</strong>로 서비스 전체가 멈춘다                                                                                                                                                    </p>
</li>
<li><p>롤백 범위가 의도치 않게 넓어진다                                                                                                                                                                                      </p>
<h3 id="해결-2계층-서비스-구조">해결: 2계층 서비스 구조</h3>
<pre><code>WebhookService (오케스트레이터)          — @Transactional 없음
  │                                                                                                                                                                                                                   
  ├─ 멱등성 체크 ──────────────────▶  WebhookTransactionService (짧은 트랜잭션)                                                                                                                                     
  │                                                                                                                                                                                                                   
  ├─ 포트원 SDK 조회 ───────────────  외부 API (트랜잭션 밖)                                                                                                                                                          
  │                                                                                                                                                                                                                   
  ├─ Payment 조회 ─────────────────▶  WebhookTransactionService (readOnly 트랜잭션)                                                                                                                                   
  │                                                                                                                                                                                                                   
  └─ 상태 전환 + DB 반영 ──────────▶  WebhookTransactionService (짧은 트랜잭션)                                                                                                                                     </code></pre><pre><code class="language-java">// WebhookService.java — @Transactional 없음                                                                                                                                                                          
public void handleWebhook(WebhookRequest request) {

  // 1. 멱등성 체크 (짧은 트랜잭션)
  WebhookEvent webhookEvent = webhookTransactionService.prepareWebhookEvent(                                                                                                                                          
          idempotencyKey, eventType, paymentId, rawPayload                                                                                                                                                          
  );                                                                                                                                                                                                                  
  if (webhookEvent == null) return;  // 이미 처리됨                                                                                                                                                                 

  // 2. 포트원 SDK 조회 — 위조 방지 검증 (트랜잭션 밖)                                                                                                                                                              
  io.portone.sdk.server.payment.Payment portOnePayment;                                                                                                                                                               
  try {                                                                                                                                                                                                               
      portOnePayment = paymentClient.getPayment(paymentId).get(10, TimeUnit.SECONDS);                                                                                                                                 
  } catch (Exception e) {                                                                                                                                                                                             
      webhookTransactionService.markEventFailed(webhookEvent.getId(), &quot;SDK 조회 실패: &quot; + e.getMessage());                                                                                                          
      return;                                                                                                                                                                                                         
  }                                                                                                                                                                                                                 

  // 3. Payment 조회 (readOnly 트랜잭션)                                                                                                                                                                            
  Payment payment = webhookTransactionService.getPaymentByKey(paymentId);

  // 4. 상태 기반 분기 + DB 반영 (짧은 트랜잭션)
  if (portOnePayment instanceof PaidPayment paidPayment) {                                                                                                                                                            
      completePaymentFromWebhook(webhookEvent, payment, paidPayment, paymentId);                                                                                                                                    
  } else if (portOnePayment instanceof FailedPayment) {                                                                                                                                                               
      webhookTransactionService.failPendingPayment(webhookEvent.getId(), payment.getId());                                                                                                                          
  }                                                                                                                                                                                                                   
  ...                                                                                                                                                                                                               
}                                                                                                                                                                                                                       </code></pre>
<p>트랜잭션 전담 서비스의 각 메서드는 <strong>한 가지 DB 작업만</strong> 담당한다.                                                                                                                                                      </p>
<pre><code class="language-java">// WebhookTransactionService.java — 각 메서드가 단일 책임                                                                                                                                                             
@Transactional                                                                                                                                                                                                          
public WebhookEvent prepareWebhookEvent(...)  // 멱등성 체크 + 이벤트 생성

@Transactional(readOnly = true)                                                                                                                                                                                       
public Payment getPaymentByKey(...)           // Payment 조회만                                                                                                                                                         

@Transactional
public void completePendingPayment(...)       // COMPLETED 전환 + Purchase 저장                                                                                                                                         

@Transactional
public void markEventComplete(...)            // 이벤트 COMPLETE 전환만                                                                                                                                                 

@Transactional
public void failPendingPayment(...)           // FAILED 전환만                                                                                                                                                          </code></pre>
<h3 id="환불-처리에서의-보상-트랜잭션">환불 처리에서의 보상 트랜잭션</h3>
<p>환불 흐름에서는 포트원 API 실패 시 <strong>선차감한 포인트를 되돌리는 보상 트랜잭션</strong>을 적용한다.                                                                                                                             </p>
<pre><code class="language-java">// PaymentService.java — cancelPayment()                                                                                                                                                                              
// 1. 포인트 선차감 (트랜잭션 완료)                                                                                                                                                                                     
pointService.deduct(userId, payment.getAmount());                                                                                                                                                                       

try {                                                                                                                                                                                                                   
  // 2. 포트원 환불 요청 (외부 API, 트랜잭션 밖)                                                                                                                                                                    
  paymentClient.cancelPayment(...).get(20, TimeUnit.SECONDS);                                                                                                                                                         
} catch (Exception e) {                                                                                                                                                                                                 
  // 3. 실패 시 보상: 선차감 포인트 복구                                                                                                                                                                              
  pointService.compensateDeduct(userId, payment.getAmount());                                                                                                                                                         
  throw new ServiceErrorException(PaymentExceptionEnum.ERR_PORTONE_API_ERROR);                                                                                                                                      
}                                                                                                                                                                                                                       

// 4. REFUNDED 전환 (포인트는 이미 차감됨)                                                                                                                                                                              
paymentTransactionService.finalizeCancel(payment.getId());                                                                                                                                                            </code></pre>
<h3 id="결과-3">결과</h3>
<p>외부 API 응답 대기 시간이 DB 커넥션을 점유하지 않아 <strong>커넥션 풀을 효율적으로 사용</strong>한다.                                                                                                                                </p>
</li>
</ul>
<hr>
<h2 id="5-웹훅-폭주-대응-구조">5. 웹훅 폭주 대응 구조</h2>
<h3 id="문제-4">문제</h3>
<p>  포트원은 웹훅 응답이 없으면 <strong>재전송</strong>한다. 서버가 잠깐 다운됐다 올라오면 밀린 웹훅이 한꺼번에 몰린다.                                                                                                                  </p>
<h3 id="대응-전략">대응 전략</h3>
<p>  <strong>① 멱등성 체크로 빠른 종료</strong>                                                                                                                                                                                           </p>
<p>  이미 COMPLETE 처리된 이벤트는 DB 조회 한 번으로 즉시 리턴한다.                                                                                                                                                          </p>
<pre><code class="language-java">  if (existing != null &amp;&amp; existing.getStatus() == WebhookEventStatus.COMPLETE) {                                                                                                                                        
      return null;  // 추가 처리 없이 즉시 종료
  }                                                                                                                                                                                                                       </code></pre>
<p>  <strong>② 처리 불필요한 상태면 스킵</strong>                                                                                                                                                                                         </p>
<p>  Payment가 이미 최종 상태면 외부 API 호출 없이 빠르게 종료한다.                                                                                                                                                          </p>
<pre><code class="language-java">  if (payment.getStatus() == PaymentStatus.COMPLETED) {                                                                                                                                                                 
      webhookTransactionService.markEventComplete(webhookEvent.getId());
      return;  // 포트원 SDK 호출 없음
  }                                                                                                                                                                                                                       </code></pre>
<p>  <strong>③ Lock 실패 시 WebhookEvent를 PENDING으로 유지</strong>                                                                                                                                                                      </p>
<p>  Lock을 못 얻은 이벤트는 PENDING 상태로 남겨 포트원이 재전송할 때 다시 처리한다.<br>  이벤트를 FAIL로 마킹하면 포트원이 재시도를 멈추므로 주의해야 한다.                                                                                                                                                    </p>
<pre><code class="language-java">  if (!redisUtil.acquireLock(lockKey)) {                                                                                                                                                                                  
      // PENDING 유지 → 포트원이 나중에 재전송                                                                                                                                                                          
      log.warn(&quot;웹훅: Lock 획득 실패 → 포트원이 재시도 예정 paymentId={}&quot;, paymentId);                                                                                                                                    
      return;                                                                                                                                                                                                             
  }                                                                                                                                                                                                                       </code></pre>
<p>  <strong>④ 처리 실패 시 FAIL 마킹으로 수동 보정 경로 확보</strong>                                                                                                                                                                    </p>
<p>  포인트 차감 등 복구 불가능한 오류는 FAIL로 남겨 운영자가 수동 보정하도록 한다.                                                                                                                                          </p>
<pre><code class="language-java">  // WebhookTransactionService.java — finalizeRefundFromWebhook()                                                                                                                                                       
  } catch (ServiceErrorException e) {
      // ERR_INSUFFICIENT_POINT: compensateDeduct 실행 후 유저가 포인트를 소비한 경우와                                                                                                                                   
      // compensateDeduct가 실행되지 않은 경우를 구분할 수 없음                                                                                                                                                           
      // → payment.cancel()까지 진행하면 환불금 + 소비 포인트 모두 유저가 챙기는 손실 발생                                                                                                                                
      // → 모든 차감 오류는 FAIL로 남겨 수동 보정 경로로 전달                                                                                                                                                             
      log.error(&quot;웹훅 환불 보정 실패: 포인트 차감 오류 — 수동 보정 필요 userId={} error={}&quot;,                                                                                                                              
              payment.getUserId(), e.getMessage());                                                                                                                                                                       
      webhookEventRepository.findById(webhookEventId).ifPresent(event -&gt; event.fail(e.getMessage()));                                                                                                                     
      return;                                                                                                                                                                                                             
  }                                                                                                                                                                                                                       </code></pre>
<h3 id="webhookevent-상태-의미">WebhookEvent 상태 의미</h3>
<table>
<thead>
<tr>
<th>상태</th>
<th>의미</th>
<th>포트원 재전송</th>
</tr>
</thead>
<tbody><tr>
<td><code>PENDING</code></td>
<td>처리 중 또는 Lock 실패</td>
<td>재전송됨</td>
</tr>
<tr>
<td><code>COMPLETE</code></td>
<td>정상 처리 완료</td>
<td>없음</td>
</tr>
<tr>
<td><code>FAIL</code></td>
<td>오류로 처리 불가</td>
<td>재전송됨 → 운영자 확인 필요</td>
</tr>
</tbody></table>
<hr>
<h2 id="6-전체-처리-흐름">6. 전체 처리 흐름</h2>
<pre><code>  [포트원] 웹훅 전송
        │
        ▼                                                                                                                                                                                                                 
  [멱등성 체크] WebhookEvent 조회
        │                                                                                                                                                                                                                 
        ├─ COMPLETE → 중복 무시, 즉시 리턴                                                                                                                                                                              
        ├─ PENDING/FAIL → 재시도, 이어서 처리                                                                                                                                                                             
        └─ 신규 → 저장 후 처리 시작                                                                                                                                                                                       
        │                                                                                                                                                                                                                 
        ▼                                                                                                                                                                                                                 
  [외부 API] 포트원 SDK 실제 상태 조회 (위조 방지 검증)                                                                                                                                                                 
        │                                                                                                                                                                                                                 
        ▼
  [Payment 조회] DB에서 현재 결제 상태 확인                                                                                                                                                                               
        │                                                                                                                                                                                                               
        ├─ Payment 없음 → 이벤트 COMPLETE (로그만)                                                                                                                                                                        
        │                                                                                                                                                                                                                 
        ▼
  [상태 기반 분기]                                                                                                                                                                                                        
        │                                                                                                                                                                                                               
        ├─ PaidPayment                                                                                                                                                                                                    
        │     ├─ COMPLETED/REFUNDED → 이미 처리됨, 스킵                                                                                                                                                                 
        │     └─ PENDING/FAILED → Lock 획득 → completePendingPayment()                                                                                                                                                    
        │                                                                                                                                                                                                                 
        ├─ FailedPayment                                                                                                                                                                                                  
        │     ├─ PENDING이 아니면 → 스킵                                                                                                                                                                                  
        │     └─ PENDING → failPendingPayment()                                                                                                                                                                           
        │
        ├─ CancelledPayment                                                                                                                                                                                               
        │     ├─ REFUNDED/FAILED → 이미 처리됨, 스킵                                                                                                                                                                      
        │     ├─ COMPLETED → Lock 획득 → finalizeRefundFromWebhook()
        │     └─ PENDING → failPendingPayment()                                                                                                                                                                           
        │                                                                                                                                                                                                               
        └─ PartialCancelledPayment → 미지원, 이벤트 COMPLETE만                                                                                                                                                            </code></pre><hr>
<h2 id="결론">결론</h2>
<p>  웹훅 기반 시스템에서는                                                                                                                                                                                                  </p>
<blockquote>
<p>&quot;정확히 한 번 처리&quot;가 아니라 &quot;여러 번 와도 동일 결과 보장&quot;이 핵심이다                                                                                                                                                 </p>
</blockquote>
<p>  이를 위해 4가지 전략을 조합했다.                                                                                                                                                                                        </p>
<table>
<thead>
<tr>
<th>전략</th>
<th>해결하는 문제</th>
</tr>
</thead>
<tbody><tr>
<td>WebhookEvent 멱등성 키</td>
<td>동일 이벤트 중복 처리 방지</td>
</tr>
<tr>
<td>Redis Lock (공유 키)</td>
<td><code>/confirm</code> ↔ 웹훅 동시 처리 방지</td>
</tr>
<tr>
<td>상태 기반 분기</td>
<td>순서 불일치, 잘못된 상태 전이 방지</td>
</tr>
<tr>
<td>트랜잭션 분리</td>
<td>외부 API 지연으로 인한 커넥션 고갈 방지</td>
</tr>
</tbody></table>
<blockquote>
<p>웹훅의 중복·지연·순서 불일치 문제를 멱등성, 분산 락, 상태 기반 설계로 해결하여 안정적인 결제 시스템을 구축했다</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[환불 시스템에서 분산 트랜잭션과 보상 트랜잭션 설계]]></title>
            <link>https://velog.io/@eno_lj/%ED%99%98%EB%B6%88-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%97%90%EC%84%9C-%EB%B6%84%EC%82%B0-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EB%B3%B4%EC%83%81-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@eno_lj/%ED%99%98%EB%B6%88-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%97%90%EC%84%9C-%EB%B6%84%EC%82%B0-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EB%B3%B4%EC%83%81-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Sat, 02 May 2026 06:25:02 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-정의">문제 정의</h2>
<p>  환불 기능은 단순 상태 변경이 아니다. <strong>서로 다른 세 시스템</strong>이 동시에 관여한다.                                                                                                                                         </p>
<pre><code>  [내부 DB]         결제 상태 (COMPLETED → REFUNDED)                                                                                                                                                                    
  [포인트 시스템]   사용자 포인트 잔액 차감
  [포트원 PG]       실제 환불 API 호출                                                                                                                                                                                    </code></pre><p>  이 세 시스템은 <strong>하나의 트랜잭션으로 묶을 수 없다.</strong> 포트원 API는 DB 트랜잭션 밖에 있는 외부 시스템이라 롤백이 불가능하다.                                                                                              </p>
<hr>
<h2 id="단순-구현의-한계">단순 구현의 한계</h2>
<p>  처음 생각할 수 있는 구현은 이렇다.                                                                                                                                                                                      </p>
<pre><code class="language-java">  // 위험한 구현 — 외부 API를 트랜잭션 안에 포함                                                                                                                                                                        
  @Transactional                                                                                                                                                                                                          
  public void cancelPayment(Long userId, Long paymentId) {
      pointService.deduct(userId, amount);       // 포인트 차감                                                                                                                                                           
      paymentClient.cancelPayment(paymentKey);   // 포트원 환불 API                                                                                                                                                     
      payment.cancel();                          // DB 상태 변경                                                                                                                                                          
  }                                                                                                                                                                                                                     </code></pre>
<p>  이 구조에서 포트원 API가 실패하면?                                                                                                                                                                                      </p>
<pre><code>  포인트 차감 완료                                                                                                                                                                                                      
      ↓
  포트원 API 실패 → 예외 발생 → @Transactional 롤백
      ↓                                                                                                                                                                                                                   
  DB는 롤백됨 ← 포인트도 롤백됨? NO</code></pre><p>  <code>pointService.deduct()</code>가 <strong>별도 서비스</strong>이므로 이미 자체 트랜잭션이 커밋된 상태다. <code>@Transactional</code> 롤백은 같은 트랜잭션 안의 DB 변경만 되돌린다. 포트원은 외부 시스템이므로 애초에 롤백 대상이 아니다.                </p>
<p>  결과적으로 모든 것을 하나의 <code>@Transactional</code>로 묶어도 <strong>분산 트랜잭션 문제는 해결되지 않는다.</strong>                                                                                                                         </p>
<hr>
<h2 id="해결-전략-saga--보상-트랜잭션">해결 전략: Saga + 보상 트랜잭션</h2>
<blockquote>
<p>하나로 묶는 대신, 단계적으로 처리하고 실패 시 이전 단계를 되돌린다                                                                                                                                                    </p>
</blockquote>
<pre><code>  [1] Redis Lock 획득                 (중복 요청 차단)                                                                                                                                                                  
  [2] 환불 가능 상태 검증              (readOnly 트랜잭션)                                                                                                                                                                
  [3] 포인트 선차감                    (짧은 트랜잭션 — 여기서 잔액 부족이면 즉시 중단)
  [4] 포트원 환불 API 호출             (트랜잭션 밖 — 외부 시스템)                                                                                                                                                        
      ├─ 성공 → [5] REFUNDED 상태 변경 (짧은 트랜잭션)                                                                                                                                                                    
      └─ 실패 → [보상] 포인트 복구     (보상 트랜잭션)                                                                                                                                                                    
  [6] Redis Lock 해제                                                                                                                                                                                                     </code></pre><hr>
<h2 id="1-분산-락으로-동시성-제어">1. 분산 락으로 동시성 제어</h2>
<h3 id="문제">문제</h3>
<p>  동일 결제에 대해 중복 환불 요청이 동시에 들어오면 포인트가 두 번 차감되고 환불도 두 번 일어난다.                                                                                                                        </p>
<pre><code>  [요청 A] deduct(10000P) → 포트원 환불 요청                                                                                                                                                                            
  [요청 B] deduct(10000P) → 포트원 환불 요청   ← 동시에 실행                                                                                                                                                              </code></pre><h3 id="해결">해결</h3>
<pre><code class="language-java">  // PaymentService.java
  public PaymentResponse cancelPayment(Long userId, Long paymentId, String reason) {                                                                                                                                      

      String lockKey = &quot;payment:cancel:lock:&quot; + paymentId;

      if (!redisUtil.acquireLock(lockKey)) {
          // 이미 다른 요청이 처리 중                                                                                                                                                                                     
          log.warn(&quot;[환불] Lock 획득 실패 (이미 처리 중) userId={} paymentId={}&quot;, userId, paymentId);                                                                                                                   
          throw new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_ALREADY_CANCELING);                                                                                                                            
      }                                                                                                                                                                                                                   

      try {                                                                                                                                                                                                               
          // 환불 처리                                                                                                                                                                                                  
          ...
      } finally {
          redisUtil.releaseLock(lockKey);  // 예외 발생 시에도 반드시 해제
      }                                                                                                                                                                                                                   
  }</code></pre>
<p>  <code>finally</code>에서 락을 해제하는 것은 선택이 아니다. 해제하지 않으면 TTL 만료 전까지 동일 결제에 대한 모든 요청이 차단된다.                                                                                                  </p>
<hr>
<h2 id="2-환불-가능-상태-검증">2. 환불 가능 상태 검증</h2>
<p>  불필요한 외부 API 호출 전에 먼저 환불이 가능한 상태인지 확인한다. COMPLETED 상태가 아니라면 포트원 API를 호출할 이유가 없다.                                                                                            </p>
<pre><code class="language-java">  // PaymentTransactionService.java                                                                                                                                                                                     
  @Transactional(readOnly = true)
  public Payment getPaymentForCancel(Long userId, Long paymentId) {
      Payment payment = paymentRepository.findByIdAndUserId(paymentId, userId)                                                                                                                                            
              .orElseThrow(() -&gt; {
                  if (paymentRepository.existsById(paymentId)) {                                                                                                                                                          
                      log.warn(&quot;[환불] 다른 사용자 결제 접근 시도 userId={} paymentId={}&quot;, userId, paymentId);                                                                                                            
                      return new ServiceErrorException(PaymentExceptionEnum.ERR_NOT_MY_ORDER);                                                                                                                            
                  }                                                                                                                                                                                                       
                  return new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_NOT_FOUND);                                                                                                                           
              });                                                                                                                                                                                                         

      // COMPLETED 상태만 환불 가능                                                                                                                                                                                       
      if (payment.getStatus() != PaymentStatus.COMPLETED) {                                                                                                                                                             
          log.warn(&quot;[환불] 환불 불가 상태 status={}&quot;, payment.getStatus());
          throw new ServiceErrorException(PaymentExceptionEnum.ERR_INVALID_COMPLETE);                                                                                                                                     
      }
      return payment;                                                                                                                                                                                                     
  }                                                                                                                                                                                                                     </code></pre>
<p>  <code>readOnly = true</code> 트랜잭션으로 최대한 짧게 유지한다.                                                                                                                                                                    </p>
<hr>
<h2 id="3-포인트-선차감--핵심-설계-결정">3. 포인트 선차감 — 핵심 설계 결정</h2>
<h3 id="왜-포트원-api-전에-포인트를-먼저-차감하는가">왜 포트원 API 전에 포인트를 먼저 차감하는가</h3>
<p>  순서를 바꾸면 어떻게 되는지 비교한다.                                                                                                                                                                                   </p>
<p>  <strong>후차감 구조 (포트원 먼저)</strong>                                                                                                                                                                                           </p>
<pre><code>  포트원 환불 성공                                                                                                                                                                                                      
      ↓
  포인트 차감 실패 (잔액 부족 등)
      ↓                                                                                                                                                                                                                   
  사용자: 환불금도 받고, 포인트도 그대로
      → 서비스 손실 발생                                                                                                                                                                                                  </code></pre><p>  <strong>선차감 구조 (포인트 먼저)</strong>                                                                                                                                                                                           </p>
<pre><code>  포인트 잔액 부족 → 즉시 중단 (포트원 API 호출 안 함)
      ↓                                                                                                                                                                                                                   
  포인트 차감 성공 → 포트원 환불 진행
      ↓                                                                                                                                                                                                                   
  포트원 실패 → compensateDeduct() 로 복구                                                                                                                                                                              
      → 내부 상태를 먼저 확정하고 외부 연동                                                                                                                                                                               </code></pre><p>  내부 시스템 상태를 먼저 확정하고 외부 연동하면, 외부 실패 시 <strong>내부 상태만 되돌리면</strong> 된다.                                                                                                                             </p>
<pre><code class="language-java">  // PaymentService.java                                                                                                                                                                                                
  // 2. 포인트 선차감 — 잔액 부족 시 여기서 실패, PortOne 미호출                                                                                                                                                          
  pointService.deduct(userId, payment.getAmount());
  log.info(&quot;[환불] 포인트 선차감 완료 userId={} amount={}P&quot;, userId, payment.getAmount());                                                                                                                                </code></pre>
<hr>
<h2 id="4-포트원-환불-api-호출-트랜잭션-밖">4. 포트원 환불 API 호출 (트랜잭션 밖)</h2>
<pre><code class="language-java">  // PaymentService.java
  try {
      // 트랜잭션 밖에서 실행 — DB 커넥션 점유 없음
      paymentClient.cancelPayment(                                                                                                                                                                                        
              payment.getPaymentKey(),
              null, null, null,                                                                                                                                                                                           
              reason,                                                                                                                                                                                                   
              null, null, null, null, null, null
      ).get(20, TimeUnit.SECONDS);  // 타임아웃 20초                                                                                                                                                                      
      log.info(&quot;[환불] 포트원 환불 완료 paymentKey={}&quot;, payment.getPaymentKey());

  } catch (Exception e) {                                                                                                                                                                                               
      // 보상 트랜잭션: 선차감한 포인트 복구                                                                                                                                                                              
      pointService.compensateDeduct(userId, payment.getAmount());                                                                                                                                                         
      log.error(&quot;[환불] 포트원 실패 → 포인트 복구 완료 userId={} amount={}P&quot;, userId, payment.getAmount(), e);
      throw new ServiceErrorException(PaymentExceptionEnum.ERR_PORTONE_API_ERROR);                                                                                                                                        
  }                                                                                                                                                                                                                       </code></pre>
<h3 id="트랜잭션-밖에서-호출하는-이유">트랜잭션 밖에서 호출하는 이유</h3>
<p>  포트원 API 응답에 최대 20초를 기다린다. 이 대기 시간 동안 DB 커넥션이 점유되면 커넥션 풀이 고갈된다.                                                                                                                    </p>
<pre><code>  @Transactional 안에서 20초 대기                                                                                                                                                                                       
      → DB 커넥션 1개가 20초간 점유                                                                                                                                                                                       
      → 동시 요청 20건 → 커넥션 풀 20개 모두 점유                                                                                                                                                                         
      → 이후 모든 DB 요청 대기 상태                                                                                                                                                                                       </code></pre><p>  외부 API 호출을 트랜잭션 밖으로 꺼내면 대기 시간 동안 커넥션이 유휴 상태가 된다.                                                                                                                                        </p>
<h3 id="타임아웃-20초를-설정하는-이유">타임아웃 20초를 설정하는 이유</h3>
<p>  타임아웃 없이 무한 대기하면 네트워크 문제가 발생했을 때 스레드가 영구적으로 점유된다.                                                                                                                                   </p>
<pre><code>  타임아웃 없음: 포트원 응답 없음 → 스레드 영구 점유 → 서버 전체 정체                                                                                                                                                   
  타임아웃 있음: 20초 초과 → TimeoutException → 보상 트랜잭션 실행                                                                                                                                                        </code></pre><hr>
<h2 id="5-보상-트랜잭션-compensation-transaction">5. 보상 트랜잭션 (Compensation Transaction)</h2>
<p>  포트원 API가 실패하면 이미 차감된 포인트를 되돌린다. 이것이 <strong>보상 트랜잭션</strong>이다.                                                                                                                                      </p>
<pre><code class="language-java">  // 포트원 실패 시 보상                                                                                                                                                                                                
  pointService.compensateDeduct(userId, payment.getAmount());                                                                                                                                                             </code></pre>
<p>  보상 트랜잭션의 핵심은 <strong>&quot;롤백&quot;이 아니라 &quot;역작업&quot;</strong>이라는 점이다.                                                                                                                                                       </p>
<pre><code>  일반 트랜잭션 롤백: DB 변경 사항을 없었던 것으로 되돌림 (원자적)                                                                                                                                                      
  보상 트랜잭션:      이미 커밋된 작업을 별도의 새 트랜잭션으로 역방향 처리                                                                                                                                               </code></pre><p>  <code>pointService.deduct()</code>는 이미 자체 트랜잭션이 커밋됐다. 따라서 <code>@Transactional</code>이 롤백해도 포인트는 되돌아오지 않는다. 반드시 <code>compensateDeduct()</code>라는 별도 트랜잭션으로 명시적으로 복구해야 한다.                     </p>
<hr>
<h2 id="6-환불-완료-상태-확정">6. 환불 완료 상태 확정</h2>
<p>  포트원 환불이 성공하면 마지막으로 DB 상태를 REFUNDED로 전환한다.                                                                                                                                                        </p>
<pre><code class="language-java">  // PaymentTransactionService.java                                                                                                                                                                                     
  @Transactional
  public Payment finalizeCancel(Long paymentId) {
      Payment payment = paymentRepository.findById(paymentId)
              .orElseThrow(() -&gt; new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_NOT_FOUND));

      // Redis Lock으로 이 케이스는 발생하지 않지만 방어 코드
      if (payment.getStatus() != PaymentStatus.COMPLETED) {                                                                                                                                                               
          log.warn(&quot;[환불] REFUNDED 전환 스킵 - 이미 최종 상태 status={}&quot;, payment.getStatus());                                                                                                                        
          return payment;                                                                                                                                                                                                 
      }                                                                                                                                                                                                                 

      payment.cancel();  // REFUNDED 전환                                                                                                                                                                               
      log.info(&quot;[환불] REFUNDED 전환 완료 dbPaymentId={}&quot;, paymentId);                                                                                                                                                    
      return payment;                                                                                                                                                                                                     
  }                                                                                                                                                                                                                       </code></pre>
<pre><code class="language-java">  // Payment.java
  public void cancel() {
      this.status = PaymentStatus.REFUNDED;
      this.cancelledAt = LocalDateTime.now();                                                                                                                                                                             
  }</code></pre>
<p>  포인트는 3단계에서 이미 차감됐으므로 여기서는 <strong>상태 변경만</strong> 수행한다.                                                                                                                                                 </p>
<hr>
<h2 id="7-타임아웃-기반-설계의-한계와-보완">7. 타임아웃 기반 설계의 한계와 보완</h2>
<h3 id="발생-가능한-정합성-불일치">발생 가능한 정합성 불일치</h3>
<pre><code>  포인트 선차감 완료                                                                                                                                                                                                    
      ↓
  포트원 환불 요청 → 20초 타임아웃
      ↓ (실제로는 포트원이 환불 처리를 완료했을 수도 있음)                                                                                                                                                                
  TimeoutException → compensateDeduct() → 포인트 복구                                                                                                                                                                     
      ↓                                                                                                                                                                                                                   
  결과: 포트원은 환불 완료, 내부 DB는 COMPLETED, 포인트는 복구됨                                                                                                                                                          
      → 사용자: 환불금도 받고, 포인트도 복구 → 서비스 손실                                                                                                                                                                </code></pre><p>  타임아웃이 발생해도 포트원 서버에서는 환불이 완료됐을 수 있다. 응답을 못 받은 것이지 요청이 실패한 게 아닐 수 있다.                                                                                                     </p>
<h3 id="보완-웹훅-기반-최종-정합성">보완: 웹훅 기반 최종 정합성</h3>
<p>  포트원은 환불이 완료되면 <code>Transaction.Cancelled</code> 웹훅을 전송한다. 웹훅이 도착하면 실제 상태 기준으로 DB를 보정한다.                                                                                                     </p>
<pre><code class="language-java">  // WebhookTransactionService.java                                                                                                                                                                                     
  @Transactional
  public void finalizeRefundFromWebhook(Long webhookEventId, Long paymentDbId) {
      Payment payment = paymentRepository.findById(paymentDbId)                                                                                                                                                           
              .orElseThrow(() -&gt; new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_NOT_FOUND));

      // COMPLETED 상태만 처리 (이미 REFUNDED면 스킵)                                                                                                                                                                   
      if (payment.getStatus() != PaymentStatus.COMPLETED) {                                                                                                                                                               
          log.info(&quot;웹훅 환불 보정 스킵 - 이미 처리됨 status={}&quot;, payment.getStatus());                                                                                                                                   
          webhookEventRepository.findById(webhookEventId).ifPresent(WebhookEvent::complete);                                                                                                                              
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                   

      try {
          // compensateDeduct가 실행됐다면 포인트가 복구된 상태 → 다시 차감
          pointService.deduct(payment.getUserId(), payment.getAmount());                                                                                                                                                  
          log.info(&quot;웹훅 환불 보정: 포인트 차감 완료 userId={} amount={}P&quot;, payment.getUserId(), payment.getAmount());
      } catch (ServiceErrorException e) {                                                                                                                                                                                 
          // ERR_INSUFFICIENT_POINT: compensateDeduct 후 유저가 포인트를 소비한 케이스와                                                                                                                                
          // compensateDeduct 미실행 케이스를 구분할 수 없음                                                                                                                                                              
          // → payment.cancel()까지 진행하면 환불금 + 소비 포인트 모두 유저 소유 → 서비스 손실                                                                                                                            
          // → 모든 차감 오류는 FAIL로 남겨 수동 보정                                                                                                                                                                     
          log.error(&quot;웹훅 환불 보정 실패: 포인트 차감 오류 — 수동 보정 필요 userId={} error={}&quot;,                                                                                                                          
                  payment.getUserId(), e.getMessage());                                                                                                                                                                   
          webhookEventRepository.findById(webhookEventId).ifPresent(event -&gt; event.fail(e.getMessage()));                                                                                                                 
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                   

      payment.cancel();  // COMPLETED → REFUNDED
      webhookEventRepository.findById(webhookEventId).ifPresent(WebhookEvent::complete);                                                                                                                                  
      log.info(&quot;웹훅 환불 보정 완료 (COMPLETED→REFUNDED) paymentDbId={}&quot;, paymentDbId);
  }                                                                                                                                                                                                                       </code></pre>
<p>  <code>ERR_INSUFFICIENT_POINT</code>가 발생했을 때 그냥 넘어가면 안 되는 이유가 있다. <code>compensateDeduct()</code>가 실행된 후 사용자가 그 포인트를 소비했다면 잔액이 부족할 수 있는데, 이 경우에 <code>payment.cancel()</code>까지 진행하면 사용자는<br>  환불금도 받고 포인트 소비도 유효하게 된다. 그래서 차감 실패는 무조건 수동 보정으로 보낸다.</p>
<hr>
<h2 id="8-전체-처리-흐름">8. 전체 처리 흐름</h2>
<pre><code>  [클라이언트] 환불 요청
        │
        ▼                                                                                                                                                                                                                 
  [Redis Lock 획득] &quot;payment:cancel:lock:{paymentId}&quot;
        │ 실패 → ERR_PAYMENT_ALREADY_CANCELING                                                                                                                                                                            
        ▼                                                                                                                                                                                                                 
  [상태 검증] COMPLETED 상태인지 확인 (readOnly 트랜잭션)
        │ COMPLETED 아님 → ERR_INVALID_COMPLETE                                                                                                                                                                           
        ▼                                                                                                                                                                                                                 
  [포인트 선차감] deduct(userId, amount) (짧은 트랜잭션)                                                                                                                                                                  
        │ 잔액 부족 → ERR_INSUFFICIENT_POINT (포트원 미호출)                                                                                                                                                              
        ▼                                                                                                                                                                                                                 
  [포트원 환불 API] cancelPayment(paymentKey) — 타임아웃 20초 (트랜잭션 밖)
        │                                                                                                                                                                                                                 
        ├─ 성공 ──────────────────────────────────────────────────────┐                                                                                                                                                 
        │                                                             ▼                                                                                                                                                   
        │                                                 [REFUNDED 전환] finalizeCancel()                                                                                                                              
        │                                                                                                                                                                                                                 
        └─ 실패 ─────────────────────────────────────────────────────┐                                                                                                                                                  
                                                                      ▼                                                                                                                                                   
                                                          [보상 트랜잭션] compensateDeduct()                                                                                                                              
                                                          포인트 복구 후 에러 반환

        ▼                                                                                                                                                                                                               
  [Redis Lock 해제] (finally — 항상 실행)                                                                                                                                                                                 

  --- 타임아웃으로 불일치가 발생했다면 ---

  [포트원] Transaction.Cancelled 웹훅 전송                                                                                                                                                                              
        ▼                                                                                                                                                                                                                 
  [웹훅 보정] finalizeRefundFromWebhook()                                                                                                                                                                               
        COMPLETED → 포인트 재차감 → REFUNDED 전환                                                                                                                                                                         </code></pre><hr>
<h2 id="결론">결론</h2>
<p>  환불 시스템에서는</p>
<blockquote>
<p>원자성을 포기하고, 복구 가능성을 선택하는 설계가 필요하다                                                                                                                                                             </p>
</blockquote>
<table>
<thead>
<tr>
<th>문제</th>
<th>해결책</th>
</tr>
</thead>
<tbody><tr>
<td>분산 트랜잭션 불가</td>
<td>Saga 패턴 — 단계적 처리 + 보상 트랜잭션</td>
</tr>
<tr>
<td>중복 환불 요청</td>
<td>Redis 분산 락</td>
</tr>
<tr>
<td>DB 커넥션 점유</td>
<td>외부 API를 트랜잭션 밖으로 분리</td>
</tr>
<tr>
<td>서비스 지연/무한 대기</td>
<td>타임아웃 20초 설정</td>
</tr>
<tr>
<td>타임아웃 후 정합성 불일치</td>
<td>웹훅 기반 Eventual Consistency 보정</td>
</tr>
</tbody></table>
<blockquote>
<p>분산 트랜잭션을 Saga 패턴과 보상 트랜잭션으로 해결하고, 락과 타임아웃으로 안정성을, 웹훅으로 최종 정합성을 확보한 환불 구조를 설계했다</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[결제 시스템에서 트랜잭션 분리와 정합성 확보 전략]]></title>
            <link>https://velog.io/@eno_lj/%EA%B2%B0%EC%A0%9C-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%97%90%EC%84%9C-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC%EC%99%80-%EC%A0%95%ED%95%A9%EC%84%B1-%ED%99%95%EB%B3%B4-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@eno_lj/%EA%B2%B0%EC%A0%9C-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%97%90%EC%84%9C-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC%EC%99%80-%EC%A0%95%ED%95%A9%EC%84%B1-%ED%99%95%EB%B3%B4-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Sat, 02 May 2026 05:57:05 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-정의">문제 정의</h2>
<p>  결제 confirm 로직을 구현하면서 다음 세 가지 문제를 동시에 해결해야 했다.                                                                                                                                                </p>
<table>
<thead>
<tr>
<th>문제</th>
<th>원인</th>
</tr>
</thead>
<tbody><tr>
<td>DB 커넥션 점유</td>
<td>외부 PG API 호출이 트랜잭션 안에 포함됨</td>
</tr>
<tr>
<td>결제 금액 위변조</td>
<td>클라이언트가 전달한 금액은 신뢰할 수 없음</td>
</tr>
<tr>
<td>동일 결제 중복 처리</td>
<td><code>/confirm</code> 중복 호출, 웹훅과의 동시 실행</td>
</tr>
</tbody></table>
<p>  단순 CRUD가 아니라 <strong>성능 + 보안 + 동시성 + 정합성</strong>을 모두 고려해야 하는 문제였다.                                                                                                                                     </p>
<hr>
<h2 id="1-db-커넥션-점유-문제--트랜잭션과-외부-api-분리">1. DB 커넥션 점유 문제 — 트랜잭션과 외부 API 분리</h2>
<h3 id="문제">문제</h3>
<p>  외부 PG API 호출을 <code>@Transactional</code> 안에 넣으면 API 응답을 기다리는 동안 DB 커넥션이 계속 점유된다.                                                                                                                     </p>
<pre><code class="language-java">  // 나쁜 예 — 외부 API 호출이 트랜잭션 안에 있음                                                                                                                                                                       
  @Transactional                                                                                                                                                                                                          
  public void confirmPayment(...) {                                                                                                                                                                                       
      Payment payment = paymentRepository.find(...);

      // 포트원 API 응답 대기 (최대 10초) 동안 DB 커넥션 점유                                                                                                                                                           
      Payment portOnePayment = paymentClient.getPayment(...);                                                                                                                                                             

      payment.complete();                                                                                                                                                                                                 
  }                                                                                                                                                                                                                       </code></pre>
<p>  커넥션 풀이 20개라면, 동시에 20건만 느린 외부 API를 기다려도 이후 모든 요청이 대기 상태가 된다.                                                                                                                         </p>
<h3 id="해결-2계층-서비스-구조">해결: 2계층 서비스 구조</h3>
<p>  오케스트레이터와 트랜잭션 전담 서비스를 분리한다.                                                                                                                                                                       </p>
<pre><code>  PaymentService              — @Transactional 없음 (흐름 제어, 외부 API 담당)                                                                                                                                          
      │                                                                                                                                                                                                                   
      ├─ preparePendingPayment()  ──▶  PaymentTransactionService (짧은 트랜잭션)
      ├─ paymentClient.getPayment()    외부 API (트랜잭션 밖)                                                                                                                                                             
      └─ completePayment()        ──▶  PaymentTransactionService (짧은 트랜잭션)                                                                                                                                          </code></pre><pre><code class="language-java">  // PaymentService.java — @Transactional 없음
  public PaymentResponse confirmPayment(Long userId, PaymentConfirmRequest request) {                                                                                                                                     

      // 1. PENDING Payment 조회 + 검증 (짧은 트랜잭션)                                                                                                                                                                   
      Payment pendingPayment = paymentTransactionService.savePendingPayment(userId, request);                                                                                                                             

      // 2. 포트원 SDK 조회 — 트랜잭션 밖에서 실행 (DB 커넥션 미점유)                                                                                                                                                     
      io.portone.sdk.server.payment.Payment portOnePayment =                                                                                                                                                              
              paymentClient.getPayment(request.paymentId()).get(10, TimeUnit.SECONDS);                                                                                                                                    

      // 3. 검증 통과 후 COMPLETED 전환 (짧은 트랜잭션)                                                                                                                                                                   
      Payment completedPayment = paymentTransactionService.completePayment(                                                                                                                                               
              pendingPayment.getId(), userId, request.amount(), resolvedMethod                                                                                                                                            
      );
  }                                                                                                                                                                                                                       </code></pre>
<p>  트랜잭션 전담 서비스의 각 메서드는 <strong>한 가지 DB 작업만</strong> 담당한다.                                                                                                                                                      </p>
<pre><code class="language-java">  // PaymentTransactionService.java — DB 작업 전담                                                                                                                                                                      
  @Transactional
  public Payment savePendingPayment(Long userId, PaymentConfirmRequest request) { ... }                                                                                                                                   

  @Transactional                                                                                                                                                                                                          
  public Payment completePayment(Long paymentId, Long userId, Long amount, PaymentMethod method) { ... }                                                                                                                

  @Transactional
  public void failPayment(Long paymentId) { ... }                                                                                                                                                                         

  @Transactional(readOnly = true)
  public Payment getPaymentForCancel(Long userId, Long paymentId) { ... }

  @Transactional
  public Payment finalizeCancel(Long paymentId) { ... }                                                                                                                                                                   </code></pre>
<h3 id="결과">결과</h3>
<p>  외부 API가 10초 걸려도 그 시간 동안 DB 커넥션이 유휴 상태로 다른 요청을 처리할 수 있다.                                                                                                                                 </p>
<hr>
<h2 id="2-결제-금액-위변조-방지--2단계-검증">2. 결제 금액 위변조 방지 — 2단계 검증</h2>
<h3 id="문제-1">문제</h3>
<p>  클라이언트가 <code>/confirm</code> 요청에 임의로 금액을 바꿔 보낼 수 있다. 클라이언트 금액을 그대로 쓰면 1만 원짜리 결제로 100만 포인트를 충전하는 공격이 가능하다.                                                                </p>
<h3 id="해결-서버-선저장--pg-응답-최종-검증">해결: 서버 선저장 + PG 응답 최종 검증</h3>
<p>  <strong>1단계 — <code>/prepare</code> 단계에서 서버가 금액을 DB에 선저장</strong>                                                                                                                                                               </p>
<p>  서버가 <code>paymentKey</code>를 생성하고 금액을 미리 DB에 저장한다. 클라이언트는 이 키를 포트원 SDK에 전달할 뿐이다.                                                                                                              </p>
<pre><code class="language-java">  // PaymentTransactionService.java                                                                                                                                                                                     
  @Transactional
  public PaymentPrepareResponse preparePendingPayment(Long userId, Long amount) {
      String paymentKey = &quot;payment-&quot; + UUID.randomUUID().toString().replace(&quot;-&quot;, &quot;&quot;);                                                                                                                                     
      Payment payment = paymentRepository.save(
              Payment.create(userId, paymentKey, amount, PaymentMethod.CARD)                                                                                                                                              
      );                                                                                                                                                                                                                
      log.info(&quot;[결제 준비] PENDING 생성 userId={} paymentKey={}&quot;, userId, paymentKey);                                                                                                                                   
      return new PaymentPrepareResponse(payment.getPaymentKey(), amount);                                                                                                                                                 
  }                                                                                                                                                                                                                       </code></pre>
<p>  <strong>2단계 — <code>/confirm</code> 단계에서 prepare 금액과 비교</strong>                                                                                                                                                                   </p>
<pre><code class="language-java">  // PaymentTransactionService.java
  @Transactional                                                                                                                                                                                                          
  public Payment savePendingPayment(Long userId, PaymentConfirmRequest request) {

      Payment existing = paymentRepository.findByPaymentKey(request.paymentId())                                                                                                                                        
              .orElseThrow(() -&gt; new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_NOT_FOUND));                                                                                                                  

      // prepare 없이 confirm을 직접 호출하는 시도 차단                                                                                                                                                                   
      if (!existing.getUserId().equals(userId)) {                                                                                                                                                                         
          throw new ServiceErrorException(PaymentExceptionEnum.ERR_NOT_MY_ORDER);                                                                                                                                         
      }                                                                                                                                                                                                                   

      // prepare 금액 vs confirm 금액 비교                                                                                                                                                                              
      if (!existing.getAmount().equals(request.amount())) {                                                                                                                                                               
          log.warn(&quot;[결제] 금액 불일치 prepare금액={} confirm금액={}&quot;, existing.getAmount(), request.amount());
          throw new ServiceErrorException(PaymentExceptionEnum.ERR_AMOUNT_MISMATCH);                                                                                                                                      
      }                                                                                                                                                                                                                 

      if (existing.getStatus() != PaymentStatus.PENDING) {
          throw new ServiceErrorException(PaymentExceptionEnum.ERR_ALREADY_PAID);
      }

      return existing;
  }</code></pre>
<p>  <strong>3단계 — 포트원 SDK 응답 금액으로 최종 검증 (진짜 기준)</strong></p>
<pre><code class="language-java">  // PaymentService.java
  if (!(portOnePayment instanceof PaidPayment paidPayment)) {
      paymentTransactionService.failPayment(pendingPayment.getId());                                                                                                                                                      
      throw new ServiceErrorException(PaymentExceptionEnum.ERR_INVALID_PENDING);
  }                                                                                                                                                                                                                       

  // 포트원 실제 결제 금액 vs 요청 금액                                                                                                                                                                                   
  long actualAmount = paidPayment.getAmount().getTotal();                                                                                                                                                               
  if (actualAmount != request.amount()) {                                                                                                                                                                                 
      paymentTransactionService.failPayment(pendingPayment.getId());
      log.warn(&quot;[결제] 금액 위변조 감지 요청금액={} 실제금액={}&quot;, request.amount(), actualAmount);                                                                                                                        
      throw new ServiceErrorException(PaymentExceptionEnum.ERR_AMOUNT_MISMATCH);                                                                                                                                        
  }                                                                                                                                                                                                                       </code></pre>
<h3 id="검증-흐름-요약">검증 흐름 요약</h3>
<pre><code>  [클라이언트] /prepare 요청 → 서버가 금액 DB 저장 + paymentKey 반환
  [클라이언트] paymentKey로 포트원 결제창 열기                                                                                                                                                                            
  [클라이언트] /confirm 요청 (금액 포함)                                                                                                                                                                                  
  [서버]       1차 검증: DB의 prepare 금액 == confirm 요청 금액?                                                                                                                                                          
  [서버]       포트원 SDK 조회                                                                                                                                                                                            
  [서버]       2차 검증: 포트원 실제 결제 금액 == confirm 요청 금액?  ← 최종 기준                                                                                                                                         </code></pre><p>  클라이언트가 금액을 조작해도 <strong>포트원 실제 결제 금액</strong>과 다르면 실패한다.                                                                                                                                               </p>
<hr>
<h2 id="3-동일-결제-중복-처리-방지--redis-분산-락">3. 동일 결제 중복 처리 방지 — Redis 분산 락</h2>
<h3 id="문제-2">문제</h3>
<p>  다음 두 경로가 동시에 실행될 수 있다.                                                                                                                                                                                   </p>
<pre><code>  [클라이언트] /confirm 중복 호출 ──────────────────────────────┐                                                                                                                                                       
                                                                ↓ 포인트 2회 충전                                                                                                                                         
  [포트원]     Transaction.Paid 웹훅 전송 ──────────────────────┘                                                                                                                                                         </code></pre><h3 id="해결-동일-lock-키로-상호-배제">해결: 동일 Lock 키로 상호 배제</h3>
<p>  <code>/confirm</code> API와 웹훅이 <strong>동일한 Lock 키</strong>를 사용하여 하나만 실행되도록 강제한다.                                                                                                                                       </p>
<pre><code class="language-java">  // PaymentService.java — /confirm API                                                                                                                                                                                 
  public PaymentResponse confirmPayment(Long userId, PaymentConfirmRequest request) {                                                                                                                                     

      String lockKey = &quot;payment:confirm:lock:&quot; + request.paymentId();                                                                                                                                                     

      if (!redisUtil.acquireLock(lockKey)) {
          log.warn(&quot;[결제] Lock 획득 실패 (이미 처리 중) paymentKey={}&quot;, request.paymentId());                                                                                                                            
          throw new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_PROCESSING);                                                                                                                                   
      }                                                                                                                                                                                                                   

      try {                                                                                                                                                                                                               
          // 포트원 SDK 검증 + 포인트 충전 + COMPLETED 전환                                                                                                                                                             
          ...                                                                                                                                                                                                             
      } finally {
          redisUtil.releaseLock(lockKey);  // 예외 발생 시에도 반드시 해제                                                                                                                                                
      }                                                                                                                                                                                                                   
  }</code></pre>
<p>  웹훅도 동일한 키를 사용한다.</p>
<pre><code class="language-java">  // WebhookService.java — Transaction.Paid 웹훅
  String lockKey = &quot;payment:confirm:lock:&quot; + paymentId;  // /confirm과 동일한 키                                                                                                                                          

  if (!redisUtil.acquireLock(lockKey)) {                                                                                                                                                                                  
      // /confirm이 처리 중 → PENDING 유지 → 포트원이 재전송                                                                                                                                                              
      log.warn(&quot;웹훅: Lock 획득 실패 → 포트원이 재시도 예정 paymentId={}&quot;, paymentId);                                                                                                                                    
      return;                                                                                                                                                                                                             
  }                                                                                                                                                                                                                       </code></pre>
<h3 id="lock-안에서도-멱등성-방어">Lock 안에서도 멱등성 방어</h3>
<p>  Lock을 얻기 직전에 다른 인스턴스가 처리를 완료했을 수 있으므로, Lock 획득 후 상태를 재검증한다.                                                                                                                         </p>
<pre><code class="language-java">  // PaymentTransactionService.java                                                                                                                                                                                     
  @Transactional
  public Payment completePayment(Long paymentId, Long userId, Long amount, PaymentMethod method) {
      Payment payment = paymentRepository.findById(paymentId)
              .orElseThrow(() -&gt; new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_NOT_FOUND));                                                                                                                  

      // 이미 COMPLETED면 멱등성 보장 — 포인트 재충전 없이 그냥 반환                                                                                                                                                      
      if (payment.getStatus() == PaymentStatus.COMPLETED) {                                                                                                                                                               
          log.info(&quot;[결제] 이미 COMPLETED 상태 dbPaymentId={}&quot;, paymentId);
          return payment;                                                                                                                                                                                                 
      }                                                                                                                                                                                                                 

      // PENDING + FAILED(confirm 타임아웃으로 잘못 처리된 케이스) 모두 허용                                                                                                                                            
      if (payment.getStatus() != PaymentStatus.PENDING                                                                                                                                                                    
              &amp;&amp; payment.getStatus() != PaymentStatus.FAILED) {                                                                                                                                                           
          log.warn(&quot;[결제] 처리 불가 상태 dbPaymentId={} status={}&quot;, paymentId, payment.getStatus());                                                                                                                     
          return payment;                                                                                                                                                                                                 
      }                                                                                                                                                                                                                 

      payment.complete(method);                                                                                                                                                                                         
      pointService.charge(userId, amount);
      purchaseRepository.save(Purchase.create(userId, PurchaseType.POINT, amount, paymentId));                                                                                                                            
      return payment;                                                                                                                                                                                                     
  }                                                                                                                                                                                                                       </code></pre>
<h3 id="결과-1">결과</h3>
<table>
<thead>
<tr>
<th>시나리오</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td><code>/confirm</code> 중복 호출</td>
<td>두 번째 요청이 Lock 실패 → 에러 반환</td>
</tr>
<tr>
<td><code>/confirm</code> 처리 중 웹훅 도착</td>
<td>웹훅이 Lock 실패 → PENDING 유지 → 포트원 재전송</td>
</tr>
<tr>
<td>웹훅 처리 중 <code>/confirm</code> 호출</td>
<td><code>/confirm</code>이 Lock 실패 → 에러 반환</td>
</tr>
</tbody></table>
<hr>
<h2 id="4-웹훅으로-최종-정합성-보완-eventual-consistency">4. 웹훅으로 최종 정합성 보완 (Eventual Consistency)</h2>
<h3 id="문제-3">문제</h3>
<p>  결제는 성공했는데 <code>/confirm</code> API가 실패하면 DB는 PENDING 상태로 남는다.                                                                                                                                                 </p>
<pre><code>  [클라이언트] /confirm 호출                                                                                                                                                                                            
  [서버]       포트원 SDK 타임아웃 → Payment를 FAILED로 저장
                                      ↓ (실제로는 결제 완료됨)                                                                                                                                                            
  [포트원]     Transaction.Paid 웹훅 전송                                                                                                                                                                                 </code></pre><h3 id="해결-failed-→-completed-보정-허용">해결: FAILED → COMPLETED 보정 허용</h3>
<p>  웹훅이 도착하면 포트원 실제 상태를 기준으로 DB를 보정한다. confirm 타임아웃으로 FAILED가 된 결제도 COMPLETED로 복구한다.                                                                                                </p>
<pre><code class="language-java">  // WebhookTransactionService.java                                                                                                                                                                                     
  @Transactional
  public void completePendingPayment(Long webhookEventId, Long paymentDbId, PaymentMethod method) {
      Payment payment = paymentRepository.findById(paymentDbId)                                                                                                                                                           
              .orElseThrow(() -&gt; new ServiceErrorException(PaymentExceptionEnum.ERR_PAYMENT_NOT_FOUND));

      if (payment.getStatus() == PaymentStatus.COMPLETED) {                                                                                                                                                             
          // 이미 처리됨 — 멱등성 보장                                                                                                                                                                                    
          webhookEventRepository.findById(webhookEventId).ifPresent(WebhookEvent::complete);                                                                                                                              
          return;                                                                                                                                                                                                         
      }                                                                                                                                                                                                                   

      // PENDING + FAILED 모두 허용 (confirm 타임아웃 보정 케이스)                                                                                                                                                      
      if (payment.getStatus() != PaymentStatus.PENDING                                                                                                                                                                    
              &amp;&amp; payment.getStatus() != PaymentStatus.FAILED) {
          log.warn(&quot;웹훅 보정 스킵 - 처리 불가 상태 paymentDbId={} status={}&quot;, paymentDbId, payment.getStatus());                                                                                                         
          webhookEventRepository.findById(webhookEventId).ifPresent(WebhookEvent::complete);                                                                                                                              
          return;
      }                                                                                                                                                                                                                   

      payment.complete(method);
      pointService.charge(payment.getUserId(), payment.getAmount());
      purchaseRepository.save(                                                                                                                                                                                            
              Purchase.create(payment.getUserId(), PurchaseType.POINT, payment.getAmount(), paymentDbId)
      );                                                                                                                                                                                                                  
      webhookEventRepository.findById(webhookEventId).ifPresent(WebhookEvent::complete);                                                                                                                                  
      log.info(&quot;웹훅 보정 완료 (/confirm 누락) paymentDbId={}&quot;, paymentDbId);
  }                                                                                                                                                                                                                       </code></pre>
<hr>
<h2 id="5-전체-처리-흐름">5. 전체 처리 흐름</h2>
<pre><code>  [클라이언트] /prepare 요청                                                                                                                                                                                            
        │
        ▼
  [서버] PENDING Payment 생성 (서버가 paymentKey + 금액 저장)
        │                                                                                                                                                                                                                 
  [클라이언트] 포트원 결제창에서 결제 완료
        │                                                                                                                                                                                                                 
  [클라이언트] /confirm 요청                                                                                                                                                                                            
        │                                                                                                                                                                                                                 
        ▼                                                                                                                                                                                                               
  [서버] Redis Lock 획득 (&quot;payment:confirm:lock:{paymentId}&quot;)                                                                                                                                                             
        │
        ▼                                                                                                                                                                                                                 
  [서버] PENDING Payment 조회 + prepare 금액 1차 검증                                                                                                                                                                   
        │                                                                                                                                                                                                                 
        ▼
  [서버] 포트원 SDK getPayment() — 외부 API (트랜잭션 밖)                                                                                                                                                                 
        │                                                                                                                                                                                                                 
        ▼
  [서버] 포트원 응답 금액 2차 검증 (위변조 최종 차단)                                                                                                                                                                     
        │                                                                                                                                                                                                                 
        ▼
  [서버] COMPLETED 전환 + 포인트 충전 + 구매 이력 저장 (짧은 트랜잭션)                                                                                                                                                    
        │                                                                                                                                                                                                                 
        ▼
  [서버] Redis Lock 해제                                                                                                                                                                                                  

  --- 만약 /confirm이 실패했다면 ---

  [포트원] Transaction.Paid 웹훅 전송
        │                                                                                                                                                                                                                 
        ▼                                                                                                                                                                                                               
  [서버] 동일 Lock 키 획득 시도 (confirm과 상호 배제)
        │                                                                                                                                                                                                                 
        ▼                                                                                                                                                                                                                 
  [서버] FAILED → COMPLETED 보정 + 포인트 충전                                                                                                                                                                            </code></pre><hr>
<h2 id="결론">결론</h2>
<p>  결제 시스템에서는                                                                                                                                                                                                       </p>
<blockquote>
<p>트랜잭션을 짧게 유지하면서도, 외부 시스템과의 정합성을 보장하는 것이 핵심이다                                                                                                                                         </p>
</blockquote>
<p>  이를 위해 4가지 전략을 조합했다.                                                                                                                                                                                        </p>
<table>
<thead>
<tr>
<th>문제</th>
<th>해결책</th>
</tr>
</thead>
<tbody><tr>
<td>DB 커넥션 점유</td>
<td>오케스트레이터 / 트랜잭션 서비스 2계층 분리</td>
</tr>
<tr>
<td>금액 위변조</td>
<td>서버 선저장 + 포트원 응답 2단계 검증</td>
</tr>
<tr>
<td>동시 중복 처리</td>
<td>Redis 분산 락 (동일 키로 <code>/confirm</code> ↔ 웹훅 상호 배제)</td>
</tr>
<tr>
<td>confirm 누락 보정</td>
<td>웹훅 기반 Eventual Consistency (FAILED → COMPLETED 허용)</td>
</tr>
</tbody></table>
<blockquote>
<p>짧은 트랜잭션 + 외부 API 검증 + 분산 락 + 웹훅 보정으로 결제 시스템의 성능과 정합성을 동시에 확보했다</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[외부 API 호출과 DB 트랜잭션 분리 (결제 오케스트레이션)]]></title>
            <link>https://velog.io/@eno_lj/%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C%EA%B3%BC-DB-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EA%B2%B0%EC%A0%9C-%EC%98%A4%EC%BC%80%EC%8A%A4%ED%8A%B8%EB%A0%88%EC%9D%B4%EC%85%98</link>
            <guid>https://velog.io/@eno_lj/%EC%99%B8%EB%B6%80-API-%ED%98%B8%EC%B6%9C%EA%B3%BC-DB-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC-%EA%B2%B0%EC%A0%9C-%EC%98%A4%EC%BC%80%EC%8A%A4%ED%8A%B8%EB%A0%88%EC%9D%B4%EC%85%98</guid>
            <pubDate>Sat, 02 May 2026 05:31:50 GMT</pubDate>
            <description><![CDATA[<h2 id="문제-정의">문제 정의</h2>
<p>결제 confirm 로직을 구현하면서 다음과 같은 구조적 문제가 있었다.</p>
<ul>
<li><p>결제 검증을 위해 외부 API(포트원)를 반드시 호출해야 함</p>
</li>
<li><p>동시에 결제 상태 변경, 포인트 충전 등은 DB 트랜잭션으로 묶어야 함</p>
</li>
</ul>
<h2 id="이-둘을-하나의-트랜잭션으로-묶으면">이 둘을 하나의 트랜잭션으로 묶으면:</h2>
<ul>
<li><p>외부 API 응답을 기다리는 동안 DB 커넥션 점유</p>
</li>
<li><p>트랜잭션 길어짐 → 락 점유 시간 증가</p>
</li>
<li><p>트래픽 증가 시 커넥션 풀 고갈 위험</p>
</li>
</ul>
<p>즉, I/O(bound) 작업과 DB 트랜잭션을 함께 묶는 것은 비효율적이다.</p>
<h2 id="잘못된-접근">잘못된 접근</h2>
<pre><code class="language-java">@Transactional
public void confirmPayment(...) {
    Payment payment = paymentRepository.findById(...);

    // ❌ 외부 API 호출
    Payment portOnePayment = paymentClient.getPayment(...);

    payment.complete();
}</code></pre>
<p>문제의 본질:</p>
<blockquote>
<p>트랜잭션이 “DB 작업 보호”가 아니라 “외부 API 대기 시간까지 포함”하게 됨</p>
</blockquote>
<h2 id="해결-전략">해결 전략</h2>
<p>-&gt; 오케스트레이터 패턴 적용 + 트랜잭션 분리</p>
<ul>
<li><p>PaymentService → 전체 흐름 제어 (비즈니스 orchestration)</p>
</li>
<li><p>PaymentTransactionService → DB 변경만 담당 (@Transactional)</p>
</li>
</ul>
<pre><code class="language-java">public PaymentResponse confirmPayment(...) {

    // 1. 짧은 트랜잭션
    Payment pending = paymentTransactionService.savePendingPayment(...);

    // 2. 트랜잭션 밖에서 외부 API 호출
    Payment portOnePayment = paymentClient.getPayment(...);

    // 3. 검증 후 다시 짧은 트랜잭션
    paymentTransactionService.completePayment(...);
}</code></pre>
<h2 id="핵심-설계-포인트">핵심 설계 포인트</h2>
<h3 id="1-트랜잭션을-db-작업-단위로-한정">1. 트랜잭션을 “DB 작업 단위”로 한정</h3>
<ul>
<li><p>트랜잭션 = 상태 변경 (PENDING → COMPLETED)</p>
</li>
<li><p>외부 API 호출은 트랜잭션 경계 밖</p>
</li>
</ul>
<h3 id="--결과">-&gt; 결과:</h3>
<ul>
<li><p>트랜잭션 유지 시간 최소화</p>
</li>
<li><p>DB 커넥션 효율 증가</p>
</li>
</ul>
<h3 id="2-흐름-제어와-상태-변경의-책임-분리">2. 흐름 제어와 상태 변경의 책임 분리</h3>
<table>
<thead>
<tr>
<th>역할</th>
<th>책임</th>
</tr>
</thead>
<tbody><tr>
<td>PaymentService</td>
<td>전체 흐름 orchestration</td>
</tr>
<tr>
<td>PaymentTransactionService</td>
<td>DB 상태 변경</td>
</tr>
</tbody></table>
<p>-&gt; 장점:</p>
<ul>
<li><p>트랜잭션 범위 명확</p>
</li>
<li><p>코드 가독성 및 유지보수성 향상</p>
</li>
<li><p>테스트 분리 용이</p>
</li>
</ul>
<h3 id="3-단계적-처리-step-based-processing">3. 단계적 처리 (Step-based Processing)</h3>
<p>결제 로직을 하나의 트랜잭션이 아니라 단계로 분리</p>
<ol>
<li><p>PENDING 확보</p>
</li>
<li><p>외부 결제 검증</p>
</li>
<li><p>COMPLETED 전환</p>
</li>
</ol>
<p>-&gt; 이 구조는 다음을 가능하게 함:</p>
<ul>
<li><p>중간 실패 시 재시도 전략 적용 가능</p>
</li>
<li><p>상태 기반으로 흐름 제어 가능</p>
</li>
</ul>
<h3 id="4-트랜잭션-분리의-핵심-의도">4. 트랜잭션 분리의 핵심 의도</h3>
<p>이 설계의 본질은 단순히 “분리”가 아니라:</p>
<blockquote>
<p>트랜잭션을 짧고 예측 가능하게 유지하기 위함</p>
</blockquote>
<h3 id="얻은-효과">얻은 효과</h3>
<ul>
<li><p>DB 커넥션 점유 시간 감소</p>
</li>
<li><p>트랜잭션 락 유지 시간 최소화</p>
</li>
<li><p>외부 API 지연이 DB 성능에 미치는 영향 차단</p>
</li>
<li><p>고트래픽 상황에서 안정성 향상</p>
</li>
</ul>
<h3 id="트레이드오프">트레이드오프</h3>
<p>이 방식이 무조건 좋은 건 아니다.</p>
<p>단점</p>
<ul>
<li><p>하나의 트랜잭션으로 묶이지 않음 → 완전한 원자성 없음</p>
</li>
<li><p>중간 단계 실패 시 상태 불일치 가능성 존재</p>
</li>
</ul>
<p>대응 전략 (현재 코드 기준)</p>
<ul>
<li><p>PENDING 상태를 통해 중간 상태 관리 (완료)</p>
</li>
<li><p>실패 시 FAILED 처리 (완료)</p>
</li>
<li><p>웹훅/재시도 로직으로 정합성 보완 가능 (완료)</p>
</li>
</ul>
<h2 id="결론">결론</h2>
<p>결제 시스템에서는</p>
<blockquote>
<p>“외부 API 호출을 트랜잭션 안에 넣지 않는다”</p>
</blockquote>
<p>는 원칙이 매우 중요하다.</p>
<p>이를 위해</p>
<ul>
<li><p>오케스트레이터 패턴으로 흐름을 분리하고</p>
</li>
<li><p>트랜잭션을 DB 작업 단위로 최소화하여</p>
</li>
<li><p>성능과 안정성을 동시에 확보했다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[커피 주문 시스템 설계]]></title>
            <link>https://velog.io/@eno_lj/%EC%BB%A4%ED%94%BC-%EC%A3%BC%EB%AC%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@eno_lj/%EC%BB%A4%ED%94%BC-%EC%A3%BC%EB%AC%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Mon, 06 Apr 2026 03:07:44 GMT</pubDate>
            <description><![CDATA[<h3 id="1-설계-방향">1. 설계 방향</h3>
<p>이번 과제는 단순 기능 구현이 아니라 다수 서버 환경에서 안정적으로 동작하는 시스템 설계가 핵심이라고 판단했다.</p>
<p>그래서 다음 3가지를 중심으로 구조를 설계했다.</p>
<ul>
<li>동시성 제어</li>
<li>데이터 일관성 보장</li>
<li>확장 가능한 구조</li>
</ul>
<p>전체 흐름은 다음과 같이 구성했다.</p>
<ul>
<li>메뉴 조회 → Redis 캐시 적용</li>
<li>포인트 충전 → Redis 분산 락 적용</li>
<li>주문/결제 → 하나의 트랜잭션으로 처리</li>
<li>주문 이벤트 → Outbox + Kafka로 비동기 전송</li>
<li>인기 메뉴 → Redis ZSet 기반 집계</li>
</ul>
<hr>
<h3 id="2-동시성-해결-전략">2. 동시성 해결 전략</h3>
<p>포인트는 동일 사용자에 대해 동시에 수정될 가능성이 높기 때문에 userId 기준 Redis 분산 락을 적용했다.</p>
<ul>
<li>락 키: lock:point:user:{userId}</li>
<li>동일 사용자 요청은 직렬화</li>
<li>다른 사용자 요청은 병렬 처리 가능</li>
</ul>
<p>또한 락과 트랜잭션을 분리하여 DB 반영 완료 후 락이 해제되도록 구조를 설계했다.</p>
<p>-&gt; 결과적으로 다중 서버 환경에서도 포인트 데이터 충돌을 방지할 수 있다.</p>
<hr>
<h3 id="3-데이터-일관성-보장-전략">3. 데이터 일관성 보장 전략</h3>
<p>주문/결제 API에서는 다음 작업을 하나의 트랜잭션으로 묶었다.</p>
<ul>
<li>포인트 차감</li>
<li>포인트 사용 이력 저장</li>
<li>주문 생성</li>
<li>Outbox 이벤트 저장</li>
</ul>
<p>이렇게 설계한 이유는:</p>
<ul>
<li>부분 성공(주문만 성공, 포인트 미차감 등)을 방지</li>
<li>실패 시 전체 롤백 보장</li>
</ul>
<p>또한 Kafka 전송은 트랜잭션 외부에서 처리하기 위해 Outbox 패턴을 적용했다.</p>
<p>-&gt; DB와 메시지 전송 간 불일치를 최소화하고, 재처리가 가능한 구조를 확보했다.</p>
<hr>
<h3 id="4-확장성-고려">4. 확장성 고려</h3>
<h4 id="4-1-kafka-기반-이벤트-구조">4-1. Kafka 기반 이벤트 구조</h4>
<p>주문 완료 후 이벤트를 Kafka로 전송하고, Consumer가 다음 작업을 수행하도록 분리했다.</p>
<ul>
<li>주문 이벤트 로그 저장</li>
<li>인기 메뉴 집계</li>
</ul>
<p>-&gt; 이후 통계, 알림, 추천 시스템 등으로 쉽게 확장 가능</p>
<hr>
<h4 id="4-2-redis-zset-기반-인기-메뉴">4-2. Redis ZSet 기반 인기 메뉴</h4>
<p>인기 메뉴는 최근 7일 기준으로 집계해야 하므로 날짜별 ZSet 구조로 설계했다.</p>
<ul>
<li>key: popular:menu:{date}</li>
<li>score: 주문 횟수</li>
</ul>
<p>조회 시 최근 7일 데이터를 합산하여 Top3를 계산한다.</p>
<p>-&gt; 빠른 조회 성능 + 기간 기반 집계 가능</p>
<hr>
<h4 id="4-3-조회쓰기-역할-분리">4-3. 조회/쓰기 역할 분리</h4>
<ul>
<li>정합성 중요한 데이터 → DB 중심 처리</li>
<li>조회 성능 중요한 데이터 → Redis 활용</li>
</ul>
<p>-&gt; 트래픽 증가 시 병목 분리 가능</p>
<hr>
<h3 id="5-설계-선택-이유-정리">5. 설계 선택 이유 정리</h3>
<table>
<thead>
<tr>
<th>관점</th>
<th>선택</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>동시성</td>
<td>Redis 분산 락</td>
<td>다중 서버 환경에서도 사용자 단위 동기화 가능</td>
</tr>
<tr>
<td>데이터 일관성</td>
<td>트랜잭션 + Outbox</td>
<td>부분 성공 방지 및 이벤트 유실 최소화</td>
</tr>
<tr>
<td>확장성</td>
<td>Kafka + Consumer</td>
<td>기능 확장 시 구조 변경 없이 대응 가능</td>
</tr>
<tr>
<td>성능</td>
<td>Redis 캐시 &amp; ZSet</td>
<td>조회 속도 향상 및 실시간 집계</td>
</tr>
</tbody></table>
<hr>
<h3 id="6-결론">6. 결론</h3>
<p>이번 설계는 단순 기능 구현이 아니라 안정성과 확장성을 고려한 구조 설계에 초점을 맞췄다.</p>
<ul>
<li>동시성 → Redis 분산 락으로 해결</li>
<li>데이터 일관성 → 트랜잭션 + Outbox로 보장</li>
<li>확장성 → Kafka 기반 이벤트 구조로 확보</li>
</ul>
<p>-&gt; 결국 이 시스템은 <strong>“문제가 생기지 않도록 미리 구조를 설계한 시스템”</strong>이라고 정리할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[비관적 락 / 낙관적 락 / 분산 락 비교 분석 및 선택 근거]]></title>
            <link>https://velog.io/@eno_lj/%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EB%B9%84%EA%B5%90-%EB%B6%84%EC%84%9D-%EB%B0%8F-%EC%84%A0%ED%83%9D-%EA%B7%BC%EA%B1%B0</link>
            <guid>https://velog.io/@eno_lj/%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD-%EB%B6%84%EC%82%B0-%EB%9D%BD-%EB%B9%84%EA%B5%90-%EB%B6%84%EC%84%9D-%EB%B0%8F-%EC%84%A0%ED%83%9D-%EA%B7%BC%EA%B1%B0</guid>
            <pubDate>Thu, 19 Mar 2026 07:30:03 GMT</pubDate>
            <description><![CDATA[<h3 id="1-락-방식별-개요">1. 락 방식별 개요</h3>
<p>동시성 제어를 위한 대표적인 락 방식으로는 낙관적 락, 비관적 락, 분산 락이 있다.</p>
<p>각 방식은 락을 관리하는 주체, 보호 범위, 성능 특성, 그리고 적합한 적용 시나리오가 다르기 때문에 서비스의 구조와 트래픽 특성에 맞게 선택해야 한다.</p>
<hr>
<h3 id="2-락-방식-비교-분석">2. 락 방식 비교 분석</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>낙관적 락</th>
<th>비관적 락</th>
<th>분산 락</th>
</tr>
</thead>
<tbody><tr>
<td><strong>관리 주체</strong></td>
<td>JPA/Hibernate (<code>@Version</code>)</td>
<td>DB (Row Lock)</td>
<td>Redis 등 외부 시스템</td>
</tr>
<tr>
<td><strong>동작 방식</strong></td>
<td>충돌 발생 후 감지</td>
<td>충돌 발생 전 차단</td>
<td>비즈니스 로직 진입 전 차단</td>
</tr>
<tr>
<td><strong>보호 범위</strong></td>
<td>DB UPDATE 시점</td>
<td>DB Row 수정 시점</td>
<td>비즈니스 로직 전체</td>
</tr>
<tr>
<td><strong>성능 특성</strong></td>
<td>충돌 적을 때 유리</td>
<td>충돌 많을 때 유리</td>
<td>분산 환경에서 유리</td>
</tr>
<tr>
<td><strong>적합한 환경</strong></td>
<td>서버 1대, 읽기 많고 충돌 적음</td>
<td>서버 1대, 충돌 많음</td>
<td>서버 여러 대, Scale-out 환경</td>
</tr>
<tr>
<td><strong>대표 예시</strong></td>
<td>게시글 수정, 프로필 수정</td>
<td>재고 차감, 포인트 사용</td>
<td>선착순 쿠폰, 입찰, 분산 자원 보호</td>
</tr>
</tbody></table>
<hr>
<h3 id="3-낙관적-락optimistic-lock">3. 낙관적 락(Optimistic Lock)</h3>
<p>낙관적 락은 충돌이 적을 것이라고 가정하고, DB 락 없이 @Version 컬럼을 통해 충돌을 감지하는 방식이다.</p>
<p>데이터 수정 시점에 version 값을 비교하여 이전 버전과 다르면 OptimisticLockException이 발생한다.</p>
<h4 id="장점">장점</h4>
<ul>
<li><p>별도의 DB Lock이 없어 성능상 유리</p>
</li>
<li><p>읽기 비중이 높은 환경에 적합</p>
</li>
<li><p>락 대기가 없어 처리량이 좋음</p>
</li>
</ul>
<h4 id="단점">단점</h4>
<ul>
<li><p>충돌 발생 시 재시도 필요</p>
</li>
<li><p>충돌이 잦은 환경에서는 예외와 재시도가 반복되어 성능 저하 가능</p>
</li>
</ul>
<h4 id="적합한-상황">적합한 상황</h4>
<ul>
<li><p>서버 1대 환경</p>
</li>
<li><p>조회가 많고 수정 충돌이 적은 서비스</p>
</li>
<li><p>예: 게시글 수정, 사용자 프로필 수정</p>
</li>
</ul>
<hr>
<h3 id="4-비관적-락pessimistic-lock">4. 비관적 락(Pessimistic Lock)</h3>
<p>비관적 락은 충돌이 발생할 가능성이 높다고 보고, DB Row에 직접 락을 걸어 다른 트랜잭션의 접근을 대기시키는 방식이다.</p>
<p>JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE) 또는 MySQL의 SELECT ... FOR UPDATE로 구현할 수 있다.</p>
<h4 id="장점-1">장점</h4>
<ul>
<li><p>충돌을 사전에 차단할 수 있어 데이터 정합성이 높음</p>
</li>
<li><p>재고, 포인트처럼 충돌이 자주 발생하는 상황에서 안정적</p>
</li>
</ul>
<h4 id="단점-1">단점</h4>
<ul>
<li><p>DB 락 대기 발생</p>
</li>
<li><p>동시 요청이 많아질수록 DB 부하 증가</p>
</li>
<li><p>Scale-out 환경에서는 한계가 있음</p>
</li>
</ul>
<h4 id="적합한-상황-1">적합한 상황</h4>
<ul>
<li><p>서버 1대 환경</p>
</li>
<li><p>동일 데이터에 대한 충돌이 잦은 경우</p>
</li>
<li><p>예: 재고 차감, 계좌 잔액 수정</p>
</li>
</ul>
<hr>
<h3 id="5-분산-락distributed-lock">5. 분산 락(Distributed Lock)</h3>
<p>분산 락은 Redis와 같은 외부 시스템을 이용해 비즈니스 로직 전체를 보호하는 방식이다.</p>
<p>DB 수정 시점만 보호하는 것이 아니라, 서비스 메서드 시작부터 종료까지 특정 자원에 대한 동시 접근을 제어할 수 있다.</p>
<p>예를 들어:</p>
<ul>
<li><p>선착순 쿠폰 발급</p>
</li>
<li><p>실시간 입찰</p>
</li>
<li><p>다중 인스턴스 환경의 자원 보호</p>
</li>
</ul>
<p>와 같이 여러 서버에서 동시에 접근할 수 있는 상황에서 사용한다.</p>
<h4 id="장점-2">장점</h4>
<ul>
<li><p>서버가 여러 대인 환경에서도 동시성 제어 가능</p>
</li>
<li><p>DB 진입 전 요청 자체를 제어 가능</p>
</li>
<li><p>비즈니스 로직 전체를 보호 가능</p>
</li>
</ul>
<h4 id="단점-2">단점</h4>
<ul>
<li><p>Redis 같은 별도 인프라 필요</p>
</li>
<li><p>락 획득/해제 로직 설계가 필요</p>
</li>
<li><p>잘못 구현하면 락 누수, TTL 문제 가능</p>
</li>
</ul>
<h4 id="적합한-상황-2">적합한 상황</h4>
<ul>
<li><p>Scale-out 환경</p>
</li>
<li><p>다중 서버에서 동일 자원을 동시에 처리할 가능성이 있는 경우</p>
</li>
<li><p>예: 선착순 쿠폰, 분산 환경 입찰, 예약 시스템</p>
</li>
</ul>
<hr>
<h3 id="6-우리-프로젝트에서-최종-선택한-락-방식">6. 우리 프로젝트에서 최종 선택한 락 방식</h3>
<p>우리 프로젝트에서는 Redis 기반 분산 락을 최종 선택하였다.</p>
<h4 id="선택-이유">선택 이유</h4>
<h4 id="1-보호-대상이-db-수정-한-줄이-아니라-비즈니스-로직-전체였기-때문">1) 보호 대상이 DB 수정 한 줄이 아니라 비즈니스 로직 전체였기 때문</h4>
<p>선착순 쿠폰 발급이나 입찰은 단순히 DB update 한 번만 보호하면 되는 문제가 아니라,</p>
<ul>
<li><p>중복 발급 여부 확인</p>
</li>
<li><p>수량 확인</p>
</li>
<li><p>쿠폰 생성</p>
</li>
<li><p>발급 수량 증가</p>
</li>
</ul>
<p>처럼 하나의 비즈니스 흐름 전체가 원자적으로 보호되어야 했다.</p>
<p>비관적 락과 낙관적 락은 주로 DB 수정 시점 중심의 제어에 적합하지만, 우리 프로젝트는 서비스 로직 전체를 보호하는 것이 더 중요했다.</p>
<h4 id="2-scale-out-환경까지-고려했기-때문">2) Scale-out 환경까지 고려했기 때문</h4>
<p>분산 락은 여러 서버 인스턴스가 동시에 실행되는 환경에서도 동일한 락 저장소(Redis)를 기준으로 동시성 제어가 가능하다.</p>
<p>즉, 서버가 1대일 때는 DB 락도 가능하지만, 향후 확장성을 고려하면 Redis 분산 락이 더 적합하다고 판단하였다.</p>
<h4 id="3-선착순-쿠폰--입찰은-충돌이-매우-잦은-상황이기-때문">3) 선착순 쿠폰 / 입찰은 충돌이 매우 잦은 상황이기 때문</h4>
<p>낙관적 락은 충돌이 적은 상황에서는 효율적이지만, 선착순 쿠폰이나 입찰처럼 동시에 요청이 몰리는 구조에서는 충돌 예외와 재시도가 반복되어 오히려 비효율적일 수 있다.</p>
<p>비관적 락은 DB 레벨에서 안정적이지만, 이 경우 DB 부하와 락 대기 문제가 발생할 수 있다.</p>
<p>따라서 외부 시스템인 Redis를 통해 DB 진입 전부터 동시 요청을 제어하는 분산 락이 더 적합하다고 판단하였다.</p>
<h4 id="4-aop-기반-구조와-결합하기-좋았기-때문">4) AOP 기반 구조와 결합하기 좋았기 때문</h4>
<p>우리 프로젝트에서는 @RedisLock 커스텀 애노테이션과 @Aspect를 사용하여 락 획득/해제 로직을 비즈니스 로직과 분리하였다.</p>
<p>이를 통해:</p>
<ul>
<li><p>서비스 코드는 비즈니스 로직에 집중</p>
</li>
<li><p>락 로직은 공통 처리</p>
</li>
<li><p>재사용성과 유지보수성 향상</p>
</li>
</ul>
<p>효과를 얻을 수 있었다.</p>
<hr>
<ol start="7">
<li>최종 결론</li>
</ol>
<p>낙관적 락은 충돌이 적은 환경에 적합하고, 비관적 락은 서버 1대 환경에서 충돌이 잦은 DB 자원 보호에 적합하다.</p>
<p>반면 우리 프로젝트의 주요 기능인 선착순 쿠폰 발급과 입찰은 동시에 많은 요청이 들어오고, DB 수정 시점뿐 아니라 비즈니스 로직 전체를 보호해야 하며, 향후 여러 서버로 확장될 가능성도 고려해야 했다.</p>
<p>따라서 우리 프로젝트에서는 Redis 기반 분산 락이 가장 적합한 선택이라고 판단하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[왜 Lettuce가 아니라 Redisson을 사용하는가?]]></title>
            <link>https://velog.io/@eno_lj/%EC%99%9C-Lettuce%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-Redisson%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%EA%B0%80</link>
            <guid>https://velog.io/@eno_lj/%EC%99%9C-Lettuce%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-Redisson%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94%EA%B0%80</guid>
            <pubDate>Thu, 19 Mar 2026 07:12:15 GMT</pubDate>
            <description><![CDATA[<h3 id="1-분산-락-구현이-더-단순하다">1. 분산 락 구현이 더 단순하다</h3>
<p>Lettuce를 사용할 경우 분산 락을 구현하려면 다음을 직접 처리해야 한다.</p>
<ul>
<li><p>SETNX를 이용한 락 획득</p>
</li>
<li><p>TTL 설정</p>
</li>
<li><p>락 소유자 식별값 관리</p>
</li>
<li><p>Lua 스크립트를 통한 안전한 해제</p>
</li>
<li><p>락 재시도 로직</p>
</li>
</ul>
<p>즉, 락 구현에 필요한 세부 로직을 모두 직접 작성해야 한다.</p>
<p>반면 Redisson은 RLock 같은 API를 제공하므로 Java의 Lock을 사용하듯이 락을 쉽게 적용할 수 있다.</p>
<p>예를 들어:</p>
<ul>
<li><p>락 획득</p>
</li>
<li><p>대기 시간 설정</p>
</li>
<li><p>lease time 설정</p>
</li>
<li><p>unlock 처리</p>
</li>
</ul>
<p>를 훨씬 간단하게 사용할 수 있다.</p>
<hr>
<h3 id="2-분산-락-기능을-안정적으로-제공한다">2. 분산 락 기능을 안정적으로 제공한다</h3>
<p>Lettuce로 직접 구현한 락은 개발자가 TTL, unlock, 예외 처리 등을 모두 신경 써야 한다.</p>
<p>반면 Redisson은 분산 락에 필요한 기능을 라이브러리 수준에서 제공하므로 구현 실수를 줄일 수 있다.</p>
<p>특히 락 관련 기능은 단순해 보여도 실제로는 잘못 구현하면 락 누수, 잘못된 해제, TTL 만료 문제 등이 발생할 수 있다.</p>
<p>Redisson은 이런 부분을 추상화해서 제공하기 때문에 더 안정적으로 사용할 수 있다.</p>
<hr>
<h3 id="3-재시도와-대기-전략을-쉽게-적용할-수-있다">3. 재시도와 대기 전략을 쉽게 적용할 수 있다</h3>
<p>Lettuce 기반 구현은 보통 SETNX 한 번 시도 후 실패 시 직접 로직을 작성해야 한다.</p>
<p>반면 Redisson은 락 획득 시:</p>
<ul>
<li><p>대기 시간(wait time)</p>
</li>
<li><p>점유 시간(lease time)</p>
</li>
</ul>
<p>을 설정할 수 있어
즉시 실패 전략뿐 아니라 대기 후 획득 전략도 자연스럽게 구현 가능하다.</p>
<p>즉, 쿠폰 발급이나 재고 처리처럼 “무조건 한 번만 시도하고 실패”가 아니라 “일정 시간 동안 락을 기다렸다가 성공시키는 방식”이 필요한 경우 Redisson이 더 적합하다.</p>
<hr>
<h3 id="4-watchdog-기능을-지원한다">4. Watchdog 기능을 지원한다</h3>
<p>Redisson은 내부적으로 Watchdog 기능을 제공한다.</p>
<p>이 기능은 락을 획득한 스레드가 아직 작업 중이라면 락 만료 시간을 자동으로 연장해주는 기능이다.</p>
<p>즉, 예상보다 비즈니스 로직이 오래 걸려도 락이 중간에 만료되어 다른 요청이 잘못 들어오는 상황을 줄일 수 있다.</p>
<p>Lettuce로 직접 구현하면 이런 부분도 직접 관리해야 한다.</p>
<hr>
<h3 id="5-코드-가독성과-유지보수성이-좋다">5. 코드 가독성과 유지보수성이 좋다</h3>
<p>Lettuce 기반 락은 보통 다음과 같은 코드가 필요하다.</p>
<ul>
<li><p>Redis key 생성</p>
</li>
<li><p>UUID 생성</p>
</li>
<li><p>setIfAbsent</p>
</li>
<li><p>TTL 지정</p>
</li>
<li><p>finally에서 Lua script unlock</p>
</li>
</ul>
<p>반면 Redisson은 비교적 단순한 형태로 작성할 수 있다.</p>
<p>즉, 비즈니스 로직에서 락 코드가 더 직관적으로 보이고유지보수도 쉬워진다.</p>
<hr>
<h3 id="redisson을-선택하기-좋은-상황">Redisson을 선택하기 좋은 상황</h3>
<p>Redisson은 다음과 같은 경우 특히 적합하다.</p>
<ul>
<li><p>Redis를 이용한 분산 락이 핵심인 경우</p>
</li>
<li><p>선착순 쿠폰, 재고 차감, 입찰처럼 동시성 제어가 중요한 경우</p>
</li>
<li><p>락 재시도, 대기, 자동 연장 같은 기능이 필요한 경우</p>
</li>
<li><p>직접 락 로직을 구현하기보다 안정적인 라이브러리를 사용하고 싶은 경우</p>
</li>
</ul>
<hr>
<h3 id="최종-정리">최종 정리</h3>
<p>Lettuce는 Redis와 통신하기 위한 기본 클라이언트이고, Redisson은 Redis를 기반으로 분산 락과 같은 고수준 기능을 제공하는 라이브러리이다.</p>
<p>따라서 단순한 Redis 데이터 접근에는 Lettuce도 충분하지만, 분산 락처럼 동시성 제어가 중요한 상황에서는 락 획득/해제, 재시도, lease time, watchdog 등을 안정적으로 제공하는 Redisson이 더 적합하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis 대신 MySQL을 이용한 Lock 구현 정리]]></title>
            <link>https://velog.io/@eno_lj/Redis-%EB%8C%80%EC%8B%A0-MySQL%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-Lock-%EA%B5%AC%ED%98%84-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@eno_lj/Redis-%EB%8C%80%EC%8B%A0-MySQL%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-Lock-%EA%B5%AC%ED%98%84-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Thu, 19 Mar 2026 06:47:07 GMT</pubDate>
            <description><![CDATA[<h3 id="1-mysql-기반-lock-구현-방식">1. MySQL 기반 Lock 구현 방식</h3>
<p>Redis 대신 MySQL을 이용해 Lock을 구현하는 방법으로는 대표적으로 다음 두 가지가 있다.</p>
<h4 id="1-jpa-비관적-락-pessimistic-lock">1) JPA 비관적 락 (Pessimistic Lock)</h4>
<p>JPA에서 @Lock(LockModeType.PESSIMISTIC_WRITE)를 사용하면 조회 시점에 해당 row에 배타적 락을 걸 수 있다.</p>
<p>이 방식은 다른 트랜잭션이 같은 row를 수정하거나 락을 획득하려고 할 때 대기(wait) 하게 만들어 동시성을 제어한다.</p>
<p>예를 들어 재고 차감, 결제, 입찰 등 동일 데이터에 대한 동시 수정이 발생할 수 있는 상황에서 사용할 수 있다.</p>
<hr>
<h4 id="2-mysql-exclusive-lock">2) MySQL Exclusive Lock</h4>
<p>MySQL에서는 row-level lock을 통해 특정 row에 대해 Exclusive Lock(X Lock) 을 걸 수 있다.</p>
<p>대표적으로 SELECT ... FOR UPDATE 방식이 이에 해당하며, 트랜잭션이 종료될 때까지 다른 트랜잭션의 수정 접근을 막는다.</p>
<p>즉, MySQL 자체의 락 메커니즘을 활용하여 데이터 정합성을 보장하는 방식이다.</p>
<hr>
<h3 id="2-mysql-lock의-장점">2. MySQL Lock의 장점</h3>
<p>1) 별도 인프라가 필요 없다</p>
<p>Redis를 따로 구축하지 않아도 되므로 기존 MySQL만으로 동시성 제어를 구현할 수 있다.</p>
<p>즉, 시스템 구성이 단순해지고 운영 복잡도가 줄어든다는 장점이 있다.</p>
<hr>
<h4 id="2-데이터와-락이-같은-저장소에-있다">2) 데이터와 락이 같은 저장소에 있다</h4>
<p>락 대상 데이터와 락을 관리하는 시스템이 모두 MySQL에 있기 때문에 트랜잭션과 함께 다루기 편하다.</p>
<p>예를 들어 row 수정과 락 획득이 같은 DB 안에서 이뤄지므로 정합성 측면에서 이해하기 쉽다.</p>
<hr>
<h4 id="3-구현-난이도가-비교적-낮다">3) 구현 난이도가 비교적 낮다</h4>
<p>JPA에서는 @Lock만 추가해도 비관적 락을 사용할 수 있어 구현이 직관적이다.</p>
<hr>
<h3 id="3-mysql-lock의-단점">3. MySQL Lock의 단점</h3>
<h4 id="1-db-부하-증가">1) DB 부하 증가</h4>
<p>락을 DB가 직접 처리하기 때문에 동시 요청이 많아질수록 DB 부하가 커질 수 있다.</p>
<p>특히 락 대기가 길어지면 전체 처리량이 감소할 수 있다.</p>
<hr>
<h4 id="2-확장성-한계">2) 확장성 한계</h4>
<p>Redis는 분산 락 용도로 많이 사용되지만, MySQL Lock은 결국 DB 의존적이기 때문에 트래픽이 커질수록 확장성 측면에서 불리할 수 있다.</p>
<hr>
<h4 id="3-락-대기로-인한-성능-저하">3) 락 대기로 인한 성능 저하</h4>
<p>비관적 락은 충돌을 예방하는 대신 다른 트랜잭션이 대기하게 만든다.</p>
<p>그래서 충돌이 많은 환경에서는 처리 속도가 느려질 수 있다.</p>
<hr>
<h4 id="4-분산-환경에서-유연성이-떨어진다">4) 분산 환경에서 유연성이 떨어진다</h4>
<p>Redis는 여러 인스턴스에서 공통된 락 저장소로 사용하기 좋지만, MySQL은 본질적으로 DB 락이기 때문에 분산 락 전용 솔루션에 비해 유연성이 떨어진다.</p>
<hr>
<h3 id="4-mysql-lock이-적합한-상황">4. MySQL Lock이 적합한 상황</h3>
<p>MySQL 기반 락은 다음과 같은 상황에서 적합하다.</p>
<ul>
<li><p>별도 Redis 인프라를 두기 어려운 경우</p>
</li>
<li><p>트래픽이 매우 크지 않은 경우</p>
</li>
<li><p>데이터 정합성이 중요하고, DB 트랜잭션과 함께 관리하고 싶은 경우</p>
</li>
<li><p>동일 row에 대한 동시 수정 제어가 필요한 경우</p>
</li>
</ul>
<p>예:</p>
<ul>
<li><p>재고 차감</p>
</li>
<li><p>포인트 사용</p>
</li>
<li><p>주문 상태 변경</p>
</li>
</ul>
<hr>
<h3 id="최종-정리">최종 정리</h3>
<p>MySQL을 이용한 Lock은 별도의 Redis 없이도 JPA 비관적 락이나 SELECT ... FOR UPDATE를 통해 구현할 수 있다.</p>
<p>구조가 단순하고 DB 트랜잭션과 함께 다루기 쉽다는 장점이 있지만, 락 대기로 인한 DB 부하와 확장성 한계가 존재한다.</p>
<p>따라서 트래픽 규모와 서비스 특성에 따라 MySQL Lock과 Redis Lock 중 적절한 방식을 선택하는 것이 중요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[낙관적 락(Optimistic Lock) 개념 및 고려사항]]></title>
            <link>https://velog.io/@eno_lj/%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BDOptimistic-Lock-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EA%B3%A0%EB%A0%A4%EC%82%AC%ED%95%AD</link>
            <guid>https://velog.io/@eno_lj/%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BDOptimistic-Lock-%EA%B0%9C%EB%85%90-%EB%B0%8F-%EA%B3%A0%EB%A0%A4%EC%82%AC%ED%95%AD</guid>
            <pubDate>Thu, 19 Mar 2026 06:28:51 GMT</pubDate>
            <description><![CDATA[<h3 id="1-낙관적-락의-개념">1. 낙관적 락의 개념</h3>
<p>낙관적 락은 데이터에 대한 충돌이 적을 것이라고 가정하고, 별도의 DB 락을 사용하지 않고 버전(version) 값을 통해 동시성 충돌을 감지하는 방식이다.</p>
<p>JPA에서는 @Version 필드를 사용하여 구현할 수 있으며, 데이터를 수정할 때 기존 버전과 현재 버전을 비교하여 값이 다를 경우 OptimisticLockException을 발생시킨다.</p>
<p>즉, 낙관적 락은 충돌을 예방하는 방식이 아니라, 충돌 발생 시 감지하고 처리하는 방식이다.</p>
<hr>
<h3 id="2-낙관적-락이-유리한-상황">2. 낙관적 락이 유리한 상황</h3>
<p>낙관적 락은 다음과 같은 환경에서 효과적이다.</p>
<ul>
<li><p>읽기 비중이 높고, 쓰기 충돌이 적은 서비스</p>
</li>
<li><p>동일 데이터에 대해 동시에 수정 요청이 자주 발생하지 않는 경우</p>
</li>
<li><p>DB 레벨의 락으로 인한 대기(wait)를 최소화하고 싶은 경우</p>
</li>
</ul>
<p>예를 들어:</p>
<ul>
<li><p>게시글 조회/수정 시스템</p>
</li>
<li><p>사용자 프로필 수정</p>
</li>
<li><p>통계 데이터 조회</p>
</li>
</ul>
<p>이러한 경우에는 비관적 락보다 낙관적 락이 더 효율적이며, 불필요한 DB Lock을 사용하지 않아 성능 측면에서 유리하다.</p>
<hr>
<h3 id="3-재시도retry-전략">3. 재시도(Retry) 전략</h3>
<p>낙관적 락에서는 충돌 발생 시 OptimisticLockException이 발생하므로, 이를 처리하기 위한 재시도 로직(Retry)이 필요하다.</p>
<h4 id="고려-사항">고려 사항</h4>
<p>1) 재시도 횟수</p>
<ul>
<li><p>일반적으로 3~5회 정도로 제한하는 것이 적절하다</p>
</li>
<li><p>무한 재시도는 시스템 부하를 증가시키므로 피해야 한다</p>
</li>
</ul>
<p>2) 재시도 간격 (Backoff)</p>
<ul>
<li><p>단순 즉시 재시도보다는 지연(backoff)을 두는 것이 좋다</p>
</li>
<li><p>예:</p>
</li>
</ul>
<pre><code>- 100ms → 200ms → 400ms (지수 증가)</code></pre><ul>
<li>이를 통해 충돌이 반복되는 상황을 완화할 수 있다</li>
</ul>
<p>3) 실패 처리 전략</p>
<ul>
<li>일정 횟수 이상 실패 시:</li>
</ul>
<pre><code>- 사용자에게 재시도 요청


- 또는 실패 응답 반환</code></pre><hr>
<h3 id="4-낙관적-락의-한계와-성능-이슈">4. 낙관적 락의 한계와 성능 이슈</h3>
<p>낙관적 락은 충돌이 적은 환경에서는 효율적이지만, 다음과 같은 상황에서는 오히려 성능 저하를 유발할 수 있다.</p>
<h4 id="충돌이-잦은-환경">충돌이 잦은 환경</h4>
<p>예를 들어:</p>
<ul>
<li><p>선착순 쿠폰 발급</p>
</li>
<li><p>재고 감소 처리</p>
</li>
<li><p>입찰 시스템</p>
</li>
</ul>
<p>이와 같이 동일 데이터에 대한 동시 수정이 빈번한 경우, 다음과 같은 문제가 발생할 수 있다.</p>
<ul>
<li><p>OptimisticLockException 빈번 발생</p>
</li>
<li><p>재시도 로직 반복 실행</p>
</li>
<li><p>DB 요청 증가</p>
</li>
<li><p>전체 처리량 감소</p>
</li>
</ul>
<p>즉, 충돌이 많은 환경에서는 낙관적 락이 오히려 비효율적인 구조가 될 수 있다.</p>
<hr>
<h3 id="5-낙관적-락-vs-비관적-락-선택-기준">5. 낙관적 락 vs 비관적 락 선택 기준</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>낙관적 락</th>
<th>비관적 락</th>
</tr>
</thead>
<tbody><tr>
<td>방식</td>
<td>충돌 발생 후 감지</td>
<td>충돌 발생 전 차단</td>
</tr>
<tr>
<td>DB</td>
<td>Lock 사용하지 않음</td>
<td>사용함</td>
</tr>
<tr>
<td>성능</td>
<td>읽기 많을 때 유리</td>
<td>충돌 많을 때 유리</td>
</tr>
<tr>
<td>적합한 환경</td>
<td>조회 위주, 충돌 적음</td>
<td>동시 수정 많음</td>
</tr>
</tbody></table>
<hr>
<h3 id="최종-정리">최종 정리</h3>
<p>낙관적 락은 DB 락 없이 version 값을 통해 충돌을 감지하는 방식으로,
읽기 중심의 서비스에서는 높은 성능을 제공할 수 있다.</p>
<p>하지만 충돌이 빈번한 환경에서는 재시도 로직으로 인해
오히려 성능 저하가 발생할 수 있으므로,
서비스 특성에 따라 비관적 락 또는 분산 락과의 적절한 선택이 필요하다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AOP 기반 Redis Lock 구현 및 고려사항]]></title>
            <link>https://velog.io/@eno_lj/AOP-%EA%B8%B0%EB%B0%98-Redis-Lock-%EA%B5%AC%ED%98%84-%EB%B0%8F-%EA%B3%A0%EB%A0%A4%EC%82%AC%ED%95%AD</link>
            <guid>https://velog.io/@eno_lj/AOP-%EA%B8%B0%EB%B0%98-Redis-Lock-%EA%B5%AC%ED%98%84-%EB%B0%8F-%EA%B3%A0%EB%A0%A4%EC%82%AC%ED%95%AD</guid>
            <pubDate>Thu, 19 Mar 2026 05:49:00 GMT</pubDate>
            <description><![CDATA[<h3 id="1-aop-기반-lock-구조-적용">1. AOP 기반 Lock 구조 적용</h3>
<p>프로젝트에서 분산 락 처리를 비즈니스 로직과 분리하기 위해Spring AOP와 커스텀 애노테이션(@RedisLock)을 활용한 구조로 구현하였다.</p>
<p>@RedisLock 애노테이션을 메서드에 선언하면, AOP의 @Around 어드바이스가 동작하여 다음 흐름을 수행하도록 설계하였다.</p>
<ul>
<li><p>Redis 락 획득 시도</p>
</li>
<li><p>락 획득 성공 시 비즈니스 로직 실행 (joinPoint.proceed())</p>
</li>
<li><p>실행 종료 후 finally 블록에서 락 해제</p>
</li>
</ul>
<p>이를 통해 서비스 계층에서는 락 관련 코드 없이 비즈니스 로직에만 집중할 수 있도록 관심사를 분리하였다.</p>
<h3 id="2-lock-key-동적-추출-방식">2. Lock Key 동적 추출 방식</h3>
<p>분산 락은 동일 자원에 대한 동시 접근만 제어해야 하므로, 메서드마다 다른 리소스를 기준으로 락을 생성할 필요가 있다.</p>
<p>이를 해결하기 위해 본 프로젝트에서는 SpEL(Spring Expression Language)을 활용하여 Lock Key를 동적으로 생성하였다.</p>
<p>애노테이션 사용 예시는 다음과 같다.</p>
<pre><code class="language-java">@RedisLock(prefix = &quot;event&quot;, key = &quot;#eventId&quot;)
public void issueCoupon(AuthUser authUser, Long eventId)</code></pre>
<p>AOP에서는 ProceedingJoinPoint를 통해 메서드의 파라미터 이름과 값을 추출한 뒤, StandardEvaluationContext와 SpelExpressionParser를 이용하여 #eventId, #request.eventId 등의 표현식을 실제 값으로 평가하였다.</p>
<p>최종적으로 생성되는 락 키는 다음과 같은 형태이다.</p>
<ul>
<li><p>lock:event:1</p>
</li>
<li><p>lock:coupon:5</p>
</li>
</ul>
<p>이와 같은 방식으로 리소스 단위로 락을 분리할 수 있으며, 서로 다른 이벤트나 쿠폰 요청이 불필요하게 서로를 блок킹하지 않도록 설계하였다.</p>
<h3 id="3-트랜잭션-경계와-락-처리-시점">3. 트랜잭션 경계와 락 처리 시점</h3>
<p>AOP의 @Around 어드바이스에서는 락을 먼저 획득한 뒤 비즈니스 로직을 실행하는 구조로 구현하였다.</p>
<p>처리 흐름은 다음과 같다.</p>
<ol>
<li><p>Redis 락 획득 시도</p>
</li>
<li><p>락 획득 성공 시 joinPoint.proceed() 호출</p>
</li>
<li><p>내부에서 @Transactional이 적용된 서비스 로직 실행</p>
</li>
<li><p>메서드 종료 후 finally 블록에서 락 해제</p>
</li>
</ol>
<p>즉,</p>
<blockquote>
<p>락 획득 → 트랜잭션 시작 및 비즈니스 로직 실행 → 트랜잭션 종료 → 락 해제</p>
</blockquote>
<p>순서로 동작하도록 구성하였다.</p>
<p>이러한 구조를 통해 동일 자원에 대한 동시 요청을
트랜잭션 시작 이전 단계에서 차단할 수 있으며,
데이터 정합성을 보다 안정적으로 보장할 수 있다.</p>
<h3 id="4-락-해제-안정성-확보">4. 락 해제 안정성 확보</h3>
<p>Redis 락은 DB 트랜잭션과 별개로 동작하기 때문에, 트랜잭션이 롤백되더라도 자동으로 해제되지 않는다.</p>
<p>이를 해결하기 위해 다음과 같은 방식을 적용하였다.</p>
<ul>
<li><p>finally 블록에서 락 해제를 수행하여 예외 발생 여부와 관계없이 항상 해제</p>
</li>
<li><p>Lua 스크립트를 사용하여 현재 스레드가 획득한 락인지 검증 후에만 삭제</p>
</li>
</ul>
<p>이를 통해 락 누수(lock leak) 및 잘못된 락 해제 문제를 방지하였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[기존 Filter + ArgumentResolver 구조를 Spring Security + JWT로 전환하기]]></title>
            <link>https://velog.io/@eno_lj/%EA%B8%B0%EC%A1%B4-Filter-ArgumentResolver-%EA%B5%AC%EC%A1%B0%EB%A5%BC-Spring-Security-JWT%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@eno_lj/%EA%B8%B0%EC%A1%B4-Filter-ArgumentResolver-%EA%B5%AC%EC%A1%B0%EB%A5%BC-Spring-Security-JWT%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 04 Mar 2026 03:59:54 GMT</pubDate>
            <description><![CDATA[<p>기존에 직접 구현했던 Servlet Filter(JwtFilter) + ArgumentResolver(AuthUserArgumentResolver) 기반 인증/인가 구조를 Spring Security 기반으로 전환했다.
토큰 기반 인증 방식(JWT)은 유지하면서, 권한 체크는 Spring Security의 인가 기능(authorizeHttpRequests, hasRole)을 사용하도록 변경했다.</p>
<hr>
<h3 id="기존-구조변경-전">기존 구조(변경 전)</h3>
<h4 id="1-jwtfilter서블릿-필터">1) JwtFilter(서블릿 필터)</h4>
<ul>
<li>모든 요청에서 Authorization 헤더의 JWT를 꺼냄</li>
<li>토큰 검증 후 Claims에서 값을 꺼내서 request.setAttribute(...)로 저장<ul>
<li>userId, email, userRole<h4 id="2-authuserargumentresolver">2) AuthUserArgumentResolver</h4>
</li>
</ul>
</li>
<li>컨트롤러 파라미터에 @Auth AuthUser가 있으면</li>
<li>request.getAttribute(...)에서 위 값들을 가져와 AuthUser 객체로 만들어 주입<h4 id="3-권한-체크-방식">3) 권한 체크 방식</h4>
</li>
<li>/admin/** 접근 여부를 필터에서 직접 if문으로 체크</li>
<li>관리자 아닐 경우 403 반환<h4 id="동작은-하지만">동작은 하지만,</h4>
</li>
<li>인증/인가 로직이 Security 표준 흐름(SecurityContext, Authentication)과 분리되어 있고</li>
<li>역할(Role) 기반 인가를 Security에게 맡길 수 없어서 코드가 커질 가능성이 있었다.</li>
</ul>
<hr>
<h3 id="목표변경-후">목표(변경 후)</h3>
<ul>
<li>인증: JWT 기반 유지</li>
<li>사용자 정보 전달: request attribute 대신 SecurityContext에 Authentication 저장</li>
<li>권한(Role) 체크: Spring Security의 인가 기능 사용<ul>
<li>/admin/**는 ADMIN만 접근 가능</li>
</ul>
</li>
</ul>
<hr>
<h3 id="변경-후-구조핵심-흐름">변경 후 구조(핵심 흐름)</h3>
<h4 id="1-jwtauthenticationfilteronceperrequestfilter로-교체">1) JwtAuthenticationFilter(OncePerRequestFilter)로 교체</h4>
<ul>
<li>기존 JwtFilter implements Filter 대신, Spring Security 필터 체인에 들어갈 수 있는 OncePerRequestFilter 기반 JwtAuthenticationFilter를 구현했다.</li>
</ul>
<p>이 필터에서 하는 일은:</p>
<ol>
<li>Authorization 헤더에서 JWT 추출</li>
<li>JwtUtil로 검증 + Claims 추출</li>
<li>Claims 기반으로 principal 생성 (AuthUser)</li>
<li>권한을 GrantedAuthority로 변환 (ROLE_ADMIN / ROLE_USER)</li>
<li>SecurityContextHolder.getContext().setAuthentication(authentication) 저장</li>
</ol>
<p>이제 컨트롤러/서비스는 “이 요청이 누구인지”를 SecurityContext에서 표준 방식으로 꺼낼 수 있다.</p>
<hr>
<h3 id="2-securityconfig에서-인가-규칙-선언">2) SecurityConfig에서 인가 규칙 선언</h3>
<p>서블릿 필터에서 if문으로 처리하던 권한 체크를 아래처럼 Security 설정으로 옮겼다.</p>
<ul>
<li>/auth/** : 로그인/회원가입 등 → permitAll()</li>
<li>/admin/** : 관리자만 → hasRole(&quot;ADMIN&quot;)</li>
<li>그 외 → authenticated()</li>
</ul>
<p>또한 JWT 기반이라 서버 세션은 사용하지 않으므로:</p>
<ul>
<li>SessionCreationPolicy.STATELESS 적용</li>
<li>REST API라 CSRF는 비활성화(일반적인 JWT API 패턴)</li>
</ul>
<p>이렇게 해서 “권한 정책”이 한곳(SecurityConfig)에 모이게 됐다.</p>
<hr>
<h3 id="3-argumentresolver-제거하고-principal-기반으로-주입">3) ArgumentResolver 제거하고 Principal 기반으로 주입</h3>
<p>기존에는 AuthUserArgumentResolver가 request attribute를 기반으로 AuthUser를 만들어줬다.
이제는 Authentication의 principal에 AuthUser가 들어있기 때문에 아래 방식으로 받을 수 있다.</p>
<ul>
<li>@AuthenticationPrincipal AuthUser authUser</li>
</ul>
<p>또는, 기존 형태(@Auth AuthUser)를 유지하고 싶으면 @Auth를 @AuthenticationPrincipal로 매핑하는 meta-annotation으로 바꾸면 된다.
결과적으로 WebConfig.addArgumentResolvers()와 AuthUserArgumentResolver는 더 이상 필요 없다.</p>
<hr>
<h3 id="적용-후-장점">적용 후 장점</h3>
<ul>
<li>인가(권한 체크)가 표준화됨<ul>
<li>컨트롤러/필터에 if문으로 권한 분기할 필요가 없음</li>
</ul>
</li>
<li>인증 정보가 SecurityContext에 들어가서<ul>
<li>@AuthenticationPrincipal, SecurityContextHolder 등 Spring Security 표준 기능을 그대로 활용 가능</li>
</ul>
</li>
<li>기능이 커질수록(Security, Method Security, @PreAuthorize 등) 확장하기가 훨씬 쉬워짐</li>
</ul>
<hr>
<p>오늘의 결론</p>
<blockquote>
<p>JWT 인증은 유지하되, “사용자 정보 전달”과 “권한(Role) 기반 인가”를 Spring Security 표준 흐름으로 옮기면 코드가 단순해지고 정책이 한곳에 모여 유지보수가 쉬워진다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Security + JWT 인증/인가 + Refresh Token 도입 삽질 기록]]></title>
            <link>https://velog.io/@eno_lj/Spring-Security-JWT-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80-Refresh-Token-%EB%8F%84%EC%9E%85-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D</link>
            <guid>https://velog.io/@eno_lj/Spring-Security-JWT-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80-Refresh-Token-%EB%8F%84%EC%9E%85-%EC%82%BD%EC%A7%88-%EA%B8%B0%EB%A1%9D</guid>
            <pubDate>Thu, 12 Feb 2026 11:31:16 GMT</pubDate>
            <description><![CDATA[<p>오늘은 결제 프로젝트에서 Spring Security를 JWT 기반(stateless)으로 구성하고, Access Token / Refresh Token을 도입하면서 겪었던 이슈들과 해결 과정을 정리했다.</p>
<hr>
<h3 id="1-목표-세션-없이-jwt로-인증하기-stateless">1. 목표: 세션 없이 JWT로 인증하기 (Stateless)</h3>
<h4 id="핵심-설정">핵심 설정</h4>
<ul>
<li>CSRF 비활성화 (JWT는 보통 쿠키 세션 기반이 아니라서)</li>
<li>SessionCreationPolicy.STATELESS</li>
<li>/api/**는 기본 인증 필요</li>
<li>로그인/회원가입/리프레시는 permitAll<pre><code class="language-java">.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -&gt; session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -&gt; auth
  .requestMatchers(HttpMethod.POST, &quot;/api/login&quot;, &quot;/api/signup&quot;, &quot;/api/refresh&quot;).permitAll()
  .requestMatchers(&quot;/api/**&quot;).authenticated()
  .anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);</code></pre>
</li>
</ul>
<hr>
<h3 id="2-jwt-인증-흐름-정리">2. JWT 인증 흐름 정리</h3>
<h4 id="로그인-시">로그인 시</h4>
<ol>
<li>AuthenticationManager.authenticate()로 인증 시도</li>
<li>성공하면 principal에서 MemberUserDetails 꺼냄</li>
<li>Access/Refresh 토큰 발급</li>
<li>Refresh 토큰은 DB에 저장(원문보단 해시 저장 추천)</li>
</ol>
<pre><code class="language-java">Authentication authentication = authenticationManager.authenticate(
    new UsernamePasswordAuthenticationToken(email, password)
);

MemberUserDetails userDetails = (MemberUserDetails) authentication.getPrincipal();
String accessToken = jwtProvider.createAccessToken(userDetails.getUsername(), userDetails.getMember().getRole());
String refreshToken = jwtProvider.createRefreshToken(userDetails.getUsername());</code></pre>
<hr>
<h3 id="3-userdetails--userdetailsservice-붙이기">3. UserDetails / UserDetailsService 붙이기</h3>
<h4 id="왜-붙였나">왜 붙였나?</h4>
<p>기존에는 로그인 시 내가 직접 memberRepository.findByEmail 후 passwordEncoder.matches 했는데 Spring Security 정석 흐름으로 가려면 UserDetailsService가 필요하다.</p>
<ul>
<li>UserDetailsService.loadUserByUsername(email)에서 회원 조회</li>
<li>UserDetails에서 password/authorities 제공</li>
<li>AuthenticationManager가 알아서 비밀번호 검증까지 처리</li>
</ul>
<h4 id="배운-점-userdetailsservice에서는-security-표준-예외를-던져야-한다">배운 점: UserDetailsService에서는 “Security 표준 예외”를 던져야 한다</h4>
<p>처음에 회원 없을 때 내가 만든 예외(ServiceErrorException)을 던졌더니…</p>
<p>로그인 호출 시 아래 예외로 감싸져서 올라오며 500이 됨:</p>
<ul>
<li>InternalAuthenticationServiceException</li>
<li>caused by ServiceErrorException</li>
</ul>
<p>-&gt; 결론: UserDetailsService에서 회원 없으면 UsernameNotFoundException을 던지는 게 정석.</p>
<hr>
<h3 id="4-jwtauthenticationfilter-구성--refresh-token-인증-방지">4. JwtAuthenticationFilter 구성 + Refresh Token 인증 방지</h3>
<h4 id="필터의-역할">필터의 역할</h4>
<ul>
<li>요청에서 Authorization: Bearer <token> 추출</li>
<li>토큰 검증</li>
<li>토큰에서 email 꺼내기</li>
<li>UserDetailsService로 유저 조회</li>
<li>SecurityContextHolder에 Authentication 세팅</li>
</ul>
<pre><code class="language-java">if (token != null &amp;&amp; jwtProvider.validateToken(token)) {

    // refresh token은 인증용이 아님
    if (jwtProvider.isRefreshToken(token)) {
        filterChain.doFilter(request, response);
        return;
    }

    String email = jwtProvider.getClaims(token).getSubject();
    UserDetails userDetails = userDetailsService.loadUserByUsername(email);

    UsernamePasswordAuthenticationToken authentication =
        new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    SecurityContextHolder.getContext().setAuthentication(authentication);
}</code></pre>
<h4 id="또-하나-배운-점">또 하나 배운 점</h4>
<ul>
<li>필터에서 에러 응답 내려주면 반드시 return 해야 한다
(filterChain.doFilter까지 타버리면 응답이 섞이거나 이상해질 수 있음)</li>
</ul>
<hr>
<h3 id="5-serviceerrorexception인데-왜-500이-떠요-원인-파악">5. “ServiceErrorException인데 왜 500이 떠요?” 원인 파악</h3>
<h4 id="내가-원했던-기대">내가 원했던 기대</h4>
<pre><code class="language-java">memberRepository.findByEmail(email).orElseThrow(() -&gt; new ServiceErrorException(...));</code></pre>
<p>이러면 GlobalExceptionHandler에서 잡혀서 400으로 떨어지길 기대했다.</p>
<h4 id="실제-원인">실제 원인</h4>
<ul>
<li>예외가 컨트롤러/서비스가 아니라 Security 내부 인증 과정(AuthenticationManager) 에서 터짐</li>
<li>DaoAuthenticationProvider가 InternalAuthenticationServiceException으로 감싸서 던짐</li>
<li>그래서 내가 만든 ServiceErrorException 핸들러가 아니라, Exception.class 핸들러로 떨어지거나 예상치 못한 코드로 응답이 나감</li>
</ul>
<p>-&gt; 해결 방향</p>
<ul>
<li>UserDetailsService에서 UsernameNotFoundException 사용</li>
<li>또는 GlobalExceptionHandler에서 AuthenticationException 계열을 별도로 처리</li>
</ul>
<hr>
<h3 id="7-오늘-얻은-결론--체크리스트">7. 오늘 얻은 결론 / 체크리스트</h3>
<h4 id="jwt--security-구성-체크리스트">JWT + Security 구성 체크리스트</h4>
<ul>
<li>SecurityConfig permitAll 경로가 실제 컨트롤러 경로와 일치하는가?</li>
<li>Authorization 헤더는 항상 Bearer prefix가 붙는가?</li>
<li>Refresh Token은 인증 필터에서 막고 있는가?</li>
<li>UserDetailsService는 UsernameNotFoundException 등 표준 예외를 쓰는가?</li>
<li>필터에서 에러 응답 시 return으로 체인을 끊는가?</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[JWT Access Token 블랙리스트 적용]]></title>
            <link>https://velog.io/@eno_lj/JWT-Access-Token-%EB%B8%94%EB%9E%99%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@eno_lj/JWT-Access-Token-%EB%B8%94%EB%9E%99%EB%A6%AC%EC%8A%A4%ED%8A%B8-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Thu, 12 Feb 2026 10:59:23 GMT</pubDate>
            <description><![CDATA[<h3 id="1-왜-블랙리스트가-필요했을까">1. 왜 블랙리스트가 필요했을까?</h3>
<p>JWT는 기본적으로 Stateless 인증 방식이다.
즉,</p>
<ul>
<li>서버는 토큰을 저장하지 않는다.</li>
<li>토큰이 만료되기 전까지는 항상 유효하다.</li>
</ul>
<p>문제는 로그아웃이다.</p>
<p>사용자가 로그아웃해도:</p>
<ul>
<li>이미 발급된 Access Token은</li>
<li>만료 전까지 계속 사용 가능하다.</li>
</ul>
<p>그래서 “즉시 무효화”를 위해 블랙리스트를 도입했다.</p>
<hr>
<h3 id="2-설계-방향">2. 설계 방향</h3>
<h4 id="access-token만-블랙리스트-적용">Access Token만 블랙리스트 적용</h4>
<ul>
<li>Refresh Token은 DB에 직접 저장하고 관리</li>
<li>Access Token은 서버에 저장하지 않기 때문에</li>
<li><blockquote>
<p>jti 기반 블랙리스트로 관리</p>
</blockquote>
</li>
</ul>
<h3 id="멀티-디바이스-허용-x">멀티 디바이스 허용 X</h3>
<ul>
<li>한 계정당 refresh 1개만 유지</li>
<li>Member 엔티티에 refresh 저장</li>
</ul>
<hr>
<h3 id="3-accesstokenblacklist-엔티티-설계">3. AccessTokenBlacklist 엔티티 설계</h3>
<pre><code class="language-java">@Entity
@Table(name = &quot;access_token_blacklist&quot;)
public class AccessTokenBlacklist {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String jti;

    @Column(nullable = false)
    private LocalDateTime expiresAt;

    @Column(nullable = false)
    private LocalDateTime createdAt;
}</code></pre>
<p>왜 jti만 저장했을까?</p>
<ul>
<li>토큰 전체를 저장할 필요 없음</li>
<li>블랙리스트는 &quot;이 토큰은 무효다&quot;라는 식별만 필요</li>
<li>jti는 토큰 고유 식별자</li>
<li>인덱스도 가볍고 조회도 빠름</li>
</ul>
<hr>
<h3 id="4-jwtauthenticationfilter-수정">4. JwtAuthenticationFilter 수정</h3>
<p>요청이 들어올 때:</p>
<ol>
<li>토큰 유효성 검증</li>
<li>refresh 토큰이면 인증 처리 안 함</li>
<li>access 토큰이면 jti 추출</li>
<li>블랙리스트에 존재하면 401 반환<pre><code class="language-java">String jti = jwtProvider.getClaims(token).getId();
</code></pre>
</li>
</ol>
<p>if (blacklistService.isBlacklisted(jti)) {
    sendErrorResponse(response, 401, &quot;로그아웃된 토큰입니다&quot;);
    return;
}</p>
<pre><code>이 로직이 핵심.

---

### 5. logout 로직 수정
로그아웃 시:
1. Access Token에서 jti 추출
2. 만료 시간 추출
3. 블랙리스트 테이블에 저장
4. Member의 refresh 제거
```java
Claims claims = jwtProvider.getClaims(access);

blacklistService.blacklist(
        claims.getId(),
        toLocalDateTime(claims.getExpiration())
);</code></pre><p>이제 로그아웃하면 즉시 access 사용 불가.</p>
<hr>
<h3 id="6-refresh-구조-정리">6. refresh 구조 정리</h3>
<p>Member 엔티티에 추가한 필드:</p>
<pre><code class="language-java">private String refreshToken;
private String refreshJti;
private LocalDateTime refreshExpiresAt;</code></pre>
<p>refresh 성공 시:</p>
<ul>
<li>기존 refresh 폐기</li>
<li>새 refresh 발급</li>
<li>DB 값 교체 (rotate)</li>
</ul>
<p>-&gt; 멀티 디바이스를 허용하지 않기 때문에
이 방식이 블랙리스트 역할을 자연스럽게 해준다.</p>
<hr>
<h3 id="7-느낀-점">7. 느낀 점</h3>
<ul>
<li>JWT는 Stateless라서 편하지만</li>
<li>로그아웃, 강제 만료, 탈취 대응 같은 “상태 관리”가 필요하면 결국 서버에 최소한의 정보는 저장해야 한다.</li>
</ul>
<p>이번 작업을 하면서:</p>
<ul>
<li>jti의 역할</li>
<li>Access vs Refresh 책임 분리</li>
<li>블랙리스트 설계의 의미</li>
</ul>
<p>를 정확히 이해하게 되었다.</p>
<hr>
<h3 id="오늘-배운-핵심">오늘 배운 핵심</h3>
<ul>
<li>JWT는 기본적으로 취소 불가능하다.</li>
<li>로그아웃 즉시 무효화를 위해 jti 기반 블랙리스트를 도입한다.</li>
<li>멀티 디바이스 미허용이면 refresh는 Member에 하나만 유지하면 충분하다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Admin API 접근 로그를 AOP로 남기기 (@Around + RequestBody/ResponseBody JSON 로깅)]]></title>
            <link>https://velog.io/@eno_lj/Admin-API-%EC%A0%91%EA%B7%BC-%EB%A1%9C%EA%B7%B8%EB%A5%BC-AOP%EB%A1%9C-%EB%82%A8%EA%B8%B0%EA%B8%B0-Around-RequestBodyResponseBody-JSON-%EB%A1%9C%EA%B9%85</link>
            <guid>https://velog.io/@eno_lj/Admin-API-%EC%A0%91%EA%B7%BC-%EB%A1%9C%EA%B7%B8%EB%A5%BC-AOP%EB%A1%9C-%EB%82%A8%EA%B8%B0%EA%B8%B0-Around-RequestBodyResponseBody-JSON-%EB%A1%9C%EA%B9%85</guid>
            <pubDate>Sat, 24 Jan 2026 07:08:00 GMT</pubDate>
            <description><![CDATA[<p>오늘은 어드민 전용 API에 접근할 때마다</p>
<ul>
<li>요청 사용자 ID</li>
<li>요청 시각</li>
<li>요청 URL</li>
<li>요청 본문(RequestBody)</li>
<li>응답 본문(ResponseBody)
을 AOP(@Around) 로 로깅하는 기능을 구현했다.</li>
</ul>
<h3 id="요구사항-정리">요구사항 정리</h3>
<p>어드민 사용자만 접근 가능한 특정 API에 접근할 때마다 로그 기록</p>
<h4 id="대상-메서드">대상 메서드:</h4>
<ul>
<li>org.example.expert.domain.comment.controller.CommentAdminController.deleteComment()</li>
<li>org.example.expert.domain.user.controller.UserAdminController.changeUserRole()<h4 id="조건">조건:</h4>
</li>
<li>@Around로 메서드 실행 전/후 로깅</li>
<li>RequestBody / ResponseBody는 JSON 형식</li>
<li>Logger 사용</li>
</ul>
<p>구현 코드 (최종)</p>
<pre><code class="language-java">package org.example.expert.common.aop;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.lang.reflect.Parameter;
import java.time.LocalDateTime;

@Aspect
@Component
@RequiredArgsConstructor
public class ApiAuditLoggingAspect {

    private static final Logger log = LoggerFactory.getLogger(ApiAuditLoggingAspect.class);

    private final ObjectMapper objectMapper;

    @Around(
            &quot;execution(* org.example.expert.domain.comment.controller.CommentAdminController.deleteComment(..))&quot;
                    + &quot; || execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))&quot;
    )
    public Object logAdminApiAccess(ProceedingJoinPoint joinPoint) throws Throwable {

        // 메서드 실행 전
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes == null ? null : attributes.getRequest();

        if (request == null) {
            log.warn(&quot;HttpServletRequest가 null 입니다.&quot;);
            return joinPoint.proceed();
        }

        String methodName = joinPoint.getSignature().getName();
        Long userId = (Long) request.getAttribute(&quot;userId&quot;);
        LocalDateTime requestTime = LocalDateTime.now();
        String requestURI = request.getRequestURI();

        Object requestBody = extractRequestBody(joinPoint);
        String requestBodyJson = safeToJson(requestBody);

        log.info(&quot;Request 정보: Method: {}, URI: {}, time: {}, userId: {}, RequestBody: {}&quot;,
                methodName, requestURI, requestTime, userId, requestBodyJson);

        // 메서드 실행
        Object result = joinPoint.proceed();

        // 메서드 실행 후
        LocalDateTime responseTime = LocalDateTime.now();
        Object responseBody = extractResponseBody(result);
        String responseBodyJson = safeToJson(responseBody);
        log.info(&quot;Response 정보: method: {}, time: {}, ResponseBody: {}&quot;,
                methodName, responseTime, responseBodyJson);

        return result;
    }

    private Object extractRequestBody(ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Parameter[] parameters = signature.getMethod().getParameters();
        Object[] args = joinPoint.getArgs();

        for (int i = 0; i &lt; parameters.length; i++) {
            if (parameters[i].isAnnotationPresent(RequestBody.class)) {
                return args[i];
            }
        }
        return null;
    }

    private Object extractResponseBody(Object result) {
        if (result == null) return null; // void
        if (result instanceof ResponseEntity&lt;?&gt; re) return re.getBody();
        return result;
    }

    private String safeToJson(Object obj) {
        if (obj == null) return &quot;null&quot;;
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
            return &quot;\&quot;&lt;json-error:&quot; + e.getClass().getSimpleName() + &quot;&gt;\&quot;&quot;;
        }
    }
}
</code></pre>
<h3 id="핵심-포인트-정리">핵심 포인트 정리</h3>
<h4 id="1-왜-aoparound를-썼나">1) 왜 AOP(@Around)를 썼나?</h4>
<p>@Around는 메서드 실행 전/후를 모두 감쌀 수 있어서 요청 전 로그 + 응답 후 로그를 한 곳에서 처리하기 딱 좋다.</p>
<pre><code class="language-java">Object result = joinPoint.proceed();</code></pre>
<p>이 코드가 실제 컨트롤러 메서드를 실행시키고, 실행 결과(응답 값)를 그대로 반환한다.</p>
<h4 id="2-requestbody는-왜-httpservletrequest에서-읽지-않았나">2) RequestBody는 왜 HttpServletRequest에서 읽지 않았나?</h4>
<p>HttpServletRequest.getInputStream() 같은 걸로 Body를 다시 읽으려고 하면 이미 Spring MVC가 읽어서 DTO로 변환한 뒤라 비어있거나 예외가 날 수 있다.</p>
<p>그래서 joinPoint.getArgs()에서 직접 꺼내는 게 안정적이다.</p>
<p>다만 @PathVariable 같은 값도 args에 들어가기 때문에 정확하게 RequestBody만 추출하려면 어노테이션 기반으로 찾는다.</p>
<pre><code class="language-java">if (parameters[i].isAnnotationPresent(RequestBody.class)) {
    return args[i];
}</code></pre>
<h4 id="3-responsebody는-어디서-꺼내나">3) ResponseBody는 어디서 꺼내나?</h4>
<p>ResponseBody는 joinPoint.proceed()의 결과 자체다.</p>
<ul>
<li>void 반환이면 result == null</li>
<li>ResponseEntity&lt;?&gt;를 반환하면 실제 body는 re.getBody()</li>
</ul>
<p>그래서 아래처럼 처리했다.</p>
<pre><code class="language-java">if (result instanceof ResponseEntity&lt;?&gt; re) return re.getBody();
return result;</code></pre>
<h4 id="4-json-직렬화-실패해도-api가-죽지-않도록-방어">4) JSON 직렬화 실패해도 API가 죽지 않도록 방어</h4>
<p>로그를 찍다가 ObjectMapper.writeValueAsString()에서 예외가 터지면 AOP가 예외를 던져서 API가 실패할 수도 있다.
그래서 safeToJson()으로 감싸서 로그는 실패해도 요청 흐름은 유지되도록 했다.</p>
<h3 id="느낀-점--개선-아이디어">느낀 점 / 개선 아이디어</h3>
<ul>
<li>다음 단계로는 HTTP 상태 코드(status) 도 함께 로깅하면 더 운영 친화적이다.</li>
<li>또, request/response에 password, token 같은 민감 정보가 있다면 마스킹 처리도 필요하다.</li>
<li>예외 발생 시에도 로그를 남기려면 try/catch로 감싸서 EXCEPTION 로그를 추가하면 좋다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[일정 목록 조회에서 N+1 문제를 한방 쿼리로 개선]]></title>
            <link>https://velog.io/@eno_lj/%EC%9D%BC%EC%A0%95-%EB%AA%A9%EB%A1%9D-%EC%A1%B0%ED%9A%8C%EC%97%90%EC%84%9C-N1-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%ED%95%9C%EB%B0%A9-%EC%BF%BC%EB%A6%AC%EB%A1%9C-%EA%B0%9C%EC%84%A0</link>
            <guid>https://velog.io/@eno_lj/%EC%9D%BC%EC%A0%95-%EB%AA%A9%EB%A1%9D-%EC%A1%B0%ED%9A%8C%EC%97%90%EC%84%9C-N1-%EB%AC%B8%EC%A0%9C%EB%A5%BC-%ED%95%9C%EB%B0%A9-%EC%BF%BC%EB%A6%AC%EB%A1%9C-%EA%B0%9C%EC%84%A0</guid>
            <pubDate>Sun, 11 Jan 2026 10:32:44 GMT</pubDate>
            <description><![CDATA[<h3 id="1-문제-상황">1. 문제 상황</h3>
<p>일정 목록을 조회하면서 각 일정에 달린 댓글 개수를 함께 응답하는 기능을 구현했다.
초기 구현에서는 일정 목록을 조회한 뒤, 각 일정마다 댓글 개수를 따로 조회하는 방식을 사용했다.
기능은 정상적으로 동작했지만, 내부적으로 N+1 문제가 발생하고 있었다.</p>
<h3 id="2-변경-전-코드">2. 변경 전 코드</h3>
<p>변경 전 서비스 로직은 다음과 같은 형태였다.</p>
<pre><code class="language-java">@Transactional(readOnly = true)
public List&lt;GetSchedulesResponse&gt; findAll(String author, int page, int size) {
    Sort.Direction direction = Sort.Direction.DESC;
    Sort sort = Sort.by(direction, &quot;modifiedAt&quot;);
    Pageable pageable = PageRequest.of(page, size, sort);

    Page&lt;Schedule&gt; schedules = (author == null)
            ? scheduleRepository.findAll(pageable)
            : scheduleRepository.findAllByUser_Name(author, pageable);

    return schedules.stream()
            .map(schedule -&gt; {
                int commentCount =
                        commentRepository.countByScheduleId(schedule.getId());
                return GetSchedulesResponse.from(schedule, commentCount);
            })
            .toList();
}</code></pre>
<h4 id="이-코드의-문제점">이 코드의 문제점</h4>
<ul>
<li>일정 목록 조회 쿼리 1번 실행</li>
<li>일정이 N개일 경우<ul>
<li>countByScheduleId() 쿼리가 N번 추가 실행</li>
</ul>
</li>
</ul>
<p>즉, 1 + N 쿼리가 발생하는 구조였다.
일정 개수가 많아질수록 DB 부하가 급격히 증가할 수 있는 전형적인 N+1 문제였다.</p>
<h3 id="3-해결-전략">3. 해결 전략</h3>
<p>N+1 문제를 해결하기 위해 접근 방식을 변경했다.</p>
<p>핵심 아이디어는 다음과 같다.</p>
<ul>
<li>일정 조회 시 댓글 테이블을 LEFT JOIN</li>
<li>GROUP BY + COUNT를 사용해 댓글 개수를 한 번에 계산</li>
<li>author 조건 유무를 하나의 쿼리로 처리</li>
<li>Projection을 사용해 일정과 댓글 개수를 함께 조회</li>
</ul>
<p>즉, <strong>“조회 단계에서 필요한 모든 데이터를 한 번에 가져온다”</strong>는 전략이다.</p>
<h3 id="4-변경-후-코드">4. 변경 후 코드</h3>
<p>변경 후 서비스 로직은 다음과 같다.</p>
<pre><code class="language-java">@Transactional(readOnly = true)
public List&lt;GetSchedulesResponse&gt; findAll(String author, int page, int size) {
    Sort.Direction direction = Sort.Direction.DESC;
    Sort sort = Sort.by(direction, &quot;modifiedAt&quot;);
    Pageable pageable = PageRequest.of(page, size, sort);

    Page&lt;ScheduleWithCommentCount&gt; schedules =
            scheduleRepository.findAllWithCommentCount(author, pageable);

    return schedules.stream()
            .map(scheduleWithCommentCount -&gt;
                    GetSchedulesResponse.from(
                            scheduleWithCommentCount.getSchedule(),
                            (int) scheduleWithCommentCount.getCommentCount()
                    )
            )
            .toList();
}</code></pre>
<p>변경 후에는</p>
<ul>
<li>댓글 개수를 따로 조회하지 않는다.</li>
<li>Repository에서 이미 댓글 개수가 포함된 결과를 받아온다.</li>
<li>서비스에서는 DTO 변환만 수행한다.</li>
</ul>
<h3 id="5-개선-효과">5. 개선 효과</h3>
<p>이번 개선을 통해 다음과 같은 효과를 얻었다.</p>
<ol>
<li>N+1 문제 해결</li>
</ol>
<ul>
<li>변경 전: 일정 1번 + 댓글 N번</li>
<li>변경 후: 일정+댓글 조회 1번 + count 쿼리 1번</li>
</ul>
<ol start="2">
<li>서비스 로직 단순화</li>
</ol>
<ul>
<li>반복적인 repository 호출 제거</li>
<li>서비스의 책임이 명확해짐</li>
</ul>
<ol start="3">
<li>성능과 확장성 향상</li>
</ol>
<ul>
<li>일정 개수가 늘어나도 쿼리 수는 일정</li>
<li>운영 환경에서도 안정적인 성능 기대 가능</li>
</ul>
<h3 id="6-느낀-점">6. 느낀 점</h3>
<p>처음에는 “동작하는 코드”에만 집중했지만, 조회 로직에서는 쿼리 구조가 곧 성능이라는 점을 확실히 체감했다.</p>
<p>특히 JPA를 사용할 때는 stream 내부에서 repository를 호출하고 있지는 않은지, 반복 쿼리가 발생하지는 않는지, 항상 의식적으로 점검해야 한다는 것을 배웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SOLID 원칙 정리]]></title>
            <link>https://velog.io/@eno_lj/SOLID-%EC%9B%90%EC%B9%99-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@eno_lj/SOLID-%EC%9B%90%EC%B9%99-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Fri, 09 Jan 2026 10:57:55 GMT</pubDate>
            <description><![CDATA[<h3 id="solid-원칙이란">SOLID 원칙이란?</h3>
<p>SOLID 원칙은 시간이 지나도 유지보수와 확장이 쉬운, 그리고 이해하기 편하고 유연한 소프트웨어를 만들기 위한 다섯 가지 객체 지향 설계 원칙의 모음이다.</p>
<p>객체 지향 프로그래밍을 하다 보면 코드는 점점 커지고, 기능은 계속 추가된다.
이때 SOLID 원칙을 지키지 않으면 코드가 쉽게 망가진다.</p>
<p>SOLID는 이런 상황을 방지하기 위한 설계 기준이다.</p>
<h3 id="1-srp--단일-책임-원칙-single-responsibility-principle">1. SRP — 단일 책임 원칙 (Single Responsibility Principle)</h3>
<blockquote>
<p>클래스는 단 하나의 책임만 가져야 한다.</p>
</blockquote>
<h4 id="핵심-의미">핵심 의미</h4>
<ul>
<li>하나의 클래스는 하나의 이유로만 변경되어야 한다</li>
<li>여러 책임을 가지면 변경 시 연쇄 수정이 발생한다<h4 id="문제점">문제점</h4>
</li>
<li>한 클래스가<ul>
<li>데이터 관리</li>
<li>계산</li>
<li>저장</li>
<li>출력
같은 역할을 모두 맡으면</li>
</ul>
</li>
<li>코드가 커지고 수정이 어려워진다<h4 id="해결-방법">해결 방법</h4>
</li>
<li>책임을 기준으로 클래스를 분리한다</li>
<li>“이 클래스는 무엇 때문에 바뀌는가?”를 기준으로 나눈다</li>
</ul>
<h3 id="2-ocp--개방폐쇄-원칙-openclosed-principle">2. OCP — 개방/폐쇄 원칙 (Open/Closed Principle)</h3>
<blockquote>
<p>확장에는 열려 있고, 수정에는 닫혀 있어야 한다.</p>
</blockquote>
<h4 id="핵심-의미-1">핵심 의미</h4>
<ul>
<li>기존 코드를 고치지 않고</li>
<li>새로운 기능을 추가할 수 있어야 한다<h4 id="문제점-1">문제점</h4>
</li>
<li>조건문(if/else, switch)이 계속 늘어나는 구조</li>
<li>새로운 타입이 추가될 때마다 기존 코드 수정 필요<h4 id="해결-방법-1">해결 방법</h4>
</li>
<li>인터페이스(추상화)를 사용한다</li>
<li>구현체를 교체하거나 추가하는 방식으로 확장한다</li>
</ul>
<p>-&gt; 새로운 기능이 생겨도 기존 코드에는 손대지 않는 것이 목표다.</p>
<h3 id="3-lsp--리스코프-치환-원칙-liskov-substitution-principle">3. LSP — 리스코프 치환 원칙 (Liskov Substitution Principle)</h3>
<blockquote>
<p>자식 클래스는 부모 클래스를 완전히 대체할 수 있어야 한다.</p>
</blockquote>
<h4 id="핵심-의미-2">핵심 의미</h4>
<ul>
<li>부모 타입으로 사용하는 코드가</li>
<li>자식 객체로 바뀌어도 동작이 깨지면 안 된다<h4 id="문제점-2">문제점</h4>
</li>
<li>상속을 잘못 사용하면</li>
<li>부모의 계약(의도)을 자식이 깨뜨릴 수 있다<h4 id="대표적인-실수">대표적인 실수</h4>
</li>
<li>“정사각형은 직사각형이다”라는 상속</li>
<li>행동 규칙이 달라져 예상 결과가 달라짐<h4 id="해결-방법-2">해결 방법</h4>
</li>
<li>상속보다 인터페이스 기반 설계</li>
<li>공통된 행동만 추상화한다</li>
</ul>
<h3 id="4-isp--인터페이스-분리-원칙-interface-segregation-principle">4. ISP — 인터페이스 분리 원칙 (Interface Segregation Principle)</h3>
<blockquote>
<p>클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다.</p>
</blockquote>
<h4 id="여기서-클라이언트란">여기서 클라이언트란?</h4>
<ul>
<li>이미 구현된 코드를 사용하는 쪽 코드<h4 id="문제점-3">문제점</h4>
</li>
<li>하나의 인터페이스에 너무 많은 기능이 들어 있음</li>
<li>구현 클래스가 필요 없는 메서드까지 억지로 구현해야 함<h4 id="해결-방법-3">해결 방법</h4>
</li>
<li>인터페이스를 작은 단위로 분리</li>
<li>필요한 기능만 선택해서 구현하도록 설계</li>
</ul>
<p>-&gt; “뚱뚱한 인터페이스”를 만들지 말자.</p>
<h3 id="5-dip--의존-역전-원칙-dependency-inversion-principle">5. DIP — 의존 역전 원칙 (Dependency Inversion Principle)</h3>
<blockquote>
<p>고수준 모듈과 저수준 모듈은 둘 다 추상화에 의존해야 한다.</p>
</blockquote>
<h4 id="핵심-의미-3">핵심 의미</h4>
<ul>
<li>구현 클래스에 직접 의존하지 않는다</li>
<li>인터페이스에 의존하도록 만든다<h4 id="문제점-4">문제점</h4>
</li>
<li>고수준 로직이 특정 구현에 묶여 있으면</li>
<li>구현 변경 시 코드 수정이 불가피하다<h4 id="해결-방법-4">해결 방법</h4>
</li>
<li>인터페이스를 통해 의존성을 역전시킨다</li>
<li>생성자 주입(Dependency Injection)을 사용한다</li>
</ul>
<p>-&gt; “무엇을 하는지”에 의존하고
-&gt; “어떻게 하는지”에는 의존하지 않는다.</p>
<h3 id="solid-원칙을-지키면-얻는-것">SOLID 원칙을 지키면 얻는 것</h3>
<ol>
<li>유지보수성 향상
→ 수정 범위가 줄어든다</li>
<li>확장성 증가
→ 새로운 기능 추가가 쉽다</li>
<li>테스트 용이성
→ 각 컴포넌트를 독립적으로 테스트 가능</li>
<li>코드 재사용성
→ 다른 프로젝트에서도 활용 가능</li>
<li>팀 협업에 유리
→ 구조가 명확해 이해하기 쉽다</li>
</ol>
<h3 id="오늘의-정리">오늘의 정리</h3>
<p>SOLID 원칙은 “지금 당장 코드를 잘 짜기 위한 규칙”이 아니라 미래의 변경을 대비하기 위한 설계 철학이다.</p>
<ul>
<li>클래스는 하나의 책임만 가진다</li>
<li>기존 코드는 건드리지 않고 확장한다</li>
<li>상속은 조심해서 사용한다</li>
<li>인터페이스는 작게 나눈다</li>
<li>구현이 아니라 추상화에 의존한다</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[사용자 선착순 이벤트 기능을 DDD 관점에서 설계해 보기]]></title>
            <link>https://velog.io/@eno_lj/%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%8A%A5%EC%9D%84-DDD-%EA%B4%80%EC%A0%90%EC%97%90%EC%84%9C-%EC%84%A4%EA%B3%84%ED%95%B4-%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@eno_lj/%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%84%A0%EC%B0%A9%EC%88%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%8A%A5%EC%9D%84-DDD-%EA%B4%80%EC%A0%90%EC%97%90%EC%84%9C-%EC%84%A4%EA%B3%84%ED%95%B4-%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Fri, 09 Jan 2026 05:53:59 GMT</pubDate>
            <description><![CDATA[<h2 id="0-먼저-보편적-언어부터-고정한다-ubiquitous-language">0) 먼저 보편적 언어부터 고정한다 (Ubiquitous Language)</h2>
<p>팀이 모두 같은 단어를 써야 헷갈리지 않습니다.</p>
<ul>
<li>Member(회원): 서비스에 가입한 사용자</li>
<li>Admin(관리자): 운영 권한을 가진 Member</li>
<li>Event(이벤트): 선착순 참여형 이벤트</li>
<li>Participation(참여): 특정 Member가 특정 Event에 참여한 기록</li>
<li>Winner(당첨자): 참여자 중 당첨된 사람</li>
<li>WinnerLimit(N명): 당첨자 정원</li>
<li>EventPeriod(이벤트 기간): 시작일시~종료일시</li>
<li>EventStatus(상태): READY / OPEN / CLOSED</li>
<li>Participate(참여한다): 이벤트에 참여를 시도한다(성공/실패)</li>
</ul>
<hr>
<h2 id="1-bounded-context-나누기-두-덩어리로-나누면-깔끔">1) Bounded Context 나누기 (두 덩어리로 나누면 깔끔)</h2>
<h3 id="member-context-회원권한">Member Context (회원/권한)</h3>
<p>회원/관리자, 권한 변경 관리</p>
<h3 id="event-context-이벤트참여당첨">Event Context (이벤트/참여/당첨)</h3>
<p>이벤트 생성/진행/종료, 참여/당첨 관리</p>
<blockquote>
<p>이 둘은 연결되지만 “책임”이 다르기 때문에 분리하는 게 DDD답습니다.</p>
</blockquote>
<hr>
<h2 id="2-애그리거트와-루트-결정">2) 애그리거트와 루트 결정</h2>
<p>요구사항에서 “독립적으로 중요한 것”이 애그리거트가 됩니다.</p>
<h3 id="aggregate-1-member">Aggregate 1: Member</h3>
<ul>
<li>Aggregate Root: Member<h3 id="aggregate-2-event">Aggregate 2: Event</h3>
</li>
<li>Aggregate Root: Event</li>
</ul>
<p>Participation은 단독으로 존재하기보다는 Event에 딸린 기록으로 보거나, 트래픽/동시성 때문에 별도 애그리거트로 분리할 수도 있습니다.
여기서는 과제 난이도와 확장성을 고려해서 Participation을 별도 Aggregate로 분리하는 설계를 추천합니다.</p>
<h3 id="aggregate-3-participation">Aggregate 3: Participation</h3>
<ul>
<li>Aggregate Root: Participation</li>
</ul>
<hr>
<h2 id="3-엔티티--값-객체--애그리거트--루트-설계">3) “엔티티 / 값 객체 / 애그리거트 / 루트” 설계</h2>
<h3 id="a-member-aggregate">A. Member Aggregate</h3>
<h3 id="member-entity--aggregate-root">Member (Entity + Aggregate Root)</h3>
<h4 id="member-속성attributes">Member 속성(Attributes)</h4>
<ul>
<li>memberId (고유 식별자)</li>
<li>email (로그인/식별)</li>
<li>nickname</li>
<li>role (USER / ADMIN)</li>
<li>createdAt<h4 id="member-행위behavior">Member 행위(Behavior)</h4>
</li>
<li>promoteToAdmin() : 관리자로 승격</li>
<li>isAdmin() : 관리자 여부 확인<h4 id="member-규칙rules">Member 규칙(Rules)</h4>
</li>
<li>role은 USER 또는 ADMIN이다</li>
<li>최초 관리자 1명은 DB에 존재한다(운영 정책)</li>
<li>관리자 변경은 관리자만 할 수 있다(권한 규칙)</li>
</ul>
<h4 id="value-object-후보">Value Object 후보</h4>
<p>Role (USER/ADMIN)
→ Enum으로도 가능하지만 “권한 정책”이 늘면 VO도 좋습니다.</p>
<hr>
<h3 id="b-event-aggregate">B. Event Aggregate</h3>
<h4 id="event-entity--aggregate-root">Event (Entity + Aggregate Root)</h4>
<h4 id="event-속성attributes">Event 속성(Attributes)</h4>
<ul>
<li>eventId</li>
<li>title</li>
<li>description</li>
<li>period (EventPeriod), 값 객체</li>
<li>winnerLimit (N명)</li>
<li>status (EventStatus), 값 객체/Enum</li>
<li>createdBy (adminId 또는 memberId)<blockquote>
<p>참여자 수/당첨자 수는 “실시간 노출”이 필요하므로
participantCount, winnerCount를 Event에 캐싱(저장)할지 / 조회로 계산할지 설계 포인트입니다.
성능 때문에 보통 카운트 컬럼을 Event에 둡니다.</p>
</blockquote>
</li>
<li>participantCount (실시간 참여자 수)</li>
<li>winnerCount (실시간 당첨자 수)<h4 id="event-행위behavior">Event 행위(Behavior)</h4>
</li>
<li>create() : 이벤트 생성</li>
<li>update() : 이벤트 수정</li>
<li>open(now) : 이벤트 시작(상태 변경)</li>
<li>close() : 이벤트 종료</li>
<li>canParticipate(now) : 참여 가능 여부 판단</li>
<li>increaseParticipantCount() : 참여자 수 증가</li>
<li>increaseWinnerCount() : 당첨자 수 증가</li>
<li>isWinnerFull() : winnerCount == winnerLimit 확인<h4 id="event-규칙rules">Event 규칙(Rules)</h4>
</li>
<li>시작일시는 종료일시보다 늦을 수 없다</li>
<li>시작 전, 종료 후 참여 불가</li>
<li>winnerCount가 winnerLimit에 도달하면 이벤트는 종료 상태가 된다</li>
<li>종료 상태에서는 참여 불가</li>
</ul>
<h4 id="eventperiod-value-object">EventPeriod (Value Object)</h4>
<h4 id="속성">속성</h4>
<ul>
<li>startAt</li>
<li>endAt<h4 id="규칙">규칙</h4>
</li>
<li>startAt &lt;= endAt</li>
<li>now가 startAt 이전이면 시작 전</li>
<li>now가 endAt 이후이면 종료 후<h4 id="eventstatus-value-object--enum">EventStatus (Value Object / Enum)</h4>
</li>
<li>READY (시작 전)</li>
<li>OPEN (진행 중)</li>
<li>CLOSED (종료)</li>
</ul>
<hr>
<h3 id="c-participation-aggregate">C. Participation Aggregate</h3>
<h4 id="participation-entity--aggregate-root">Participation (Entity + Aggregate Root)</h4>
<p>Participation은 “누가 어떤 이벤트에 참여했는지”를 나타내는 기록입니다.</p>
<h4 id="속성attributes">속성(Attributes)</h4>
<ul>
<li>participationId</li>
<li>eventId</li>
<li>memberId</li>
<li>participatedAt</li>
<li>resultStatus (WIN / LOSE) 또는 isWinner(boolean)<h4 id="행위behavior">행위(Behavior)</h4>
</li>
<li>markWinner() : 당첨 처리</li>
<li>markLose() : 낙첨 처리<h4 id="규칙rules">규칙(Rules)</h4>
</li>
<li>한 member는 같은 event에 단 한 번만 Participation이 존재해야 한다 (중복 참여 금지)</li>
<li>이벤트가 참여 불가 상태면 Participation 생성 불가<blockquote>
<p>“중복 참여 방지”는 반드시 DB에서도 막아야 합니다.
(eventId + memberId 유니크 제약이 핵심)</p>
</blockquote>
</li>
</ul>
<hr>
<h2 id="4-도메인-서비스-설계-객체-하나에-넣기-애매한-규칙-처리">4) 도메인 서비스 설계 (객체 하나에 넣기 애매한 규칙 처리)</h2>
<p>DDD에서는 “주어가 애매한 규칙”이 있으면 Domain Service가 등장합니다.</p>
<h3 id="domain-service-1-eventparticipationservice">Domain Service 1: EventParticipationService</h3>
<p>역할: “참여 요청”이라는 한 행동에는 Event도 필요하고, Member도 필요하고, Participation도 필요합니다.
그래서 도메인 서비스로 빼는 게 자연스럽습니다.</p>
<p>책임</p>
<ul>
<li>회원인지 확인</li>
<li>이벤트 참여 가능 시간인지 확인</li>
<li>중복 참여인지 확인</li>
<li>참여 기록 생성</li>
<li>당첨 처리(선착순 N명)<blockquote>
<p>특히 “선착순”은 동시성 문제가 있기 때문에
도메인 서비스에서 트랜잭션/락/원자적 업데이트를 고려해야 합니다.</p>
</blockquote>
<h3 id="domain-service-2-adminmemberservice-권한-관리">Domain Service 2: AdminMemberService (권한 관리)</h3>
책임</li>
<li>특정 회원을 관리자로 승격</li>
<li>“관리자만 가능” 규칙 검증</li>
</ul>
<hr>
<h2 id="5-도메인-이벤트-설계-어떤-일이-일어났음을-알리는-신호">5) 도메인 이벤트 설계 (어떤 일이 일어났음을 알리는 신호)</h2>
<p>도메인 이벤트는 나중에 확장에 매우 강합니다.</p>
<p>예: “당첨되면 알림 보내기”, “로그 쌓기”, “통계 반영하기” 같은 작업을 쉽게 붙일 수 있습니다.</p>
<h3 id="domain-event-후보들">Domain Event 후보들</h3>
<h4 id="1-eventcreated">1) EventCreated</h4>
<ul>
<li>이벤트가 생성되었음<h4 id="2-eventupdated">2) EventUpdated</h4>
</li>
<li>이벤트가 수정되었음<h4 id="3-participationcreated">3) ParticipationCreated</h4>
</li>
<li>누군가 이벤트에 참여했음 (참여자 수 증가 트리거)<h4 id="4-winnerselected">4) WinnerSelected</h4>
</li>
<li>누군가 당첨되었음 (알림/통계/포인트 등 확장 포인트)<h4 id="5-eventclosed">5) EventClosed</h4>
</li>
<li>당첨자가 다 차서 이벤트가 종료되었음</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>