Skip to content

Conversation

@sgn07124
Copy link
Owner

@sgn07124 sgn07124 commented Sep 1, 2025

User description

관련 이슈 (Related Issues)

🚀 PR 개요

Answer–Feedback 비동기 처리 구조를 리팩토링하여 데이터 정합성을 보장하고 성능을 유지했습니다.

📌 변경 배경

  • 기존 구조는 AnswerService.saveAnswer() 안에서 @Async @Transactional이 적용된 FeedbackService.saveFeedback()을 직접 호출하는 방식이었습니다.
  • 이 경우 saveAnswer() 트랜잭션이 롤백되더라도, saveFeedback()은 별도 트랜잭션에서 실행되어 Feedback이 DB에 커밋되는 문제가 있었습니다.
  • 현재는 FK 제약조건 덕분에 “Answer 없는 Feedback”이 DB 레벨에서 차단되지만, 이는 단순 방어일 뿐 애플리케이션 레벨에서는 구조적으로 불완전했습니다.

🔍 원인 분석

Spring의 @Async는 별도의 스레드와 별도의 트랜잭션에서 실행됩니다. 따라서 AnswerService.saveAnswer()에서 예외 발생 시 Answer와 Question은 롤백되지만, Feedback은 이미 커밋되어 데이터 불일치가 발생할 수 있었습니다.

✅ 해결 과정

  • 트랜잭션 커밋 후에만 Feedback 생성을 실행할 수 있도록 이벤트 기반 구조로 변경했습니다.
    • AnswerSavedEvent를 발행하도록 수정
    • @TransactionalEventListener(phase = AFTER_COMMIT)에서만 Feedback 생성이 실행되도록 보장
  • GPT 호출은 여전히 @Async로 비동기 처리하여 서비스 응답 속도 유지
  • 이를 통해 Answer가 실제 DB에 커밋된 이후에만 Feedback 생성이 실행되도록 보장했습니다.

🎯 결과

  • 데이터 정합성 보장: Answer 없는 Feedback이 생성될 수 있는 가능성을 원천 차단
  • 성능 유지: Feedback 생성은 여전히 비동기 처리되어 사용자 응답 속도에는 영향 없음
  • 안정적 아키텍처 확보: FK 제약조건 의존에서 벗어나 애플리케이션 레벨에서도 완전한 일관성 확보

PR Type

Bug fix, Tests, Enhancement


Description

  • 이벤트 기반으로 피드백 생성 전환

  • 트랜잭션 커밋 후 비동기 처리 보장

  • 콘솔 로그 제거 및 예외 처리 추가

  • 단위 테스트 추가 및 검증 강화


Diagram Walkthrough

flowchart LR
  AnswerService["Answer 저장 (트랜잭션)"]
  Event["AnswerSavedEvent 발행"]
  TxCommit["트랜잭션 커밋"]
  Listener["FeedbackEventListener (@Async, AFTER_COMMIT)"]
  GPT["GptService 호출"]
  Persist["FeedbackMapper.insertFeedback"]

  AnswerService -- "publishEvent" --> Event
  Event -- "AFTER_COMMIT" --> TxCommit
  TxCommit -- "트리거" --> Listener
  Listener -- "질문/답변 검증" --> GPT
  GPT -- "FeedbackResponse" --> Persist
Loading

File Walkthrough

Relevant files
Enhancement
AnswerSavedEvent.java
Answer 저장 이벤트 객체 도입                                                                           

src/main/java/com/project/InsightPrep/domain/question/event/AnswerSavedEvent.java

  • AnswerSavedEvent 레코드 추가
  • Answer 엔티티를 이벤트 페이로드로 보관
+6/-0     
FeedbackEventListener.java
트랜잭션 후 비동기 피드백 생성 리스너                                                                       

src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java

  • AFTER_COMMIT 이벤트 리스너 추가
  • @async로 GPT 호출 비동기화
  • Answer/Question null 검증 및 예외 처리
  • Feedback 생성 후 매퍼로 저장
+49/-0   
AnswerServiceImpl.java
서비스에서 이벤트 발행으로 피드백 분리                                                                       

src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java

  • ApplicationEventPublisher 주입
  • 직접 호출 제거 후 AnswerSavedEvent 발행
  • 트랜잭션 내 Answer 저장 후 이벤트 퍼블리시
+5/-1     
Formatting
FeedbackServiceImpl.java
디버그 출력 제거로 정리                                                                                       

src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java

  • 불필요한 System.out 제거
  • 로직은 기존과 동일 유지
+0/-3     
Tests
FeedbackEventListenerTest.java
이벤트 리스너 단위 테스트 추가                                                                               

