현재 모아모아의 경우 Github 를 통한 로그인 기능을 지원해주고 있다.
로그인 과정을 간략히 정리하면 다음과 같다.
1. github에 Authorization code 를 전달하고 AccessToken을 받는다.
2. 해당 액세스 토큰을 이용하여 github에게 사용자의 정보를 조회한다.
3. 조회된 사용자 정보를 우리쪽 DB에 저장한다.
4. 사용자 정보를 기반으로 우리측에서 토큰을 만들어 전달한다.
@Transactional
public TokenResponse createToken(final String code) {
final String accessToken = oAuthClient.getAccessToken(code);
final GithubProfileResponse githubProfileResponse = oAuthClient.getProfile(accessToken);
memberService.saveOrUpdate(githubProfileResponse.toMember());
final String jwtToken = jwtTokenProvider.createToken(githubProfileResponse.getGitgubId());
return new TokenResponse(jwtToken);
}
위 과정을 통해 알 수 있다시피 우리는 /api/auth/login
이라는 API에서 Github 과의 통신이 필요하다.
즉, Github쪽 트랜잭션 + 우리쪽 트랜잭션 이 하나의 트랜잭션으로 묶이게 된다.
그런데 만약 이와 같은 상황에서 깃헙쪽 액세스토큰을 받아오는 과정이나 사용자 정보를 조회하는 과정에 문제가 발생하면 어떻게 될까?
우리는 DB 커넥션을 계속해서 유지하고 있을 것인데, 일반적으로 DB 커넥션은 개수가 제한적이기 때문에 커넥션을 물고 있는 시간이 길어질 수록 호라용할 수 있는 커넥션의 개수는 줄어들게 될 것이다. 심지어 짧은 시간에 많은 사용자가 로그인 요청을 한다면 이 문제는 더욱 커질 것이다.
따라서 프로그램의 코드 상에서도 데이터베이스 커넥션을 가지고 있는 범위를 최소화하고 트랜잭션이 활성화 되어 있는 범위를 최소화하기 위해 이를 분리하게 되었다.
현재 모아모아에서의 로그인 과정을 다시 한 번 정리해보자.
AuthService의 createToken()
을 보면 전체 로직이 트랜잭션으로 묶이게 된다.
스프링에서는 AOP를 통해서 제공하는 @Transactional
어노테이션의 경우 메소드 시작과 동시에 트랜잭션을 시작하고, 해당 메소드가 끝날 때 트랜잭션이 끝나면서 commit을 하게 된다. (이 사이에 예외가 발생하면 rollback 될 것이다.) 하지만 우리에게 정말 트랜잭션이 필요한 부분은 다음 코드 부분이다.
(Github쪽 통신은 제외하고, 우리쪽 DB와의 통신이 필요한 부분)
final MemberResponse memberResponse = memberService.saveOrUpdate(githubProfileResponse.toMember());
final Long memberId = memberResponse.getId();
final Optional<Token> token = tokenRepository.findByMemberId(memberId);
final TokensResponse tokenResponse = tokenProvider.createToken(memberId);
if (token.isPresent()) {
token.get().updateRefreshToken(tokenResponse.getRefreshToken());
return tokenResponse;
}
tokenRepository.save(new Token(memberId, tokenResponse.getRefreshToken()));
return tokenResponse;
따라서 외부 네트워크와 통신하는 작업을 하는 oAuthClient
를 우리 트랜잭션에서 제거하여 불필요한 커넥션을 가지고 있는 범위와 트랜잭션 활성화 범위를 제거해야 한다.
처음에는 정말 단순하게 생각했다. 기존에 @Transactional
어노테이션 아래에서 하나의 메소드에서 모두 처리하던 것을 두 개의 메소드로 분리하고, 우리쪽 DB와의 커넥션이 필요한 부분에만 @Transactional
어노테이션을 달도록 구현하였다.
public TokensResponse createToken(final String code) {
final GithubProfileResponse githubProfileResponse = getGithubProfileResponse(code);
return makeToken(githubProfileResponse);
}
private GithubProfileResponse getGithubProfileResponse(final String code) {
final String accessToken = oAuthClient.getAccessToken(code);
return oAuthClient.getProfile(accessToken);
}
@Transactional
TokensResponse makeToken(final GithubProfileResponse githubProfileResponse) {
final MemberResponse memberResponse = memberService.saveOrUpdate(githubProfileResponse.toMember());
final Long memberId = memberResponse.getId();
final Optional<Token> token = tokenRepository.findByMemberId(memberId);
final TokensResponse tokenResponse = tokenProvider.createToken(memberId);
if (token.isPresent()) {
token.get().updateRefreshToken(tokenResponse.getRefreshToken());
return tokenResponse;
}
tokenRepository.save(new Token(memberId, tokenResponse.getRefreshToken()));
return tokenResponse;
}
하지만 여기서 고민이 들기 시작했다.
실질적으로 makeToken()
메소드는 해당 메소드가 선언되어 있는 AuthService
클래스 외부에서 사용되지 않는 메소드이다. 따라서 나는 private
접근 제어자, 혹은 적어도 Default 로 접근 제어자를 부여하여 다른 패키지에 존재하는 Controller 나 Service에서 해당 메소드를 호출할 수 없도록 만들고 싶었다.
하지만 앞서 언급한 것과 같이 @Transactional
어노테이션을 AOP를 통해서 구현되어 있다. 즉, 프록시 패턴을 이용해서 트랜잭션의 시작, commit 및 rollback 과 같이 트랜잭션을 종료하는 작업을 우리가 구현한 서비스 부분을 실행하는 부분 앞뒤에서 실행시켜주는 프록시 객체를 하나 만들어서 이를 사용해서 트랜잭션의 동기화를 지원해준다.
그런데 여기서 private 이면 우리가 작성한 메소드에 대한 호출이 불가능하다. (조금 더 찾아보니 reflection 방식을 사용하는 JDK Proxy 가 아닌 바이트 코드 조작을 통해서 프록시 객체를 생성하는 CGLib 의 경우 가능하다고 하는 것 같은데 이 부분은 추가적인 학습이 필요하므로, 향후 보충하도록 하겠습니다..😅)
추가적으로 SonarLint 에서는 다음과 같은 경고를 띄워주고 있었다.
따라서 public 으로 열어야 정상적으로 @Transactional
이 동작할 것으로 기대할 수 있기 때문에 public으로 열어주는 것으로 일차적으로 구현을 마쳤었다.
여전히 개선 방안에 대해서 고민하였던 같은 팀원인 베루스와 이야기를 하던 중 OAuthClient 코드를 확인해보았다.
public interface OAuthClient {
String getAccessToken(final String code);
GithubProfileResponse getProfile(final String accessToken);
}
위의 인터페이스에서 확인 할 수 있다시피 2가지 메소드를 제공해주고 있다.
하지만 실질적으로 외부에서 필요하고 사용되는 메소드는 getProfile()
이다.
우리는 "code를 통해서 GithubProfile 정보를 가져온다" 만 알면되지 구체적으로 액세스 토큰이 발급되고 이를 통해서 가져오고 이런 내부 과정이 불필요하다.
(순서가 앞서 갔던 것 같은데, 위에서 변경 이후에 등장하는 아래 코드도 이와 같은 변경을 AuthService와 OAuthClient를 함께 진행해준 것이다.)
public TokensResponse createToken(final String code) {
final GithubProfileResponse githubProfileResponse = getGithubProfileResponse(code);
return makeToken(githubProfileResponse);
}
따라서 우리는 OAuthClient의 인터페이스를 다음과 같이 변경해줄 수 있다.
(내부 구현이 궁금하다면 GithubOAuthClient 를 확인해보길 바란다.)
public interface OAuthClient {
GithubProfileResponse getProfile(final String code);
}
그리고 위의 createToken()
에서 githubProfileResponse 를 받아오는 부분을 컨트롤러 쪽으로 빼고, makeToken 부분만 AuthService
에 두면 앞서 고민하였던 public 접근제어자에 대한 고민을 해결할 수 있게 된다.
@PostMapping("/api/auth/login")
public ResponseEntity<AccessTokenResponse> login(@RequestParam final String code) {
final GithubProfileResponse profile = oAuthClient.getProfile(code);
final AccessTokenResponse token = authService.createToken(profile);
return ResponseEntity.ok().body(token);
}
그리고 컨트롤러 쪽에서도 명확해진다. OAuthClient
를 이용해서 Github쪽으로부터 깃허브 정보를 받아오고, AuthService는 이를 통해서(GithubProfileResponse) 멤버를 저장하거나 멤버 정보를 업데이트해주는 로직을 수행해주면 된다.
여기서 주목할 것은 로직만이 분리된 것이 아니라 트랜잭션도 완벽하게 분리되었다는 것이다. AuthService는 우리쪽 DB와의 트랜잭션에만 관여하게 되었고, OAuthClient는 외부 리소스 요청과 관련된 트랜잭션을 담당하게 분리되었다는 것이다.
이와 관련되어서는 매트가 테코블에 정리해주었고, 이 글을 보고 의문이 드는 부분이 있거나 더 구체적인 내용에 대해서 학습하고 싶다면 이를 참고해봐도 좋을 것 같다.