[트러블 슈팅] MapStruct의 자동 매핑을 제대로 숙지하지 못한 죄

jkky98·2025년 3월 7일
0

ProjectSpring

목록 보기
16/20

문제

아래와 같은 ObjectOptimisticLockingFailureException 예외가 발생하였다.

org.springframework.orm.ObjectOptimisticLockingFailureException: 
Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): 
[com.github.jkky_98.noteJ.domain.Contact#2]

해당 예외가 발생한 서비스 계층의 코드는 다음과 같다.

@Transactional
public void addContact(ContactForm form, Optional<User> sessionUser) {
    Contact contact = sessionUser
            .map(user -> ContactMapper.INSTANCE.toContactByContactForm(form, userService.findUserById(user.getId())))
            .orElseGet(() -> ContactMapper.INSTANCE.toContactByContactForm(form, null));

    contactRepository.save(contact);
}

이 메서드는 두 가지 동작을 수행한다.

  1. sessionUser가 존재하는 경우 Contact 엔티티를 생성하여 저장
  2. sessionUser가 존재하지 않는 경우 Contact 엔티티를 생성하여 저장 (정상 동작)

그러나 1번 상황에서 원하지 않는 두 가지 현상이 발생하였다.

  1. ObjectOptimisticLockingFailureException 예외 발생
  2. 기존 Contact 엔티티가 업데이트되는 현상 확인

로그 및 발생 쿼리 살펴보기

2025-03-07T10:36:01.867+09:00  INFO 16799 --- [nio-8080-exec-3] c.g.j.noteJ.aop.ThreadLocalLogTrace      : [d571d444] |   |   |<--CrudRepository.findById(..) time=2ms
2025-03-07T10:36:01.867+09:00  INFO 16799 --- [nio-8080-exec-3] c.g.j.noteJ.aop.ThreadLocalLogTrace      : [d571d444] |   |<--UserService.findUserById(..) time=2ms
2025-03-07T10:36:01.868+09:00  INFO 16799 --- [nio-8080-exec-3] c.g.j.noteJ.aop.ThreadLocalLogTrace      : [d571d444] |   |-->CrudRepository.save(..)
Hibernate: 
    select
        c1_0.contact_id,
        c1_0.content,
        c1_0.create_by,
        c1_0.create_dt,
        c1_0.email,
        c1_0.last_modified_by,
        c1_0.last_modified_dt,
        c1_0.user_id 
    from
        contact c1_0 
    where
        c1_0.contact_id=?
Hibernate: 
    select
        c1_0.contact_id,
        c1_0.content,
        c1_0.create_by,
        c1_0.create_dt,
        c1_0.email,
        c1_0.last_modified_by,
        c1_0.last_modified_dt,
        c1_0.user_id 
    from
        contact c1_0 
    where
        c1_0.contact_id=?
2025-03-07T10:36:01.881+09:00  INFO 16799 --- [nio-8080-exec-3] c.g.j.noteJ.aop.ThreadLocalLogTrace      : [d571d444] |   |<X-CrudRepository.save(..) time=13ms ex=org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.github.jkky_98.noteJ.domain.Contact#2]
2025-03-07T10:36:01.882+09:00  INFO 16799 --- [nio-8080-exec-3] c.g.j.noteJ.aop.ThreadLocalLogTrace      : [d571d444] |<X-ContactService.addContact(..) time=19ms ex=org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.github.jkky_98.noteJ.domain.Contact#2]
2025-03-07T10:36:01.883+09:00  INFO 16799 --- [nio-8080-exec-3] c.g.j.noteJ.aop.ThreadLocalLogTrace      : [d571d444] ContactController.contact(..) time=22ms ex=org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.github.jkky_98.noteJ.domain.Contact#2]
2025-03-07T10:36:01.886+09:00  INFO 16799 --- [nio-8080-exec-3] c.g.j.noteJ.aop.ThreadLocalLogTrace      : [7459bf67] GlobalExceptionHandler.handleAllExceptions(..)
2025-03-07T10:36:01.886+09:00 ERROR 16799 --- [nio-8080-exec-3] c.g.j.n.e.h.GlobalExceptionHandler       : Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.github.jkky_98.noteJ.domain.Contact#2]

위 로그를 분석해 보면, CrudRepository.save(..) 호출 시 단순한 INSERT 또는 ID 생성용 SELECT가 아닌 엔티티 전체를 조회하는 SELECT 쿼리가 실행되었음을 확인할 수 있다.

이러한 쿼리는 보통 기존 엔티티를 업데이트할 때 발생하는데, 이는 id 값이 존재하여 JPA가 새 엔티티가 아닌 기존 엔티티의 업데이트로 인식했기 때문일 가능성이 높다.

따라서 id가 자동으로 채워졌을 가능성을 의심하였고, 매핑 로직을 확인하였다.


문제 원인

문제의 원인은 ContactMapper의 동작 방식에 있었다.

@Mapper(componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.IGNORE,
        unmappedSourcePolicy = ReportingPolicy.IGNORE
)
public interface ContactMapper {

    ContactMapper INSTANCE = Mappers.getMapper(ContactMapper.class);

    @Mapping(source = "form.email", target = "email")
    @Mapping(source = "form.content", target = "content")
    @Mapping(source = "user", target = "user")
    Contact toContactByContactForm(ContactForm form, User user);
}

여기서 id에 대한 매핑을 설정하지 않았음에도 불구하고 user가 들어왔을 때 Contactid 필드에 user.id 값이 자동으로 매핑되었다.

이는 MapStruct의 기본 동작으로, 같은 이름을 가진 필드는 자동으로 매핑되는 규칙 때문이다.
즉, ContactUser에 모두 id 필드가 존재하기 때문에 MapStruct가 user.idcontact.id에 자동으로 할당한 것이다.


해결

이 문제를 해결하기 위해서는 id 필드의 자동 매핑을 방지해야 한다.
MapStruct에서는 특정 필드의 매핑을 무시할 수 있도록 @Mapping(target = "id", ignore = true) 설정을 추가할 수 있다.

@Mapper(componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.IGNORE,
        unmappedSourcePolicy = ReportingPolicy.IGNORE
)
public interface ContactMapper {

    ContactMapper INSTANCE = Mappers.getMapper(ContactMapper.class);

    @Mapping(target = "id", ignore = true) // id 필드 자동 매핑 방지
    @Mapping(source = "form.email", target = "email")
    @Mapping(source = "form.content", target = "content")
    @Mapping(source = "user", target = "user")
    Contact toContactByContactForm(ContactForm form, User user);
}

이렇게 설정하면 Contactid 필드가 자동으로 user.id 값으로 매핑되지 않고, 새로운 Contact 객체를 생성할 때 id=null을 유지하게 된다.

이를 통해 JPA가 INSERT를 수행하도록 유도할 수 있으며, 기존 데이터의 UPDATE로 잘못 인식하는 문제를 방지할 수 있다.

착각했던 것: unmappedTargetPolicy = ReportingPolicy.IGNORE, unmappedSourcePolicy = ReportingPolicy.IGNORE는 자동 매핑을 막아줄 것이라 생각함

나는 MapStruct의 설정 중

unmappedTargetPolicy = ReportingPolicy.IGNORE,
unmappedSourcePolicy = ReportingPolicy.IGNORE

이 옵션이 매핑하지 않은 필드들을 무시해주는 역할을 하므로, 자동 매핑도 방지할 것이라 생각했다.
즉, 명시적으로 @Mapping을 설정하지 않으면 필드가 자동으로 매핑되지 않을 것이라 착각했다.

그러나 현실은 그렇지 않았다.
이 설정은 단순히 "매핑되지 않은 필드가 있어도 경고나 오류를 발생시키지 않음"을 의미할 뿐, 자동 매핑을 방지하는 기능은 없다.
결국 MapStruct는 여전히 같은 이름을 가진 필드를 자동으로 매핑하는 기본 동작을 수행했다.


🚨 자동 매핑을 막아주지 않은 이유

MapStruct의 자동 매핑 동작을 다시 한 번 살펴보면,

  • 같은 이름을 가진 필드는 기본적으로 자동 매핑된다.
  • unmappedTargetPolicy = ReportingPolicy.IGNORE매핑되지 않은 필드가 있어도 무시할 뿐, 자동 매핑을 막지 않는다.
  • unmappedSourcePolicy = ReportingPolicy.IGNORE 역시 소스 객체에 없는 필드가 있어도 그냥 넘어가는 역할을 할 뿐, 자동 매핑을 막지 않는다.

즉, 이 설정들은 개발자가 명시적으로 @Mapping을 지정하지 않은 필드에 대해 경고를 없애는 역할만 할 뿐,
자동 매핑 자체를 막아주는 것이 아니었다.

MapStruct의 자동 매핑

MapStruct는 기본적으로 소스 객체와 대상 객체의 필드명이 동일할 경우 자동으로 매핑하는 기능을 제공한다.
이 때문에 @Mapping 어노테이션을 사용하지 않더라도 같은 이름의 필드가 존재하면 자동으로 값이 매핑될 수 있다.

자동 매핑 동작 방식

  1. 같은 이름을 가진 필드는 자동 매핑
    • 예를 들어, ContactUserid 필드가 존재하면 user.idcontact.id로 자동 매핑됨.
  2. 명시적인 @Mapping이 없을 경우에도 자동 적용
    • @Mapping(source = "user", target = "user")을 설정하면, user 객체 내의 같은 이름을 가진 필드들도 자동으로 매핑될 수 있음.
  3. 자동 매핑을 방지하려면 @Mapping(target = "id", ignore = true)를 설정
    • id 필드의 자동 매핑을 막아야 할 경우, 이를 명시적으로 ignore = true로 설정해야 함.

결론

MapStruct의 자동 매핑 기능은 편리하지만, 의도치 않은 값 할당이 발생할 수 있다.
특히, 엔티티의 id 값이 잘못 매핑되면 데이터베이스의 INSERTUPDATE 동작이 예상과 다르게 수행될 수 있으므로 주의가 필요하다.
이를 방지하려면 필요한 필드만 명시적으로 매핑하고, 원치 않는 필드는 @Mapping(target = "id", ignore = true)로 무시하는 것이 안전하다.

profile
자바집사의 거북이 수련법

0개의 댓글

관련 채용 정보