JPA 변경감지와 Merge

Agida·2025년 9월 13일

JPA

목록 보기
4/8
post-thumbnail

JPA 변경감지(Dirty Checking)와 병합(Merge)의 차이점


변경감지(Dirty Checking)

개념

변경감지는 JPA의 핵심 기능 중 하나로, 영속성 컨텍스트가 관리하는 엔티티의 변경사항을 자동으로 감지하여 데이터베이스에 반영하는 메커니즘이다.

상세 동작 원리

1. 스냅샷 저장

// 엔티티 최초 로딩 시
User user = em.find(User.class, 1L);

영속성 컨텍스트는 엔티티를 최초 로딩할 때 해당 엔티티의 상태를 스냅샷으로 저장한다.

// 내부적으로 이런 구조
Map<EntityKey, Object> entityMap = new HashMap<>(); // 1차 캐시
Map<EntityKey, Object> loadedStateMap = new HashMap<>(); // 스냅샷

2. 변경 감지 과정

@Transactional
public void updateUser(Long userId, String newName) {
    User user = userRepository.findById(userId).orElseThrow(); // 영속 상태
    user.setName(newName); // 엔티티 변경
    
    // 트랜잭션 커밋 시점에 자동으로 변경감지 실행
    // em.flush() 호출 없이도 UPDATE 쿼리 실행됨
}

3. flush() 시점에서의 처리

// JPA 내부 동작 (의사코드)
public void flush() {
    for (Entity entity : managedEntities) {
        Object[] currentState = extractState(entity);
        Object[] loadedState = getLoadedState(entity);
        
        if (!Arrays.equals(currentState, loadedState)) {
            generateUpdateQuery(entity, getDirtyFields(currentState, loadedState));
        }
    }
}

변경감지의 특징

장점

  • 자동화: 개발자가 명시적으로 update 메서드를 호출할 필요가 없다
  • 선택적 업데이트: 변경된 필드만 UPDATE 쿼리에 포함된다
  • 성능 최적화: 불필요한 필드 업데이트를 방지한다
  • 트랜잭션 안전성: 트랜잭션 범위 내에서만 동작한다

제약사항

  • 영속 상태에서만 동작: 준영속, 비영속 상태에서는 작동하지 않는다
  • 필드별 비교: 모든 필드를 스냅샷과 비교하므로 필드가 많으면 오버헤드가 발생한다

병합(Merge)

개념

병합은 준영속 상태의 엔티티를 영속 상태로 만들거나, 기존 영속 엔티티의 상태를 완전히 대체하는 작업이다.

상세 동작 원리

1. EntityManager.merge() 동작

@Transactional
public User updateUser(User detachedUser) {
    User mergedUser = em.merge(detachedUser);
    return mergedUser; // 새로운 영속 인스턴스 반환
}

2. Spring Data JPA save() 메서드의 merge 동작

// SimpleJpaRepository.save() 내부 구현
@Transactional
public <S extends T> S save(S entity) {
    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity); // 기존 엔티티가 있으면 merge 실행
    }
}

3. merge() 상세 과정

// JPA 내부 merge 동작 (의사코드)
public <T> T merge(T entity) {
    Object id = getId(entity);
    
    // 1. 1차 캐시에서 엔티티 조회
    T managedEntity = find(entityClass, id);
    
    if (managedEntity == null) {
        // 2. 데이터베이스에서 엔티티 조회
        managedEntity = loadFromDatabase(entityClass, id);
    }
    
    if (managedEntity == null) {
        // 3. 신규 엔티티인 경우 persist
        persist(entity);
        return entity;
    } else {
        // 4. 기존 엔티티의 모든 필드를 전달받은 엔티티로 교체
        copyAllFields(entity, managedEntity);
        return managedEntity;
    }
}

병합의 특징

장점

  • 준영속 엔티티 처리: 영속성 컨텍스트 밖에서 생성된 엔티티도 처리 가능하다
  • 신규/기존 자동 판별: 엔티티 존재 여부를 자동으로 판별하여 persist 또는 merge를 수행한다
  • 완전한 상태 교체: 엔티티의 모든 상태를 새로운 값으로 교체한다

