Spring/JPA 심화

JPA 영속성 관리 (3편) : 영속성 컨텍스트와 엔티티 생명주기 관리

JuNo_12 2025. 6. 27. 14:08

전체 시리즈 목차

  • 1편: N+1 문제 해결과 Fetch Join 전략
  • 2편: OSIV 패턴과 API 설계 전략
  • 3편: 영속성 컨텍스트와 엔티티 생명주기 관리 ← 현재
  • 4편: 대용량 데이터 처리와 성능 최적화

1. 영속성 컨텍스트의 핵심 원리

같은 트랜잭션 내에서의 동일성 보장

JPA의 가장 중요한 원칙 중 하나는 같은 트랜잭션 안에서는 항상 같은 영속성 컨텍스트에 접근한다는 것입니다.

@Service
@Transactional
public class TodoService {
    
    private final TodoRepository todoRepository;
    
    public void demonstratePersistenceContext(Long todoId) {
        // 첫 번째 조회
        Todo todo1 = todoRepository.findById(todoId).get();
        System.out.println("첫 번째 조회 완료");
        
        // 두 번째 조회 - DB에 쿼리하지 않고 1차 캐시에서 반환
        Todo todo2 = todoRepository.findById(todoId).get();
        System.out.println("두 번째 조회 완료");
        
        // 동일성 검증
        System.out.println("동일한 객체인가? " + (todo1 == todo2)); // true
        System.out.println("동일한 해시코드? " + (todo1.hashCode() == todo2.hashCode())); // true
        
        // 한 객체를 수정하면 다른 참조도 자동으로 변경됨
        todo1.updateTitle("새로운 제목");
        System.out.println("todo2의 제목: " + todo2.getTitle()); // "새로운 제목"
    }
}

 

영속성 컨텍스트 동작 시각화

변경 감지(Dirty Checking) 활용

@Service
@Transactional
public class TodoService {
    
    public void updateTodoWithDirtyChecking(Long todoId, String newTitle, String newContents) {
        // 엔티티 조회
        Todo todo = todoRepository.findById(todoId)
            .orElseThrow(() -> new InvalidRequestException("Todo not found"));
        
        // 엔티티 상태 변경 (setter 호출만)
        todo.updateTitle(newTitle);
        todo.updateContents(newContents);
        
        // save() 호출 없이도 트랜잭션 커밋 시 자동으로 UPDATE 쿼리 실행
        // 이것이 변경 감지(Dirty Checking)!
    }
    
    // 잘못된 예: 불필요한 save() 호출
    public void updateTodoWrongWay(Long todoId, String newTitle) {
        Todo todo = todoRepository.findById(todoId).get();
        todo.updateTitle(newTitle);
        todoRepository.save(todo); // 불필요한 코드!
    }
}

2. 컬렉션 매핑 최적화

컬렉션 종류별 특징과 활용법

@Entity
public class Todo {
    
    // List: 순서 보장, 중복 허용, 인덱스 접근 가능
    @OneToMany(mappedBy = "todo", cascade = CascadeType.ALL)
    @OrderBy("createdAt DESC") // 생성일 역순 정렬
    private List<Comment> comments = new ArrayList<>();
    
    // Set: 중복 불허, 순서 보장 안됨, 고유성 필요시 사용
    @OneToMany(mappedBy = "todo", cascade = CascadeType.ALL)
    private Set<Manager> managers = new HashSet<>();
    
    // LinkedHashSet: 중복 불허 + 삽입 순서 보장
    @OneToMany(mappedBy = "todo")
    private Set<Tag> tags = new LinkedHashSet<>();
}

 

@OrderBy 고급 활용

@Entity
public class Todo {
    
    // 단일 필드 정렬
    @OneToMany(mappedBy = "todo")
    @OrderBy("createdAt DESC")
    private List<Comment> recentComments = new ArrayList<>();
    
    // 복합 정렬
    @OneToMany(mappedBy = "todo")
    @OrderBy("priority ASC, createdAt DESC")
    private List<Comment> prioritizedComments = new ArrayList<>();
    
