Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -0,0 +1,30 @@
package com.semosan.api.common.exception;

import org.hibernate.exception.ConstraintViolationException;

import java.util.regex.Pattern;

public final class ConstraintViolationUtils {

private ConstraintViolationUtils() {}

public static boolean isViolation(Throwable exception, String constraintName) {
Pattern constraintNamePattern = Pattern.compile(
"(?<![A-Za-z0-9_])" + Pattern.quote(constraintName) + "(?![A-Za-z0-9_])"
);
Comment on lines +7 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[P3] 정규식 패턴 매번 컴파일하는 성능 비효율 개선

isViolation 메서드가 호출될 때마다 Pattern.compile을 사용하여 정규식 패턴을 매번 새로 생성하고 있습니다. 예외 처리 흐름이라 하더라도, 동시 요청이 많거나 예외가 빈번히 발생하는 상황에서는 CPU 자원을 불필요하게 소모할 수 있습니다.

제약 조건 이름(constraintName)별로 컴파일된 Pattern 객체를 캐싱하여 재사용하도록 개선하는 것을 권장합니다. ConcurrentHashMap을 사용해 간단히 캐싱할 수 있습니다.

public final class ConstraintViolationUtils {

    private static final java.util.Map<String, Pattern> PATTERN_CACHE = new java.util.concurrent.ConcurrentHashMap<>();

    private ConstraintViolationUtils() {}

    public static boolean isViolation(Throwable exception, String constraintName) {
        Pattern constraintNamePattern = PATTERN_CACHE.computeIfAbsent(constraintName, name ->
                Pattern.compile("(?<![A-Za-z0-9_])" + Pattern.quote(name) + "(?![A-Za-z0-9_])")
        );
References
  1. 우선순위 규칙에 따라 성능(Performance) 관련 개선 사항을 제안합니다. 정규식 패턴을 매번 컴파일하는 비효율을 캐싱을 통해 해결합니다. (link)

Throwable current = exception;
while (current != null) {
if (current instanceof ConstraintViolationException constraintViolation
&& constraintName.equals(constraintViolation.getConstraintName())) {
return true;
}
String message = current.getMessage();
// 드라이버마다 다른 예외 래핑에 대응하되, 다른 제약명 일부와 겹치는 오탐은 막는다.
if (message != null && constraintNamePattern.matcher(message).find()) {
return true;
}
current = current.getCause();
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.semosan.api.common.alert.DiscordAlertClient;
import com.semosan.api.common.alert.dto.DiscordEmbed;
import com.semosan.api.common.alert.dto.DiscordMessage;
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;
Expand All @@ -13,7 +14,6 @@
import com.semosan.api.domain.user.entity.User;
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;
Expand Down Expand Up @@ -61,7 +61,7 @@ public void afterCommit() {
});
return saved;
} catch (DataIntegrityViolationException e) {
if (isUniqueViolation(e)) {
if (ConstraintViolationUtils.isViolation(e, REPORT_UNIQUE_CONSTRAINT)) {
throw new GeneralException(ErrorStatus.FREE_POST_REPORT_ALREADY_EXISTS);
}
throw e;
Expand Down Expand Up @@ -93,22 +93,6 @@ private DiscordMessage buildDiscordMessage(FreePostReport report) {
);
}

private boolean isUniqueViolation(Throwable exception) {
Throwable current = exception;
while (current != null) {
if (current instanceof ConstraintViolationException constraintViolation
&& REPORT_UNIQUE_CONSTRAINT.equals(constraintViolation.getConstraintName())) {
return true;
}
String message = current.getMessage();
if (message != null && message.contains(REPORT_UNIQUE_CONSTRAINT)) {
return true;
}
current = current.getCause();
}
return false;
}

private User findReporterOrThrow(Long reporterId) {
return userRepository.findByIdAndDeletedFalse(reporterId)
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}