Skip to content

Comments

[fix] MOA-218 동아리 정보나 지원서 수정 시에 동시성 문제를 해결한다#724

Merged
Zepelown merged 1 commit intodevelop/befrom
fix/#723-concurrency-MOA-218
Sep 9, 2025
Merged

[fix] MOA-218 동아리 정보나 지원서 수정 시에 동시성 문제를 해결한다#724
Zepelown merged 1 commit intodevelop/befrom
fix/#723-concurrency-MOA-218

Conversation

@Zepelown
Copy link
Member

@Zepelown Zepelown commented Sep 5, 2025

#️⃣연관된 이슈

#723

📝작업 내용

동시성 문제 발생 시에 에러 핸들링 추가

-> 커스텀 에러 반환
image

동아리 지원서 동시성 문제 테스트 코드 추가

image

application.properties에 spring.data.mongodb.write-concern.w=majority 추가

-> MongoDB에 데이터를 쓸 때 어느 수준까지 데이터의 안정성을 보장받을 것인지를 정의하는 옵션으로 MongoDB Altas 샤딩에서 쓰기 작업이 과반수 노드에 안전하게 저장되었음이 보장됨.
-> 낙관적 락의 Version 필드가 정상적으로 다 반영이 됐다고 가정할 수 있게 됨.
-> 낙관적 락이 정상적으로 동작함.

중점적으로 리뷰받고 싶은 부분(선택)

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요

ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요?

논의하고 싶은 부분(선택)

논의하고 싶은 부분이 있다면 작성해주세요.

🫡 참고사항

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 5, 2025

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
Validation error: Invalid regex pattern for base branch. Received: "**" at "reviews.auto_review.base_branches[0]"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Walkthrough

전역 예외 처리기에 낙관적 락 등 동시성 예외 처리(HTTP 409)가 추가되었고, 테스트들에서 동시성 시나리오(동시 업데이트) 검증 로직이 강화·개편되었다. 서비스 소스에는 포맷팅 변경만 있다.

Changes

Cohort / File(s) Summary
Global exception handling for concurrency
backend/src/main/java/moadong/global/exception/GlobalExceptionHandler.java
@Slf4j 추가, OptimisticLockingFailureExceptionUncategorizedMongoDbException 처리 핸들러 추가(409 CONFLICT, ErrorCode.CONCURRENCY_CONFLICT 응답)
Service formatting only
backend/src/main/java/moadong/club/service/ClubApplyService.java
메서드 사이 공백 라인 추가 등 포맷팅 변경만 수행, 로직/시그니처 변경 없음
Concurrency test revisions
backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java, backend/src/test/java/moadong/club/service/ClubProfileServiceTest.java
멀티스레드 수 확대 및 CyclicBarrier 동기화 도입, 저장소 직접 업데이트 방식으로 전환(일부), 성공 1/나머지 충돌 검증, MockMvc 자동 구성과 PasswordEncoder 주입, 테스트 메서드명 변경

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Client
  participant API as Controller
  participant S as Service
  participant R as Repository/DB
  participant GX as GlobalExceptionHandler

  C->>API: PUT /clubs/... (update)
  API->>S: edit(...)
  S->>R: save(entity)
  R-->>S: OptimisticLockingFailureException
  S-->>API: throw
  API-->>GX: propagate exception
  GX-->>C: HTTP 409 CONFLICT (ErrorCode.CONCURRENCY_CONFLICT)
  note over GX: 중앙집중 예외 처리로 동시성 충돌 표준화 응답
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Assessment against linked issues

Objective Addressed Explanation
동아리 정보/지원서 수정 시 낙관적 락 충돌 발생 시 Retry로 최신 데이터 재로딩 후 수정 구현 (MOA-218) 재시도/재로딩 로직이 서비스 계층에 구현되지 않음. 전역 409 처리 및 테스트 개편만 확인됨.

Assessment against linked issues: Out-of-scope changes

(해당 없음)

Possibly related PRs

Suggested labels

🐞 Bug, 🛠Fix, 💾 BE, ✅ Test

Suggested reviewers

  • lepitaaar
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/#723-concurrency-MOA-218

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@vercel
Copy link