단점

  • 전체 필드 업데이트: 변경되지 않은 필드도 모두 UPDATE 쿼리에 포함된다
  • 추가 SELECT 쿼리: 기존 엔티티 조회를 위한 SELECT 쿼리가 실행된다
  • 데이터 손실 위험: null 값이 설정된 필드도 업데이트되어 데이터가 손실될 수 있다

상세한 동작 원리 비교

1. SQL 쿼리 생성 방식

변경감지

@Entity
public class User {
    private Long id;
    private String name;
    private String email;
    private Integer age;
    // getter, setter
}

@Transactional
public void updateUserName(Long id, String newName) {
    User user = userRepository.findById(id).orElseThrow();
    user.setName(newName); // name 필드만 변경
}

생성되는 SQL:

-- 변경된 필드만 포함
UPDATE user SET name = ? WHERE id = ?

병합

@Transactional
public void updateUserByMerge(UserUpdateDto dto) {
    User user = new User();
    user.setId(dto.getId());
    user.setName(dto.getName());
    user.setEmail(dto.getEmail());
    // age 필드는 설정하지 않음 (null)
    
    userRepository.save(user); // merge 실행
}

생성되는 SQL:

-- 기존 엔티티 조회
SELECT id, name, email, age FROM user WHERE id = ?

-- 모든 필드 업데이트 (age는 null로 업데이트됨)
UPDATE user SET name = ?, email = ?, age = ? WHERE id = ?

2. 메모리 사용량 비교

변경감지 메모리 구조

// 영속성 컨텍스트 내부 구조 (간소화)
class PersistenceContext {
    Map<EntityKey, Object> entities;        // 1차 캐시
    Map<EntityKey, Object[]> loadedStates;  // 스냅샷
    List<EntityEntry> entityEntries;        // 엔티티 상태 정보
}

메모리 사용량: 엔티티 + 스냅샷 (약 2배)

병합 메모리 구조

// merge 시 메모리 사용
T detachedEntity;  // 전달받은 준영속 엔티티
T managedEntity;   // 영속성 컨텍스트의 관리 엔티티

메모리 사용량: 일시적으로 2개 인스턴스 존재 (merge 완료 후 1개)


성능 비교 분석

1. 쿼리 수행 성능

테스트 시나리오

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private String email;
    private String phone;
    private String address;
    private Integer age;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    // 총 8개 필드
}

성능 테스트 결과

변경감지 방식:

@Test
public void testDirtyCheckingPerformance() {
    // 1000번 반복 테스트
    long startTime = System.currentTimeMillis();
    
    for (int i = 0; i < 1000; i++) {
        User user = userRepository.findById((long) i).orElseThrow();
        user.setName("Updated Name " + i);
        // 트랜잭션 커밋 시 자동 업데이트
    }
    
    long endTime = System.currentTimeMillis();
    // 평균 실행시간: 약 800ms
}

생성되는 SQL (1회당):

-- 1. 조회 쿼리 (findById)
SELECT id, name, email, phone, address, age, created_at, updated_at FROM users WHERE id = ?

-- 2. 업데이트 쿼리 (변경된 필드만)
UPDATE users SET name = ? WHERE id = ?

병합 방식:

@Test
public void testMergePerformance() {
    // 1000번 반복 테스트
    long startTime = System.currentTimeMillis();
    
    for (int i = 0; i < 1000; i++) {
        User user = new User();
        user.setId((long) i);
        user.setName("Updated Name " + i);
        userRepository.save(user); // merge 실행
    }
    
    long endTime = System.currentTimeMillis();
    // 평균 실행시간: 약 1200ms
}

생성되는 SQL (1회당):

-- 1. 존재 여부 확인 쿼리
SELECT id FROM users WHERE id = ?

-- 2. 전체 데이터 조회 쿼리 
SELECT id, name, email, phone, address, age, created_at, updated_at FROM users WHERE id = ?

-- 3. 전체 필드 업데이트 쿼리
UPDATE users SET name = ?, email = ?, phone = ?, address = ?, age = ?, created_at = ?, updated_at = ? WHERE id = ?

2. 네트워크 트래픽 비교

