1010import org .apache .kafka .clients .consumer .ConsumerRecord ;
1111import org .springframework .kafka .annotation .KafkaListener ;
1212import org .springframework .kafka .annotation .RetryableTopic ;
13- import org .springframework .kafka .core .KafkaTemplate ;
1413import org .springframework .kafka .support .Acknowledgment ;
1514import org .springframework .retry .annotation .Backoff ;
1615import org .springframework .stereotype .Component ;
@@ -23,9 +22,8 @@ public class UserFinalDeleteListener {
2322
2423 private final UserRepository userRepo ;
2524 private final ProcessedEventRepository peRepo ;
26- private final KafkaTemplate <String ,String > kafka ;
2725 private final ObjectMapper om ;
28-
26+ private final ResilientSender sender ;
2927 /** 멱등 시작 마킹: (없으면 INSERT) → 이미 SUCCESS면 스킵 */
3028 private boolean tryBegin (String eventId , String type ){
3129 if (!peRepo .existsById (eventId )) {
@@ -61,81 +59,84 @@ private void markError(String eventId, String msg){
6159
6260 /**
6361 * 최종 삭제 처리 플로우:
64- * - @RetryableTopic: 최대 5회 지수 백오프 재시도 → 실패 시 user.final. delete.dlt 로 이동
62+ * - @RetryableTopic: 최대 5회 지수 백오프 재시도 → 실패 시 user.final- delete.command .dlt 로 이동
6563 * - InvalidPayloadException 은 exclude → 즉시 DLT(재시도 없음)
6664 * - 수신 → 파싱/검증(독성은 즉시 DLT) → 멱등 시작 → 비즈니스(하드 삭제) → reply 동기 전송
6765 * - 커밋 이후에만 수동 ACK(MANUAL_IMMEDIATE)로 오프셋 커밋 → DB/메시지 정합 보장
6866 */
6967 @ RetryableTopic (
7068 attempts = "5" ,
7169 backoff = @ Backoff (delay = 1000 , multiplier = 2.0 ),
72- autoCreateTopics = "true " ,
70+ autoCreateTopics = "false " ,
7371 dltTopicSuffix = ".dlt" ,
74- exclude = { InvalidPayloadException .class } // ★ 독성 페이로드는 즉시 DLT
72+ exclude = { InvalidPayloadException .class }
7573 )
7674 @ KafkaListener (
77- topics = "user.final. delete" ,
75+ topics = "user.final- delete.command " ,
7876 groupId = "user-service" ,
7977 containerFactory = "kafkaManualAckFactory"
8078 )
8179 @ Transactional
8280 public void onFinalDelete (ConsumerRecord <String ,String > rec , Acknowledgment ack ) throws Exception {
8381
84- // 0) 파싱 + 유효성 검증 — 독성(InvalidPayloadException)은 즉시 DLT 보내도록 로깅 후 재던짐
82+ // 0) 파싱 + 유효성 검증 — 독성(InvalidPayloadException)은 즉시 DLT
8583 final JsonNode n ;
8684 try {
8785 n = om .readTree (rec .value ());
88- String eventId = n . path ( "eventId" ). asText ( "" ). trim ();
89- long userNo = n . path ( "userNo" ). asLong ( 0L );
90- if ( eventId . isEmpty ()) throw new InvalidPayloadException ("Missing or empty 'eventId'" );
91- if ( userNo <= 0L ) throw new InvalidPayloadException ( "Invalid 'userNo'" );
86+ } catch ( Exception e ) {
87+ log . error ( "[user-service] toxic payload -> DLT, value={}" , rec . value () );
88+ throw new InvalidPayloadException ("Invalid JSON: " + e . getMessage (), e );
89+ }
9290
93- // 1) 멱등 처리(이미 성공이면 스킵)
94- if (!tryBegin (eventId , "FINAL_DELETE" )) {
95- ack .acknowledge ();
96- return ;
97- }
91+ final String eventId = n .path ("eventId" ).asText ("" ).trim ();
92+ final long userNo = n .path ("userNo" ).asLong (0L );
93+ if (eventId .isEmpty ()) throw new InvalidPayloadException ("Missing or empty 'eventId'" );
94+ if (userNo <= 0L ) throw new InvalidPayloadException ("Invalid 'userNo'" );
9895
99- try {
100- // 2) 실제 하드 삭제(멱등)
101- userRepo .deleteById (userNo );
96+ // 1) 멱등 처리(이미 SUCCESS면 스킵)
97+ if (!tryBegin (eventId , "FINAL_DELETE" )) {
98+ ack .acknowledge ();
99+ return ;
100+ }
102101
103- // 3) 성공 reply 동기 전송(acks=all, .get())
104- var reply = om .createObjectNode ()
105- .put ("eventId" , eventId )
106- .put ("userNo" , userNo )
107- .put ("status" , "SUCCESS" )
108- .put ("type" , "FINAL_DELETE" );
109- kafka .send ("user.final.reply" , String .valueOf (userNo ), om .writeValueAsString (reply )).get ();
102+ // 2) 실제 하드 삭제(멱등)
103+ try {
104+ userRepo .deleteById (userNo );
110105
111- // 운영 관측성: 키/이벤트/유저
112- log . info ( "FINAL_DELETE ok key={}, eventId={}, userNo={}" , rec . key (), eventId , userNo );
106+ // 3) 성공 마킹(DB 커밋 대상)
107+ markSuccess ( eventId );
113108
114- // 4) 성공 마킹
115- markSuccess (eventId );
109+ // 4) 커밋 이후 reply 전송 + ACK
110+ final String replyPayload = om .createObjectNode ()
111+ .put ("eventId" , eventId )
112+ .put ("userNo" , userNo )
113+ .put ("status" , "SUCCESS" )
114+ .put ("type" , "FINAL_DELETE" )
115+ .toString ();
116116
117- // 5) 트랜잭션 커밋 이후 ACK (정합성 보장)
118- org .springframework .transaction .support .TransactionSynchronizationManager .registerSynchronization (
119- new org .springframework .transaction .support .TransactionSynchronization () {
120- @ Override public void afterCommit () { ack .acknowledge (); }
117+ org .springframework .transaction .support .TransactionSynchronizationManager .registerSynchronization (
118+ new org .springframework .transaction .support .TransactionSynchronization () {
119+ @ Override public void afterCommit () {
120+ try {
121+ // 커밋 확정 후에만 브로커에 회신(정합성 ↑)
122+ sender .sendSync ("user.final-delete.reply" , String .valueOf (userNo ), replyPayload );
123+ ack .acknowledge (); // 전송 성공시에만 오프셋 커밋
124+ log .info ("FINAL_DELETE ok key={}, eventId={}, userNo={}" , rec .key (), eventId , userNo );
125+ } catch (Exception sendEx ) {
126+ // 전송 실패: ACK 하지 않음 → 오프셋 미커밋 상태 유지
127+ // 컨슈머 재시작/리밸런스 시 같은 레코드 재처리됨(@RetryableTopic와는 별개)
128+ log .error ("FINAL_DELETE reply send failed (will be retried on re-consume). " +
129+ "eventId={}, userNo={}, err={}" , eventId , userNo , sendEx .toString ());
130+ }
121131 }
122- );
123- } catch (Exception ex ) {
124- log .warn ("FINAL_DELETE failed userNo={}, eventId={}, err={}" , userNo , eventId , ex .toString ());
125- markError (eventId , ex .getMessage ());
126- throw ex ; // 재시도 → 최대 횟수 후 DLT
127- }
132+ }
133+ );
128134
129- } catch (InvalidPayloadException bad ) {
130- // ★ 독성 페이로드 → 재시도 없이 즉시 DLT (exclude 규칙)
131- log .error ("[user-service] toxic payload -> DLT, value={}" , rec .value ());
132- throw bad ;
135+ } catch (Exception ex ) {
136+ // 비즈니스/기술 예외 → 재시도/DTL 체인
137+ log .warn ("FINAL_DELETE failed userNo={}, eventId={}, err={}" , userNo , eventId , ex .toString ());
138+ markError (eventId , ex .getMessage ());
139+ throw ex ;
133140 }
134141 }
135-
136- @ KafkaListener (topics = "user.final.delete.dlt" , groupId = "user-service" )
137- public void onFinalDeleteDlt (String payload ){
138- // DLT 모니터링(알람/대시보드 연계 지점)
139- log .error ("[DLT][user.final.delete] {}" , payload );
140- }
141- }
142+ }
0 commit comments