vercel bot commented Sep 5, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
moadong Ready Ready Preview Comment Sep 5, 2025 8:32am

@github-actions
Copy link

github-actions bot commented Sep 5, 2025

Test Results

80 tests   77 ✅  6s ⏱️
14 suites   3 💤
14 files     0 ❌

Results for commit 1eb97b4.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/src/main/java/moadong/club/service/ClubApplyService.java (1)

51-59: 개별 질문 수정에도 동일한 재시도 패턴을 적용하세요.

단발 저장으로는 충돌 시 항상 실패합니다. 아래와 같이 동일 패턴으로 보완을 제안합니다.

-    @Transactional
-    public void editClubApplicationQuestion(String questionId, CustomUserDetails user, ClubApplicationEditRequest request) {
-        ClubQuestion clubQuestion = clubQuestionRepository.findById(questionId)
-                .orElseThrow(() -> new RestApiException(ErrorCode.QUESTION_NOT_FOUND));
-
-        updateQuestions(clubQuestion, request);
-        clubQuestion.updateEditedAt();
-
-        clubQuestionRepository.save(clubQuestion);
-    }
+    @Transactional
+    public void editClubApplicationQuestion(String questionId, CustomUserDetails user, ClubApplicationEditRequest request) {
+        final int maxRetries = 3;
+        for (int attempt = 1; attempt <= maxRetries; attempt++) {
+            try {
+                ClubQuestion clubQuestion = clubQuestionRepository.findById(questionId)
+                        .orElseThrow(() -> new RestApiException(ErrorCode.QUESTION_NOT_FOUND));
+                updateQuestions(clubQuestion, request);
+                clubQuestion.updateEditedAt();
+                clubQuestionRepository.save(clubQuestion);
+                return;
+            } catch (org.springframework.dao.OptimisticLockingFailureException | org.springframework.dao.DataAccessException e) {
+                if (!isWriteConflict(e) || attempt == maxRetries) {
+                    throw e;
+                }
+            }
+        }
+    }
🧹 Nitpick comments (9)
backend/src/test/java/moadong/club/service/ClubProfileServiceTest.java (3)

3-3: 사용되지 않는 MockMvc/ObjectMapper 제거 또는 실제 사용으로 전환하세요.

현재 MockMvc/ObjectMapper가 주입되지만 사용되지 않습니다. 불필요한 의존성은 테스트 안정성을 해칩니다.

-import com.fasterxml.jackson.databind.ObjectMapper;
@@
-@AutoConfigureMockMvc
@@
-    @Autowired
-    private PasswordEncoder passwordEncoder;
-    @Autowired
-    private MockMvc mockMvc;
+    @Autowired
+    private PasswordEncoder passwordEncoder;

Also applies to: 15-20


89-91: 스레드 풀 종료 안전성 보강 제안.

플래키 상황에서 풀 미종료를 막기 위해 awaitTermination + shutdownNow를 보강하세요.

-        latch.await(); // 모든 스레드가 작업을 마칠 때까지 대기
-        executorService.shutdown();
+        latch.await(); // 모든 스레드가 작업을 마칠 때까지 대기
+        executorService.shutdown();
+        if (!executorService.awaitTermination(1, TimeUnit.SECONDS)) {
+            executorService.shutdownNow();
+        }

69-77: 메시지 기반 WriteConflict 판정은 취약 — 원인 체인 검사로 완화하세요.

문구 변경 시 깨질 수 있습니다. helper로 cause 체인을 확인하세요.