변경감지:

  • 네트워크 전송량: 약 50% 적음
  • UPDATE 쿼리 크기가 작음

병합:

  • 네트워크 전송량: 100% (전체 필드)
  • 추가 SELECT 쿼리로 인한 왕복 통신 증가

실무

1. 상황별 선택 기준

변경감지를 사용해야 하는 경우

시나리오 1: 기존 데이터의 일부 필드 수정

@Service
@Transactional
public class UserService {
    
    // ✅ 권장: 변경감지 사용
    public void updateUserProfile(Long userId, String newName, String newEmail) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new EntityNotFoundException("User not found"));
            
        user.updateProfile(newName, newEmail); // 도메인 메서드 사용
        // repository.save() 호출 없이도 자동 업데이트
    }
}

시나리오 2: 복잡한 비즈니스 로직이 포함된 업데이트

@Service
@Transactional
public class OrderService {
    
    // ✅ 권장: 변경감지로 상태 변경 추적
    public void processOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        
        order.validateForProcessing();
        order.updateStatus(OrderStatus.PROCESSING);
        order.setProcessedAt(LocalDateTime.now());
        order.addProcessingHistory("Order processed by system");
        
        // 모든 변경사항이 자동으로 감지되어 업데이트됨
    }
}

병합을 사용해야 하는 경우

시나리오 1: 클라이언트에서 전달받은 완전한 데이터 세트

@RestController
public class UserController {
    
    @PutMapping("/users/{id}")
    public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody UserUpdateRequest request) {
        User user = User.builder()
            .id(id)
            .name(request.getName())
            .email(request.getEmail())
            .phone(request.getPhone())
            .address(request.getAddress())
            .build();
            
        // ✅ 권장: 완전한 데이터 교체를 위한 병합
        User savedUser = userRepository.save(user);
        return ResponseEntity.ok(savedUser);
    }
}

시나리오 2: 배치 작업에서의 대량 데이터 처리

@Service
public class DataMigrationService {
    
    @Transactional
    public void migrateUserData(List<UserMigrationDto> migrationData) {
        List<User> users = migrationData.stream()
            .map(dto -> User.builder()
                .id(dto.getId())
                .name(dto.getName())
                .email(dto.getEmail())
                .build())
            .collect(toList());
            
        // ✅ 권장: 대량 데이터의 일괄 저장/업데이트
        userRepository.saveAll(users); // 내부적으로 merge 사용
    }
}

2. 성능 최적화 전략

변경감지 최적화

@Entity
public class User {
    // ✅ 권장: 필드별 업데이트 메서드 제공
    public void updateName(String name) {
        this.name = name;
    }
    
    public void updateEmail(String email) {
        this.email = email;
    }
    
    // ✅ 권장: 비즈니스 메서드로 여러 필드 동시 변경
    public void updateProfile(String name, String email, String phone) {
        this.name = name;
        this.email = email;
        this.phone = phone;
    }
}

병합 최적화

@Service
public class OptimizedUserService {
    
    // ✅ 권장: 병합 전 기존 데이터 조회로 null 방지
    @Transactional
    public User safeUpdateUser(Long id, UserUpdateDto dto) {
        User existingUser = userRepository.findById(id).orElseThrow();
        
        User updatedUser = User.builder()
            .id(id)
            .name(dto.getName() != null ? dto.getName() : existingUser.getName())
            .email(dto.getEmail() != null ? dto.getEmail() : existingUser.getEmail())
            .phone(dto.getPhone() != null ? dto.getPhone() : existingUser.getPhone())
            .createdAt(existingUser.getCreatedAt()) // 기존 값 유지
            .build();
            
        return userRepository.save(updatedUser);
    }
}

코드

1. 변경감지

기본

@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private BigDecimal price;
    private Integer stockQuantity;
    private LocalDateTime lastModified;
    
    // 비즈니스 메서드
    public void updatePrice(BigDecimal newPrice) {
        this.price = newPrice;
        this.lastModified = LocalDateTime.now();
    }
    
    public void adjustStock(int quantity) {
        this.stockQuantity += quantity;
        this.lastModified = LocalDateTime.now();
    }
}

@Service
@Transactional
public class ProductService {
    
