♻️ 지연로딩, OSIV, @Transactional의 삼위일체

1. 프록시(Proxy)란?
리팩토링 이전에는 트랜잭션에 대한 어렴풋한 이해는 있었지만, 그게 정확하게 어떤 식으로 작동하는지, 지연 로딩이라는 개념은 뭔지, OSIV 설정은 어떻게 해야 하고 어떻게 @Transactional을 활용하면 되는지 전혀 몰랐다.
우선, 이 개념들을 내 프로젝트에 적용하려면 AWS 배포 과정 때처럼 직접 해보면서 배우기 보다는 이해가 선행되어야 했다. 그런데 생각보다 추상적인 개념이어서 이해하기가 쉽지 않았다.
따로 PPT를 작성해서 설명도 해보면서, 먼저 프록시라는 개념을 두 가지로 나눠서 이해했다. 그래서 이 포스트도 PPT를 기반으로 하고 있고, 여타 다른 포스트에 비해 내용이 좀 딱딱하게 보일 수 있다.
이 포스트에서 메인으로 다룰 프록시는 아래 2가지 분류 중 (1)이 아닌, (2)의 프록시 객체를 말함.
(1) 2번에 해당하지 않는, 명칭이 겹치는 다른 개념들
- 객체나 리소스에 대한 간접적인 접근을 제공하는 디자인 패턴 → 프록시 패턴
- 네트워크에서: 클라이언트와 서버 사이의 중계자
- 보안, 로드밸런싱, 캐싱 등을 수행
- HTTP 프록시: 클라이언트의 요청을 대리
(2) JPA에서 쓰이는 프록시 객체(Proxy Object)
- 실제 객체 대신 동작하는 가짜 객체를 말함
- 지연 로딩 (Lazy Loading), 접근 제어, 부가기능 삽입(트랜잭션, 로깅, 캐싱 등)에 사용
- 필드 접근 시점에 DB 쿼리 실행됨
- 프록시는 진짜 객체가 아님 → instanceof 검사 시 다르게 나올 수 있고, 디버깅 시 다른 클래스가 나옴
이 두 번째 프록시 객체가 바로 내가 아래에 수 스크롤에 걸쳐 포스팅할 내용이다.
2. 지연 로딩(Lazy Loading)과 프록시
지연 로딩(Lazy Loading)은 DB에서 실제 객체를 사용하는 시점까지 데이터를 로드하지 않고, 필요한 순간에만 로딩하는 전략이다.
- 이를 위해 프록시 객체가 사용되며, 실제 데이터에 접근할 때만 쿼리가 실행됨
- 프록시는 실제 엔티티 클래스 대신 대리 객체로 메모리에 로드, 메서드 호출 시 필요한 데이터를 조회
- 성능 최적화를 위해 대량의 데이터를 즉시 로드하지 않고, 필요할 때 로드하는 방식
- 관리되지 않는 환경에서는 데이터 조회 실패나 예외 발생 가능성
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
나도 프로젝트 내에서 Post와 밀접한 관련이 있는 Attachment나 Reply 같은 엔티티에서는 연관 관계를 LAZY로 설정했다. 실제로 연관된 엔티티가 필요한 시점까지 로딩을 미룰 수 있고, 이에 따라 불필요한 SQL 쿼리를 줄이고 성능을 최적화할 수 있기 때문.
하지만 단점도 있다. (OSIV 설정이 해제돼 있을 시) Lazy 객체는 트랜잭션 안에서만 초기화할 수 있어서, 이걸 놓치면 LazyInitializationException이 발생한다. 늘 트랜잭션 내부에서 필요한 데이터를 로딩하도록 신경써야 하는 수고로움이 있다.
그러니 프록시 객체는 언제 생성되고, 언제 데이터를 로딩하는지를 알아야 이 예외를 피할 수가 있다.
3. 프록시 객체의 생성과 데이터 로딩 시점
-
엔티티를 로딩할 때 fetch = FetchType.LAZY가 지정된 경우, 관련 엔티티를 프록시로 로딩
→ 실제 엔티티 대신 Hibernate가 만든 프록시 객체가 주입됨 - Spring이나 Hibernate의 EntityManager.getReference()를 통해 프록시 객체를 명시적으로 요청할 수 있음
-
연관된 엔티티 필드나 컬렉션이 프록시로 대체되며, 실제로 데이터를 호출하기 전까지는 데이터가 로드되지 않음
- 프록시 객체는 실제 DB 쿼리를 날리지 않고도, 엔티티와 같은 인터페이스 제공 가능
- 실제 데이터가 필요한 순간에 프록시가 활성화되어 DB에 쿼리를 실행하게 됨
→ entity.getSomething()처럼 필드 접근 시 쿼리가 발생하여 실제 데이터를 로드함
4. Transaction 내/외부에서의 프록시
트랜잭션 내부에서와, 외부에서 프록시가 작동하는 방식이 다르기 때문에 이 부분도 알아둬야 했다. OSIV 개념까지 연결됨은 물론이다.
(1) Transaction 내부에서의 프록시
- 트랜잭션 내부에서는 프록시 객체를 사용할 때 DB와 연결되어 있어 안전하게 데이터를 가져올 수 있음
- 프록시 객체는 필요에 따라 자동으로 초기화되며, LazyInitializationException이 발생하지 않음
- 트랜잭션이 관리하는 세션에서 쿼리를 실행하여 데이터를 불러올 수 있는 환경 제공
- 성능을 최적화하면서도 안전하게 데이터 조회가 가능
(2) Transaction 외부에서의 프록시
트랜잭션 외부에서는 프록시 초기화가 불가능하다.
- 프록시 객체의 필드를 호출하면 LazyInitializationException이 발생할 수 있음
→ 이는 DB와의 연결이 끊어진 상태에서 지연로딩을 시도하기 때문 - 트랜잭션이 종료된 이후에는 프록시가 더 이상 데이터를 불러올 수 없으므로, 데이터 접근에 실패
- 해결책으로는 OSIV를 활용하거나, 트랜잭션 내부에서 필요한 데이터를 미리 로딩하는 방식이 있음
5. OSIV란?
- 트랜잭션 외부에서도 프록시 객체의 지연로딩을 가능하게 하며,
- 웹 요청이 완료될 때까지 DB와의 연결을 유지
- 이를 통해 뷰 렌더링 중에도 필요한 데이터를 조회할 수 있음
- 그러나 OSIV는 성능 문제와 커넥션 유지 문제를 일으킬 수 있어 주의가 필요
- Spring Boot에서는 기본적으로 OSIV가 활성화되어 있으며, 이를 비활성화해야 해제 가능
즉, OSIV가 설정돼 있을 경우, 트랜잭션 외부에서도 프록시 객체가 안전하게 초기화된다. DB 연결을 유지하기 때문에, 뷰 렌더링 과정에서 프록시 객체의 지연 로딩이 가능해서 주로 컨트롤러와 뷰 사이에서 필요한 데이터가 늦게 로딩되는 경우에 유용하게 쓰인다. 또 DB와의 연결 시간이 길어질 수 있어, 성능에 부정적 영향을 미칠 수도 있다. 그러므로 최적화된 애플리케이션에서는 OSIV 사용을 지양하고 필요한 데이터를 미리 로딩하는 방식을 선호한다고 한다. 내가 들은 강의들에서도 OSIV는 꺼두는 것을 추천했다.
OSIV가 해제돼 있을 경우, 트랜잭션 종료 후에는 DB 연결이 끊긴다. 트랜잭션이 종료된 후에는 프록시 객체가 초기화되지 않으며, 프록시 접근 시 LazyInitializationException이 발생할 수 있다. 이를 방지하기 위해서는 트랜잭션 내부에서 모든 필요한 데이터를 즉시로딩해야 하며, 서비스 계층에서 데이터를 모두 처리한 후, 컨트롤러나 뷰에서 처리할 수 있도록 해야 한다. 나도 이 부분을 간과해 저 예외를 몇 번 실제로 마주한 적이 있다.
예를 들어,
List<Attachment> attachments = attachmentRepository.findAll();
for (Attachment attachment : attachments) {
log.info(attachment.getPost().getTitle());
// Lazy 초기화 발생
}
이런 코드가 트랜잭션 밖에서 실행되면 아래와 같은 예외가 발생한다.
org.hibernate.LazyInitializationException: could not initialize proxy - no Session
해결 방법은 위에 설명한 것처럼, 트랜잭션 안에서 모두 처리하면 된다. DTO로 Projection하거나, @EntityGraph 또는 fetch join을 활용할 수도 있다.
사실 여기까지 공부하면서 멘토링도 받고, 인프런 가서 김영한 강사님 스프링 강의도 듣고 했더니 이런 프록시 객체, 트랜잭션의 개념과 영속성 컨텍스트를 이해하기까지 시간이 좀 걸렸다. 이해가 안 될 땐 이해가 될 때까지 반복하면 된다.
OSIV를 설정하면 저런 수고 없이 뷰 렌더링까지 영속성 컨텍스트를 유지할 수 있지만, 아래와 같은 단점이 눈에 띄었다.
- 서비스 계층 밖(View 계층)에서 Lazy 객체를 로딩할 수 있게 되어 계층 간 책임이 모호해짐
- 테스트하기 어려운 코드가 됨
- 실제 웹 서비스 운영 환경에서는 DB 커넥션이 오래 잡혀 있을 수 있음
결과적으로 나도 수동으로 트랜잭션과 영속성 컨텍스트에 신경쓰기로 하고(서비스 계층 내에서 쫑내기), .yml 파일에 OSIV 설정을 false로 꺼두었다.
spring:
jpa:
hibernate:
ddl-auto: none
properties:
hibernate:
show_sql: true
format_sql: true
default_batch_fetch_size: 100
dialect: org.hibernate.dialect.MariaDBDialect
use_sql_comments: true
// OSIV 설정 해제
open-in-view: false
6. @Transactional 사용
그래서 서비스 안에서 모든 DB 작업을 해결하는데 중점을 두면서 아래처럼 프로젝트에 살살 트랜잭션을 반영해 봤다.
(1) 조회 전용 서비스에선 readOnly = True
@Transactional(readOnly = true)
public class TourService {
- 조회만 하는 거니 해당 옵션을 붙여서 변경 감지를 비활성화함. flush가 일어나지 않는 대신 성능에 이점이 있다.
(2) CUD 작업에서는 일반 @Transactional
// 커뮤니티 글 삭제 실행
@Transactional
@DeleteMapping("/{postId}")
public ResponseEntity<Void> deletePost(@PathVariable Long postId,
@RequestBody PostRequest.Delete request) {
// 로그에 컨트롤러 및 메서드명 기록
log.info("deletePost() - postId: {}", postId);
// 받은 postID와 비밀번호를 통해 글 삭제
postService.deletePost(postId, request.getPassword());
// 204 No Content 반환
return ResponseEntity.noContent().build();
}
- R 빼고 C,U,D 작업 시에는 변경 감지 및 영속성 컨텍스트를 적용했고, Lazy 객체도 이 안에서 안전하게 초기화했다.
(3) 예외 발생 시 롤백 방지 전략
// @Transactional 은 DB에 C/UD될 때 관용적으로 붙이기도 함
// 쓰지말까 고민한 이유: 중간에 자동삭제에 에러가 생기면 DB에 해당파일 시도회수를 늘리는 쿼리가 커밋되어야 함
// 쓰고서 롤백만 안 되게 처리하면 위 문제 해결: exception을 throw하지 않으면 된다.
@Transactional
@Scheduled(cron = "0 0 4 * * *")
public void autoDelete() {
List<FailedAttachment> failedAttachments = failedAttachmentRepository.findByIsDeletedFalse();
for (FailedAttachment failedAttachment : failedAttachments) {
try {
fileStorageService.deleteFile(FileUtils.extractFileName(failedAttachment.getUrl()));
failedAttachment.markAsDeleted();
} catch (Exception e) {
failedAttachment.updateTrialInfo();
}
failedAttachmentRepository.save(failedAttachment);
}
}
- 반복 작업에서 예외 하나 때문에 전체가 롤백되면 안 되는 극소수 경우에는 try-catch 처리 후 예외를 throw하지 않는 방식으로 해결했다.
(4) 독립 트랜잭션이 필요한 경우: REQUIRES_NEW
// 찌꺼기 파일 삭제 메서드
// 첨부파일, 포스트 업로드 시 DB와 매칭되지 않는 파일을 S3에서도 지워주기 위해 사용
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deleteUrls(List<String> urls) {
for (String url : urls) {
try{
fileStorageService.deleteFile(FileUtils.extractFileName(url));
} catch (Exception e) {
//별개)보통 실패하는 이유가 db인 경우도 많음. mysql 서버가 다운되면 messageQueue를 사용
//실패하는 경우
// 1.db에 실패테이블을 만들어서 url을 저장
FailedAttachment failedAttachment = new FailedAttachment(url);
failedAttachmentRepository.save(failedAttachment);
}
}
failedAttachmentRepository.flush();
}
- 기존 트랜잭션과 상관없이 별도로 커밋되며, 위의 찌꺼기 파일 삭제 메서드처럼 실패 파일 로그를 반드시 남겨야 할 작업에 유용하게 쓸 수 있다. 해당 내용은 나중에 파일 삭제 스케줄링 포스트를 작성하면서 더 자세히 작성할 예정.
(5) 표로 정리
| 항목 | 설정 / 사용 이유 |
| FetchType.LAZY | 성능 최적화, 필요한 시점에만 조회 |
| open-in-view: false | 서비스 책임 분리, 뷰에서 Lazy 호출 방지 |
| @Transactional(readOnly = true) | 조회 성능 최적화 |
| @Transactional | CUD에서 변경 감지와 트랜잭션 적용 |
| REQUIRES_NEW | 독립적 트랜잭션 처리 (실패 로그 저장 등) |
∴ 리팩토링 요약
처음엔 단순한 트랜잭션 사용에서 시작했다고 생각했는데, 사실 트랜잭션 자체가 단순하지 않았다.
게다가 함께 익힌 지연로딩 개념이나 OSIV 등은 트랜잭션 밖에서 프록시를 쓰냐 안 쓰냐만 결정하는 게 아니라, 결국 나중에 프로젝트 성능과도 연관되는 부분이기에 골격 설계와 깊이 맞물려 있었다.
모든 개념을 토씨 하나 안 틀리고 외우지는 못했지만, EntityManager를 활용하거나 Lazy어쩌고Exception이나 관련한 콘솔 예외 로그가 뜨면 무슨 문제인지 알아채고 디버깅을 할 수 있는 정도까지는 되었다.
나중에 실무에서는 트랜잭션을 훨씬 많이 활용하게 될 텐데, 그때를 대비해서 좀 더 탄탄하게 기반을 쌓아둔 것 같아서 좋았다.
'개인 프로젝트 리팩토링 > Seoulbara' 카테고리의 다른 글
| AWS 배포: 책만큼 서버도 잘 파네 (3) | 2024.11.14 |
|---|---|
| JSP에서 Thymeleaf로: FE 설계 개선기 (0) | 2024.11.13 |
| MyBatis에서 JPA로: SQL문을 다 쓸 필요가 없었다니 (0) | 2024.11.13 |