    // 연관 엔티티 필드로 정렬
    @OneToMany(mappedBy = "todo")
    @OrderBy("user.userName ASC, createdAt DESC")
    private List<Comment> commentsByUser = new ArrayList<>();
}

 

생성되는 SQL:

-- @OrderBy("priority ASC, createdAt DESC") 적용 시
SELECT c.* FROM comments c 
WHERE c.todo_id = ? 
ORDER BY c.priority ASC, c.created_at DESC;

-- @OrderBy("user.userName ASC, createdAt DESC") 적용 시  
SELECT c.*, u.user_name FROM comments c 
LEFT JOIN users u ON c.user_id = u.id
WHERE c.todo_id = ? 
ORDER BY u.user_name ASC, c.created_at DESC;

 

@Converter 실무 활용

// Boolean을 Y/N으로 저장하는 컨버터
@Converter
public class BooleanToYNConverter implements AttributeConverter<Boolean, String> {
    
    @Override
    public String convertToDatabaseColumn(Boolean attribute) {
        return Boolean.TRUE.equals(attribute) ? "Y" : "N";
    }
    
    @Override
    public Boolean convertToEntityAttribute(String dbData) {
        return "Y".equals(dbData);
    }
}

// Enum을 코드로 저장하는 컨버터
@Converter
public class UserRoleConverter implements AttributeConverter<UserRole, String> {
    
    @Override
    public String convertToDatabaseColumn(UserRole attribute) {
        return attribute != null ? attribute.getCode() : null;
    }
    
    @Override
    public UserRole convertToEntityAttribute(String dbData) {
        return UserRole.fromCode(dbData);
    }
}

// JSON 객체를 문자열로 저장하는 컨버터
@Converter
public class JsonConverter implements AttributeConverter<Map<String, Object>, String> {
    
    private final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public String convertToDatabaseColumn(Map<String, Object> attribute) {
        try {
            return attribute != null ? objectMapper.writeValueAsString(attribute) : null;
        } catch (JsonProcessingException e) {
            throw new RuntimeException("JSON 변환 실패", e);
        }
    }
    
    @Override
    public Map<String, Object> convertToEntityAttribute(String dbData) {
        try {
            return dbData != null ? objectMapper.readValue(dbData, Map.class) : null;
        } catch (JsonProcessingException e) {
            throw new RuntimeException("JSON 파싱 실패", e);
        }
    }
}

// 엔티티에서 컨버터 사용
@Entity
public class User {
    
    @Convert(converter = BooleanToYNConverter.class)
    private Boolean isActive; // DB에는 Y/N으로 저장
    
    @Convert(converter = UserRoleConverter.class)
    private UserRole userRole; // DB에는 코드로 저장
    
    @Convert(converter = JsonConverter.class)
    @Column(columnDefinition = "TEXT")
    private Map<String, Object> preferences; // DB에는 JSON 문자열로 저장
}

3. 엔티티 생명주기 이벤트 처리

생명주기 이벤트 처리 방법 비교

엔티티에 직접 적용 (비추천)

@Entity
public class Todo {
    
    @PrePersist
    public void prePersist() {
        log.info("Todo 저장 전: {}", this.getTitle());
        if (this.createdAt == null) {
            this.createdAt = LocalDateTime.now();
        }
    }
    
    @PostPersist  
    public void postPersist() {
        log.info("Todo 저장 후: ID={}, 제목={}", this.getId(), this.getTitle());
    }
    
    @PreUpdate
    public void preUpdate() {
        this.modifiedAt = LocalDateTime.now();
        log.info("Todo 수정 전: {}", this.getTitle());
    }
}

단점:

  • 엔티티에 로깅 로직이 섞임
  • 테스트하기 어려움
  • 관심사 분리 원칙 위배

 

별도의 리스너 클래스 (권장)

// 별도 리스너 클래스
@Component
public class TodoLifecycleListener {
    
    private static final Logger log = LoggerFactory.getLogger(TodoLifecycleListener.class);
    
    @PrePersist
    public void prePersist(Todo todo) {
        log.info("Todo 저장 전 - 제목: {}, 사용자: {}", 
            todo.getTitle(), todo.getUser().getUserName());
        
        // 비즈니스 로직 수행
        if (todo.getCreatedAt() == null) {
            todo.setCreatedAt(LocalDateTime.now());
        }
    }
    