    @Autowired
    private ProductRepository productRepository;
    
    // 변경감지를 활용한 가격 업데이트
    public void updateProductPrice(Long productId, BigDecimal newPrice) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new EntityNotFoundException("Product not found"));
            
        product.updatePrice(newPrice);
        // save() 호출 없이도 UPDATE 쿼리 실행됨
    }
    
    // 여러 필드 동시 변경
    public void updateProductInfo(Long productId, String newName, BigDecimal newPrice) {
        Product product = productRepository.findById(productId).orElseThrow();
        
        product.setName(newName);
        product.updatePrice(newPrice);
        // name, price, lastModified 필드만 UPDATE 쿼리에 포함됨
    }
}

연관관계에서의 변경감지

@Entity
public class Order {
    @Id
    @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    private OrderStatus status;
    private BigDecimal totalAmount;
    
    // 주문 항목 추가 시 총액 자동 계산
    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
        calculateTotalAmount(); // 총액 재계산
    }
    
    private void calculateTotalAmount() {
        this.totalAmount = orderItems.stream()
            .map(OrderItem::getSubtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    
    public void completeOrder() {
        this.status = OrderStatus.COMPLETED;
        this.completedAt = LocalDateTime.now();
    }
}

@Service
@Transactional
public class OrderService {
    
    public void addItemToOrder(Long orderId, Long productId, int quantity) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        Product product = productRepository.findById(productId).orElseThrow();
        
        OrderItem orderItem = new OrderItem(product, quantity);
        order.addOrderItem(orderItem);
        
        // order와 orderItem 모두 변경감지로 자동 업데이트됨
        // orderItems 컬렉션 변경과 totalAmount 변경이 모두 감지됨
    }
}

2. 병합

기본

@RestController
@RequestMapping("/api/users")
public class UserRestController {
    
    @Autowired
    private UserService userService;
    
    // 전체 사용자 정보 업데이트 (PUT)
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> updateUser(
            @PathVariable Long id, 
            @RequestBody @Valid UserUpdateRequest request) {
        
        // DTO를 엔티티로 변환 (준영속 상태)
        User user = User.builder()
            .id(id)
            .name(request.getName())
            .email(request.getEmail())
            .phone(request.getPhone())
            .address(request.getAddress())
            .build();
            
        // 병합을 통한 업데이트
        User updatedUser = userService.updateUser(user);
        
        return ResponseEntity.ok(UserResponse.from(updatedUser));
    }
}

@Service
public class UserService {
    
    @Transactional
    public User updateUser(User user) {
        // save() 메서드가 내부적으로 merge() 실행
        return userRepository.save(user);
    }
}

안전한 병합

@Service
public class SafeUserService {
    
    @Transactional
    public User safeUpdateUser(Long id, UserUpdateDto dto) {
        // 1. 기존 데이터 조회
        User existingUser = userRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("User not found with id: " + id));
        
        // 2. 선택적 필드 업데이트로 병합 준비
        User.UserBuilder builder = User.builder()
            .id(id)
            .createdAt(existingUser.getCreatedAt()) // 생성일은 항상 기존값 유지
            .updatedAt(LocalDateTime.now()); // 수정일은 현재 시간
        
        // 3. null이 아닌 값만 업데이트
        builder.name(dto.getName() != null ? dto.getName() : existingUser.getName());
        builder.email(dto.getEmail() != null ? dto.getEmail() : existingUser.getEmail());
        builder.phone(dto.getPhone() != null ? dto.getPhone() : existingUser.getPhone());
        builder.address(dto.getAddress() != null ? dto.getAddress() : existingUser.getAddress());
        
        User userToUpdate = builder.build();
        
        // 4. 병합 실행
        return userRepository.save(userToUpdate);
    }
}

배치 처리에서의 병합

@Service
public class BatchUserService {
    
