jpa를 사용할 때 update 로직은 dirty checking(영속성 컨텍스트 변경 감지)으로 편리하게 구현할 수 있다. 아래 코드를 참고하자.

@RequiredArgsConstructor
@Service
public class FacilityService {

    private final FacilityRepository facilityRepository;

		//...		

    @Transactional
    public void updateFacility(Long id, UpdateFacilityDto dto) {
        Facility findFacility = facilityRepository.findById(id)
                .orElseThrow(() -> new CommonException(BError.NOT_EXIST, "Facility"));
        findFacility.update(dto);
    }
		
		//...
}
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Facility extends BaseTime {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address address;

    public void update(UpdateFacilityDto dto) {
				this.address = dto.getAddress();
    }
}

Facility라는 entity에는 address 필드 하나만 존재한다. 그러나 개발이 진행될수록 필드는 추가될 수 있다. 그리고 필드가 추가될수록 update() 메서드에서 this.{fieldName} = dto.getFieldName() 를 같이 추가해야 한다. 만약 개발자의 실수로 이를 빠뜨렸다면 update 로직이 제대로 동작하지 않게 될 것이다.

그래서 이를 방지할 수 있는 해결 방안이 있다. mapstruct 라이브러리를 사용하는 것이다.

//...
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.ReportingPolicy;
import org.mapstruct.factory.Mappers;
import org.springframework.stereotype.Component;
//...

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

    FacilityMapper facilityMapper = Mappers.getMapper(FacilityMapper.class);

	  //...

    void update(UpdateFacilityDto updateRequest, @MappingTarget Facility facility);

    //...
}

mapstruct 라이브러리는 위와 같이 인터페이스만 만들어주면 애플리케이션 로딩 시점에 구현체가 만들어진다. 실제로 구현체에서는 setter를 이용하여 dto의 필드를 entity의 필드에 덮어준다. 이와 같은 방식으로 개발자가 일일이 필드를 update하는 코드를 수고를 덜어주고 실수도 방지할 수 있다.

그러나 이 방식은 entity의 setter를 열어줘야 한다는 단점이 있다. 이는 협업하는 개발자가 의도치 않은 방식으로 setter를 사용하게 될 위험이 있으므로 신중하게 고려하여 setter 사용을 결정해야 한다. 그래서 setter를 쓰지 않기 위해 Builder 패턴을 사용하자니 dirty checking을 쓸 수가 없다. 왜냐하면 Builder는 새로운 객체를 생성하는 것이므로 EntityManager의 merge()를 사용할 수 밖에 없기 때문이다. merge()를 사용하면 의도치 않게 기존 필드 값에 null 값을 저장하게 될 수 있으므로 update 로직으로 사용하기 부적합하다.

그래서 java의 reflection을 사용하는 것을 고려해보았다.

//...
import java.lang.reflect.Field;
import java.util.Objects;

public class EntityUtil {
		public static <T, S> void setValueExceptNull(T target, S source) {
		        try {
		            Class<?> targetClass = target.getClass();
		            Class<?> sourceClass = source.getClass();
		            for (Field sourceField : sourceClass.getDeclaredFields()) {
		                Field targetField = targetClass.getDeclaredField(sourceField.getName());
		                targetField.setAccessible(true);
		                Object fieldValue = sourceField.get(source);
										if (Objects.nonNull(fieldValue)) {
		                    targetField.set(target, fieldValue);
		                }
		            }
		        } catch (IllegalAccessException e) {
		            throw new CommonException(IError.FIELD_NOT_ALLOWED);
		        } catch (NoSuchFieldException e) {
		            throw new CommonException(IError.FIELD_NOT_EXIST);
		        }
		}
}
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Facility extends BaseTime {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address address;

		public void update(FacilityDto facilityDto) {
        EntityUtil.setValueExceptNull(this, facilityDto);
    }
}

위와 같이 setValueExceptNull() 메서드를 구현하면 모든 문제를 해결할 수 있다. 필드가 추가될 때마다 일일이 update 로직을 수정할 필요가 없고, setter를 사용하지 않으며 null 값을 덮어 쓰지 않는다.