    @PostPersist
    public void postPersist(Todo todo) {
        log.info("Todo 저장 완료 - ID: {}, 제목: {}", 
            todo.getId(), todo.getTitle());
        
        // 비동기 작업 트리거 (예: 알림 발송)
        ApplicationContextProvider.getBean(EventPublisher.class)
            .publishEvent(new TodoCreatedEvent(todo));
    }
    
    @PreUpdate
    public void preUpdate(Todo todo) {
        todo.setModifiedAt(LocalDateTime.now());
        log.info("Todo 수정 전 - ID: {}, 제목: {}", 
            todo.getId(), todo.getTitle());
    }
    
    @PostUpdate
    public void postUpdate(Todo todo) {
        log.info("Todo 수정 완료 - ID: {}, 제목: {}", 
            todo.getId(), todo.getTitle());
    }
    
    @PreRemove
    public void preRemove(Todo todo) {
        log.info("Todo 삭제 전 - ID: {}, 제목: {}", 
            todo.getId(), todo.getTitle());
    }
    
    @PostRemove
    public void postRemove(Todo todo) {
        log.info("Todo 삭제 완료 - 제목: {}", todo.getTitle());
    }
}

// 엔티티에 리스너 적용
@Entity
@EntityListeners(TodoLifecycleListener.class)
public class Todo extends Timestamped {
    // 엔티티는 깔끔하게 유지
}

 

글로벌 리스너 (특정 상황에서만 사용)

<!-- META-INF/orm.xml -->
<entity-mappings>
    <entity-listeners>
        <entity-listener class="com.example.GlobalAuditListener">
            <pre-persist method-name="prePersist"/>
            <pre-update method-name="preUpdate"/>
        </entity-listener>
    </entity-listeners>
</entity-mappings>

 

권장 방안: 리스너 + 스프링 이벤트 조합

// 엔티티 리스너
@Component
public class TodoEventListener {
    
    private final ApplicationEventPublisher eventPublisher;
    
    public TodoEventListener(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }
    
    @PostPersist
    public void postPersist(Todo todo) {
        eventPublisher.publishEvent(new TodoCreatedEvent(todo));
    }
    
    @PostUpdate
    public void postUpdate(Todo todo) {
        eventPublisher.publishEvent(new TodoUpdatedEvent(todo));
    }
    
    @PostRemove
    public void postRemove(Todo todo) {
        eventPublisher.publishEvent(new TodoDeletedEvent(todo));
    }
}

// 이벤트 객체
public class TodoCreatedEvent {
   private final Todo todo;
   private final LocalDateTime occurredAt;
   
   public TodoCreatedEvent(Todo todo) {
       this.todo = todo;
       this.occurredAt = LocalDateTime.now();
   }
   
   // getters...
}

// 이벤트 처리
@Component
@Slf4j
public class TodoEventHandler {
   
   private final NotificationService notificationService;
   private final AnalyticsService analyticsService;
   
   @EventListener
   @Async("taskExecutor") // 비동기 처리
   @Transactional(propagation = Propagation.REQUIRES_NEW) // 별도 트랜잭션
   public void handleTodoCreated(TodoCreatedEvent event) {
       Todo todo = event.getTodo();
       
       // 알림 발송
       try {
           notificationService.sendTodoCreatedNotification(todo);
           log.info("할일 생성 알림 발송 완료: {}", todo.getTitle());
       } catch (Exception e) {
           log.error("알림 발송 실패: {}", todo.getTitle(), e);
       }
       
       // 분석 데이터 수집
       analyticsService.recordTodoCreation(todo);
   }
   
   @EventListener
   @Async
   public void handleTodoUpdated(TodoUpdatedEvent event) {
       // 수정 이벤트 처리
       log.info("할일 수정됨: {}", event.getTodo().getTitle());
   }
   
   @EventListener
   public void handleTodoDeleted(TodoDeletedEvent event) {
       // 삭제 이벤트는 동기로 처리 (데이터 정합성 보장)
       log.info("할일 삭제됨: {}", event.getTodo().getTitle());
   }
}

