Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5b86501
fix: HikariCP 커넥션 풀 설정 추가
JanooGwan Mar 28, 2026
dd1799f
feat: 알림 전용 스레드풀 설정 추가
JanooGwan Mar 28, 2026
89ab0b6
refactor: NotificationService 알림 전용 스레드풀 적용
JanooGwan Mar 28, 2026
08e7f26
refactor: ChatRoomMembershipService 멤버 초기화 및 읽음 처리 로직 분리
JanooGwan Mar 28, 2026
d97f59b
refactor: ChatService 채팅 조회 메서드 읽기 전용 트랜잭션 분리
JanooGwan Mar 28, 2026
552fcbd
fix: ChatRoomMembershipService 캐스팅 후 공백 제거
JanooGwan Mar 30, 2026
ba2c223
refactor: AsyncConfig 기본 executor 지정 및 알림 스레드풀 포화 시 드롭 정책 적용
JanooGwan Mar 30, 2026
da7aefa
fix: ChatRoomMembershipService 채팅방 및 멤버 동시 생성 시 중복 방지 처리
JanooGwan Mar 30, 2026
3fd7520
refactor: ChatService 조회 메서드 비트랜잭션 오케스트레이터로 전환 및 presence 기록 순서 변경
JanooGwan Mar 30, 2026
c184728
fix: updateLastReadAtIfOlder 쿼리에 NULL 값 갱신 조건 추가
JanooGwan Mar 30, 2026
8c9c550
fix: ChatRoomMembershipService 클럽 방 null 방어 및 isSystemAdminRoom 중복 호출 제거
JanooGwan Mar 30, 2026
1052981
fix: AsyncConfig 기본 executor CallerRunsPolicy 적용 및 알림 거절 시 예외 전파
JanooGwan Mar 30, 2026
e3b04cb
fix: ChatRoomMembershipService 클럽 방 생성 race condition 및 ADMIN 멤버 read…
JanooGwan Mar 30, 2026
97eef0b
feat: Slack 전용 스레드풀 추가 및 미지정 @Async에 명시적 executor 지정
JanooGwan Mar 30, 2026
3bceddc
Merge branch 'develop' into fix/alert-connection-pool-limit
JanooGwan Mar 30, 2026
0e2b15d
fix: DataIntegrityViolationException 처리 시 중복 키 예외만 선별하여 처리
JanooGwan Mar 30, 2026
b6c96ee
fix: ChatService Redis presence 기록 실패 시 메시지 조회 중단 방지
JanooGwan Mar 30, 2026
77017d5
fix: isDuplicateKeyException DB 비종속적 중복 키 감지 패턴 추가
JanooGwan Mar 30, 2026
a9f0ee9
fix: slackTaskExecutor CallerRunsPolicy를 로깅 후 RejectedExecutionExcept…
JanooGwan Mar 31, 2026
c4219fc
fix: ChatService recordPresenceSafely 메서드 구문 오류 수정
JanooGwan Mar 31, 2026
0e66e5c
Merge branch 'develop' into fix/alert-connection-pool-limit
JanooGwan Mar 31, 2026
e734259
fix: ChatRoomMembershipService deadlock 방지 정렬 및 DuplicateKeyException…
JanooGwan Mar 31, 2026
a91c44e
fix: ChatService getDirectChatRoomMessages readAt 재선언 및 resolveMessag…
JanooGwan Mar 31, 2026
3472971
fix: ChatService recordPresenceSafely 무한 재귀 수정 및 중복 호출 제거
JanooGwan Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,13 @@ List<ChatRoomMember> findByChatRoomIdsAndUserId(
@Param("userId") Integer userId
);

@Modifying
@Modifying(clearAutomatically = true)
@Query("""
UPDATE ChatRoomMember crm
SET crm.lastReadAt = :lastReadAt
WHERE crm.id.chatRoomId = :chatRoomId
AND crm.id.userId = :userId
AND crm.lastReadAt < :lastReadAt
AND (crm.lastReadAt IS NULL OR crm.lastReadAt < :lastReadAt)
""")
int updateLastReadAtIfOlder(
@Param("chatRoomId") Integer chatRoomId,
Expand Down
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;
Comment on lines +40 to +45

Copy link
Copy Markdown

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
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 38 - 43, SYSTEM_ADMIN_ID is duplicated between
ChatRoomMembershipService and ChatService; extract it to a single shared
constant and reference that instead. Create a common constant (e.g.,
ApplicationConstants or SystemConstants) with a public static final int
SYSTEM_ADMIN_ID = 1, replace the local SYSTEM_ADMIN_ID declaration in
ChatRoomMembershipService and the one in ChatService to use
ApplicationConstants.SYSTEM_ADMIN_ID (or the chosen class) and update
imports/usages accordingly to ensure both services reference the same source of
truth.


@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);
}

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

