Kotlin/Kotlin | Spring 학습기록

[Spring] ArgumentResolver 사용 시 주의점 (feat. OSIV)

kth990303 2023. 2. 26. 03:13
반응형

ArgumentResolver는 컨트롤러 단에서 요청값으로부터 원하는 객체 또는 프로퍼티를 반환하게 할 수 있다.

보통은 커스텀 유저 객체를 반환할 때 HandlerMethodArgumentResolver의 구현체를 이용하여 많이 사용한다.

 

Java에서의 ArgumentResolver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@RequiredArgsConstructor
public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {
 
    private final JwtTokenProvider jwtTokenProvider;
 
    @Override
    public boolean supportsParameter(final MethodParameter parameter) {
        return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
    }
 
    @Override
    public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
                                  final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
        final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        final String token = AuthorizationExtractor.extract(Objects.requireNonNull(request));
        final String id = jwtTokenProvider.getPayload(token);
 
        return new LoginMemberRequest(Long.valueOf(id));
    }
}
cs

 

Kotlin에서의 ArgumentResolver 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
class LoginUserResolver(
        private val jwtTokenProvider: JwtTokenProvider,
) : HandlerMethodArgumentResolver {
 
    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.hasParameterAnnotation(LoggedInMember::class.java)
    }
 
    override fun resolveArgument(
            parameter: MethodParameter,
            mavContainer: ModelAndViewContainer?,
            webRequest: NativeWebRequest,
            binderFactory: WebDataBinderFactory?
    ): LoginUserRequest {
        val token = AuthorizationHeaderUtils.extractBearerToken(webRequest)
        val username = jwtTokenProvider.getPayload(token)
        return LoginUserRequest(username)
    }
}
cs

 

위처럼 argumentResolver를 작성해준 후 WebMvcConfigurer의 addArgumentResolvers 메서드를 오버라이딩하여 해당 리졸버를 등록해주면 사용이 가능하다.

 

Filter, Interceptor는 컨트롤러 단의 request 이전에 실행된다면, ArgumentResolver는 컨트롤러 단에서 실행된다. 

즉, Filter -> Interceptor -> Controller( ArgumentResolver ) 순으로 실행된다.


만약 여기서 Entity 자체를 반환한다면?

JPA를 사용한다면 ArgumentResolver에서 Entity 자체를 반환하는 것은 권장되지 않는다.

Entity 자체를 반환하는 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
class LoginUserResolver(
        private val jwtTokenProvider: JwtTokenProvider,
        private val memberService: MemberService
) : HandlerMethodArgumentResolver {
 
    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.hasParameterAnnotation(LoggedInMember::class.java)
    }
 
    override fun resolveArgument(
            parameter: MethodParameter,
            mavContainer: ModelAndViewContainer?,
            webRequest: NativeWebRequest,
            binderFactory: WebDataBinderFactory?
    ): Member {
        val token = AuthorizationHeaderUtils.extractBearerToken(webRequest)
        val userEmail = jwtTokenProvider.getPayload(token)
        return memberService.getByEmail(userEmail)
    }
}
cs

20번째 라인을 보면 memberService를 이용히여 Member Entity를 직접 반환한다.

ArgumentResolver는 컨트롤러 단에서 수행되는 작업이다. 컨트롤러가 서비스를 가지는 것은 레이어드 아키텍처 상 문제될 건 없기 때문에 잘못된 점을 찾기 쉽지 않을 수도 있다.

 

그럼 이렇게 반환할 경우에 무엇이 문제가 되냐?

바로 (OSIV 옵션이 false인 이상) 해당 Entity는 준영속 상태의 객체로 반환된다는 것이 문제이다.

 

스프링에선 기본적으로 OSIV가 ON으로 켜져있다.

spring.jpa.open-in-view:true

 

하지만 OSIV는 웬만한 상황에선 꺼두는 것이 권장되는데 그 이유는 아래와 같다.

OSIV와 JPA Persistence Context

  • OSIV를 꺼두면 트랜잭션이 시작될 때 EntityManager 생성, 트랜잭선이 종료될 때 EntityManager 종료
  • OSIV를 켜두면 요청이 시작될 때 EntityManager 생성, 뷰 렌더링이 끝날 때 EntityManager 종료

 

참고로 EntityManager는 Persistence Context라는 영역을 두어 Entity lifecycle을 관리한다.

 

OSIV를 켜두면 해당 커넥션을 트랜잭션이 끝나도 계속 물고 있기 때문에 커넥션 리소스의 낭비가 발생한다. 흔히 겪는 Connection Time Out 에러에 좀 더 취약해질 수 있다는 것이다. 뿐만 아니라, 비즈니스 로직 바깥에 해당되는 controller, view rendering 에서 엔티티를 수정해버릴 수 있다는 위험한 상황이 존재한다. 다행히 이 문제는 Entity를 서비스단 바깥에 반환하지 않고 DTO를 반환하면 해결되긴 한다.

따라서 OSIV 옵션은 OFF로 설정하는 것이 웬만한 상황에서 안전하다.

 

그런데 갑자기 OSIV 얘기는 왜 나왔을까?

앞에서 말했던 이 내용이 기억나는가?

OSIV를 꺼두면 트랜잭션이 시작될 때 EntityManager 생성, 트랜잭선이 종료될 때 EntityManager 종료

 

OSIV 옵션을 꺼둔 상태에서 ArgumentResolver에서 Entity를 반환하면 EntityManager이 종료되고 해당 Entity는 준영속 상태로 반환된다. 만약 이렇게 반환된 Entity를 서비스 단에서 update할 경우 더티 체킹 작업을 진행할 수 없으므로 제대로 업데이트가 되지 않는 문제가 발생할 수 있다.

 