// 비동기 설정
@Configuration
@EnableAsync
public class AsyncConfig {
   
   @Bean("taskExecutor")
   public ThreadPoolTaskExecutor taskExecutor() {
       ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
       executor.setCorePoolSize(5);
       executor.setMaxPoolSize(10);
       executor.setQueueCapacity(100);
       executor.setThreadNamePrefix("async-");
       executor.initialize();
       return executor;
   }
}

장점:

  • 관심사 완전 분리
  • 비동기 처리 가능
  • 테스트 용이
  • 확장성 높음

4. 엔티티 그래프 활용

@NamedEntityGraph 사용법

@Entity
@NamedEntityGraph(
    name = "Todo.withUserAndComments",
    attributeNodes = {
        @NamedAttributeNode("user"),
        @NamedAttributeNode(value = "comments", subgraph = "comments-subgraph")
    },
    subgraphs = {
        @NamedSubgraph(
            name = "comments-subgraph",
            attributeNodes = @NamedAttributeNode("user")
        )
    }
)
public class Todo extends Timestamped {
    
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
    
    @OneToMany(mappedBy = "todo")
    private List<Comment> comments = new ArrayList<>();
}

// Repository에서 엔티티 그래프 사용
public interface TodoRepository extends JpaRepository<Todo, Long> {
    
    @EntityGraph("Todo.withUserAndComments")
    @Query("SELECT t FROM Todo t WHERE t.id = :todoId")
    Optional<Todo> findByIdWithGraph(@Param("todoId") Long todoId);
    
    // 동적 엔티티 그래프
    @EntityGraph(attributePaths = {"user", "comments.user"})
    Optional<Todo> findWithUserAndCommentsById(Long id);
}

 

엔티티 그래프 vs Fetch Join 비교


5. 실무 활용 패턴과 최적화 기법

영속성 컨텍스트 최적화 패턴

@Service
@Transactional(readOnly = true)
public class OptimizedTodoService {
    
    private final TodoRepository todoRepository;
    private final EntityManager entityManager;
    
    // 패턴 1: 조회 후 즉시 분리 (Detach)
    public TodoResponse findTodoForDisplay(Long todoId) {
        Todo todo = todoRepository.findByIdWithUser(todoId)
            .orElseThrow(() -> new InvalidRequestException("Todo not found"));
        
        // DTO 변환 후 엔티티 분리
        TodoResponse response = TodoResponse.from(todo);
        entityManager.detach(todo); // 메모리 절약
        
        return response;
    }
    
    // 패턴 2: 읽기 전용 힌트 사용
    @QueryHints({
        @QueryHint(name = "org.hibernate.readOnly", value = "true"),
        @QueryHint(name = "org.hibernate.fetchSize", value = "50")
    })
    public List<TodoSummary> findTodoSummaries(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return todoRepository.findAllSummaries(pageable);
    }
    
    // 패턴 3: 배치 크기 최적화
    @Transactional
    public void bulkUpdateTodos(List<Long> todoIds, String newStatus) {
        int batchSize = 100;
        
        for (int i = 0; i < todoIds.size(); i += batchSize) {
            List<Long> batch = todoIds.subList(i, 
                Math.min(i + batchSize, todoIds.size()));
            
            // 배치 단위로 처리
            List<Todo> todos = todoRepository.findAllById(batch);
            todos.forEach(todo -> todo.updateStatus(newStatus));
            
            // 주기적 플러시와 클리어
            if ((i / batchSize) % 10 == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        }
    }
}

 

영속성 컨텍스트 모니터링

@Component
@Slf4j
public class PersistenceContextMonitor {
    
    @EventListener
    public void onApplicationReady(ApplicationReadyEvent event) {
        log.info("영속성 컨텍스트 모니터링 시작");
    }
    
    @Aspect
    @Component
    public static class PersistenceMonitoringAspect {
        