private static boolean containsWriteConflict(Throwable t) {
    while (t != null) {
        String m = t.getMessage();
        if (m != null && m.contains("WriteConflict")) return true;
        t = t.getCause();
    }
    return false;
}
-                } catch (DataAccessException e) {
-                    if (e.getMessage() != null && e.getMessage().contains("WriteConflict")) {
+                } catch (DataAccessException e) {
+                    if (containsWriteConflict(e)) {
                         conflictCount.incrementAndGet();
                     } else {
                         e.printStackTrace();
                     }
backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java (6)

4-4: 미사용 import 정리.

-import moadong.club.entity.ClubApplicationQuestion;

15-15: 미사용 import 정리.

-import org.bson.types.ObjectId;

59-64: CyclicBarrier 동기화 적절 — 플래키 완화 위해 반복 실행 도입 고려.

ClubProfileServiceTest처럼 @RepeatedIfExceptionsTest를 적용하면 간헐 실패를 줄일 수 있습니다.


70-70: 미사용 지역변수 제거.

-            final int threadNum = i;
             executorService.submit(() -> {

74-74: Optional#get 직접 호출 지양 — 존재하지 않을 때 명시 메시지로 실패하세요.

-                    ClubQuestion questionToUpdate = clubQuestionRepository.findById(this.clubQuestionId).get();
+                    ClubQuestion questionToUpdate = clubQuestionRepository.findById(this.clubQuestionId)
+                            .orElseThrow(() -> new NoSuchElementException("ClubQuestion not found: " + this.clubQuestionId));

87-95: 모든 DataAccessException을 충돌로 집계하면 과대포착 위험.

낙관적 락(OptimisticLockingFailureException)과 WriteConflict만 충돌로 집계하세요.

-                } catch (DataAccessException e) {
-                    // DataAccessException은 WriteConflict 등을 포함
-                    conflictCount.incrementAndGet();
+                } catch (OptimisticLockingFailureException e) {
+                    conflictCount.incrementAndGet();
+                } catch (DataAccessException e) {
+                    if (e.getMessage() != null && e.getMessage().contains("WriteConflict")) {
+                        conflictCount.incrementAndGet();
+                    } else {
+                        e.printStackTrace();
+                    }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f67c2d0 and 1eb97b4.

📒 Files selected for processing (4)
  • backend/src/main/java/moadong/club/service/ClubApplyService.java (1 hunks)
  • backend/src/main/java/moadong/global/exception/GlobalExceptionHandler.java (2 hunks)
  • backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java (5 hunks)
  • backend/src/test/java/moadong/club/service/ClubProfileServiceTest.java (2 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-05-19T05:45:52.957Z
Learnt from: lepitaaar
PR: Moadong/moadong#406
File: backend/src/main/java/moadong/club/service/ClubApplyService.java:34-38
Timestamp: 2025-05-19T05:45:52.957Z
Learning: The code duplication between createClubApplication and editClubApplication methods in ClubApplyService.java is acknowledged but will be addressed in a future refactoring, as per the developer's plan.

Applied to files:

  • backend/src/main/java/moadong/club/service/ClubApplyService.java
  • backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java
📚 Learning: 2025-08-25T14:43:52.320Z
Learnt from: lepitaaar
PR: Moadong/moadong#703
File: backend/src/main/java/moadong/club/controller/ClubApplyController.java:84-84
Timestamp: 2025-08-25T14:43:52.320Z
Learning: In the Moadong codebase, questionId and clubId are equivalent identifiers that represent the same entity. The ClubApplicationRepository.findAllByIdInAndQuestionId method correctly uses clubId as the questionId parameter for filtering club applications.

Applied to files:

  • backend/src/main/java/moadong/club/service/ClubApplyService.java
  • backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java
📚 Learning: 2025-05-15T12:03:57.356Z
Learnt from: Zepelown
PR: Moadong/moadong#406
File: backend/src/main/java/moadong/club/entity/ClubApplicationQuestion.java:32-33
Timestamp: 2025-05-15T12:03:57.356Z
Learning: 엔티티 클래스는 요청/응답 객체(DTO)에 의존해서는 안 됩니다. 계층 간 의존성 문제를 방지하기 위해 엔티티와 DTO는 분리되어야 합니다. 예를 들어, `ClubApplicationQuestion` 엔티티가 `ClubApplicationRequest.Options`와 같은 요청 객체를 직접 참조하는 대신, 엔티티 패키지 내에 `QuestionOptions`와 같은 별도의 클래스를 정의하고 사용해야 합니다. 이렇게 하면 요청 객체 변경이 데이터베이스 스키마나 엔티티 계층에 영향을 미치지 않습니다.

Applied to files:

  • backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java
🧬 Code graph analysis (2)
backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java (2)
backend/src/test/java/moadong/fixture/UserFixture.java (1)
  • UserFixture (9-33)
backend/src/test/java/moadong/fixture/ClubApplicationEditFixture.java (1)
  • ClubApplicationEditFixture (10-26)
backend/src/test/java/moadong/club/service/ClubProfileServiceTest.java (2)
backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java (1)
  • IntegrationTest (29-107)
backend/src/test/java/moadong/fixture/UserFixture.java (1)
  • UserFixture (9-33)
🔇 Additional comments (3)
backend/src/test/java/moadong/club/service/ClubApplyServiceTest.java (3)

41-41: 엔티티 대신 ID만 보관하는 변경 좋습니다.

동시 조회-수정 테스트에서 스냅샷 오염을 줄입니다.


49-53: setUp에서 ID만 선조회 — 동시성 테스트에 적합합니다.


104-105: 검증 로직 적절합니다.

1 성공, 나머지 충돌이라는 기대치가 명확합니다.

Comment on lines 42 to 48
@Transactional
public void editClubApplication(String clubId, CustomUserDetails user, ClubApplicationEditRequest request) {
ClubQuestion clubQuestion = getClubQuestion(clubId, user);

clubQuestion.updateEditedAt();
clubQuestionRepository.save(updateQuestions(clubQuestion, request));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

낙관적 락 충돌 시 재시도 로직이 없습니다 (MOA-218 요구사항 불충족).

이슈 체크리스트에 명시된 “충돌 시 최신 데이터 재조회 후 재적용(retry)”가 서비스 레이어에 구현되어 있지 않습니다. 409만 반환하면 사용자 경험상 손실이 큽니다. 아래처럼 최대 3회 재시도를 권장합니다.

-    @Transactional
-    public void editClubApplication(String clubId, CustomUserDetails user, ClubApplicationEditRequest request) {
-        ClubQuestion clubQuestion = getClubQuestion(clubId, user);
-
-        clubQuestion.updateEditedAt();
-        clubQuestionRepository.save(updateQuestions(clubQuestion, request));
-    }
+    @Transactional
+    public void editClubApplication(String clubId, CustomUserDetails user, ClubApplicationEditRequest request) {
+        final int maxRetries = 3;
+        for (int attempt = 1; attempt <= maxRetries; attempt++) {
+            try {
+                ClubQuestion clubQuestion = getClubQuestion(clubId, user);
+                clubQuestion.updateEditedAt();
+                clubQuestionRepository.save(updateQuestions(clubQuestion, request));
+                return;
+            } catch (org.springframework.dao.OptimisticLockingFailureException | org.springframework.dao.DataAccessException e) {
+                if (!isWriteConflict(e) || attempt == maxRetries) {
+                    throw e;
+                }
+                // 재시도: 다음 루프에서 최신 버전 재조회
+            }
+        }
+    }

도움 메서드(파일 하단 등 임의 위치에 추가):

// 동시성 충돌 여부 판별(테스트와 동일한 WriteConflict 문자열도 허용)
private boolean isWriteConflict(Throwable t) {
    while (t != null) {
        String msg = t.getMessage();
        if (msg != null && msg.contains("WriteConflict")) return true;
        t = t.getCause();
    }
    return false;
}

검증 스크립트(ClubQuestion에 @Version 존재 여부 확인):


🏁 Script executed:

#!/bin/bash
rg -n --type=java -C2 '@Version' | rg -n 'class\s+ClubQuestion|@Version|-C'

Length of output: 223


낙관적 락 충돌 시 3회 재시도 로직 구현 필요
ClubApplyService.editClubApplication 메서드가 OptimisticLockingFailureException 발생 시 최신 엔티티를 재조회해 최대 3회 재시도하도록 수정해야 합니다(ClubQuestion 엔티티에 @Version이 적용되어 있음 확인됨).

backend/src/main/java/moadong/club/service/ClubApplyService.java:42-48

-    @Transactional
-    public void editClubApplication(String clubId, CustomUserDetails user, ClubApplicationEditRequest request) {
-        ClubQuestion clubQuestion = getClubQuestion(clubId, user);
-
-        clubQuestion.updateEditedAt();
-        clubQuestionRepository.save(updateQuestions(clubQuestion, request));
-    }
+    @Transactional
+    public void editClubApplication(String clubId, CustomUserDetails user, ClubApplicationEditRequest request) {
+        final int maxRetries = 3;
+        for (int attempt = 1; attempt <= maxRetries; attempt++) {
+            try {
+                ClubQuestion clubQuestion = getClubQuestion(clubId, user);
+                clubQuestion.updateEditedAt();
+                clubQuestionRepository.save(updateQuestions(clubQuestion, request));
+                return;
+            } catch (org.springframework.dao.OptimisticLockingFailureException | org.springframework.dao.DataAccessException e) {
+                if (!isWriteConflict(e) || attempt == maxRetries) {
+                    throw e;
+                }
+                // 재시도: 다음 루프에서 최신 버전 재조회
+            }
+        }
+    }
+
+// ClubQuestion에 @Version 충돌 여부 판단용 헬퍼
+private boolean isWriteConflict(Throwable t) {
+    while (t != null) {
+        String msg = t.getMessage();
+        if (msg != null && msg.contains("WriteConflict")) return true;
+        t = t.getCause();
+    }
+    return false;
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Transactional
public void editClubApplication(String clubId, CustomUserDetails user, ClubApplicationEditRequest request) {
ClubQuestion clubQuestion = getClubQuestion(clubId, user);
clubQuestion.updateEditedAt();
clubQuestionRepository.save(updateQuestions(clubQuestion, request));
}
@Transactional
public void editClubApplication(String clubId, CustomUserDetails user, ClubApplicationEditRequest request) {
final int maxRetries = 3;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
ClubQuestion clubQuestion = getClubQuestion(clubId, user);
clubQuestion.updateEditedAt();
clubQuestionRepository.save(updateQuestions(clubQuestion, request));
return;
} catch (org.springframework.dao.OptimisticLockingFailureException | org.springframework.dao.DataAccessException e) {
if (!isWriteConflict(e) || attempt == maxRetries) {
throw e;
}
// 재시도: 다음 루프에서 최신 버전 재조회
}
}
}
// ClubQuestion에 @Version 충돌 여부 판단용 헬퍼
private boolean isWriteConflict(Throwable t) {
while (t != null) {
String msg = t.getMessage();
if (msg != null && msg.contains("WriteConflict")) return true;
t = t.getCause();
}
return false;
}

Comment on lines +28 to +34
@ExceptionHandler({OptimisticLockingFailureException.class, UncategorizedMongoDbException.class})
public ResponseEntity<Response> handleConcurrencyConflict(Exception ex) {
log.warn("데이터베이스 충돌 발생: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new Response(ErrorCode.CONCURRENCY_CONFLICT.getCode(), ErrorCode.CONCURRENCY_CONFLICT.getMessage(), null));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

❓ Verification inconclusive

UncategorizedMongoDbException을 409로 포괄 처리하면 과대포착 위험이 큽니다 — DataAccessException 포함 + WriteConflict 필터링 권장.

동시성 외의 DB 오류도 409로 변환될 수 있습니다. DataAccessException을 함께 받되, WriteConflict(또는 낙관적 락) 케이스만 409로 매핑하세요. 나머지는 500 등으로 위임하는 것이 안전합니다.

+import org.springframework.dao.DataAccessException;
@@
-    @ExceptionHandler({OptimisticLockingFailureException.class, UncategorizedMongoDbException.class})
-    public ResponseEntity<Response> handleConcurrencyConflict(Exception ex) {
-        log.warn("데이터베이스 충돌 발생: {}", ex.getMessage());
-        return ResponseEntity
-                .status(HttpStatus.CONFLICT)
-                .body(new Response(ErrorCode.CONCURRENCY_CONFLICT.getCode(), ErrorCode.CONCURRENCY_CONFLICT.getMessage(), null));
-    }
+    @ExceptionHandler({OptimisticLockingFailureException.class, UncategorizedMongoDbException.class, DataAccessException.class})
+    public ResponseEntity<Response> handleConcurrencyConflict(Exception ex) {
+        if (ex instanceof DataAccessException dae && !isWriteConflict(dae)) {
+            log.error("데이터베이스 예외(동시성 아님): {}", dae.getMessage(), dae);
+            return ResponseEntity
+                    .status(HttpStatus.INTERNAL_SERVER_ERROR)
+                    .body(new Response("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다.", null));
+        }
+        log.warn("데이터베이스 충돌 발생: {}", ex.getMessage());
+        return ResponseEntity
+                .status(HttpStatus.CONFLICT)
+                .body(new Response(ErrorCode.CONCURRENCY_CONFLICT.getCode(), ErrorCode.CONCURRENCY_CONFLICT.getMessage(), null));
+    }

도움 메서드(파일 하단 등 임의 위치 추가):

private boolean isWriteConflict(Throwable t) {
    while (t != null) {
        String msg = t.getMessage();
        if (msg != null && msg.contains("WriteConflict")) return true;
        t = t.getCause();
    }
    return false;
}

UncategorizedMongoDbException 409 처리 범위 제한 및 WriteConflict 필터링 추가
UncategorizedMongoDbException을 그대로 409로 응답하면 낙관적 락 외의 모든 DB 오류까지 과대포착됩니다. DataAccessException을 함께 받되, WriteConflict(또는 OptimisticLockingFailureException) 케이스만 409로 매핑하고 나머지는 500으로 위임하세요.

+import org.springframework.dao.DataAccessException;
@@
-    @ExceptionHandler({OptimisticLockingFailureException.class, UncategorizedMongoDbException.class})
-    public ResponseEntity<Response> handleConcurrencyConflict(Exception ex) {
-        log.warn("데이터베이스 충돌 발생: {}", ex.getMessage());
-        return ResponseEntity
-                .status(HttpStatus.CONFLICT)
-                .body(new Response(ErrorCode.CONCURRENCY_CONFLICT.getCode(), ErrorCode.CONCURRENCY_CONFLICT.getMessage(), null));
-    }
+    @ExceptionHandler({OptimisticLockingFailureException.class, UncategorizedMongoDbException.class, DataAccessException.class})
+    public ResponseEntity<Response> handleConcurrencyConflict(Exception ex) {
+        if (ex instanceof DataAccessException dae && !isWriteConflict(dae)) {
+            log.error("데이터베이스 예외(동시성 아님): {}", dae.getMessage(), dae);
+            return ResponseEntity
+                    .status(HttpStatus.INTERNAL_SERVER_ERROR)
+                    .body(new Response("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다.", null));
+        }
+        log.warn("데이터베이스 충돌 발생: {}", ex.getMessage());
+        return ResponseEntity
+                .status(HttpStatus.CONFLICT)
+                .body(new Response(ErrorCode.CONCURRENCY_CONFLICT.getCode(), ErrorCode.CONCURRENCY_CONFLICT.getMessage(), null));
+    }

도움 메서드 위치(클래스 하단 등):

```java
private boolean isWriteConflict(Throwable t) {
    while (t != null) {
        String msg = t.getMessage();
        if (msg != null && msg.contains("WriteConflict")) return true;
        t = t.getCause();
    }
    return false;
}
🤖 Prompt for AI Agents
In backend/src/main/java/moadong/global/exception/GlobalExceptionHandler.java
around lines 28-34, narrow the 409 mapping so only
optimistic-lock/write-conflict cases return CONFLICT: change the handler to
accept DataAccessException (or Exception) and implement a small
isWriteConflict(Throwable) helper that walks the cause chain checking for
"WriteConflict" in messages; if ex is an instance of
OptimisticLockingFailureException or isWriteConflict(ex) then keep returning
409, otherwise log appropriately and delegate to/return a 500
INTERNAL_SERVER_ERROR response (or rethrow to let the global fallback handle
it).

Copy link
Collaborator

@alsdddk alsdddk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우왓 수고하셨습니다!

Copy link
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다.
저번에 임시로 테스트를 통과하기위해 작성한 함수는 불필요해진건가요?

@Zepelown
Copy link
Member Author

Zepelown commented Sep 9, 2025

수고하셨습니다. 저번에 임시로 테스트를 통과하기위해 작성한 함수는 불필요해진건가요?

테스트용으로 필요합니다

@Zepelown Zepelown merged commit acc064c into develop/be Sep 9, 2025
5 checks passed
@lepitaaar lepitaaar deleted the fix/#723-concurrency-MOA-218 branch October 21, 2025 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants