[Project]/[Momo]

[이슈 #1] Spring Transaction의 Self Invocation 이슈

DevLoki 2022. 3. 4. 01:53

Spring Transaction의 Self Invocation 이슈

문제 인식

Spring Security를 이용한 OAuth 로그인 기능을 구현하기 위해 커스텀한 OAuth2UserService를 개발하던 중, 영속 상태에 있는 인스턴스의 필드 변경 사항이 DB에 저장되지 않는 문제를 발견하였습니다. 로그를 출력하며 확인해본 결과, 영속성 컨텍스트의 스냅샷을 이용한 변경 감지(dirty checking)뿐만 아니라 트랜잭션을 지원하는 쓰기 지연도 정상적으로 동작하지 않았습니다.

문제가 발생한 코드는 다음과 같습니다.

@Service
@RequiredArgsConstructor
public class OAuth2UserServiceImpl implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private static final String GITHUB = "github";
    private static final String GOOGLE = "google";
    private static final String NAVER = "naver";
    private static final String KAKAO = "kakao";

    private final MemberRepository memberRepository;
    private final ImageUrlRepository imageUrlRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegateService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegateService.loadUser(userRequest);
        Map<String, Object> attributes = oAuth2User.getAttributes();
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        String userNameAttributeKey = oAuth2User.getName();
        OAuthAttributes oAuthAttributes = null;
        if (OAuthType.GITHUB.equals(registrationId) {
            oAuthAttributes = GithubOAuthAttributes.ofAttributes(attributes);
        } else if (OAuthType.GOOGLE.equals(registrationId) {
            oAuthAttributes = GoogleOAuthAttributes.ofAttributes(attributes);
        } else if (OAuthType.NAVER.equals(registrationId) {
            oAuthAttributes = NaverOAuthAttributes.ofAttributes(attributes);
        } else if (OAuthType.KAKAO.equals(registrationId) {
            oAuthAttributes = KakaoOAuthAttributes.ofAttributes(attributes);
        } else
                        throw NoRegistrationFound.getInstance();

        saveOrUpdate(oAuthAttributes);

        return new DefaultOAuth2User(Collections.emptyList(), oAuth2User.getAttributes(), oAuthAttributes.getNameAttributeKey());
    }

    @Transactional
    public void saveOrUpdate(OAuthAttributes oAuthAttributes) {
        Optional<Member> optionalMember = memberRepository.findByOauthTypeAndOauthId(oAuthAttributes.getOauthType(), oAuthAttributes.getOauthId());
        Member member;
        if (optionalMember.isPresent()) {
            ImageUrl imageUrl = optionalMember.get().getImageUrl();
            imageUrl.update(oAuthAttributes.getImageUrl());
            optionalMember.get().update(oAuthAttributes.getName(), oAuthAttributes.getEmail(), imageUrl);
            member = optionalMember.get();
        } else {
            member = oAuthAttributes.toMember();
            memberRepository.save(member);
            ImageUrl imageUrl = ImageUrl.ofUrl(oAuthAttributes.getImageUrl(), member.getId());
            imageUrlRepository.save(imageUrl);
            //TODO
            member.setImageUrl(imageUrl);
        }
        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(member.getId(), null, Collections.emptyList()));
    }

}

OAuth Login 기능은 크게 두가지 필터 OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter를 기반으로 동작합니다.

OAuth2AuthorizationRequestRedirectFilter는 요청 URL의 registrationId(github, google, kakao 등) 값을 통해 OAuth 인증 페이지로 리다이렉트를 시켜주는 필터입니다. state를 생성하여 요청에 담아 전달하면 서비스 제공자는 이 값을 다시 응답에 포함하여 전달함으로써 CSRF 공격을 차단하는 수단이 될 수 있습니다. 응답은 사전에 정의된 리다이렉션 엔드포인트(일반적으로 /login/oauth2/code/{registrationId}?code=***&state=***입니다)로 오며 state와 code를 전달받습니다.

OAuth2LoginAuthenticationFilter는 리다이렉션 엔드포인트로 들어온 응답의 code를 사용하여 서비스 제공자의 리소스 서버에 접근 권한을 가진 access-token과 redirect-token을 교환해오는 역할과 access-token을 이용해 사전에 정의된 scope의 데이터를 가져오는 역할을 합니다.

OAuth2UserServiceImpl 는 OAuth2LoginAuthenticationFilter의 OAuth2LoginAuthenticationProvider에서 사용되는 서비스로 access-token을 사용해 사용자의 데이터를 가져온 후, 해당 데이터를 바탕으로 OAuth2User 객체를 만드는 역할을 합니다.

loadUser()를 통해 리소스 서버로부터 받아온 데이터 Map<String, Object> attributes를 적절히 파싱하여 필요 데이터를 추출하고 해당 데이터로부터 DB에서 Member를 조회합니다. 조회 결과가 없다면 DB에 신규 데이터를 저장하고, 조회 결과가 있다면 해당 정보를 Update 합니다.

OAuth2UserServiceImpl는 2개의 메서드로 구성됩니다. 리소스 서버로부터 받아온 데이터를 파싱 하는 loadUser() 와 파싱 된 데이터를 바탕으로 저장하거나 갱신하는 saveOrUpdate() 메서드입니다. SQL 쿼리가 실행되는 메서드는 saveOrUpdate()로 해당 메서드에 @Transactional을 추가하였으나 위와 같은 문제가 발생한 상황입니다.

원인 분석

Spring이 Transaction을 처리하는 방법에 있었습니다. Spring은 @Transactional을 사용한 선언적 트랜잭션을 처리할 때 프록시를 사용합니다. 다음 그림과 같이 @Transactional이 붙어있는 클래스를 CGLIB 방식을 사용하여 프록시로 만듭니다. 프록시로 만드는 과정은 간단합니다.

메서드가 시작하기전 transaction.begin()과 메서드가 끝나기 전 transaction.commit() 혹은 transaction.rollback() 메서드를 추가해 주는 것이 전부입니다.

하지만 이러한 방식에는 큰 문제가 있었습니다.

해당 클래스를 프록시로 만들면 프록시 객체의 saveOrUpdate()는 메서드의 앞뒤에 transaction관련 코드가 생성됩니다. 하지만 프록시가 호출하는 메서드는 loadUser()이고 loadUser() 내부에서 saveOrUpdate()를 호출하기 때문에 saveOrUpdate()는 트랜잭션이 적용되지 않은 것입니다.

해결 방법

원인 분석을 마치고 저는 이 문제를 해결할 4가지 방법을 떠올렸습니다.

1. 프록시가 호출하는 메서드에 @Transactional을 적용하기

public class OAuth2UserServiceImpl {

    @Transactional
    public void loadUser() {
        saveOrUpdate();
    }

    public void saveOrUpdate(){
        //do something
        //do something
        //do something
    }

}

이 방법은 Self Invocation 문제를 해결하는 가장 간단한 방법입니다.

프록시가 호출하는 메서드인 loadUser() 자체에 @Transactional을 적용하면 해당 메서드에서부터 Transaction Propagation에 따라 loadUser()에서 호출하는 saveOrUpdate()도 동일한 트랜잭션이 적용되므로 해당 문제가 해결됩니다.

문제는 올바르게 해결되었지만, 이 코드에는 문제점이 존재합니다.

데이터베이스는 Connection의 개수가 제한적이고 Connection을 소유하는 시간이 길어질수록 사용 가능한 Connection의 수가 줄어들기 때문에 트랜잭션의 범위를 최소화하여야 합니다. 따라서 트랜잭션의 범위를 확장하는 이 방식은 좋은 방법이 아닙니다.

2. 프록시 객체를 주입받아 직접 사용하기

public class OAuth2UserServiceImpl {

    @Autowired OAuth2UserServiceImpl proxy;

    public void loadUser() {
        proxy.saveOrUpdate();
    }

    @Transactional
    public void saveOrUpdate(){
        //do something
        //do something
        //do something
    }
}

이 방법은 프록시 객체를 직접 가져와서 사용하는 방법입니다.

트랜잭션의 범위가 확장되는 1번 방법의 단점을 보완할 수 있지만, 프레임워크에서 수행되는 작업을 미리 예상하고, 해당 클래스가 자신을 상속한 프록시 객체에 의존하는 구조를 가지게 됩니다.

3. 직접 트랜잭션 적용하기

public class OAuth2UserServiceImpl {

    private final TransactionManager transactionManager;

    public OAuth2UserServiceImpl (TransactionManager transactionManager) {
        this.transactionManager = transactionManager;
    }

    public void loadUser() {
        proxy.saveOrUpdate();
    }

    public void saveOrUpdate() {
        try {
            transactionManager.begin();
            //do something
            //do something
            //do something
            transactionManager.commit();
        } catch (Exception e) {
            transactionManager.rollback();
        }

    }
}

이 방법은 프록시를 직접 사용하는 2번 방법을 보완하는 @Transactional 어노테이션을 사용하지 않고 직접 트랜잭션을 관리하는 방법입니다.

해당 방식을 사용하면 1번처럼 트랜잭션의 범위가 필요 이상으로 커지지 않고, 프록시 객체에 의존적인 관계를 가지지 않아도 된다는 장점이 있지만, 이는 프레임워크에서 제공해주는 기능을 사용하지 않고 직접 개발을 해야 한다는 점과, 불필요한 코드가 길어져 가독성이 떨어진다는 단점이 있습니다.

4. 외부로 분리하기

public class OAuth2UserServiceImpl {

    private final MemberService memberService;

    public void loadUser() {
        memberService.saveOrUpdate(attributes);
    }
}

이 방법은 제가 이 문제를 해결하는데 적용한 방법으로 가장 간단하면서도 효율적인 방법입니다.

애초에 문제가 발생하는 환경을 조성하지 않는 것으로 내부 호출되는 메서드를 위부로 분리하는 것입니다.

결론

  1. 트랜잭션이 예상과 다르게 동작한다면 Self Invocation 이슈를 의심해보자
  2. Self Invocation 이슈가 발생했다면 가장 먼저 외부로 분리하기를 고려해보자

느낀 점

지금까지 개발을 하며 여러 이슈들을 직면했을 때, 이슈가 발생한 원인보다는 해결 방법에 큰 비중을 두었던 것 같습니다. 이번 프로젝트를 진행하며 겪은 이슈를 글로 남기며 정리해보니, 이슈의 원인을 깊이 있게 이해하고 분석하는 것이 오히려 문제를 해결할 수 있는 지름길이 될 수 있다는 것을 느끼게 되었습니다.