src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java

  • 정상 플로우에서 Feedback 저장 검증
  • null/invalid Answer 예외 검증
  • GPT 호출 모킹 및 매퍼 캡처 확인
+110/-0 
AnswerServiceImplTest.java
Answer 서비스 테스트를 이벤트 기반으로 수정                                                           

src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java

  • 이벤트 퍼블리시 검증으로 테스트 수정
  • AnswerSavedEvent 캡처 및 값 검증
  • 비동기 고려해 Awaitility로 검증
+40/-7   

@sgn07124 sgn07124 self-assigned this Sep 1, 2025
@sgn07124 sgn07124 added the 🚑 Hotfix 긴급 수정사항 label Sep 1, 2025
@sgn07124 sgn07124 added 🐛 Bug 버그 수정 및 오류 해결 ✅ Test 테스트 코드 추가 및 수정 labels Sep 1, 2025
@codecov
Copy link

codecov bot commented Sep 1, 2025

Codecov Report

❌ Patch coverage is 94.73684% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...p/domain/question/event/FeedbackEventListener.java 94.11% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link

github-actions bot commented Sep 1, 2025

PR Reviewer Guide 🔍

(Review updated until commit dbbe6c5)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Transactional Boundary

@async와 @TransactionalEventListener(AFTER_COMMIT) 조합은 이벤트 리스너 내부에서 발생하는 예외가 호출자 트랜잭션에 전파되지 않으며, 재시도나 보상 로직이 없을 경우 GPT 호출/저장 실패 시 피드백이 유실될 수 있습니다. 실패 처리, 재시도, DLQ 같은 보완이 필요한지 확인해주세요.

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAnswerSaved(AnswerSavedEvent event) {
    Answer answer = event.answer();
    if (answer == null) throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND);
    if (answer.getQuestion() == null) throw new QuestionException(QuestionErrorCode.QUESTION_NOT_FOUND);

    String question = answer.getQuestion().getContent();
    String userAnswer = answer.getContent();
    if (userAnswer == null) throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND);

    FeedbackResponse gptResult = gptService.callOpenAI(PromptFactory.forFeedbackGeneration(question, userAnswer), 1000, 0.4, GptResponseType.FEEDBACK);
    AnswerFeedback feedback = AnswerFeedback.builder()
            .answer(answer)
            .score(gptResult.getScore())
            .modelAnswer(gptResult.getModelAnswer())
            .improvement(gptResult.getImprovement())
            .build();

    feedbackMapper.insertFeedback(feedback);
    log.info("Feedback saved for Answer id = {}", answer.getId());
}
Null Checks

Answer와 관련 필드에 대한 null 검증은 좋지만, question.getContent()가 null인 경우도 대비할지 정책 확인이 필요합니다. 현재는 userAnswer만 null 검사합니다.

Answer answer = event.answer();
if (answer == null) throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND);
if (answer.getQuestion() == null) throw new QuestionException(QuestionErrorCode.QUESTION_NOT_FOUND);

String question = answer.getQuestion().getContent();
String userAnswer = answer.getContent();
if (userAnswer == null) throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND);
Event Publishing Location

AFTER_COMMIT 이벤트 보장은 트랜잭션 커밋 시점에만 이루어집니다. 현재 메서드에 @transactional이 있으므로 안전하지만, 메서드 분리나 트랜잭션 전파 변경 시 이벤트 발행 위치가 바뀌지 않도록 주의가 필요합니다. 필요 시 TransactionSynchronizationManager로 커밋 후 실행을 명시하는 방법도 고려해주세요.

public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) {
    Member member = securityUtil.getAuthenticatedMember();
    Question question = questionMapper.findById(questionId);

    Answer answer = Answer.builder()
            .member(member)
            .question(question)
            .content(dto.getContent())
            .build();

    questionMapper.updateStatus(questionId, AnswerStatus.ANSWERED.name());
    answerMapper.insertAnswer(answer);
    //feedbackService.saveFeedback(answer);
    eventPublisher.publishEvent(new AnswerSavedEvent(answer));
    return AnswerResponse.AnswerDto.builder()
            .answerId(answer.getId()).build();

@github-actions
Copy link

github-actions bot commented Sep 1, 2025

Persistent review updated to latest commit dbbe6c5

@sgn07124 sgn07124 merged commit 533fc78 into main Sep 1, 2025
3 checks passed
@sgn07124 sgn07124 deleted the bug/async branch September 1, 2025 03:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🚑 Hotfix 긴급 수정사항 🐛 Bug 버그 수정 및 오류 해결 Review effort 2/5 ✅ Test 테스트 코드 추가 및 수정

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@Async로 인해 트랜잭션이 분리되어 데이터 정합성이 깨지는 문제

2 participants