변경감지는 JPA의 핵심 기능 중 하나로, 영속성 컨텍스트가 관리하는 엔티티의 변경사항을 자동으로 감지하여 데이터베이스에 반영하는 메커니즘이다.
// 엔티티 최초 로딩 시
User user = em.find(User.class, 1L);
영속성 컨텍스트는 엔티티를 최초 로딩할 때 해당 엔티티의 상태를 스냅샷으로 저장한다.
// 내부적으로 이런 구조
Map<EntityKey, Object> entityMap = new HashMap<>(); // 1차 캐시
Map<EntityKey, Object> loadedStateMap = new HashMap<>(); // 스냅샷
@Transactional
public void updateUser(Long userId, String newName) {
User user = userRepository.findById(userId).orElseThrow(); // 영속 상태
user.setName(newName); // 엔티티 변경
// 트랜잭션 커밋 시점에 자동으로 변경감지 실행
// em.flush() 호출 없이도 UPDATE 쿼리 실행됨
}
// 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));
}
}
}
병합은 준영속 상태의 엔티티를 영속 상태로 만들거나, 기존 영속 엔티티의 상태를 완전히 대체하는 작업이다.
@Transactional
public User updateUser(User detachedUser) {
User mergedUser = em.merge(detachedUser);
return mergedUser; // 새로운 영속 인스턴스 반환
}
// 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 실행
}
}
// 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;
}
}
@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 = ?
// 영속성 컨텍스트 내부 구조 (간소화)
class PersistenceContext {
Map<EntityKey, Object> entities; // 1차 캐시
Map<EntityKey, Object[]> loadedStates; // 스냅샷
List<EntityEntry> entityEntries; // 엔티티 상태 정보
}
메모리 사용량: 엔티티 + 스냅샷 (약 2배)
// merge 시 메모리 사용
T detachedEntity; // 전달받은 준영속 엔티티
T managedEntity; // 영속성 컨텍스트의 관리 엔티티
메모리 사용량: 일시적으로 2개 인스턴스 존재 (merge 완료 후 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 = ?
변경감지:
병합:
시나리오 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 사용
}
}
@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);
}
}
@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 변경이 모두 감지됨
}
}
@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(); // 영속성 컨텍스트 초기화
}
}
}
}
// ❌ 잘못된 예시: 준영속 엔티티
@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); // 변경감지 동작
}
}
// ❌ 잘못된 예시: 트랜잭션 없음
@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); // 변경감지 동작
}
}
@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"만 데이터베이스에 반영됨
}
}
// ❌ 위험한 예시: 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);
}
}
// ❌ 문제 있는 예시: 연관관계 엔티티 손실
@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);
}
}
// ❌ 성능 문제: 매번 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 쿼리만 실행
}
}
@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"); // 이제 반영됨
}
}