그렇다면 해결책은 무엇이 있을까?


해결책 1. OSIV 옵션을 킨다

OSIV 옵션을 키면 영속성 컨텍스트가 트랜잭션이 끝나도 계속 유지되므로 Entity 더티 체킹 및 수정 작업은 당연히 가능해진다.

 

하지만 위에서 말했다시피 뷰 렌더링 작업에서까지 커넥션을 물고 있게 된다는 점, 비즈니스 로직을 다루는 서비스 레이어 바깥에서도 Entity의 수정이 일어날 수 있다는 점때문에 이 방법은 웬만해선 권장되지 않는다.

 

참고로 아래와 같이 FilterRegistrationBean의 filter를 OpenEntityManagerInViewFilter()로 등록하여 필터까지 osiv 범위를 늘릴 수도 있다. (당연히 `spring.jpa.open-in-view = false`로 해둔다고 해도, 아래처럼 빈으로 등록하면 osiv는 true인 셈이다.) 스프링 시큐리티를 사용한다면 아래 방법이 유용할 수 있다.

1
2
3
4
5
6
7
8
9
10
@Configuration
class OpenEntityManagerConfig {
 
    @Bean
    fun openEntityManagerInViewFilter(): FilterRegistrationBean<OpenEntityManagerInViewFilter> {
        val filterFilterRegistrationBean: FilterRegistrationBean<OpenEntityManagerInViewFilter> = FilterRegistrationBean()
        filterFilterRegistrationBean.filter = OpenEntityManagerInViewFilter()
        return filterFilterRegistrationBean
    }
}
cs

해당 방법은 아래 글에서 더 자세히 볼 수 있다.

https://tecoble.techcourse.co.kr/post/2020-09-20-entity-lifecycle-2/

 

Entity Lifecycle을 고려해 코드를 작성하자 2편

이번 편에서는 전편에서 해결하지 못한 부분이었던 “Spring Boot에서는 기본적으로 OSIV의 설정 값이 true인데도 불구하고 LazyInitializationException…

tecoble.techcourse.co.kr


해결책 2. @Transactional 내에서 영속 상태로 등록한다

ArgumentResolver에서 찾아온 Member는 준영속 상태이므로, 영속상태로 등록시킨 후 더티 체킹 작업을 거치도록 변경한다.

// AS-IS
fun updatePassword(loggedInMember: Member, newPassword: String) {
    loggedInMember.updatePassword(Password(newPassword))
}

// TO-DO
fun updatePassword(loggedInMember: Member, newPassword: String) {
    // 영속성 컨텍스트에 등록
    val findMember = memberRepository.findByEmail(loggedInMember.email)
            ?:throw NotFoundException.memberNotFound()
    findMember.updatePassword(Password(newPassword))
}

memberRepository.find 작업을 거치면 아래와 같이 영속 상태가 된다.

 

  1. 해당 Member는 영속성 컨텍스트 관리 대상이 아닌 준영속 상태이다.
  2. 따라서 실제 DB에 쿼리를 날려 Member를 찾아온다.
  3. 해당 Member는 영속 상태로 변하게 되고, update 시 더티 체킹 작업이 수행된다.

 

실제로 이미 찾아온 Member를 한 번 더 조회하는 로직이 추가되게 된다.

하지만 너무 걱정할 필요는 없다.

아마 사용자 정보를 수정하고 싶어서 그러신 것 같아요. 사용자를 한번 더 조회하더라도, 트랜잭션이 있는 곳에서 처음부터 다시 조회하는 것이 더 나은 선택입니다.

두번 조회라는 것이 성능 때문에 고민이 있을 수도 있지만, 사실 실제 애플리케이션을 생각해보면, 사용자 정보를 수정하는 경우는 거의 없습니다. 그렇기 때문에 이런 경우는 두번을 조회하더라도 전체 애플리케이션에 미치는 영향은 바다의 모래알 같은 것입니다. 이것을 어떻게든 해결하기 위해서 복잡한 방법을 사용하면 유지보수가 매우 어려운 애플리케이션이 됩니다.

사실 뭔가 엄청 고민을 한다는 것은 반대로 생각해보면, 그 고민이 근본적으로 잘못된 수일 가능성이 높습니다.

출처: 김영한님의 답변 (https://www.inflearn.com/questions/129203)

 

실제로 CRUD 중에서 Read : CUD 비율은 거의 8:2 ~ 9:1에 가깝다.

그렇기 때문에 find작업이 추가된다고 해서 엄청난 성능 저하를 일으키진 않는다.


해결책 3. ArgumentResolver에서 Entity를 반환하지 않는다 (권장)

사실 ArgumentResolver에서 Entity를 반환하지 않으면 되는 문제이다.

 

Kotlin에서의 ArgumentResolver

ArgumentResolver에서 service나 repository를 가지지 않을 수도 있어 의존성도 더욱 적어진다.

 

해결책 2 방법은 비즈니스 로직을 작성할 때마다 해당 Member가 준영속 상태인지 인지한 상태여야 한다. 이는 실수를 유발하기 정말 좋은 코드이며, 타 작업자에게 인수인계 또한 어려워질 수 있다. 버그 발생 확률도 당연히 높아진다.

 

그렇기 때문에 웬만해선 ArgumentResolver에서는 Entity를 직접 반환하지 않는 것이 좋다.


Filter, Interceptor, ArgumentResolver와 JPA의 영속성 컨텍스트 원리, OSIV 옵션 이해도의 중요성을 다시 한 번 느끼게 된다.

소규모의 애플리케이션이 아닌 이상 커넥션은 소중한 자원이기 때문에 OSIV 옵션을 꺼두고 서비스 바깥에서는 Entity 사용을 지양하도록 하자.

 

참고자료

반응형