        @Around("@annotation(Transactional)")
        public Object monitorPersistenceContext(ProceedingJoinPoint joinPoint) throws Throwable {
            long startTime = System.currentTimeMillis();
            String methodName = joinPoint.getSignature().getName();
            
            try {
                Object result = joinPoint.proceed();
                
                long duration = System.currentTimeMillis() - startTime;
                if (duration > 1000) { // 1초 이상 걸린 트랜잭션 로깅
                    log.warn("긴 트랜잭션 감지: {} - {}ms", methodName, duration);
                }
                
                return result;
            } catch (Exception e) {
                log.error("트랜잭션 실행 중 오류: {}", methodName, e);
                throw e;
            }
        }
    }
}

 

성능 최적화 체크리스트

// 성능 최적화 가이드
public class PersistenceOptimizationGuide {
    
    /**
     * 1. 적절한 Fetch 전략 사용
     */
    @Entity
    public class OptimizedTodo {
        
        // 자주 사용되는 연관관계는 EAGER
        @ManyToOne(fetch = FetchType.EAGER)
        private User user;
        
        // 가끔 사용되는 연관관계는 LAZY
        @OneToMany(mappedBy = "todo", fetch = FetchType.LAZY)
        @BatchSize(size = 50) // N+1 문제 완화
        private List<Comment> comments = new ArrayList<>();
    }
    
    /**
     * 2. 적절한 캐시 전략
     */
    @Entity
    @Cacheable
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    public class CachedUser {
        // 자주 조회되고 변경이 적은 엔티티는 2차 캐시 활용
    }
    
    /**
     * 3. 쿼리 최적화
     */
    public interface OptimizedRepository extends JpaRepository<Todo, Long> {
        
        // DTO 직접 조회로 메모리 사용량 최소화
        @Query("SELECT new com.example.dto.TodoSummary(t.id, t.title, u.userName) " +
               "FROM Todo t JOIN t.user u")
        List<TodoSummary> findAllSummaries();
        
        // 필요한 컬럼만 SELECT
        @Query("SELECT t.id, t.title FROM Todo t WHERE t.user.id = :userId")
        List<Object[]> findIdAndTitleByUserId(@Param("userId") Long userId);
    }
    
    /**
     * 4. 트랜잭션 범위 최소화
     */
    @Service
    public class OptimizedService {
        
        @Transactional(readOnly = true)
        public TodoResponse findTodo(Long id) {
            // 읽기 전용 트랜잭션
            Todo todo = todoRepository.findById(id).get();
            return TodoResponse.from(todo);
        }
        
        @Transactional
        public void updateTodoTitle(Long id, String newTitle) {
            // 최소한의 쓰기 트랜잭션
            Todo todo = todoRepository.findById(id).get();
            todo.updateTitle(newTitle);
            // save() 불필요 (변경 감지)
        }
    }
}

핵심 요약

영속성 컨텍스트 관리 핵심 원칙

  1. 동일성 보장 활용
    • 같은 트랜잭션 내에서는 동일한 엔티티 == 동일한 객체
    • 1차 캐시를 통한 성능 최적화
    • 변경 감지를 통한 자동 UPDATE
  2. 적절한 생명주기 이벤트 처리
    • 엔티티에 직접 로직 포함 지양
    • 별도 리스너 클래스로 관심사 분리
    • 스프링 이벤트와 조합하여 비동기 처리
  3. 컬렉션 매핑 최적화
    • 적절한 컬렉션 타입 선택 (List vs Set)
    • @OrderBy로 정렬 최적화
    • @Converter로 데이터 변환 처리

실무 적용 가이드

  1. JPA Auditing 적극 활용
    • 생성/수정 시간 자동 관리
    • 생성/수정 사용자 추적
    • IP 주소, 버전 등 확장 정보 관리
  2. 엔티티 그래프 vs Fetch Join 선택
    • 단순한 경우: 엔티티 그래프
    • 복잡한 조건: Fetch Join
    • 프로젝트 특성에 맞게 혼합 사용
  3. 성능 모니터링과 최적화
    • 긴 트랜잭션 감지 및 로깅
    • 적절한 배치 크기로 대용량 처리
    • 읽기 전용 힌트 활용

다음 편 예고: 4편에서는 대용량 데이터 처리와 성능 최적화에 대해 다루겠습니다. 특히 배치 처리 시 영속성 컨텍스트 관리, SQL 배치 최적화, 트랜잭션과 락 전략, 그리고 2차 캐시 활용법을 자세히 알아보겠습니다.