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차 캐시를 통한 성능 최적화
- 변경 감지를 통한 자동 UPDATE
- 적절한 생명주기 이벤트 처리
- 엔티티에 직접 로직 포함 지양
- 별도 리스너 클래스로 관심사 분리
- 스프링 이벤트와 조합하여 비동기 처리
- 컬렉션 매핑 최적화
- 적절한 컬렉션 타입 선택 (List vs Set)
- @OrderBy로 정렬 최적화
- @Converter로 데이터 변환 처리
실무 적용 가이드
- JPA Auditing 적극 활용
- 생성/수정 시간 자동 관리
- 생성/수정 사용자 추적
- IP 주소, 버전 등 확장 정보 관리
- 엔티티 그래프 vs Fetch Join 선택
- 단순한 경우: 엔티티 그래프
- 복잡한 조건: Fetch Join
- 프로젝트 특성에 맞게 혼합 사용
- 성능 모니터링과 최적화
- 긴 트랜잭션 감지 및 로깅
- 적절한 배치 크기로 대용량 처리
- 읽기 전용 힌트 활용
다음 편 예고: 4편에서는 대용량 데이터 처리와 성능 최적화에 대해 다루겠습니다. 특히 배치 처리 시 영속성 컨텍스트 관리, SQL 배치 최적화, 트랜잭션과 락 전략, 그리고 2차 캐시 활용법을 자세히 알아보겠습니다.

