트랜잭션 범위에서는 필요한 로직만 호출하자

Glen·2023년 10월 5일
2

배운것

목록 보기
23/37

서론

스프링 프레임워크를 사용하면 데이터베이스의 트랜잭션을 매우 쉽게 사용할 수 있다.

단순히 @Transactional 어노테이션만 붙이면 마법같이 해당 메소드가 시작되고 끝날때 까지 트랜잭션이 적용된다.

이처럼 트랜잭션의 적용이 매우 쉽고 간단하기 때문에 정말 트랜잭션이 필요한 기능에 붙이지 않고, 이리저리 남발하는 경우가 잦다.

그렇다면 트랜잭션을 언제 적용해야 하고, 적용하지 않아야 하는 경우는 언제일까?

본론

여기서 말하는 트랜잭션은 DB에 관련된 기능이다.

DB에서 트랜잭션의 사전적 정의는 다음과 같다.

일련의 작업을 하나의 논리적인 작업으로 묶은 연산

트랜잭션을 적용해야 할 때

여러 로직이 섞여있을 때

회원가입, 로그인 같은 보안에 관련된 기능을 구현할 때 자체적인 회원가입 시스템을 구현하는 경우도 있지만, OAuth2 같은 외부 서비스를 사용한 회원가입 시스템을 구현하는 경우가 많다.

OAuth2를 사용하려면 OAuth2 서비스 제공자에게 요청을 보내야 한다.

여기서 요청을 보내는 클라이언트는 사용자가 아닌, 서버가 될 수 있다.

OAuth2를 사용한 로그인/회원가입 로직을 다음과 같이 구현할 수 있다.

@Service
class OAuth2LoginService() {

    private final MemberRepository memberRepository;
    private final OAuth2Client oAuth2Client; // 외부 API에 요청을 보내는 컴포넌트
    private final AuthProvider authProvider; // JWT 토큰을 발급해주는 컴포넌트

    @Transactional
    public LoginResponse login(LoginRequest request) {
        UserInfo userInfo = oAuth2Client.getUserInfo(request.getToken());
        Member member = memberRepository.findBySocialId(userInfo.getSocialId)
            .orElseGet(() -> memberRepository.save(userInfo.toEntity()));
        String accessToken = authProvider.provide(member.getId());
        return LoginResponse.from(accessToken);
    }
}

해당 login() 메서드는 내부에 여러 로직들 섞여 있다.

이렇게 여러 로직들이 섞여 있을 때 발생하는 문제는 데이터를 영속함에 있다.

만약 authProvider.provide() 메서드에서 예외가 발생한다면?

위에서 호출한 memberRepository.save() 메서드에서 영속한 데이터를 되돌려야 한다.

그렇지 않으면 서버의 문제든, 클라이언트의 문제든 예외가 발생한 잘못된 요청임에도 회원이 저장되어 정
합성이 맞지 않는 문제가 발생한다.

따라서 이 경우 트랜잭션을 적용해야 한다.

DB에 관련된 메서드를 여러번 호출할 때

트랜잭션을 적용하는 하나의 이유는 데이터의 정합성을 보장하는 것도 있지만, 핵심은 일련의 작업을 하나의 논리적인 작업으로 묶음에 있다.

이것은, 여러 작업을 하나의 작업으로 묶어 성능을 개선할 수 있다는 것이다.

@Service
class MyService() {

    public Response updateData(Request request) {
        Member member = memberDao.findById(request.memberId());
        member.setNickname(request.nickname());
        memberDao.update(member);
        memberHistoryDao.save(new MemberHistory(member));
    }
}

여기서 데이터베이스에 요청하는 개수는 3번이다.

여기서 커넥션 풀을 사용하지 않으면 데이터베이스 커넥션이 3번 맺어진다.

커넥션 풀을 사용하더라도, 하나의 작업임에도 서로 다른 커넥션을 사용할 것이다.

이때 트랜잭션을 적용한다면 트랜잭션 범위안에 있는 모든 데이터베이스의 요청들은 하나의 커넥션을 공유하여 사용한다.

DAO 클래스의 내부는 JdbcTemplate를 사용한다고 가정한다.

따라서 굳이 불필요하게 다른 커넥션을 사용하지 않고 하나의 커넥션만 사용하므로 성능을 높일 수 있다.

트랜잭션을 적용하지 않아야 할 때

그렇다면 트랜잭션은 반드시 적용해야 하는 게 좋아 보인다.

데이터의 정합성을 보장해 주고, 성능도 높여주는 오히려 사용하지 않는 게 이상할 정도이다.

하지만 상황에 따라 적절한 위치에 트랜잭션을 적용하고 해제하는 것이 중요하다.

외부 API를 호출하는 로직

위의 로그인 코드에서 트랜잭션을 적용하여 데이터를 영속한 뒤 예외가 발생하더라도 영속한 데이터를 다시 되돌릴 수 있는, 정합성을 보장한 기능을 구현하였다.

그런데 위에서 트랜잭션은 DB에 관련된 기능이라고 했다.

따라서 DB에 관련되지 않은 로직을 수행한다면 트랜잭션을 적용할 필요가 없다.

위와 같은 경우 외부 API를 호출하는 oAuth2Client.getUserInfo() 메서드가 될 것이다.

외부 API를 호출하는 작업은 비용이 크다.

여기서 비용이란, 네트워크를 오가며 드는 시간이다.

이 비용은 단순히 CPU의 성능이 좋다고 더 빨라지는 것이 아닌, 오로지 네트워크와 외부 API 서버가 얼마나 빠르게 반응하냐에 따라 달린 것이라 성능을 높이기 어렵다.

만약 외부 서비스에 요청이 많이 몰려 3~4초 혹은 그 이상 지연이 된다면?

또는 네트워크 문제가 발생하여 요청이 가고, 중간에 요청이 유실되어 서버가 응답이 기다리느라 잠깐 대기를 하게 된다면?

트랜잭션을 통해 맺고 있던 커넥션이 쭉 유지된 채 있을 것이다.

따라서 외부 API에 관련된 작업은 최대한 트랜잭션 범위 밖에서 호출해야 한다.

이것이 문제가 되는 이유는 단순히 커넥션이 오래 유지 되는 것이 문제가 아닌, 커넥션 풀을 사용하기 때문이다.

기본적으로 스프링은 10개의 커넥션을 가진 풀을 제공한다.

여기서 로그인에 대한 요청이 외부 API 문제로 지연되어 커넥션을 점유하고 있다면?

당장 사용 가능한 커넥션 풀이 9개로 줄어들 것이다.

물론 요청들이 많이 온다면 사용할 수 있는 커넥션 풀은 거의 0~1개를 왔다 갔다 하겠지만, 요청이 정상적으로 처리된다고 가정할 때 빠르게 커넥션 풀을 반환할 것이기 때문에 성능이 낮아지거나 하는 상황은 없을 것이다.

하지만 이것은 요청들이 빠르게 커넥션 풀을 반환할 때 일이다.

외부 API의 문제로 커넥션을 오래 점유하게 된다면 그 시간 동안 커넥션을 사용할 수 없다.

이러한 외부 API의 문제는 수많은 요청에서 운이 나쁘게 하나의 요청에만 생기는 것이 아니라, 외부 API 서버가 마비된 시간에 오는 요청이 전부 지연 된다.

그렇다면 한 번에 로그인 요청이 10개가 들어왔는데, 우연히 외부 API 서버에 문제가 생겨 5초 정도 지연이 발생한다면?

약 5초간 커넥션 풀이 고갈되는 상황이 생긴다.

이때 DB에 있는 데이터를 조회하는 단순한 요청이 들어오면?

커넥션 풀이 고갈되었기 때문에, 해당 요청은 커넥션 풀에 커넥션이 반환될 때까지 약 5초 동안 지연되게 된다.

즉, 외부 API의 호출에서 발생한 지연이 다른 요청까지 영향을 끼치게 된다.

단순히 커넥션 풀의 개수를 늘려도 좋겠지만, 결국 이러한 요청이 계속 쌓이게 되므로 성능에 좋지 않은 결과를 줄 것이 분명하다.

따라서 트랜잭션의 범위에서 외부 API 호출을 하는 로직은 최대한 분리해야 한다.

@Service
public class OAuth2LoginService {

    private final MemberRepository memberRepository;
    private final OAuth2Client oAuth2Client; // 외부 API에 요청을 보내는 컴포넌트
    private final AuthProvider authProvider; // JWT 토큰을 발급해주는 컴포넌트

    public LoginResponse login(LoginRequest request) {
        UserInfo userInfo = getUserInfo(request.getToken());
        Member member = getMember(userInfo);
        String accessToken = authProvider.provide(member.getId());
        return LoginResponse.from(accessToken, member);
    }

    private UserInfo getUserInfo(String token) {
        return oAuth2Client.getUserInfo(request.getToken());
    }

    @Transactional
    private Member getMember(UserInfo userInfo) {
        return memberRepository.findBySocialId(userInfo.getSocialId)
            .orElseGet(() -> memberRepository.save(userInfo.toMember()));
    }
}

하지만 주의할 점은 getMember() 메서드에는 트랜잭션이 적용되지 않는다.

이유는 트랜잭션은 AOP로 구현되기 때문에, public 접근자로 공개된 메서드에만 프록시가 적용될 수 있다.

또한 self-invocation 문제를 조심해야 한다.

login() 메서드에서 트랜잭션이 적용된 getMember() 메서드를 호출하는데, 이때 getMember() 메서드는 트랜잭션이 적용된 프록시 메서드가 아닌, 실제 OAuth2LoginService의 메서드를 직접 호출하기 때문에 트랜잭션이 적용되지 않은 메서드가 호출된다.

그렇기에 authProvider.provide() 메서드에서 예외가 발생했을 때, 롤백이 되지 않는다!

이를 해결하려면 내부에 Service를 의존하게 하여 호출하게 하는 방법을 사용하면 된다.

self-invocation 문제가 발생하지 않더라도, 구현에 사용될 private 메서드가 public으로 외부에 노출되므로 객체의 캡슐화를 위반한다.

@Service
public class OAuth2LoginService {

    private final OAuth2Client oAuth2Client; // 외부 API에 요청을 보내는 컴포넌트
    private final InnerAuthService innerAuthService;

    public LoginResponse login(LoginRequest request) {
        UserInfo userInfo = getUserInfo(request.getToken());
        return innerAuthService.login(userInfo);
    }

    private UserInfo getUserInfo(String token) {
        return oAuth2Client.getUserInfo(request.getToken());
    }
}

@Service
public class InnerAuthService {

    private final MemberRepository memberRepository;
    private final AuthProvider authProvider;

    @Transactional
    public LoginResponse login(UserInfo userInfo) {
        Member member = getMember(userInfo);
        String accessToken = authProvider.provide(member.getId());
        return LoginResponse.from(accessToken, member);
    }
    
    private Member getMember(UserInfo userInfo) {
        return memberRepository.findBySocialId(userInfo.getSocialId)
            .orElseGet(() -> memberRepository.save(userInfo.toMember()));
    }
}

주의할 점은 데이터 정합성이 중요한 작업은 트랜잭션 범위 밖으로 분리하면 정합성에 문제가 발생할 수 있다.

authProvider.provide() 메서드 또한 트랜잭션 범위 밖으로 뺄 수 있겠지만, 해당 연산은 IO 작업이 주가 아닌, CPU의 연산을 사용하기 때문에 트랜잭션 범위에 포함했다.

따라서 외부 API를 호출하고 그 뒤 호출이 없을 때, 정합성에 문제가 발생하지 않는 로직만 트랜잭션의 범위 밖으로 분리해야 한다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글