    @Transactional
    public void batchUpdateUsers(List<UserBatchUpdateDto> batchData) {
        List<User> usersToUpdate = new ArrayList<>();
        
        for (UserBatchUpdateDto dto : batchData) {
            User user = User.builder()
                .id(dto.getId())
                .name(dto.getName())
                .email(dto.getEmail())
                .phone(dto.getPhone())
                .address(dto.getAddress())
                .updatedAt(LocalDateTime.now())
                .build();
                
            usersToUpdate.add(user);
        }
        
        // 배치 사이즈 설정으로 성능 최적화
        for (int i = 0; i < usersToUpdate.size(); i += 100) {
            int endIndex = Math.min(i + 100, usersToUpdate.size());
            List<User> batch = usersToUpdate.subList(i, endIndex);
            
            userRepository.saveAll(batch); // 내부적으로 merge 사용
            
            if (i % 100 == 0) {
                entityManager.flush(); // 배치 단위로 flush
                entityManager.clear(); // 영속성 컨텍스트 초기화
            }
        }
    }
}

주의사항 및 함정

1. 변경감지 사용 시 주의사항

함정 1: 준영속 엔티티에서 변경감지 시도

// ❌ 잘못된 예시: 준영속 엔티티
@Service
public class WrongUserService {
    
    @Transactional
    public void updateUserName(Long id, String newName) {
        User user = new User(); // 새로운 인스턴스 생성 (비영속)
        user.setId(id);
        user.setName(newName);
        
        // 이렇게 해도 UPDATE 쿼리가 실행되지 않음!
        // 변경감지는 영속 상태의 엔티티에서만 동작
    }
}

// ✅ 올바른 예시: 영속 엔티티
@Service
public class CorrectUserService {
    
    @Transactional
    public void updateUserName(Long id, String newName) {
        User user = userRepository.findById(id).orElseThrow(); // 영속 상태
        user.setName(newName); // 변경감지 동작
    }
}

함정 2: 트랜잭션 범위 밖에서의 변경감지 시도

// ❌ 잘못된 예시: 트랜잭션 없음
@Service
public class NonTransactionalService {
    
    // @Transactional 어노테이션이 없음
    public void updateUserName(Long id, String newName) {
        User user = userRepository.findById(id).orElseThrow();
        user.setName(newName); // 변경감지가 동작하지 않음
        
        // 트랜잭션이 없어 flush()가 실행되지 않음
    }
}

// ✅ 올바른 예시: 트랜잭션 적용
@Service
public class TransactionalService {
    
    @Transactional // 필수!
    public void updateUserName(Long id, String newName) {
        User user = userRepository.findById(id).orElseThrow();
        user.setName(newName); // 변경감지 동작
    }
}

함정 3: 동일한 트랜잭션에서 여러번 조회 시 캐시된 엔티티

@Service
@Transactional
public class CacheAwareService {
    
    public void updateUserTwice(Long id) {
        User user1 = userRepository.findById(id).orElseThrow();
        user1.setName("First Update");
        
        User user2 = userRepository.findById(id).orElseThrow();
        // user1과 user2는 같은 인스턴스! (1차 캐시에서 반환)
        
        user2.setName("Second Update");
        // 최종적으로 "Second Update"만 데이터베이스에 반영됨
    }
}

2. 병합 사용 시 주의사항

함정 1: null 값에 의한 데이터 손실

// ❌ 위험한 예시: null 값으로 인한 데이터 손실
@RestController
public class DangerousController {
    
    @PutMapping("/users/{id}")
    public User updateUser(@PathVariable Long id, @RequestBody UserPartialUpdateDto dto) {
        User user = new User();
        user.setId(id);
        user.setName(dto.getName()); // dto.getName()이 null이면 기존 name이 null로 업데이트됨
        
        return userRepository.save(user); // 다른 필드들도 모두 null로 업데이트됨!
    }
}

// ✅ 안전한 예시: 기존 데이터 보존
@RestController
public class SafeController {
    
    @PutMapping("/users/{id}")
    public User updateUser(@PathVariable Long id, @RequestBody UserPartialUpdateDto dto) {
        User existingUser = userRepository.findById(id).orElseThrow();
        
        // 기존값을 기본값으로 사용
        User user = User.builder()
            .id(id)
            .name(dto.getName() != null ? dto.getName() : existingUser.getName())
            .email(dto.getEmail() != null ? dto.getEmail() : existingUser.getEmail())
            .phone(dto.getPhone() != null ? dto.getPhone() : existingUser.getPhone())
            .createdAt(existingUser.getCreatedAt())
            .build();
            
        return userRepository.save(user);
    }
}