[LEVEL: high] ensureClubRoomMemberships()가 여전히 비결정적 순서로 chat_room_member를 갱신합니다.
문제: Lines 67-76과 86-103은 클럽/방 목록을 정렬하지 않고 받은 순서대로 updateLastReadAtIfOlder()와 insert를 수행해서, 클럽이 2개 이상인 사용자의 동시 /chats/rooms 요청마다 잠금 획득 순서가 달라질 수 있습니다.
영향: 같은 사용자에 대한 동시 재조회에서 chat_room_member 데드락이 다시 발생해 이번 PR의 장애가 500으로 재현될 수 있습니다.
제안: memberships 또는 roomsclubIdroomId 기준으로 정렬한 뒤 동일한 순서로 갱신/생성을 수행하세요.

Also applies to: 86-103

🤖 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 67 - 76, ensureClubRoomMemberships() currently processes
memberships and rooms in non-deterministic order causing inconsistent lock
acquisition on chat_room_member; before calling resolveOrCreateClubRooms(),
updateLastReadAtIfOlder(), and performing inserts (the logic around
resolveOrCreateClubRooms() and updateLastReadAtIfOlder()), sort the input
collections deterministically (e.g., sort memberships by club.getId() or sort
rooms by ChatRoom.getId()) and then iterate that sorted list for both the
updates and the inserts so all threads touch chat_room_member in a consistent
order to prevent deadlocks.

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

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

ensureClubRoomMemberships에서 lastReadAt이 null일 때 updateLastReadAtIfOlder를 호출하지만, 해당 UPDATE 쿼는 lastReadAt < :lastReadAt 조건이라 DB 값이 NULL이면 갱신되지 않습니다. DB 스키마상 last_read_at은 NOT NULL이라면 null 체크를 제거하고, null이 가능하다고 방어하려는 목적이라면 UPDATE 조건에 lastReadAt IS NULL OR ...을 포함하도록 리포지토리 쿼/로직을 정합성 있게 맞춰 주세요.

Copilot uses AI. Check for mistakes.
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());
}
Comment thread
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

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

중복키(DataIntegrityViolationException) 발생 시 즉시 재조회 후 없으면 NOT_FOUND_CHAT_ROOM을 던지는데, 동시 생성 트랜잭션이 아직 커밋되지 않은 타이밍이면 재조회가 비어 잘못된 404로 이어질 수 있습니다. 중복키 케이스는 짧은 재시도(예: 몇 회/짧은 backoff) 후 재조회하거나, 커밋 가시성 보장을 위해 별도 트랜잭션에서 재조회하는 등 ‘동시 생성 중’ 시나리오를 안정적으로 처리하는 방식을 고려해 주세요.

Suggested change
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 uses AI. Check for mistakes.
}
});
}

