들어가며
JPA를 사용하다 보면 DataIntegrityViolationException을 자주 만나게 된다. 하지만 이 예외는 여러 원인을 하나로 감싸고 있어서, 정확한 원인을 파악하고 적절히 처리하는 게 쉽지 않다.
이 글에서는 DataIntegrityViolationException의 원인별 분석, 예외 처리 전략, 그리고 실무에서 겪은 트러블슈팅 경험을 정리한다.
1. DataIntegrityViolationException이란?
1.1 예외 계층 구조
RuntimeException
└── NestedRuntimeException (Spring)
└── DataAccessException (Spring)
└── NonTransientDataAccessException
└── DataIntegrityViolationExceptionDataIntegrityViolationException은 Spring의 데이터 접근 예외 추상화 계층에 속한다. DB 벤더에 상관없이 일관된 예외로 변환해주는 역할을 한다.
1.2 언제 발생하나?
데이터 무결성 제약 조건을 위반했을 때 발생한다:
- Unique 제약 조건 위반: 중복된 값 삽입
- Foreign Key 제약 조건 위반: 존재하지 않는 FK 참조, 참조 중인 레코드 삭제
- Not Null 제약 조건 위반: null 허용 안 되는 컬럼에 null 삽입
- Check 제약 조건 위반: 컬럼 값 범위/조건 위반
- 데이터 타입/길이 위반: 컬럼 크기 초과
1.3 내부 구조
// DataIntegrityViolationException의 구조
class DataIntegrityViolationException(
msg: String,
cause: Throwable? // 실제 DB 예외가 여기에 있음
) : NonTransientDataAccessException(msg, cause)실제 원인은 cause에 감싸져 있다. 이걸 꺼내봐야 정확한 원인을 알 수 있다.
2. 원인별 분석
2.1 Unique 제약 조건 위반
가장 흔한 케이스다.
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(name = "uk_user_email", columnNames = ["email"])
]
)
class User(
@Column(nullable = false, unique = true)
val email: String,
@Column(nullable = false)
val name: String,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
}// 같은 이메일로 두 번 저장
userRepository.save(User(email = "test@test.com", name = "User1"))
userRepository.save(User(email = "test@test.com", name = "User2")) // 예외!에러 메시지 (MySQL)
DataIntegrityViolationException: could not execute statement;
SQL [n/a]; constraint [uk_user_email];
nested exception is org.hibernate.exception.ConstraintViolationException에러 메시지 (PostgreSQL)
DataIntegrityViolationException: could not execute statement;
SQL [n/a]; constraint [user_email_key];
nested exception is org.hibernate.exception.ConstraintViolationException2.2 Foreign Key 제약 조건 위반
존재하지 않는 FK 참조
@Entity
class Order(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
val user: User,
val amount: BigDecimal,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
}
// 존재하지 않는 User 참조
val fakeUser = User(email = "fake@test.com", name = "Fake").apply {
// id가 세팅되지 않은 상태
}
orderRepository.save(Order(user = fakeUser, amount = BigDecimal(1000)))참조 중인 레코드 삭제
// Order가 참조 중인 User 삭제 시도
val user = userRepository.findById(1L)
userRepository.delete(user) // Order에서 참조 중이면 예외!2.3 Not Null 제약 조건 위반
@Entity
class Product(
@Column(nullable = false)
val name: String,
@Column(nullable = false)
val price: BigDecimal,
)
// null 삽입 시도
// Kotlin에서는 컴파일 에러지만, Java나 리플렉션에서는 가능JPA/Hibernate 레벨에서 먼저 검증되기도 하지만, DB까지 가면 이 예외가 발생한다.
2.4 데이터 길이 초과
@Entity
class Post(
@Column(length = 100)
val title: String,
@Column(columnDefinition = "TEXT")
val content: String,
)
// 100자 초과
postRepository.save(Post(title = "a".repeat(200), content = "..."))에러 메시지 (MySQL)
DataIntegrityViolationException: could not execute statement;
SQL [n/a];
nested exception is org.hibernate.exception.DataException: Data truncation3. 원인 파악하기
3.1 Cause Chain 따라가기
fun getRootCause(e: DataIntegrityViolationException): Throwable {
var cause: Throwable? = e
while (cause?.cause != null && cause.cause != cause) {
cause = cause.cause
}
return cause ?: e
}3.2 Hibernate ConstraintViolationException
대부분의 경우 ConstraintViolationException이 감싸져 있다:
fun getConstraintName(e: DataIntegrityViolationException): String? {
val cause = e.cause
if (cause is ConstraintViolationException) {
return cause.constraintName // "uk_user_email" 등
}
return null
}3.3 SQL 상태 코드 확인
fun getSqlState(e: DataIntegrityViolationException): String? {
val cause = e.cause
if (cause is JDBCException) {
return cause.sqlException.sqlState
}
return null
}주요 SQL 상태 코드
| 코드 | 의미 |
|---|---|
| 23000 | 무결성 제약 위반 (일반) |
| 23001 | RESTRICT 위반 |
| 23502 | NOT NULL 위반 (PostgreSQL) |
| 23503 | FK 위반 (PostgreSQL) |
| 23505 | UNIQUE 위반 (PostgreSQL) |
| 23514 | CHECK 위반 (PostgreSQL) |
4. 예외 처리 전략
4.1 Global Exception Handler
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrityViolation(
e: DataIntegrityViolationException
): ResponseEntity<ErrorResponse> {
val constraintName = getConstraintName(e)
val errorResponse = when {
constraintName?.contains("email") == true -> {
ErrorResponse(
code = "DUPLICATE_EMAIL",
message = "이미 사용 중인 이메일입니다."
)
}
constraintName?.contains("phone") == true -> {
ErrorResponse(
code = "DUPLICATE_PHONE",
message = "이미 등록된 전화번호입니다."
)
}
else -> {
ErrorResponse(
code = "DATA_INTEGRITY_VIOLATION",
message = "데이터 무결성 오류가 발생했습니다."
)
}
}
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse)
}
private fun getConstraintName(e: DataIntegrityViolationException): String? {
val cause = e.cause
if (cause is ConstraintViolationException) {
return cause.constraintName?.lowercase()
}
return null
}
}
data class ErrorResponse(
val code: String,
val message: String,
)4.2 제약 조건 이름으로 분기
제약 조건 이름을 명확하게 지어두면 처리가 쉬워진다:
// Entity 정의 시 제약 조건 이름 명시
@Table(
uniqueConstraints = [
UniqueConstraint(name = "uk_user_email", columnNames = ["email"]),
UniqueConstraint(name = "uk_user_phone", columnNames = ["phone"]),
]
)
class User(...)// 처리
enum class ConstraintType(val pattern: String, val message: String) {
USER_EMAIL("uk_user_email", "이미 사용 중인 이메일입니다."),
USER_PHONE("uk_user_phone", "이미 등록된 전화번호입니다."),
ORDER_USER_FK("fk_order_user", "존재하지 않는 사용자입니다."),
;
companion object {
fun from(constraintName: String?): ConstraintType? {
return values().find {
constraintName?.lowercase()?.contains(it.pattern) == true
}
}
}
}
@ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrityViolation(e: DataIntegrityViolationException): ResponseEntity<ErrorResponse> {
val constraintName = getConstraintName(e)
val constraintType = ConstraintType.from(constraintName)
val errorResponse = if (constraintType != null) {
ErrorResponse(
code = constraintType.name,
message = constraintType.message
)
} else {
ErrorResponse(
code = "DATA_INTEGRITY_VIOLATION",
message = "데이터 처리 중 오류가 발생했습니다."
)
}
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse)
}4.3 Service 레벨에서 선처리
예외가 발생하기 전에 미리 체크하는 방법:
@Service
class UserService(
private val userRepository: UserRepository,
) {
@Transactional
fun createUser(request: CreateUserRequest): User {
// 미리 체크
if (userRepository.existsByEmail(request.email)) {
throw DuplicateEmailException(request.email)
}
if (userRepository.existsByPhone(request.phone)) {
throw DuplicatePhoneException(request.phone)
}
return userRepository.save(
User(email = request.email, phone = request.phone, name = request.name)
)
}
}
class DuplicateEmailException(email: String) :
BusinessException("EMAIL_DUPLICATE", "이미 사용 중인 이메일입니다: $email")
class DuplicatePhoneException(phone: String) :
BusinessException("PHONE_DUPLICATE", "이미 등록된 전화번호입니다: $phone")장단점
| 방식 | 장점 | 단점 |
|---|---|---|
| 선처리 (exists 체크) | 명확한 예외, 사용자 친화적 메시지 | 추가 쿼리, 동시성 이슈 가능 |
| 후처리 (예외 캐치) | 추가 쿼리 없음, DB 제약으로 확실한 보장 | 예외 파싱 필요, DB 벤더 의존적 |
실무에서는 둘 다 사용하는 경우가 많다:
- 일반적인 경우: 선처리로 친화적 메시지
- 동시 요청으로 선처리 통과 후 DB에서 잡힌 경우: 후처리로 커버
5. 동시성과 Race Condition
5.1 문제 상황
// 두 요청이 동시에 들어옴
// Request A: email = "test@test.com"
// Request B: email = "test@test.com"
// 시간순
// A: existsByEmail("test@test.com") → false
// B: existsByEmail("test@test.com") → false (A가 아직 커밋 안 함)
// A: save() → 성공
// B: save() → DataIntegrityViolationException!선처리만으로는 동시성 이슈를 막을 수 없다.
5.2 해결 방법
방법 1: DB 제약 + 예외 처리 (추천)
@Transactional
fun createUser(request: CreateUserRequest): User {
// 선처리 - 대부분의 중복은 여기서 걸림
if (userRepository.existsByEmail(request.email)) {
throw DuplicateEmailException(request.email)
}
return try {
userRepository.save(User(...))
} catch (e: DataIntegrityViolationException) {
// 동시 요청으로 인한 중복 - 후처리
throw DuplicateEmailException(request.email)
}
}방법 2: 비관적 락
interface UserRepository : JpaRepository<User, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.email = :email")
fun findByEmailForUpdate(email: String): User?
}하지만 이건 이메일 체크에는 적합하지 않다 (없는 레코드에 락을 걸 수 없음).
방법 3: 분산 락 (Redis 등)
@Transactional
fun createUser(request: CreateUserRequest): User {
val lockKey = "user:email:${request.email}"
return distributedLock.withLock(lockKey, timeout = 5.seconds) {
if (userRepository.existsByEmail(request.email)) {
throw DuplicateEmailException(request.email)
}
userRepository.save(User(...))
}
}6. 실무 트러블슈팅
6.1 Case 1: 테스트에서만 발생하는 예외
@Test
@Transactional
fun `중복 이메일 테스트`() {
userRepository.save(User(email = "test@test.com", name = "User1"))
assertThrows<DataIntegrityViolationException> {
userRepository.save(User(email = "test@test.com", name = "User2"))
}
// 테스트 실패! 예외가 안 터짐
}원인
@Transactional 테스트에서는 쿼리가 바로 DB에 반영되지 않는다. flush가 필요하다.
해결
@Test
@Transactional
fun `중복 이메일 테스트`() {
userRepository.save(User(email = "test@test.com", name = "User1"))
entityManager.flush() // 강제 flush
assertThrows<DataIntegrityViolationException> {
userRepository.save(User(email = "test@test.com", name = "User2"))
entityManager.flush() // 여기도 flush
}
}또는 saveAndFlush() 사용:
userRepository.saveAndFlush(User(email = "test@test.com", name = "User1"))6.2 Case 2: 예외가 트랜잭션 밖에서 발생
@Transactional
fun createUser(request: CreateUserRequest): User {
return userRepository.save(User(...))
// 여기서 예외가 안 터지고...
}
// Controller에서
fun createUser(@RequestBody request: CreateUserRequest): UserResponse {
val user = userService.createUser(request)
// 트랜잭션 커밋 시점에 예외 발생!
return UserResponse(user)
}원인
Hibernate는 기본적으로 트랜잭션 커밋 시점에 flush한다. 그래서 예외가 서비스 메서드가 아닌 트랜잭션 경계를 넘을 때 발생한다.
해결
명시적 flush 또는 saveAndFlush():
@Transactional
fun createUser(request: CreateUserRequest): User {
val user = userRepository.save(User(...))
entityManager.flush() // 여기서 예외 발생하도록
return user
}6.3 Case 3: Batch Insert에서 하나가 실패
@Transactional
fun createUsers(requests: List<CreateUserRequest>): List<User> {
return requests.map { request ->
userRepository.save(User(email = request.email, name = request.name))
}
// 10개 중 5번째가 중복이면? 전체 롤백
}해결: 개별 처리
data class CreateUserResult(
val success: List<User>,
val failed: List<FailedUser>,
)
data class FailedUser(
val email: String,
val reason: String,
)
fun createUsers(requests: List<CreateUserRequest>): CreateUserResult {
val success = mutableListOf<User>()
val failed = mutableListOf<FailedUser>()
requests.forEach { request ->
try {
val user = createUserInternal(request) // 개별 트랜잭션
success.add(user)
} catch (e: DuplicateEmailException) {
failed.add(FailedUser(request.email, e.message ?: "중복"))
}
}
return CreateUserResult(success, failed)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun createUserInternal(request: CreateUserRequest): User {
return userRepository.save(User(email = request.email, name = request.name))
}6.4 Case 4: FK 제약인데 메시지가 불친절
// 에러 메시지
// Cannot delete or update a parent row: a foreign key constraint fails
// (`db`.`order`, CONSTRAINT `fk_order_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`))해결: 미리 체크
@Transactional
fun deleteUser(userId: Long) {
// 참조 체크
if (orderRepository.existsByUserId(userId)) {
throw UserHasOrdersException(userId)
}
userRepository.deleteById(userId)
}
class UserHasOrdersException(userId: Long) :
BusinessException("USER_HAS_ORDERS", "주문 내역이 있는 사용자는 삭제할 수 없습니다.")7. DB 벤더별 차이
7.1 제약 조건 이름
| DB | Unique 제약 이름 형식 |
|---|---|
| MySQL | 인덱스 이름 그대로 (uk_user_email) |
| PostgreSQL | 테이블명_컬럼명_key (user_email_key) |
| H2 | 자동 생성 (CONSTRAINT_INDEX_4) |
7.2 처리 전략
@ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrityViolation(e: DataIntegrityViolationException): ResponseEntity<ErrorResponse> {
val constraintName = getConstraintName(e)?.lowercase()
// 여러 패턴 매칭
val errorResponse = when {
constraintName?.matches(Regex(".*(uk_user_email|user_email_key).*")) == true -> {
ErrorResponse("DUPLICATE_EMAIL", "이미 사용 중인 이메일입니다.")
}
// ...
else -> defaultErrorResponse(e)
}
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse)
}7.3 테스트 환경 주의
로컬에서 H2, 운영에서 MySQL/PostgreSQL을 쓴다면 제약 조건 이름이 다르다. 통합 테스트는 실제 DB와 같은 환경에서 하는 게 좋다.
// application-test.yml
spring:
datasource:
url: jdbc:tc:mysql:8.0:///test # Testcontainers8. Best Practices
8.1 제약 조건 이름 규칙
// Entity 정의 시 명확한 이름
@Table(
name = "users",
uniqueConstraints = [
UniqueConstraint(name = "uk_users_email", columnNames = ["email"]),
UniqueConstraint(name = "uk_users_phone", columnNames = ["phone"]),
],
indexes = [
Index(name = "idx_users_created_at", columnList = "created_at")
]
)
@Entity
class User(...)규칙:
- Unique:
uk_{테이블}_{컬럼} - Foreign Key:
fk_{테이블}_{참조테이블} - Index:
idx_{테이블}_{컬럼}
8.2 예외 처리 계층
DataIntegrityViolationException (Spring)
↓ 변환
BusinessException (도메인)
- DuplicateEmailException
- DuplicatePhoneException
- UserHasOrdersException
↓ 변환
ErrorResponse (API)
- code: "DUPLICATE_EMAIL"
- message: "이미 사용 중인 이메일입니다."8.3 로깅
@ExceptionHandler(DataIntegrityViolationException::class)
fun handleDataIntegrityViolation(e: DataIntegrityViolationException): ResponseEntity<ErrorResponse> {
// 원인 로깅 (디버깅용)
logger.warn("DataIntegrityViolation: constraint=${getConstraintName(e)}, cause=${e.cause?.message}")
// 사용자에게는 친화적 메시지
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse)
}9. 멱등성 처리와 Unique 제약
9.1 멱등성이란?
동일한 요청을 여러 번 보내도 결과가 같아야 한다. 결제, 주문 같은 중요한 작업에서 필수다.
클라이언트 → 서버 (요청)
← 타임아웃 (응답 못 받음)
클라이언트 → 서버 (재시도) ← 중복 처리 위험!9.2 Idempotency Key 패턴
클라이언트가 고유한 키를 생성해서 보내고, 서버는 이 키로 중복을 체크한다.
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(name = "uk_order_idempotency_key", columnNames = ["idempotency_key"])
]
)
class Order(
@Column(name = "idempotency_key", nullable = false, unique = true)
val idempotencyKey: String,
val userId: Long,
val amount: BigDecimal,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
}9.3 구현 전략
방법 1: 선처리 + 기존 결과 반환
@Service
class OrderService(
private val orderRepository: OrderRepository,
) {
@Transactional
fun createOrder(request: CreateOrderRequest): Order {
// 이미 처리된 요청인지 확인
val existingOrder = orderRepository.findByIdempotencyKey(request.idempotencyKey)
if (existingOrder != null) {
return existingOrder // 기존 결과 반환 (멱등성 보장)
}
return orderRepository.save(
Order(
idempotencyKey = request.idempotencyKey,
userId = request.userId,
amount = request.amount,
)
)
}
}방법 2: Upsert 패턴 (DB 제약 활용)
@Transactional
fun createOrder(request: CreateOrderRequest): Order {
return try {
orderRepository.save(
Order(
idempotencyKey = request.idempotencyKey,
userId = request.userId,
amount = request.amount,
)
)
} catch (e: DataIntegrityViolationException) {
// 중복 키 → 기존 데이터 반환
orderRepository.findByIdempotencyKey(request.idempotencyKey)
?: throw e // 다른 제약 조건 위반이면 재throw
}
}방법 3: INSERT IGNORE / ON CONFLICT (네이티브 쿼리)
interface OrderRepository : JpaRepository<Order, Long> {
// MySQL
@Modifying
@Query(
value = """
INSERT IGNORE INTO orders (idempotency_key, user_id, amount)
VALUES (:idempotencyKey, :userId, :amount)
""",
nativeQuery = true
)
fun insertIgnore(
@Param("idempotencyKey") idempotencyKey: String,
@Param("userId") userId: Long,
@Param("amount") amount: BigDecimal,
): Int // 0이면 이미 존재, 1이면 새로 생성
// PostgreSQL
@Modifying
@Query(
value = """
INSERT INTO orders (idempotency_key, user_id, amount)
VALUES (:idempotencyKey, :userId, :amount)
ON CONFLICT (idempotency_key) DO NOTHING
""",
nativeQuery = true
)
fun insertOnConflictDoNothing(...): Int
}@Transactional
fun createOrder(request: CreateOrderRequest): Order {
val inserted = orderRepository.insertIgnore(
idempotencyKey = request.idempotencyKey,
userId = request.userId,
amount = request.amount,
)
// 새로 생성이든 기존이든 조회해서 반환
return orderRepository.findByIdempotencyKey(request.idempotencyKey)!!
}9.4 비교
| 방식 | 장점 | 단점 |
|---|---|---|
| 선처리 (exists) | 명확한 로직 | 동시성 이슈, 추가 쿼리 |
| 예외 캐치 | DB 제약으로 확실한 보장 | 예외 처리 비용 |
| INSERT IGNORE | 원자적, 효율적 | DB 벤더 의존, 네이티브 쿼리 |
9.5 Idempotency Key 저장소 분리
주문과 멱등성 키를 분리하면 더 유연해진다:
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(name = "uk_idempotency_key", columnNames = ["key"])
]
)
class IdempotencyRecord(
@Column(name = "key", nullable = false, unique = true)
val key: String,
@Column(nullable = false)
val resourceType: String, // "ORDER", "PAYMENT" 등
@Column(nullable = false)
val resourceId: Long,
@Column(nullable = false)
val createdAt: LocalDateTime = LocalDateTime.now(),
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0
}@Transactional
fun createOrder(request: CreateOrderRequest): Order {
// 멱등성 체크
val existing = idempotencyRepository.findByKey(request.idempotencyKey)
if (existing != null) {
return orderRepository.findById(existing.resourceId).get()
}
// 주문 생성
val order = orderRepository.save(Order(...))
// 멱등성 키 저장 (실패하면 전체 롤백)
try {
idempotencyRepository.save(
IdempotencyRecord(
key = request.idempotencyKey,
resourceType = "ORDER",
resourceId = order.id,
)
)
} catch (e: DataIntegrityViolationException) {
// 동시 요청으로 인한 중복 → 기존 주문 반환
val record = idempotencyRepository.findByKey(request.idempotencyKey)!!
return orderRepository.findById(record.resourceId).get()
}
return order
}9.6 HTTP 응답 처리
멱등성 처리 시 HTTP 상태 코드도 고려해야 한다:
@PostMapping("/orders")
fun createOrder(
@RequestHeader("Idempotency-Key") idempotencyKey: String,
@RequestBody request: CreateOrderRequest,
): ResponseEntity<OrderResponse> {
val (order, isNew) = orderService.createOrderWithStatus(
request.copy(idempotencyKey = idempotencyKey)
)
return if (isNew) {
ResponseEntity.status(HttpStatus.CREATED).body(OrderResponse(order))
} else {
ResponseEntity.ok(OrderResponse(order)) // 이미 존재하면 200
}
}마치며
DataIntegrityViolationException은 단순한 예외가 아니다. 여러 원인이 하나로 감싸져 있고, DB 벤더마다 동작이 다르다.
핵심 정리:
- 원인 파악:
cause를 따라가서constraintName확인 - 제약 조건 이름: 규칙을 정하고 명시적으로 지정
- 이중 방어: 선처리(exists) + 후처리(예외 캐치)
- 멱등성: Unique 제약을 활용한 중복 요청 방어
- 동시성: DB 제약으로 최종 보장
- 테스트:
flush()또는saveAndFlush()사용 - 사용자 메시지: 기술적 에러를 친화적 메시지로 변환
결국 DB 제약 조건을 신뢰하되, 사용자에게는 친절하게 가 핵심이다.