함정 2: 연관관계 엔티티의 예상치 못한 동작

// ❌ 문제 있는 예시: 연관관계 엔티티 손실
@Entity
public class User {
    @Id
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Order> orders = new ArrayList<>();
}

@Service
public class ProblematicService {
    
    @Transactional
    public User updateUser(UserDto dto) {
        User user = new User();
        user.setId(dto.getId());
        user.setName(dto.getName());
        // orders 리스트를 설정하지 않음 (빈 리스트)
        
        return userRepository.save(user); // 기존 주문들이 모두 삭제될 수 있음!
    }
}

// ✅ 올바른 예시: 연관관계 보존
@Service
public class SafeService {
    
    @Transactional
    public User updateUserNameOnly(Long id, String newName) {
        // 변경감지 사용으로 연관관계 보존
        User user = userRepository.findById(id).orElseThrow();
        user.setName(newName);
        return user;
    }
    
    @Transactional
    public User updateUserWithMerge(UserDto dto) {
        User existingUser = userRepository.findById(dto.getId()).orElseThrow();
        
        User user = new User();
        user.setId(dto.getId());
        user.setName(dto.getName());
        user.setOrders(existingUser.getOrders()); // 기존 연관관계 보존
        
        return userRepository.save(user);
    }
}

함정 3: 성능 문제 - 불필요한 SELECT 쿼리

// ❌ 성능 문제: 매번 SELECT 쿼리 실행
@Service
public class PerformanceIssueService {
    
    @Transactional
    public void batchUpdateUsers(List<UserDto> users) {
        for (UserDto dto : users) {
            User user = new User();
            user.setId(dto.getId());
            user.setName(dto.getName());
            user.setEmail(dto.getEmail());
            
            userRepository.save(user); // 매번 SELECT + UPDATE 쿼리 실행
        }
        // 1000건 처리 시 2000개의 쿼리 실행!
    }
}

// ✅ 성능 최적화: 배치 처리 + 변경감지
@Service
public class OptimizedService {
    
    @Transactional
    public void batchUpdateUsers(List<UserDto> users) {
        List<Long> userIds = users.stream()
            .map(UserDto::getId)
            .collect(toList());
            
        // 한 번에 모든 사용자 조회
        List<User> existingUsers = userRepository.findAllById(userIds);
        Map<Long, User> userMap = existingUsers.stream()
            .collect(toMap(User::getId, Function.identity()));
            
        // 변경감지로 업데이트
        for (UserDto dto : users) {
            User user = userMap.get(dto.getId());
            if (user != null) {
                user.setName(dto.getName());
                user.setEmail(dto.getEmail());
            }
        }
        // 1000건 처리 시 1개의 SELECT + N개의 UPDATE 쿼리만 실행
    }
}

3. 공통 주의사항

트랜잭션 전파와 영속성 컨텍스트

@Service
public class TransactionPropagationService {
    
    @Transactional
    public void parentMethod() {
        User user = userRepository.findById(1L).orElseThrow(); // 영속 상태
        user.setName("Changed in parent");
        
        childService.childMethod(user);
        
        user.setEmail("changed@example.com"); // 이 변경사항도 반영됨
    }
}

@Service
public class ChildService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void childMethod(User user) {
        // 새로운 트랜잭션 시작 - 새로운 영속성 컨텍스트
        // user는 이 컨텍스트에서는 준영속 상태
        user.setPhone("010-1234-5678"); // 이 변경사항은 반영되지 않음!
        
        // 명시적으로 merge 해야 함
        User managedUser = entityManager.merge(user);
        managedUser.setPhone("010-1234-5678"); // 이제 반영됨
    }
}

정리

실무 권장

  • 기본적으로 변경감지를 우선 고려한다
  • 클라이언트 요청 처리 시에는 병합을 신중하게 사용한다
  • null 값 처리를 항상 고려한다
  • 성능 테스트를 통해 실제 환경에서 검증한다
  • 비즈니스 로직은 도메인 메서드로 캡슐화한다
profile
백엔드

0개의 댓글