private List<ChatRoom> resolveOrCreateClubRooms(List<ClubMember> memberships) {
Map<Integer, Club> clubById = memberships.stream()
.map(ClubMember::getClub)
.collect(Collectors.toMap(Club::getId, club -> club, (a, b) -> a));

Map<Integer, ChatRoom> roomByClubId = chatRoomRepository.findByClubIds(new ArrayList<>(clubById.keySet()))
.stream()
.filter(room -> room.getClub() != null)
.collect(Collectors.toMap(room -> room.getClub().getId(), room -> room, (a, b) -> a));

for (Map.Entry<Integer, Club> clubEntry : clubById.entrySet()) {
if (roomByClubId.containsKey(clubEntry.getKey())) {
continue;
}
try {
ChatRoom createdRoom = chatRoomRepository.save(ChatRoom.groupOf(clubEntry.getValue()));
roomByClubId.put(clubEntry.getKey(), createdRoom);
} catch (DataIntegrityViolationException e) {
if (!isDuplicateKeyException(e)) {
throw e;
}
log.debug("클럽 채팅방 동시 생성 감지, 재조회: clubId={}", clubEntry.getKey());
chatRoomRepository.findByClubId(clubEntry.getKey())
.ifPresent(room -> roomByClubId.put(clubEntry.getKey(), room));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Comment on lines +179 to +194

Copilot AI Mar 30, 2026

Copy link

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 uses AI. Check for mistakes.

return memberships.stream()
.map(membership -> roomByClubId.get(membership.getClub().getId()))
.filter(Objects::nonNull)
.toList();
}

private void ensureMember(ChatRoom room, User user, LocalDateTime baseline) {
chatRoomMemberRepository.findByChatRoomIdAndUserId(room.getId(), user.getId())
.ifPresentOrElse(member -> {
LocalDateTime lastReadAt = member.getLastReadAt();
if (lastReadAt == null || lastReadAt.isBefore(baseline)) {
member.updateLastReadAt(baseline);
}
}, () -> chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline)));
}, () -> saveRoomMemberIgnoringDuplicate(room, user, baseline));
}

@Transactional
public void removeClubMember(Integer clubId, Integer userId) {
chatRoomRepository.findByClubId(clubId)
.ifPresent(room -> chatRoomMemberRepository.deleteByChatRoomIdAndUserId(room.getId(), userId));
private void saveRoomMemberIgnoringDuplicate(ChatRoom room, User user, LocalDateTime baseline) {
try {
chatRoomMemberRepository.save(ChatRoomMember.of(room, user, baseline));
} catch (DataIntegrityViolationException e) {
if (!isDuplicateKeyException(e)) {
throw e;
}
log.debug("채팅방 멤버 동시 생성 감지, 무시: roomId={}, userId={}", room.getId(), user.getId());
}
Comment on lines +213 to +220

Copilot AI Mar 30, 2026

Copy link

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한 방식으로 중복을 처리하는 쪽이 안전합니다.

Suggested change
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)));

Copilot uses AI. Check for mistakes.
}

private void ensureDirectRoomMemberExists(ChatRoom room, User user, LocalDateTime readAt) {
boolean exists = chatRoomMemberRepository.existsByChatRoomIdAndUserId(room.getId(), user.getId());
if (exists) {
return;
}

if (user.getRole() == UserRole.ADMIN && isSystemAdminRoom(room.getId())) {
saveRoomMemberIgnoringDuplicate(room, user, readAt);
return;
}

throw CustomException.of(FORBIDDEN_CHAT_ROOM_ACCESS);
}

private boolean isSystemAdminRoom(Integer roomId) {
List<Object[]> memberIds = chatRoomMemberRepository.findRoomMemberIdsByChatRoomIds(List.of(roomId));
return memberIds.stream()
.map(row -> (Integer)row[1])
.anyMatch(userId -> userId.equals(SYSTEM_ADMIN_ID));
}
Comment on lines +237 to 242

Copy link
Copy Markdown

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.


private boolean isDuplicateKeyException(DataIntegrityViolationException e) {
if (e instanceof DuplicateKeyException) {
return true;
}
Throwable rootCause = e.getRootCause();
if (rootCause == null) {
return false;
}
String message = rootCause.getMessage();
return message != null && (message.contains("Duplicate") || message.contains("duplicate key"));
Comment on lines +244 to +253

Copilot AI Mar 31, 2026

Copy link

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을 확인하는 방식처럼 구조화된 정보를 사용하는 쪽이 안전합니다.

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +253

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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:


🏁 Script executed:

# First, check file size and examine the method containing isDuplicateKeyException
wc -l src/main/java/gg/agit/konect/domain/chat/service/ChatRoomMembershipService.java

Repository: 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.java

Repository: 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.java

Repository: 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.java

Repository: 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 2

Repository: 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 -5

Repository: 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.java

Repository: 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 -20

Repository: 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.yml

Repository: BCSDLab/KONECT_BACK_END

Length of output: 4108


🏁 Script executed:

# Check prod/dev database config
cat src/main/resources/application-db.yml

Repository: 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.

}
}
Loading
Loading