우리는 db를 이용하기 위해서 connection을 얻어야한다.
EntityManagerFactory라는 곳에서 요청이 올때마다 EntitiyManager를 생성하고, 이 엔티티매니저는 커넥션 풀 안에 있는 커넥션을 사용하여 db에 접근할 수 있게된다.
영속성 컨텍스트란
엔티티를 영구 저장하는 환경 !
- EntityManager.persist(entity); 로 영속성 컨텍스트에 엔티티를 영속화 시킨다.
- 엔티티매니저를 통해서 논리적인 개념인 영속성 컨텍스트에 접근할 수 있다.
- 스프링 프레임워크와 같은 환경에서 엔티티 매니저와 영속성 컨텍스트가 N:1의 관계이다.
- 엔티티매니저 == 영속성 컨텍스트라고 봐도 무방하다.
엔티티 생명주기
- 비영속 (new/transient) : 영속성 컨텍스트와 전혀 관계가 없는 새로운 상태
- 영속 (managed) : 영속성 컨텍스트에 관리되는 상태
- 준영속 (detached) : 영속성 컨텍스트에 저장되었다가 분리된 상태
- 삭제 (removed) : 삭제된 상태
영속성 컨텍스트는 어떤 기능을 제공할까?
1. 1차 캐시
//엔티티를 생성한 상태(비영속)
Member member = new Member();
member.setId("member1");
member.setUserName("회원1");
//엔티티를 영속
em.persist(member);
엔티티를 생성하고 em.persist를 통해 영속화시키면 1차 캐시안에 엔티티가 들어가게 된다.
이제 1차 캐시에 저장된 엔티티는 1차 캐시에서 조회가 가능하다.
1차 캐시에 없는 엔티티는 db에서 조회한 후 해당 엔티티를 1차 캐시에 저장한 후, 반화하게 된다.
2. 트랜잭션을 지원하는 쓰기 지연
엔티티 매니저는 데이터 변경 시 트랜잭션을 시작해야 하고, 트랜잭션 커밋 순간에 db에 sql을 보낸다.
3. Dirty Checking
영속 엔티티 조회 후 데이터를 수정한 후 커밋을 하게 되면 자동으로 update sql이 생성되어 쓰기 지연 저장소에 저장이 되고, 커밋 시점에 플러쉬 되어 db로 update sql이 날라간다.
이게 가능한 이유는 처음 엔티티의 스냅샷을 찍어두고 변경이 되면 변경을 감지하여 update sql이 생성되는 것이다.
이렇게 영속성 컨텍스트에 대한 개념정리를 다뤄봤다.
OSIV와 영속성 컨텍스트는 밀접한 관련이 있기 때문이다.
OSIV(Open Session In View)
영속성 컨텍스트를 뷰까지 열어두는 기능이다.
영속성 컨텍스트가 유지되면 엔티티도 영속상태로 유지된다.
뷰까지 영속성 컨텍스트가 유지된다면 뷰에서도 지연로딩을 사용할 수 있다.
스프링부트 JPA 의존성을 주입받을 경우 기본값이 OSIV가 적용된 상태이다.
OSIV가 켜진 상태에서의 동작원리
1. 클라이언트의 요청이 들어오면 서블릿 필터 or 스프링 인터셉터에서 영속성 컨텍스트를 생성한다.
2. 서비스 계층에서 @Transactional 로 트랜잭션을 시작할 때 앞서 생성해둔 영속성 컨텍스트를 찾아와서 트랜잭션을 시작한다.
3. 서비스 계층이 끝나면 트랜잭션을 커밋하고 영속성 컨텍스트를 플러시한다.
-> 트랜잭션은 끝나지만 영속성컨텍스트는 살아있다.
4. 컨트롤러와 뷰까지 영속성 컨텍스트가 유지되므로 조회한 엔티티는 영속상태를 유지한다.
5. 서블릿이나 스프링 인터셉터로 요청이 돌아오면 영속성 컨텍스트를 종료한다.
(플러시는 호출하지 않고 바로 종료함.)
서비스 계층이 끝나면 트랜잭션 또한 끝나기에 컨트롤러와 뷰에는 트랜잭션이 유지되지 않는 상태이다.
엔티티를 단순 조회할 경우에는 트랜잭션 없이도 동작한다. (트랜잭션 없이 읽기)
프록시를 뷰 렌더링하는 과정에 지연로딩이 이뤄지더라도 조회기능이므로 트랜잭션 없이 읽기가 가능하다.
영속성 컨텍스트는 트랜잭션 범위 밖에서 엔티티를 조회할 수 있다.
OSIV 단점
커넥션을 영속성 컨텍스트가 종료될때까지 계속 들고있어 리소스 낭비가 심하다.
OSIV를 끄면 트랜잭션을 종료할때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환한다.
따라서 커넥션 리소스를 낭비하지 않는다.
모든 지연로딩을 트랜잭션 안에서 처리해야하고, View 에서 지연로딩이 동작하지 않는다.
해결책
- 실시간 트래픽이 중요한 경우 OSIV를 끄고, DTO로 직접 조회하기
- 조회용 메서드의 경우 @Transactional(readOnly = true) 붙여주기
- admin 페이지의 경우 OSIV 사용해도 괜찮다.
- Command와 쿼리 분리
주의
OSIV를 끄게 되면, 조회용 메서드에 @Transactional을 붙여주지 않으면 조회한 엔티티가 영속성 컨텍스트의 관리를 받지 못하므로 지연로딩이 불가능하기 때문에 트랜잭션 어노테이션을 붙여주어야한다.
Command와 Query 분리
그렇다면 굳이 Command(비즈니스 로직)와 Query를 왜 분리할까 하는 의문이 든다.
왜할까?
- 성능 최적화 : Command와 Query를 분리함으로써 쿼리 서비스에 읽기 전용 트랜잭션을 둠으로써 Command 서비스와는 다르게 쿼리 서비스에서는 영속성 컨텍스트를 지속적으로 열지 않아도 되므로 db 연산을 최적화하기 쉽다.
- SRP 준수 : Command와 Query를 분리하면 각각의 서비스가 특정 작업에 집중할 수 있다.
- 코드 재사용 : Command와 Query를 분리하면 쿼리를 다시 사용하기 쉽다. 특히 여러 곳에서 동일한 데이터에 대한 쿼리를 수행해야 하는 경우 이 패턴은 중복 코드를 줄여준다.
- 유지 보수성 향상: Command와 Query가 분리되면 변경 사항을 추적하고 디버그하기 쉬워진다. 비즈니스 로직 변경이 화면 표시 논리에 영향을 미치지 않도록 보장할 수 있으므로 코드 유지 보수가 간편해진다.
- 보안: Command와 Query를 분리하면 읽기 서비스와 쓰기 서비스 간의 권한을 더욱 강화할 수 있다. 쓰기 서비스는 변경에 대한 권한을 갖고 있으므로 민감한 작업에 대한 보안을 더욱 강화할 수 있다.
- 테스트 용이성: Command와 Query를 분리하면 각각을 독립적으로 테스트하기가 더 쉽다.
적용해보기
적용전 코드
@Slf4j
@Service
@RequiredArgsConstructor
public class MakerService {
private final MakerRepository makerRepository;
/**
* Maker 회원가입
*/
@Transactional
public Long signUpMaker(MakerCreateRequestDTO dto) {
checkDuplicateName(dto.makerName());
checkDuplicateEmail(dto.makerEmail());
Maker maker = Maker.builder()
.makerName(dto.makerName())
.makerBrand(dto.makerBrand())
.makerEmail(dto.makerEmail())
.build();
Maker savedMaker = makerRepository.save(maker);
return savedMaker.getMakerId();
}
/**
* Maker 조회
*/
@Transactional(readOnly = true)
public MakerResponseDTO getMaker(Long makerId) {
Maker maker = makerRepository.findById(makerId)
.orElseThrow(() -> {
log.warn("Maker {} is not found", makerId);
return new BaseException(ErrorCode.MAKER_NOT_FOUND);
});
return MakerResponseDTO.from(maker);
}
/**
* Maker 정보 수정
*/
@Transactional
public MakerResponseDTO updateMaker(
Long makerId,
MakerUpdateRequestDTO dto
) {
checkDuplicateName(dto.makerName());
checkDuplicateEmail(dto.makerEmail());
Maker maker = makerRepository.findById(makerId)
.orElseThrow(() -> {
log.warn("Maker {} is not found", makerId);
return new BaseException(ErrorCode.MAKER_NOT_FOUND);
});
maker.updateMaker(
dto.makerName(),
dto.makerEmail(),
dto.makerBrand()
);
return MakerResponseDTO.of(
maker.getMakerName(),
maker.getMakerBrand(),
maker.getMakerEmail()
);
}
/**
* Maker 탈퇴
*/
@Transactional
public void deleteMaker(Long makerId) {
Maker maker = makerRepository.findById(makerId)
.orElseThrow(() -> {
log.warn("Maker {} is not found", makerId);
return new BaseException(ErrorCode.MAKER_NOT_FOUND);
});
maker.unregisteredMaker();
}
/**
* Maker 이메일 중복 검증
*/
public void checkDuplicateEmail(String email) {
if(makerRepository.existsByMakerEmail(email)){
log.warn("Duplicate email exists.");
throw new BaseException((ErrorCode.DUPLICATED_EMAIL));
}
}
/**
* Maker 이름 중복 검증
*/
public void checkDuplicateName(String name) {
if(makerRepository.existsByMakerName(name)){
log.warn("Duplicate name exists.");
throw new BaseException((ErrorCode.DUPLICATED_NAME));
}
}
}
하나의 서비스 코드에 비즈니스로직 + 조회용 메서드가 다 담겨있다.
Command와 Query를 분리해보자.
Command
@Slf4j
@Service
@RequiredArgsConstructor
public class MakerCommandService {
private final MakerRepository makerRepository;
@Transactional
public Long signUpMaker(MakerCreateRequestDTO dto) {
checkDuplicateName(dto.makerName());
checkDuplicateEmail(dto.makerEmail());
Maker maker = Maker.builder()
.makerName(dto.makerName())
.makerBrand(dto.makerBrand())
.makerEmail(dto.makerEmail())
.build();
Maker savedMaker = makerRepository.save(maker);
return savedMaker.getMakerId();
}
@Transactional
public MakerResponseDTO updateMaker(
Long makerId,
MakerUpdateRequestDTO dto
) {
checkDuplicateName(dto.makerName());
checkDuplicateEmail(dto.makerEmail());
Maker maker = makerRepository.findById(makerId)
.orElseThrow(() -> {
log.warn("Maker {} is not found", makerId);
return new BaseException(ErrorCode.MAKER_NOT_FOUND);
});
maker.updateMaker(
dto.makerName(),
dto.makerEmail(),
dto.makerBrand()
);
return MakerResponseDTO.of(
maker.getMakerName(),
maker.getMakerBrand(),
maker.getMakerEmail()
);
}
@Transactional
public void deleteMaker(Long makerId) {
Maker maker = makerRepository.findById(makerId)
.orElseThrow(() -> {
log.warn("Maker {} is not found", makerId);
return new BaseException(ErrorCode.MAKER_NOT_FOUND);
});
maker.unregisteredMaker();
}
public void checkDuplicateEmail(String email) {
if (makerRepository.existsByMakerEmail(email)) {
log.warn("Duplicate email exists.");
throw new BaseException((ErrorCode.DUPLICATED_EMAIL));
}
}
public void checkDuplicateName(String name) {
if (makerRepository.existsByMakerName(name)) {
log.warn("Duplicate name exists.");
throw new BaseException((ErrorCode.DUPLICATED_NAME));
}
}
}
Query
@Slf4j
@Service
@RequiredArgsConstructor
public class MakerQueryService {
private final MakerRepository makerRepository;
@Transactional(readOnly = true)
public MakerResponseDTO getMaker(Long makerId) {
Maker maker = makerRepository.findById(makerId)
.orElseThrow(() -> {
log.warn("Maker {} is not found", makerId);
return new BaseException(ErrorCode.MAKER_NOT_FOUND);
});
return MakerResponseDTO.from(maker);
}
// 다른 Query 메서드들 추가
}
맺음말
OSIV 가 처음 개념을 익힐땐 무척 어렵게 다가왔으나, 왜 이해가 안될까?를 생각해본 결과
영속성 컨텍스트에서 엔티티가 관리되는 동작 원리와 트랜잭션에 대한 이해가 부족했던 것을 알게되었다.
아마 OSIV가 어렵다면, 영속성 컨텍스트와 트랜잭션의 이해가 부족한 것일거다.
따라서 이번 포스팅은 영속성 컨텍스트의 개념 정림부터 시작해서 OSIV를 끌경우 해결책까지 다뤄보았다.
매번 공부를 할때마다 내가 가지고 있던 배경 지식들을 재정립 해보게 되는거 같다.
처음 공부하고 한번에 다 아는 사람은 없다. 이게 끊임없는 배움이 필요한 이유가 아닐까 ?
References
자바 ORM 표준 JPA 프로그래밍 | 김영한님 저서
'스프링 > ORM JPA' 카테고리의 다른 글
지연로딩과 즉시로딩 (0) | 2023.09.17 |
---|---|
영속성 컨텍스트 (0) | 2023.09.12 |
고급매핑 (0) | 2023.04.06 |
연관관계 매핑 (0) | 2023.04.04 |
양방향 연관관계 매핑 (0) | 2023.04.04 |