JPA DataIntegrityViolationException 완벽 가이드

2024년 12월 08일

spring

# Spring# JPA# Exception# Kotlin# 실무

들어가며

JPA를 사용하다 보면 DataIntegrityViolationException을 자주 만나게 된다. 하지만 이 예외는 여러 원인을 하나로 감싸고 있어서, 정확한 원인을 파악하고 적절히 처리하는 게 쉽지 않다.

이 글에서는 DataIntegrityViolationException의 원인별 분석, 예외 처리 전략, 그리고 실무에서 겪은 트러블슈팅 경험을 정리한다.


1. DataIntegrityViolationException이란?

1.1 예외 계층 구조

RuntimeException
└── NestedRuntimeException (Spring)
    └── DataAccessException (Spring)
        └── NonTransientDataAccessException
            └── DataIntegrityViolationException

DataIntegrityViolationException은 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.ConstraintViolationException

2.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 truncation

3. 원인 파악하기

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  # Testcontainers

8. 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 벤더마다 동작이 다르다.

핵심 정리:

  1. 원인 파악: cause를 따라가서 constraintName 확인
  2. 제약 조건 이름: 규칙을 정하고 명시적으로 지정
  3. 이중 방어: 선처리(exists) + 후처리(예외 캐치)
  4. 멱등성: Unique 제약을 활용한 중복 요청 방어
  5. 동시성: DB 제약으로 최종 보장
  6. 테스트: flush() 또는 saveAndFlush() 사용
  7. 사용자 메시지: 기술적 에러를 친화적 메시지로 변환

결국 DB 제약 조건을 신뢰하되, 사용자에게는 친절하게 가 핵심이다.


참고자료

© 2025, 미나리와 함께 만들었음