-
Notifications
You must be signed in to change notification settings - Fork 1
fix: 채팅방 커넥션 풀 고갈 문제 해결 #459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5b86501
dd1799f
89ab0b6
08e7f26
d97f59b
552fcbd
ba2c223
da7aefa
3fd7520
c184728
8c9c550
1052981
e3b04cb
97eef0b
3bceddc
0e2b15d
b6c96ee
77017d5
a9f0ee9
c4219fc
0e66e5c
e734259
a91c44e
3472971
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,32 +1,53 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| package gg.agit.konect.domain.chat.service; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import static gg.agit.konect.global.code.ApiResponseCode.FORBIDDEN_CHAT_ROOM_ACCESS; | ||||||||||||||||||||||||||||||||||||||||||||
| import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CHAT_ROOM; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import java.time.LocalDateTime; | ||||||||||||||||||||||||||||||||||||||||||||
| import java.util.ArrayList; | ||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Comparator; | ||||||||||||||||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Objects; | ||||||||||||||||||||||||||||||||||||||||||||
| import java.util.stream.Collectors; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.dao.DataIntegrityViolationException; | ||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.dao.DuplicateKeyException; | ||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.transaction.annotation.Propagation; | ||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.transaction.annotation.Transactional; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.chat.model.ChatRoom; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.chat.model.ChatRoomMember; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.chat.repository.ChatRoomMemberRepository; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.chat.repository.ChatRoomRepository; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.club.model.Club; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.club.model.ClubMember; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.club.repository.ClubMemberRepository; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.user.enums.UserRole; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.user.model.User; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.domain.user.repository.UserRepository; | ||||||||||||||||||||||||||||||||||||||||||||
| import gg.agit.konect.global.exception.CustomException; | ||||||||||||||||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| @Slf4j | ||||||||||||||||||||||||||||||||||||||||||||
| @Service | ||||||||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||||||||
| @Transactional(readOnly = true) | ||||||||||||||||||||||||||||||||||||||||||||
| public class ChatRoomMembershipService { | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| private static final int SYSTEM_ADMIN_ID = 1; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| private final ChatRoomRepository chatRoomRepository; | ||||||||||||||||||||||||||||||||||||||||||||
| private final ChatRoomMemberRepository chatRoomMemberRepository; | ||||||||||||||||||||||||||||||||||||||||||||
| private final ClubMemberRepository clubMemberRepository; | ||||||||||||||||||||||||||||||||||||||||||||
| private final UserRepository userRepository; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| @Transactional | ||||||||||||||||||||||||||||||||||||||||||||
| public void addClubMember(ClubMember clubMember) { | ||||||||||||||||||||||||||||||||||||||||||||
| LocalDateTime baseline = Objects.requireNonNull(clubMember.getCreatedAt()); | ||||||||||||||||||||||||||||||||||||||||||||
| ChatRoom room = chatRoomRepository.findByClubId(clubMember.getClub().getId()) | ||||||||||||||||||||||||||||||||||||||||||||
| .orElseGet(() -> chatRoomRepository.save(ChatRoom.groupOf(clubMember.getClub()))); | ||||||||||||||||||||||||||||||||||||||||||||
| ChatRoom room = findOrCreateClubRoom(clubMember.getClub()); | ||||||||||||||||||||||||||||||||||||||||||||
| ensureMember(room, clubMember.getUser(), baseline); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -37,19 +58,198 @@ public void addDirectMembers(ChatRoom room, User firstUser, User secondUser, Loc | |||||||||||||||||||||||||||||||||||||||||||
| ensureMember(room, secondUser, baseline); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| @Transactional | ||||||||||||||||||||||||||||||||||||||||||||
| public void removeClubMember(Integer clubId, Integer userId) { | ||||||||||||||||||||||||||||||||||||||||||||
| chatRoomRepository.findByClubId(clubId) | ||||||||||||||||||||||||||||||||||||||||||||
| .ifPresent(room -> chatRoomMemberRepository.deleteByChatRoomIdAndUserId(room.getId(), userId)); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| @Transactional(propagation = Propagation.REQUIRES_NEW) | ||||||||||||||||||||||||||||||||||||||||||||
| public void ensureClubRoomMemberships(Integer userId) { | ||||||||||||||||||||||||||||||||||||||||||||
| List<ClubMember> memberships = clubMemberRepository.findAllByUserId(userId); | ||||||||||||||||||||||||||||||||||||||||||||
| if (memberships.isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Map<Integer, ClubMember> membershipByClubId = memberships.stream() | ||||||||||||||||||||||||||||||||||||||||||||
| .collect(Collectors.toMap(cm -> cm.getClub().getId(), cm -> cm, (a, b) -> a)); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| List<ChatRoom> rooms = resolveOrCreateClubRooms(memberships).stream() | ||||||||||||||||||||||||||||||||||||||||||||
| .sorted(Comparator.comparing(ChatRoom::getId)) | ||||||||||||||||||||||||||||||||||||||||||||
| .toList(); | ||||||||||||||||||||||||||||||||||||||||||||
| List<Integer> roomIds = rooms.stream().map(ChatRoom::getId).toList(); | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+69
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LEVEL: high] Also applies to: 86-103 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
| if (roomIds.isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Map<Integer, ChatRoomMember> memberByRoomId = chatRoomMemberRepository | ||||||||||||||||||||||||||||||||||||||||||||
| .findByChatRoomIdsAndUserId(roomIds, userId) | ||||||||||||||||||||||||||||||||||||||||||||
| .stream() | ||||||||||||||||||||||||||||||||||||||||||||
| .collect(Collectors.toMap(ChatRoomMember::getChatRoomId, member -> member, (a, b) -> a)); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| for (ChatRoom room : rooms) { | ||||||||||||||||||||||||||||||||||||||||||||
| ClubMember member = membershipByClubId.get(room.getClub().getId()); | ||||||||||||||||||||||||||||||||||||||||||||
| if (member == null) { | ||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| ChatRoomMember existingMember = memberByRoomId.get(room.getId()); | ||||||||||||||||||||||||||||||||||||||||||||
| if (existingMember != null) { | ||||||||||||||||||||||||||||||||||||||||||||
| LocalDateTime lastReadAt = existingMember.getLastReadAt(); | ||||||||||||||||||||||||||||||||||||||||||||
| if (lastReadAt == null || lastReadAt.isBefore(member.getCreatedAt())) { | ||||||||||||||||||||||||||||||||||||||||||||
| chatRoomMemberRepository.updateLastReadAtIfOlder( | ||||||||||||||||||||||||||||||||||||||||||||
| room.getId(), userId, member.getCreatedAt() | ||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+96
to
+103
|
||||||||||||||||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| saveRoomMemberIgnoringDuplicate(room, member.getUser(), member.getCreatedAt()); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| @Transactional(propagation = Propagation.REQUIRES_NEW) | ||||||||||||||||||||||||||||||||||||||||||||
| public void updateLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { | ||||||||||||||||||||||||||||||||||||||||||||
| chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| @Transactional(propagation = Propagation.REQUIRES_NEW) | ||||||||||||||||||||||||||||||||||||||||||||
| public void updateDirectRoomLastReadAt(Integer roomId, Integer userId, LocalDateTime readAt) { | ||||||||||||||||||||||||||||||||||||||||||||
| User user = userRepository.getById(userId); | ||||||||||||||||||||||||||||||||||||||||||||
| ChatRoom room = chatRoomRepository.findById(roomId) | ||||||||||||||||||||||||||||||||||||||||||||
| .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| ensureDirectRoomMemberExists(room, user, readAt); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if (user.getRole() == UserRole.ADMIN) { | ||||||||||||||||||||||||||||||||||||||||||||
| List<ChatRoomMember> members = chatRoomMemberRepository.findByChatRoomId(roomId); | ||||||||||||||||||||||||||||||||||||||||||||
| boolean isSystemAdmin = members.stream() | ||||||||||||||||||||||||||||||||||||||||||||
| .anyMatch(member -> Objects.equals(member.getUserId(), SYSTEM_ADMIN_ID)); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if (isSystemAdmin) { | ||||||||||||||||||||||||||||||||||||||||||||
| for (ChatRoomMember member : members) { | ||||||||||||||||||||||||||||||||||||||||||||
| if (member.getUser().getRole() == UserRole.ADMIN) { | ||||||||||||||||||||||||||||||||||||||||||||
| chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, member.getUserId(), readAt); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| chatRoomMemberRepository.updateLastReadAtIfOlder(roomId, userId, readAt); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| @Transactional(propagation = Propagation.REQUIRES_NEW) | ||||||||||||||||||||||||||||||||||||||||||||
| public void ensureClubRoomMember(Integer roomId, Integer userId) { | ||||||||||||||||||||||||||||||||||||||||||||
| ChatRoom room = chatRoomRepository.findById(roomId) | ||||||||||||||||||||||||||||||||||||||||||||
| .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); | ||||||||||||||||||||||||||||||||||||||||||||
| if (!room.isGroupRoom() || room.getClub() == null) { | ||||||||||||||||||||||||||||||||||||||||||||
| throw CustomException.of(NOT_FOUND_CHAT_ROOM); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| ClubMember member = clubMemberRepository.getByClubIdAndUserId(room.getClub().getId(), userId); | ||||||||||||||||||||||||||||||||||||||||||||
| ensureMember(room, member.getUser(), member.getCreatedAt()); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| private ChatRoom findOrCreateClubRoom(Club club) { | ||||||||||||||||||||||||||||||||||||||||||||
| return chatRoomRepository.findByClubId(club.getId()) | ||||||||||||||||||||||||||||||||||||||||||||
| .orElseGet(() -> { | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| return chatRoomRepository.save(ChatRoom.groupOf(club)); | ||||||||||||||||||||||||||||||||||||||||||||
| } catch (DataIntegrityViolationException e) { | ||||||||||||||||||||||||||||||||||||||||||||
| if (!isDuplicateKeyException(e)) { | ||||||||||||||||||||||||||||||||||||||||||||
| throw e; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| log.debug("클럽 채팅방 동시 생성 감지, 재조회: clubId={}", club.getId()); | ||||||||||||||||||||||||||||||||||||||||||||
| return chatRoomRepository.findByClubId(club.getId()) | ||||||||||||||||||||||||||||||||||||||||||||
| .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+163
to
+164
|
||||||||||||||||||||||||||||||||||||||||||||
| return chatRoomRepository.findByClubId(club.getId()) | |
| .orElseThrow(() -> CustomException.of(NOT_FOUND_CHAT_ROOM)); | |
| // 동시 생성 트랜잭션 커밋 지연에 대비해 짧은 backoff를 두고 여러 번 재조회 | |
| int maxAttempts = 3; | |
| long backoffMillis = 50L; | |
| for (int attempt = 1; attempt <= maxAttempts; attempt++) { | |
| ChatRoom existing = chatRoomRepository.findByClubId(club.getId()) | |
| .orElse(null); | |
| if (existing != null) { | |
| return existing; | |
| } | |
| if (attempt < maxAttempts) { | |
| try { | |
| Thread.sleep(backoffMillis); | |
| } catch (InterruptedException ie) { | |
| Thread.currentThread().interrupt(); | |
| break; | |
| } | |
| } | |
| } | |
| throw CustomException.of(NOT_FOUND_CHAT_ROOM); |
Copilot
AI
Mar 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveOrCreateClubRooms()에서 채팅방이 없으면 바로 save()로 생성하는데, DB에는 chat_room.club_id에 UNIQUE 인덱스(uq_chat_room_group_club_id)가 있어 동시 요청에서 경쟁적으로 생성 시 DataIntegrityViolationException이 발생할 수 있습니다. 특히 ensureClubRoomMemberships가 목록 조회 시점마다 호출되면 동시성이 높아집니다. 생성 시 예외를 캐치해서 재조회로 회복하거나, clubId 단위로 다시 find 후 insert하는 방식(낙관적 재시도)을 넣어 중복 생성 레이스를 안전하게 처리해 주세요.
Copilot
AI
Mar 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
saveRoomMemberIgnoringDuplicate()에서 DataIntegrityViolationException을 catch해서 무시하도록 되어 있는데, ChatRoomMember는 복합키(assigned id)라 insert가 flush/commit 시점에 발생할 수 있어 이 try/catch로 중복 예외를 잡지 못할 가능성이 큽니다. 또한 제약 위반이 한 번 발생하면 JPA 영속성 컨텍스트/트랜잭션이 실패 상태가 되어 커밋 시 UnexpectedRollbackException 등으로 이어질 수 있으니, 예외 무시 방식 대신 DB upsert(INSERT ... ON DUPLICATE KEY UPDATE 등)나 ‘재조회 후 종료’ 같은 idempotent한 방식으로 중복을 처리하는 쪽이 안전합니다.
| try { | |
| chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline)); | |
| } catch (DataIntegrityViolationException e) { | |
| log.debug("채팅방 멤버 동시 생성 감지, 무시: roomId={}, userId={}", room.getId(), user.getId()); | |
| } | |
| chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId()) | |
| .ifPresentOrElse(existingMember -> { | |
| LocalDateTime lastReadAt = existingMember.getLastReadAt(); | |
| if (lastReadAt == null || lastReadAt.isBefore(baseline)) { | |
| existingMember.updateLastReadAt(baseline); | |
| } | |
| }, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
[LEVEL: low] isSystemAdminRoom 메서드가 ChatService와 중복됩니다.
ChatService.isSystemAdminRoom (lines 732-741)과 동일한 로직이 다른 구현으로 존재합니다. 공통 유틸리티로 추출하면 일관성과 유지보수성이 향상됩니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java`
around lines 203 - 208, Duplicate logic exists in
ChatRoomMembershipService.isSystemAdminRoom and ChatService.isSystemAdminRoom;
extract it into a single shared utility method (e.g.,
ChatUtils.isSystemAdminRoom or a shared service method) that accepts the roomId
and uses chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds and
SYSTEM_ADMIN_ID to determine membership, then replace both existing
implementations to call the new shared method and remove the duplicated
isSystemAdminRoom implementation from ChatRoomMembershipService (or ChatService)
so only the common utility is used.
Copilot
AI
Mar 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isDuplicateKeyException()이 rootCause 메시지 문자열에 "Duplicate"/"duplicate key" 포함 여부로 중복키를 판별하고 있는데, 드라이버/DB/로케일에 따라 메시지가 달라질 수 있어 오탐/미탐 가능성이 있습니다. MySQL이라면 SQLException의 errorCode(예: 1062)나 SQLState(23000) 기반으로 판별하거나, Hibernate ConstraintViolationException에서 constraintName을 확인하는 방식처럼 구조화된 정보를 사용하는 쪽이 안전합니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Spring Framework의 DataIntegrityViolationException과 DuplicateKeyException의 차이는 무엇이며, unique constraint 위반만 안전하게 구분하는 권장 방법은 무엇인가요?
💡 Result:
Spring Framework의 DataIntegrityViolationException과 DuplicateKeyException의 차이는 다음과 같습니다. DataIntegrityViolationException은 데이터 무결성 제약 조건 위반(예: NOT NULL, CHECK, FOREIGN KEY, UNIQUE 등 모든 integrity constraint violations)을 포괄하는 일반적인 예외입니다. DuplicateKeyException은 DataIntegrityViolationException의 하위 클래스이며, primary key 또는 unique constraint 위반에 특화된 예외입니다. 공식 문서에 따르면, DataIntegrityViolationException을 직접 처리하는 것을 권장하며, 특정 하위 클래스에 의존하지 말 것을 명시합니다. 그러나 unique constraint 위반만 안전하게 구분하려면 catch 블록에서 DuplicateKeyException을 먼저 catch하는 것이 표준 방법입니다. 이렇게 하면 unique constraint 위반(DuplicateKeyException)은 별도로 처리되고, 그 외 무결성 위반(DataIntegrityViolationException)은 일반적으로 처리됩니다. 예시 코드: try { // 데이터베이스 작업 } catch (DuplicateKeyException dke) { // unique constraint 위반 처리 (예: "이미 존재합니다" 메시지) } catch (DataIntegrityViolationException dive) { // 다른 무결성 위반 처리 (예: NOT NULL, FK 등) } 이 방법은 Spring의 예외 변환 계층에서 DBMS별 SQLException을 적절히 DuplicateKeyException으로 매핑할 때 작동하며, @Repository 어노테이션과 PersistenceExceptionTranslationPostProcessor가 이를 지원합니다. 그러나 일부 DBMS나 설정에서 DuplicateKeyException이 아닌 DataIntegrityViolationException만 발생할 수 있으므로, 100% 안전하게 구분하려면 ex.getMostSpecificCause.getMessage에서 "duplicate", "unique constraint" 등의 키워드를 검사하는 추가 체크를 권장합니다.
Citations:
- 1: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/dao/DataIntegrityViolationException.html
- 2: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/dao/DuplicateKeyException.html
- 3: https://docs.spring.vmware.com/spring-framework/docs/6.0.27/javadoc-api/org/springframework/dao/DataIntegrityViolationException.html
- 4: https://docs.spring.io/spring-framework/docs/5.3.39/javadoc-api/org/springframework/dao/DataIntegrityViolationException.html
- 5: https://docs.spring.io/spring-framework/docs/6.2.0-RC1/javadoc-api/org/springframework/dao/DataIntegrityViolationException.html
- 6: https://stackoverflow.com/questions/42886369/distinguish-between-dataintegrityviolationexception-exceptions
- 7: https://stackoverflow.com/questions/28626835/spring-mvc-handle-unique-constraint-violation
🏁 Script executed:
# First, check file size and examine the method containing isDuplicateKeyException
wc -l src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.javaRepository: BCSDLab/KONECT_BACK_END
Length of output: 150
🏁 Script executed:
# Get context around lines 240-246 and the full isDuplicateKeyException method
sed -n '200,280p' src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.javaRepository: BCSDLab/KONECT_BACK_END
Length of output: 2033
🏁 Script executed:
# Search for where isDuplicateKeyException is called
rg -n "isDuplicateKeyException" src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.javaRepository: BCSDLab/KONECT_BACK_END
Length of output: 316
🏁 Script executed:
# Check if DuplicateKeyException is already imported or used in this file
rg -n "DuplicateKeyException" src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.javaRepository: BCSDLab/KONECT_BACK_END
Length of output: 316
🏁 Script executed:
# Look for exception handling patterns across the service layer
rg -n "catch.*DataIntegrityViolationException|catch.*DuplicateKeyException" src/main/java/gg/agit/konect/domain/ -A 2Repository: BCSDLab/KONECT_BACK_END
Length of output: 1616
🏁 Script executed:
# Check database configuration
fd -e "yml" -e "yaml" -e "properties" | head -20 | xargs grep -l "datasource\|database\|h2\|mysql\|postgres" 2>/dev/null | head -5Repository: BCSDLab/KONECT_BACK_END
Length of output: 188
🏁 Script executed:
# Check imports in ChatRoomMembershipService
head -30 src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.javaRepository: BCSDLab/KONECT_BACK_END
Length of output: 1376
🏁 Script executed:
# Check pom.xml for DB drivers
cat pom.xml | grep -A 2 "<groupId>.*database\|<groupId>.*h2\|<groupId>.*mysql\|<groupId>.*postgres" | head -20Repository: BCSDLab/KONECT_BACK_END
Length of output: 106
🏁 Script executed:
# Search for any existing DuplicateKeyException usage in the codebase
rg "DuplicateKeyException" src/main/java/Repository: BCSDLab/KONECT_BACK_END
Length of output: 620
🏁 Script executed:
# Check if PersistenceExceptionTranslationPostProcessor or exception translation is configured
rg -n "@Repository|PersistenceExceptionTranslationPostProcessor|@EnableJpaRepositories" src/main/java/Repository: BCSDLab/KONECT_BACK_END
Length of output: 355
🏁 Script executed:
# Check test database config (likely H2)
cat src/test/resources/application-test.ymlRepository: BCSDLab/KONECT_BACK_END
Length of output: 4108
🏁 Script executed:
# Check prod/dev database config
cat src/main/resources/application-db.ymlRepository: BCSDLab/KONECT_BACK_END
Length of output: 805
🏁 Script executed:
# Check build.gradle for database dependencies
fd -e "gradle" | xargs cat 2>/dev/null | grep -A 2 "h2\|mysql\|postgres"Repository: BCSDLab/KONECT_BACK_END
Length of output: 624
🏁 Script executed:
# Find and check build tool config
ls -la | grep -E "build\.|pom\.|gradle"Repository: BCSDLab/KONECT_BACK_END
Length of output: 372
DuplicateKeyException을 먼저 catch하여 메시지 문자열 의존성을 제거하세요.
[LEVEL: medium] 문제: isDuplicateKeyException()은 루트 원인 메시지에 "Duplicate" 또는 "duplicate key"가 포함되었을 때만 unique 위반으로 판단하는데, H2의 실제 예외 메시지 "Unique index or primary key violation"은 이 조건을 만족하지 않습니다. 영향: 테스트 환경(H2)에서 동시 생성 race condition 감지가 실패하고, saveRoomMemberIgnoringDuplicate() 및 ensureDirectRoomMemberExists()의 idempotency가 깨져 500 에러로 드러날 수 있습니다. 제안: try-catch에서 DuplicateKeyException을 먼저 catch하는 것이 Spring의 표준 방법입니다. 현재는 DataIntegrityViolationException만 import되어 있으므로 org.springframework.dao.DuplicateKeyException을 추가로 import하고 순서대로 catch하세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java`
around lines 240 - 246, The current
isDuplicateKeyException/DataIntegrityViolationException handling relies on
rootCause message text and misses H2's "Unique index or primary key violation";
update the try-catch to catch org.springframework.dao.DuplicateKeyException
first (before DataIntegrityViolationException) and import that class, then fall
back to DataIntegrityViolationException where you can keep the current
isDuplicateKeyException check as a secondary safeguard; apply this change to the
methods saveRoomMemberIgnoringDuplicate and ensureDirectRoomMemberExists so
duplicate-key errors are detected reliably without brittle message matching.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
[LEVEL: low]
SYSTEM_ADMIN_ID상수가ChatService와 중복 정의되어 있습니다.두 서비스 모두
SYSTEM_ADMIN_ID = 1을 독립적으로 선언하고 있어 값 불일치 위험이 있습니다. 공통 상수 클래스나 설정으로 추출하면 유지보수가 용이합니다.🤖 Prompt for AI Agents