OSIV 문제
- Filter 단에서 유저를 검증하기 위한 member 조회 쿼리 반드시 1번씩 발생
authCheker
를 통한 검증 로직을 분리했지만, 이 역시 Service 로직 접근 전에 실행되기에 OSIV false로 인해 영속성 컨텍스트의 관리를 받지 않음- 이로 인해 중복 쿼리가 2, 3개씩 발생..
- 다음은 나의 펫에게 먹이를 주는 API에서 발생하는 쿼리들이다
2024-09-13T15:01:39.371+09:00 DEBUG 10375 --- [nio-8080-exec-3] org.hibernate.SQL :
select -- 1)
m1_0.id,
m1_0.created_at,
m1_0.daily_achievement_cnt,
m1_0.diligence_id,
m1_0.email,
m1_0.food_cnt,
m1_0.is_subscribed,
m1_0.pet_house_size,
m1_0.profile_image_url,
m1_0.provider,
m1_0.provider_id,
m1_0.represent_pet_id,
m1_0.role,
m1_0.scheduled_reward,
m1_0.star_point,
m1_0.title_name_id,
m1_0.updated_at,
m1_0.username
from
member m1_0
where
m1_0.email=?
2024-09-13T15:01:39.411+09:00 DEBUG 10375 --- [nio-8080-exec-3] org.hibernate.SQL :
select -- 2)
p1_0.id,
p1_0.appearance,
p1_0.color,
p1_0.created_at,
p1_0.evolution_cnt,
p1_0.gauge,
p1_0.level,
p1_0.member_id,
p1_0.name,
p1_0.pet_type,
p1_0.rarity,
p1_0.updated_at
from
pet p1_0
where
p1_0.id=?
2024-09-13T15:01:39.418+09:00 DEBUG 10375 --- [nio-8080-exec-3] org.hibernate.SQL :
select -- 3)
p1_0.id,
p1_0.appearance,
p1_0.color,
p1_0.created_at,
p1_0.evolution_cnt,
p1_0.gauge,
p1_0.level,
p1_0.member_id,
p1_0.name,
p1_0.pet_type,
p1_0.rarity,
p1_0.updated_at
from
pet p1_0
where
p1_0.id=?
2024-09-13T15:01:39.420+09:00 DEBUG 10375 --- [nio-8080-exec-3] org.hibernate.SQL :
select -- 4)
m1_0.id,
m1_0.created_at,
m1_0.daily_achievement_cnt,
m1_0.diligence_id,
m1_0.email,
m1_0.food_cnt,
m1_0.is_subscribed,
m1_0.pet_house_size,
m1_0.profile_image_url,
m1_0.provider,
m1_0.provider_id,
m1_0.represent_pet_id,
m1_0.role,
m1_0.scheduled_reward,
m1_0.star_point,
m1_0.title_name_id,
m1_0.updated_at,
m1_0.username
from
member m1_0
where
m1_0.id=?
2024-09-13T15:01:39.427+09:00 DEBUG 10375 --- [nio-8080-exec-3] org.hibernate.SQL :
delete -- 5)
from
pet
where
id=?
1) JwtVerfificationFilter
에서 로그인한 유저가 데이터베이스에 존재하는지 먼저 확인하는 쿼리 발생
2) AuthChecker
를 통해 전달받은 petId에 해당하는 pet을 조회
3) 비즈니스 로직으로 들어와 영속성 컨텍스트에서 관리되는 Pet 엔터티를 얻기 위해 조회
4) 삭제 대상 펫이 멤버의 대표펫인지 여부를 확인하고, 맞다면 대표펫을 null로 설정하기 위해 영속성 컨텍스트에서 관리되는 Member 엔터티 조회
5) Pet 삭제 쿼리
이처럼 2)과 3)에서 정확히 같은 데이터를 중복해서 조회하는 경우가 있으며, 이러한 경우가 매우 빈번했다.
뿐만 아니라, JwtVerificationFilter
에서 조회한 Member 객체 역시 영속성 컨텍스트에서 관리되지 않기 때문에,
비즈니스 로직에서 Member 객체가 필요한 경우, 마찬가지로 다시 조회하는 쿼리가 필요했다.
이를 해결하기 위해 나는 spring.jpa.open-in-view
프로퍼티를 true
로 설정해주면 해결될 줄 알았다..
하지만, 문제는 여전히 동일하게 발생했다.. OSIV의 문제가 아닌 것인가..?
원인
스프링에서는 OpenEntityManagerInViewInterceptor
를 인터셉터로 등록하여 OSIV를 구현한다.
하지만, 스프링의 구조 상 Filter -> DispatcherServlet -> Interceptor -> HandlerAdapter -> ArgumentResolver -> Controller 형태인데, Spring Security는 Filter
로 구현되고, OSIV는 Interceptor
로 구현이 된다. 또한 @AuthenticationPrincipal
은 ArugmentResolver
에서 실행될 것이다.
OSIV 인터셉터의 구현체를 조금 들여다 보면..
public class OpenEntityManagerInViewInterceptor extends ... {
...
@Override
public void preHandle(WebRequest request) throws DataAccessException {
...
logger.debug("Opening JPA EntityManager in OpenEntityManagerInViewInterceptor");
try {
EntityManager em = createEntityManager();
EntityManagerHolder emHolder = new EntityManagerHolder(em);
TransactionSynchronizationManager.bindResource(emf, emHolder);
...
}
@Override
public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException {
...
logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor");
EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());
}
...
}
preHandle
을 통해EntityManager
를 Open하고,afterCompletion
을 통해 Close한다.
하지만, Spring Interceptor에서 preHandle
은 Controller에 Request를 보내기 전, afterCompletion
은 view에서 결과가 생성되고 난 후 호출된다.
Filter를 제외한 대부분의 영역에서 EntityManager
의 도움을 받을 수 있게 된다는 것인데, Spring Security는 Filter를 기반으로 동작하기에 @Transactional
이 적용되어 있는 memberService
를 통해 멤버 엔터티를 조회했다고 해도, 해당 부분에서만 영속성 컨텍스트가 유지된다.
이후, Filter를 벗어난 후에야 OSIV가 열리게 되고, @AuthenticationPrincipal
을 통해 가져온 객체는 준영속 상태일 수밖에 없다.
=> 따라서 OSIV가 작동한다고 해도 Spring Security Filter들은 OSIV 이전에 실행이 되고 트랜잭션이 닫히므로, 준영속 상태에 있게 된다.
해결
OpenEntityManagerInterceptor
가 아닌 OpenEntityManager
가 Filter로 동작할 수 있도록 OpenEntityManagerInViewFilter
를 사용하며, 이를 Bean으로 등록한 후 최우선으로 실행되도록 하면 된다.
또한 OpenEntityManagerInView
가 사용자에 의해 Bean으로 등록되면@ConditionalOnMissingBean({ OpenEntityManagerInViewInterceptor.class, OpenEntityManagerInViewFilter.class }), @ConditionalOnMissingFilterBean(OpenEntityManagerInViewFilter.class)
를 통해 자동 설정이 되는 Bean들은 무시된다.
@ConditionalOnMissingBean : 어노테이션에 명시된 Bean이 존재 하지 않을때 Bean 등록이 실행될 수 있도록 하는 어노테이션이다.
@Component
@Configuration
public class OpenEntityManagerConfig {
@Bean
public FilterRegistrationBean<OpenEntityManagerInViewFilter> openEntityManagerInViewFilter() {
FilterRegistrationBean<OpenEntityManagerInViewFilter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
filterFilterRegistrationBean.setFilter(new OpenEntityManagerInViewFilter());
filterFilterRegistrationBean.setOrder(Integer.MIN_VALUE); // 예시를 위해 최우선 순위로 Filter 등록
return filterFilterRegistrationBean;
}
}
결과
2024-09-13T15:00:10.234+09:00 DEBUG 10132 --- [nio-8080-exec-5] org.hibernate.SQL :
select
m1_0.id,
m1_0.created_at,
m1_0.daily_achievement_cnt,
m1_0.diligence_id,
m1_0.email,
m1_0.food_cnt,
m1_0.is_subscribed,
m1_0.pet_house_size,
m1_0.profile_image_url,
m1_0.provider,
m1_0.provider_id,
m1_0.represent_pet_id,
m1_0.role,
m1_0.scheduled_reward,
m1_0.star_point,
m1_0.title_name_id,
m1_0.updated_at,
m1_0.username
from
member m1_0
where
m1_0.email=?
2024-09-13T15:00:10.249+09:00 DEBUG 10132 --- [nio-8080-exec-5] org.hibernate.SQL :
select
p1_0.id,
p1_0.appearance,
p1_0.color,
p1_0.created_at,
p1_0.evolution_cnt,
p1_0.gauge,
p1_0.level,
p1_0.member_id,
p1_0.name,
p1_0.pet_type,
p1_0.rarity,
p1_0.updated_at
from
pet p1_0
where
p1_0.id=?
2024-09-13T15:00:10.263+09:00 DEBUG 10132 --- [nio-8080-exec-5] org.hibernate.SQL :
delete
from
pet
where
id=?
- 쿼리가 5번 -> 3번으로 줄어든 것을 확인할 수 있으며, 모든 기능도 정상적으로 동작한다.
새로운 문제
하지만..... 이는 권장사항이 아니라고 봐야한다. OSIV를 Filter로 사용하여 가장 최우선적으로 실행되게 한다는 것은 클라이언트 요청 시점부터 뷰 렌더링 시점까지, 즉 거의 전 과정에서 데이터 커넥션을 계속해서 유지한다는 의미가 되며 데이터베이스 입장에서는 매우 큰 부담이 될 것이다.
적당한 타협을 하거나 다른 방법을 모색해야할 것 같다..
스프링 AOP를 이용한 해결
권한 검사라는 관심사를 하나의 모듈로 만들어서 _권한 검사가 필요한 곳에 애노테이션을 붙이기만 하면 로직이 수행될 수 있도록 하는 것_이 내 목적이었다.
이를 위해서 처음 도입한 것이, Spring Security에서 제공하는 공통 권한 검사 클래스를 @PreAuthorize
에서 사용하는 것이었다.
하지만, 위 방식은 필터에서 로직이 수행되어 영속성 컨텍스트를 유지할 수 없다는 문제가 있었고, 이로 인해 권한 검사 부분과 비즈니스 로직 부분에서 동일한 쿼리가 중복해서 발생하는 문제가 있었다.
위에서 첫번째 해결 방법으로 OSIV 필터를 최우선으로 실행되게 하여 요청 전반에 걸쳐 영속성 컨텍스트가 유지되게 변경할 수 있었으나, 이는 권장되는 사항이 아니고 DB에 큰 부하를 줄 수 있기에 다른 방법이 필요했다.
이를 위해 새롭게 떠올린 것이 스프링 AOP이다. 권한 검사라는 접근 제어로직은 프록시에게 맡길 수 있을 것이고, 이를 Aspect
로 작성하여 같은 트랜잭션 안에서 실행되게 하면 영속성 컨텍스트를 유지할 수 있을 것이라고 생각했다.
그렇게 구현한 코드는 다음과 같다.
IsMyPetOrAdmin
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface IsMyPetOrAdmin {
}
MyPetOrAdminAspect
@Aspect
@Component
@RequiredArgsConstructor
public class MyPetOrAdminAspect {
private final PetRepository petRepository;
private final EntityManager entityManager;
@Pointcut("@annotation(com.maruhxn.todomon.core.global.auth.checker.IsMyPetOrAdmin)")
public void isMyPetOrAdminPointcut() {
}
@Around("isMyPetOrAdminPointcut() && args(memberId, petId,..)")
public void checkIsMyPetOrAdmin(ProceedingJoinPoint joinPoint, Long memberId, Long petId) throws Throwable {
TodomonOAuth2User todomonOAuth2User = getPrincipal();
if (memberId == null | petId == null) return;
Pet findPet = petRepository.findOneByIdWithMember(petId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_PET));
if (!hasAdminAuthority() && isNotMyPet(findPet, todomonOAuth2User)) {
throw new ForbiddenException(ErrorCode.FORBIDDEN);
}
joinPoint.proceed();
}
private static boolean isNotMyPet(Pet findPet, TodomonOAuth2User todomonOAuth2User) {
return !findPet.getMember().getId().equals(todomonOAuth2User.getId());
}
private boolean hasAdminAuthority() {
return getPrincipal().getAuthorities().contains(new SimpleGrantedAuthority(ROLE_ADMIN.name()));
}
private TodomonOAuth2User getPrincipal() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return (TodomonOAuth2User) authentication.getPrincipal();
}
}
처음에 @Around
가 아니라 @Before
를 어드바이스로 사용했는데 영속성 컨텍스트가 유지되지 않았다. 하지만 @Around
로 고치니 유지가 잘된다! 이유는 다음에 알아보겠다....
PetService.deletePet
@Transactional
@IsMyPetOrAdmin
public void deletePet(Long memberId, Long petId) {
Pet findPet = petRepository.findById(petId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_PET));
Member findMember = memberRepository.findById(memberId)
.orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_MEMBER));
findMember.getRepresentPet().ifPresent(representPet -> { // 삭제하려는 펫이 대표 펫이었을 경우, 대표펫을 null로 설정
if (representPet.getId().equals(petId)) {
findMember.setRepresentPet(null);
}
});
petRepository.delete(findPet);
}
실행 결과
2024-09-15T18:32:27.812+09:00 DEBUG 57398 --- [nio-8080-exec-5] org.hibernate.SQL :
select
m1_0.id,
m1_0.created_at,
m1_0.daily_achievement_cnt,
m1_0.diligence_id,
m1_0.email,
m1_0.food_cnt,
m1_0.is_subscribed,
m1_0.pet_house_size,
m1_0.profile_image_url,
m1_0.provider,
m1_0.provider_id,
m1_0.represent_pet_id,
m1_0.role,
m1_0.scheduled_reward,
m1_0.star_point,
m1_0.title_name_id,
m1_0.updated_at,
m1_0.username
from
member m1_0
where
m1_0.email=?
2024-09-15T18:32:27.828+09:00 DEBUG 57398 --- [nio-8080-exec-5] org.hibernate.SQL :
select
p1_0.id,
p1_0.appearance,
p1_0.color,
p1_0.created_at,
p1_0.evolution_cnt,
p1_0.gauge,
p1_0.level,
m1_0.id,
m1_0.created_at,
m1_0.daily_achievement_cnt,
m1_0.diligence_id,
m1_0.email,
m1_0.food_cnt,
m1_0.is_subscribed,
m1_0.pet_house_size,
m1_0.profile_image_url,
m1_0.provider,
m1_0.provider_id,
m1_0.represent_pet_id,
m1_0.role,
m1_0.scheduled_reward,
m1_0.star_point,
m1_0.title_name_id,
m1_0.updated_at,
m1_0.username,
p1_0.name,
p1_0.pet_type,
p1_0.rarity,
p1_0.updated_at
from
pet p1_0
join
member m1_0
on m1_0.id=p1_0.member_id
where
p1_0.id=?
2024-09-15T18:32:27.844+09:00 DEBUG 57398 --- [nio-8080-exec-5] org.hibernate.SQL :
delete
from
pet
where
id=?
위처럼 적용하니 영속성 컨텍스트가 잘 유지되어 중복 쿼리가 발생하지 않았다!
발생한 쿼리는 다음과 같다.
- 처음 JwtVerificationFilter에서 멤버를 조회하는 쿼리 1번
- MyPetOrAdminAspect에서 pet과 member를 조인하여 조회하는 쿼리 1번
- 마지막으로 실제 관심사인 pet을 삭제하는 쿼리 1번
-> 쿼리 5번 -> 3번으로 줄일 수 있었다.
단점
스프링 AOP를 적용함으로써 중복 쿼리를 제거할 수 있었지만, 하나 아쉬운 점은 권한 검사 애노테이션이 Controller에 붙어있지 않고 Service에 붙어있게 된다는 점이었다..
이전 @PreAuthorize
를 사용하는 코드에서는 중복 쿼리가 있긴 했지만, Controller만 보고도 해당 API에서는 권한 검사가 필요하다고 바로 확인할 수 있었지만, 이제는 Service 코드까지 내려와야 확인할 수 있다는 점이 조금 아쉽다..
남아있는 문제
아직 끝이 아니다! 우리는 jwt를 사용하고 있음에도 JwtVerification
에서 매 요청마다 DB에서 멤버를 조회하고 있다.
이는 jwt를 사용하는 이점이 없다고 본다.. 또한, 여기서 발생한 멤버 조회 쿼리는 영속성 컨택스트에 저장되지도 않기에 조회한 멤버를 SecurityContext
에 담아도 데이터를 변경할 수 없으며, 실제 데이터를 변경하기 위해서는 비즈니스 레이어에서 다시 DB에 쿼리를 날려야 한다.
또한, 요청한 API에서는 유저에 대한 정보가 필요없는 경우에도 굳이 쿼리가 나가는 부분이 마음에 들지 않는다..
=> Member를 조회하는 쿼리가 나가지 않게 jwt 자체에 유저 정보를 담도록 Jwt 사용 로직 자체를 변경해야겠다.
TodomonOAuth2User
변경
public class TodomonOAuth2User implements OAuth2User {
private Map<String, Object> attributes;
private MemberDTO userInfo;
public TodomonOAuth2User(MemberDTO userInfo) {
this.userInfo = userInfo;
}
private TodomonOAuth2User(MemberDTO userInfo, OAuth2ProviderUser oAuth2ProviderUser) {
this.userInfo = userInfo;
this.attributes = oAuth2ProviderUser.getAttributes();
}
public static TodomonOAuth2User from(MemberDTO dto) {
return new TodomonOAuth2User(dto);
}
public static TodomonOAuth2User of(MemberDTO dto, OAuth2ProviderUser oAuth2ProviderUser) {
return new TodomonOAuth2User(dto, oAuth2ProviderUser);
}
@Override
public Map<String, Object> getAttributes() {
return this.attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> authorities = new ArrayList<>();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(userInfo.getRole());
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public String getName() {
return this.userInfo.getUsername();
}
public Long getId() {
return this.userInfo.getId();
}
public String getEmail() {
return this.userInfo.getEmail();
}
public String getRole() {
return this.userInfo.getRole();
}
public String getProvider() {
return this.userInfo.getProvider();
}
}
- 기존에 불필요하게
Member
를 필드로 받아왔던 구조에서 필요한 데이터만을 받아오도록 변경했다.
JwtService.getPrincipal()
// 변경 전
public TodomonOAuth2User getPrincipal(String accessToken) {
Claims payload = jwtProvider.getPayload(accessToken);
String email = payload.getSubject();
Member findMember = memberService.findMemberByEmail(email);
return TodomonOAuth2User.of(findMember);
}
// 변경 후
public TodomonOAuth2User getPrincipal(String accessToken) {
Long id = jwtProvider.getId(accessToken);
String username = jwtProvider.getUsername(accessToken);
String email = jwtProvider.getEmail(accessToken);
String role = jwtProvider.getRole(accessToken);
String provider = jwtProvider.getProvider(accessToken);
MemberDTO dto = MemberDTO.builder()
.id(id)
.username(username)
.email(email)
.role(role)
.provider(provider)
.build();
return TodomonOAuth2User.from(dto);
}
- DB에서 멤버를 조회하는 로직을 삭제
- jwt subject와 claim에 저장된 데이터를 다루는 DTO를 만들어 해당 DTO로만
TodomonOAuth2User
를 생성하도록 변경했다.
결과
2024-09-15T20:58:14.309+09:00 DEBUG 72506 --- [nio-8080-exec-3] org.hibernate.SQL :
select
p1_0.id,
p1_0.appearance,
p1_0.color,
p1_0.created_at,
p1_0.evolution_cnt,
p1_0.gauge,
p1_0.level,
m1_0.id,
m1_0.created_at,
m1_0.daily_achievement_cnt,
m1_0.diligence_id,
m1_0.email,
m1_0.food_cnt,
m1_0.is_subscribed,
m1_0.pet_house_size,
m1_0.profile_image_url,
m1_0.provider,
m1_0.provider_id,
m1_0.represent_pet_id,
m1_0.role,
m1_0.scheduled_reward,
m1_0.star_point,
m1_0.title_name_id,
m1_0.updated_at,
m1_0.username,
p1_0.name,
p1_0.pet_type,
p1_0.rarity,
p1_0.updated_at
from
pet p1_0
join
member m1_0
on m1_0.id=p1_0.member_id
where
p1_0.id=?
2024-09-15T20:58:14.315+09:00 DEBUG 72506 --- [nio-8080-exec-3] org.hibernate.SQL :
delete
from
pet
where
id=?
- 매 요청마다 멤버가 존재하는지 확인하기 위한 조회 쿼리가 사라지고 정말 필요한 2번의 쿼리만 남은 것을 확인할 수 있다!
주의할 점
물론 'Jwt token을 신뢰할 수 있느냐?'라는 문제가 남아있긴 하다. jwt token이 탈취될 경우라거나 오래된 jwt의 경우 최신 정보를 반영하지 못한다는 점이 문제이다. 하지만, 이는 Jwt access token의 유효기간을 짧게 가져가고 Refresh Token을 사용하면 완화할 수 있는 문제이다!
'Project' 카테고리의 다른 글
[TODOMON] EP.16 코드 리팩토링 (0) | 2025.02.09 |
---|---|
[TODOMON] EP.15 로그관리 with ELK (0) | 2025.02.07 |
[TODOMON] EP.13 OneToOne 양방향 관계 조회 시 지연 로딩이 적용되지 않는 문제 (0) | 2025.02.07 |
[TODOMON] EP.12 아무것도 모르는 나의 동시성 문제 해결기 (0) | 2025.02.05 |
[TODOMON] EP.11 인덱스를 통한 투두 조회 API 성능 개선 feat. 인덱스 컨디션 푸시다운 (0) | 2025.02.04 |