From 7f26682a395744c736f491c4cfffcf0158800f92 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 2 Jun 2026 16:31:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20ConstraintViolationException=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=20=EB=A1=9C=EC=A7=81=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3곳에 중복된 예외 체인 탐색 로직을 ConstraintViolationUtils.isViolation()으로 추출. 버그 수정 시 한 곳만 고치면 되고, 제약 조건 이름 변경에도 누락 없이 대응 가능. Co-Authored-By: Claude Sonnet 4.6 --- .../exception/ConstraintViolationUtils.java | 29 +++++++++++++++++++ .../hiking/service/HikingRecordService.java | 21 ++------------ .../service/TrackingSessionService.java | 21 ++------------ .../domain/user/service/UserBlockService.java | 21 ++------------ 4 files changed, 38 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java diff --git a/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java b/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java new file mode 100644 index 00000000..0e339982 --- /dev/null +++ b/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java @@ -0,0 +1,29 @@ +package com.semosan.api.common.exception; + +import org.hibernate.exception.ConstraintViolationException; + +public final class ConstraintViolationUtils { + + private ConstraintViolationUtils() {} + + /** + * 예외 체인 전체를 탐색해 특정 DB 제약 조건 이름이 원인인지 확인한다. + * Hibernate ConstraintViolationException의 constraintName과 메시지 문자열을 모두 검사해 + * 드라이버마다 다른 예외 래핑 방식에 대응한다. + */ + public static boolean isViolation(Throwable exception, String constraintName) { + Throwable current = exception; + while (current != null) { + if (current instanceof ConstraintViolationException constraintViolation + && constraintName.equals(constraintViolation.getConstraintName())) { + return true; + } + String message = current.getMessage(); + if (message != null && message.contains(constraintName)) { + return true; + } + current = current.getCause(); + } + return false; + } +} diff --git a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java index ac948419..9a8200e5 100644 --- a/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java +++ b/src/main/java/com/semosan/api/domain/hiking/service/HikingRecordService.java @@ -19,10 +19,10 @@ import com.semosan.api.domain.tracking.repository.TrackingPhotoRepository; import com.semosan.api.domain.tracking.repository.TrackingPointRepository; import com.semosan.api.domain.tracking.repository.projection.TrackingPathProjection; +import com.semosan.api.common.exception.ConstraintViolationUtils; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; -import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -147,26 +147,11 @@ public CourseDifficultyFeedbackResponse createCourseDifficultyFeedback( try { return CourseDifficultyFeedbackResponse.from(courseDifficultyFeedbackRepository.saveAndFlush(feedback)); } catch (DataIntegrityViolationException e) { - if (isDifficultyFeedbackHikingRecordUniqueViolation(e)) { + // 동시 요청으로 유니크 제약 위반 시 이미 피드백이 존재하는 에러로 변환 + if (ConstraintViolationUtils.isViolation(e, DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT)) { throw new GeneralException(ErrorStatus.COURSE_DIFFICULTY_FEEDBACK_ALREADY_EXISTS); } throw e; } } - - private boolean isDifficultyFeedbackHikingRecordUniqueViolation(Throwable exception) { - Throwable current = exception; - while (current != null) { - if (current instanceof ConstraintViolationException constraintViolation - && DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) { - return true; - } - String message = current.getMessage(); - if (message != null && message.contains(DIFFICULTY_FEEDBACK_HIKING_RECORD_UNIQUE_CONSTRAINT)) { - return true; - } - current = current.getCause(); - } - return false; - } } diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index d088ed2e..b86b62d0 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -16,11 +16,11 @@ import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; import com.semosan.api.domain.tracking.event.TrackingSessionTerminatedEvent; import com.semosan.api.domain.tracking.repository.TrackingSessionRepository; +import com.semosan.api.common.exception.ConstraintViolationUtils; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserReader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.hibernate.exception.ConstraintViolationException; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -178,26 +178,11 @@ private TrackingSession saveSession(TrackingSession session) { try { return trackingSessionRepository.save(session); } catch (DataIntegrityViolationException e) { - if (isActiveSessionUniqueViolation(e)) { + // 동시 요청으로 유니크 제약 위반 시 이미 진행 중인 세션 존재 에러로 변환 + if (ConstraintViolationUtils.isViolation(e, ACTIVE_SESSION_UNIQUE_INDEX)) { throw new GeneralException(ErrorStatus.TRACKING_SESSION_ALREADY_IN_PROGRESS); } throw e; } } - - private boolean isActiveSessionUniqueViolation(Throwable exception) { - Throwable current = exception; - while (current != null) { - if (current instanceof ConstraintViolationException constraintViolation - && ACTIVE_SESSION_UNIQUE_INDEX.equals(constraintViolation.getConstraintName())) { - return true; - } - String message = current.getMessage(); - if (message != null && message.contains(ACTIVE_SESSION_UNIQUE_INDEX)) { - return true; - } - current = current.getCause(); - } - return false; - } } diff --git a/src/main/java/com/semosan/api/domain/user/service/UserBlockService.java b/src/main/java/com/semosan/api/domain/user/service/UserBlockService.java index a93ad651..caff515d 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserBlockService.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserBlockService.java @@ -1,5 +1,6 @@ package com.semosan.api.domain.user.service; +import com.semosan.api.common.exception.ConstraintViolationUtils; import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.community.post.entity.FreePost; @@ -9,7 +10,6 @@ import com.semosan.api.domain.user.repository.UserBlockRepository; import com.semosan.api.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; -import org.hibernate.exception.ConstraintViolationException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,7 +40,8 @@ public void block(Long blockerId, Long blockedUserId) { try { userBlockRepository.saveAndFlush(UserBlock.create(blocker, blockedUser)); } catch (DataIntegrityViolationException e) { - if (!isUniqueViolation(e)) { + // 동시 요청으로 유니크 제약 위반 시 멱등 처리 (이미 차단된 상태로 간주) + if (!ConstraintViolationUtils.isViolation(e, BLOCK_UNIQUE_CONSTRAINT)) { throw e; } } @@ -53,22 +54,6 @@ public void blockByPost(Long blockerId, Long postId) { block(blockerId, post.getAuthor().getId()); } - private boolean isUniqueViolation(Throwable exception) { - Throwable current = exception; - while (current != null) { - if (current instanceof ConstraintViolationException constraintViolation - && BLOCK_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) { - return true; - } - String message = current.getMessage(); - if (message != null && message.contains(BLOCK_UNIQUE_CONSTRAINT)) { - return true; - } - current = current.getCause(); - } - return false; - } - private User findActiveUserOrThrow(Long userId) { return userRepository.findByIdAndDeletedFalse(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); From 1bcd51a9857d66c5f2e72e49693c911b6ee1cb6e Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 2 Jun 2026 16:38:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=A0=9C=EC=95=BD=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=9D=B4=EB=A6=84=20=EB=A7=A4=EC=B9=AD=20=EC=98=A4?= =?UTF-8?q?=ED=83=90=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/ConstraintViolationUtils.java | 13 ++--- .../post/service/FreePostReportService.java | 20 +------ .../ConstraintViolationUtilsTest.java | 52 +++++++++++++++++++ 3 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 src/test/java/com/semosan/api/common/exception/ConstraintViolationUtilsTest.java diff --git a/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java b/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java index 0e339982..8a9599d1 100644 --- a/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java +++ b/src/main/java/com/semosan/api/common/exception/ConstraintViolationUtils.java @@ -2,16 +2,16 @@ import org.hibernate.exception.ConstraintViolationException; +import java.util.regex.Pattern; + public final class ConstraintViolationUtils { private ConstraintViolationUtils() {} - /** - * 예외 체인 전체를 탐색해 특정 DB 제약 조건 이름이 원인인지 확인한다. - * Hibernate ConstraintViolationException의 constraintName과 메시지 문자열을 모두 검사해 - * 드라이버마다 다른 예외 래핑 방식에 대응한다. - */ public static boolean isViolation(Throwable exception, String constraintName) { + Pattern constraintNamePattern = Pattern.compile( + "(? new GeneralException(ErrorStatus.USER_NOT_FOUND)); diff --git a/src/test/java/com/semosan/api/common/exception/ConstraintViolationUtilsTest.java b/src/test/java/com/semosan/api/common/exception/ConstraintViolationUtilsTest.java new file mode 100644 index 00000000..ac0d76cb --- /dev/null +++ b/src/test/java/com/semosan/api/common/exception/ConstraintViolationUtilsTest.java @@ -0,0 +1,52 @@ +package com.semosan.api.common.exception; + +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConstraintViolationUtilsTest { + + private static final String CONSTRAINT_NAME = "uk_user_blocks_blocker_blocked"; + + @Test + void isViolationReturnsTrueWhenHibernateConstraintNameMatches() { + ConstraintViolationException cause = new ConstraintViolationException( + "duplicate", + new SQLException("unique violation"), + CONSTRAINT_NAME + ); + + boolean result = ConstraintViolationUtils.isViolation( + new DataIntegrityViolationException("duplicate", cause), + CONSTRAINT_NAME + ); + + assertThat(result).isTrue(); + } + + @Test + void isViolationReturnsTrueWhenMessageContainsExactConstraintToken() { + DataIntegrityViolationException exception = new DataIntegrityViolationException( + "ERROR: duplicate key value violates unique constraint \"" + CONSTRAINT_NAME + "\"" + ); + + boolean result = ConstraintViolationUtils.isViolation(exception, CONSTRAINT_NAME); + + assertThat(result).isTrue(); + } + + @Test + void isViolationReturnsFalseWhenMessageContainsOnlyConstraintNamePrefix() { + DataIntegrityViolationException exception = new DataIntegrityViolationException( + "ERROR: duplicate key value violates unique constraint \"" + CONSTRAINT_NAME + "_idx\"" + ); + + boolean result = ConstraintViolationUtils.isViolation(exception, CONSTRAINT_NAME); + + assertThat(result).isFalse(); + } +}