눈 씻고 로그인 로직 버그 찾기

    지난 이슈 트래커 프로젝트를 진행하다가 아주 기묘한 버그를 마주했다. 아래는 문제가 되었던 Oauth 로그인 코드.

    public JwtResponseDto loginWithOauth(OauthLoginRequestDto requestDto) {
            // (1) Oauth 클라이언트 구현체 선택
            OauthClient oauthClient = oauthClientMapper.getOauthClient(requestDto.getOauthClientName())
                    .orElseThrow(() -> new InvalidOauthClientNameException());
                    
            // (2) 유저 정보 가져오기
            OauthUserInfo userInfo = oauthClient.getUserInfo(requestDto.getAuthCode());
            
            // (3) 유저 검색 후, 없을 경우 새로 등록
            User user = userRepository.findByEmail(userInfo.getEmail())
                    .orElse(registerUser(userInfo));
    
            // (4) JWT 토큰 발급
            JwtToken accessToken = jwtProvider.createToken(user, JwtTokenType.ACCESS);
            JwtToken refreshToken = jwtProvider.createToken(user, JwtTokenType.REFRESH);
    
            return JwtResponseDto.of(accessToken, refreshToken);
        }

    대충 로직을 설명해 보면 아래와 같다.

     

    • OauthClient oauthClient: 클라이언트 요청에서 Oauth 클라이언트 이름을 get 해오고, 그에 맞는 OauthClient 구현체를 oauthClientMapper에서 가져온다.
      • 프로젝트에서는 GITHUB과 KAKAO 두 개의 OauthClient 구현체가 나뉘어 있다. 클라이언트 요청 시 이를 명시하도록 해서 해당 서비스에 맞는 로그인 로직을 다형성으로 구현했다.
    • UserInfo userInfo: OauthClient에 클라이언트가 보내 준 auth code를 넣으면, 해당 Oauth 서버와 통신해 유저의 정보를 가져온다.
    • User user: 앞에서 가져온 UserInfo 객체에서 이메일을 꺼내 유저를 조회하고, 없을 경우 해당 UserInfo를 바탕으로 새로운 유저를 등록한다.
    • 조회된 User 객체를 바탕으로 JWT 토큰을 발행해 리턴한다.

     

    자, 이 코드만 보고 어디서 어떤 문제가 생길 지 파악이 되는가? 아직 모르겠다면 알더라도... 계속해서 이유를 찾아 보자.

     

     

    시작은 순조로웠다

    자, 이 코드에 기반해서 로그인 요청을 보내면, JWT 토큰이 날아가야 한다. 기존에 등록된 회원이라면 DB에서 조회된 회원을 가지고 JWT가 반환될 것이고, 새로운 회원이라면 새로운 유저가 등록되어서 JWT가 반환될 것이다. 처음엔 별 문제가 없어 보였다.

     

    토큰이 잘 날아온다!

     

    근데 어느 순간부터 요상한 일이 벌어지기 시작한다. 몇 번 로그인을 시도하다 보면 갑자기 500 에러가 떨어진다는 것.

     

    세상에서 제일 무서운 상태코드... 500...

     

    그래서 로그를 확인해 봤더니, 다음과 같은 에러가 터져 있었다.

     

    쿼리 결과가 하나가 아니야!

     

    위 코드의 (3)번 부분에서 이런 터진 에러였다. “query did not return a unique result: 2” 즉 findByEmail() 메서드에서 유저의 이메일 정보로 계정을 찾았는데, 해당 이메일에 해당하는 결과값이 2개가 나왔다는 것이다. 아니 이럴 수는 없는 것인데… 부랴부랴 DB를 확인해 본다.

     

    street62 계정이 2개인 모습. 위 사진은 이 에러를 재현하기 위해 로컬에서 다시 돌려 본 상황이다.

     하나만 있어야 할 street62 계정이 2개다. 로컬에서 다시 테스트를 해 보니 다음과 같은 문제를 발견할 수 있었다.

    두 번째까지는 로그인이 정상적으로 진행돼서 엑세스 토큰이 날아오는데, 세 번째 요청부터 문제가 발생한다

     

    그리고 DB에는 street62 계정이 2개가 생성되어 있다. 그래서 처음에는 findByEmail() 메서드가 제대로 작동하지 않아서, 이미 존재하는 유저를 못 찾아내는 것일까? 하고 생각했다. 하지만 findByEmail()은 Spring Data JPA에서 제공하는 형식대로 JpaRepository를 상속한 UserRepository에 정의되어 있다. 그러면 findByEmail()은 매개변수로 들어오는 String email 값에 맞는 유저를 검색한다.

     

    public interface UserRepository extends JpaRepository<User, Long> {
    
        Optional<User> findByEmail(String email);
    }

     

    … 이상한데, 틀릴 부분이 없는데.

     

    그리고 무엇보다 userRepositoryfindByEmail() 메서드는 패스워드로 로그인 할 때에도 그대로 사용되고 있었다. 구현 편의상 패스워드를 사용하는 유저는 회원 가입 로직과 로그인 로직을 분리했는데, 로그인 시에findByEmail() 메서드를 동일하게 사용한다. 해당 Email에 해당하는 유저가 없으면 UserNotFoundException이라는 에러를 던져서 클라이언트에게 에러 메시지를 전송하게 되어 있다. 그런데 패스워드를 사용하는 로그인은 같은 계정으로 몇 번씩 로그인을 해도 문제없이 로그인이 처리되어 JWT 토큰이 리턴되는 걸 볼 수 있었다.

     

    public JwtResponseDto loginWithPassword(PasswordLoginRequestDto requestDto) throws NoSuchAlgorithmException {
            PasswordUserInfo userInfo = PasswordUserInfo.ofLoginRequest(requestDto);
            User user = userRepository.findByEmail(userInfo.getEmail())
                    .orElseThrow(() -> new UserNotFoundException());
                    // findByEmail()에 문제가 있다면 이 로직이 제대로 작동하지 않을 것이다.
    
            validatePassword(user, userInfo);
    
            JwtToken accessToken = jwtProvider.createToken(user, JwtTokenType.ACCESS);
            JwtToken refreshToken = jwtProvider.createToken(user, JwtTokenType.REFRESH);
    
            return JwtResponseDto.of(accessToken, refreshToken);
        }

     

    이걸 봐도 findByEmail() 메서드는 문제 없이 작동한다는 것을 확인할 수 있었다. 그렇다면 위의 Oauth 로그인 로직 userRepository.findByEmail(userInfo.getEmail()).orElse(registerUser(userInfo)); 에서 남은 건 orElse() 부분 밖에 없다. 그런데 논리적으로 orElse()가 작동을 안 했으면 애초에 해당 Oauth 계정으로 처음 로그인할 때, 즉 DB에 요청으로 들어온 email에 해당하는 유저가 없을 때에 회원가입 자체가 안 될 것이다. 그런데 처음 요청할 때 회원가입은 또 잘 되었다.

     

    ‘도대체 이게 뭐지…?’ 점점 미궁으로 빠지는 심정으로 우선 구글링을 해 보았다. 그러다가 아주 기가 막힌 사실을 배우게 된다.

     

     

    orElse() 메서드의 비밀

    문제는 역시 orElse()였다. Optional의 orElse()는 메서드 이름만 보면 옵셔널에 값이 없을 때에만 실행될 것 같지만, 값이 있을 때에도 orElse() 절에 해당하는 로직이 일단 실행된다. 그리고 실행은 일단 되었는데 옵셔널에 값이 있다면 새로 실행된 값은 버려진다.

    여기까지 알고 나니 비로소 버그의 원인을 이해할 수 있었다.

    • 첫 번째 로그인 요청: findByEmail()이 호출되었는데 해당 email에 해당하는 유저가 없으므로, orElse() 절의 로직이 작동해 유저를 새로 등록하고 해당 유저가 반환된다. 로그인 성공.
    • 두 번째 요청: findByEmail()이 호출된 후 해당 유저가 있든 없든 일단 orElse() 절의 로직이 작동해 클라이언트 요청 정보를 바탕으로 유저를 새로 등록한다. 이제 DB에는 같은 유저가 2명 있게 된다. 하지만 내 코드는 아무 일도 없었다는 듯이 새로 등록된 유저는 무시하고 findByEmail()이 찾아온 기존 유저를 반환한다. 여기서부터 소리없이 문제가 시작되지만… 일단 2번째까지 로그인은 성공하는 게 포인트.
    • 세 번째 요청: 세 번째 요청을 할 때부터는 DB에 해당 email에 해당하는 유저 데이터가 2개로 중복된다. 그래서 findByEmail()을 해오다가 에러를 뿜는다.

     

    그럼 옵셔널에 값이 없을 때에만 로직을 작동하게 하려면 어떻게 해야 할까? orElse()가 아니라 orElseGet()을 써야 한다.

     

    public JwtResponseDto loginWithOauth(OauthLoginRequestDto requestDto) {
            // (1) Oauth 클라이언트 구현체 선택
            OauthClient oauthClient = oauthClientMapper.getOauthClient(requestDto.getOauthClientName())
                    .orElseThrow(() -> new InvalidOauthClientNameException());
                    
            // (2) 유저 정보 가져오기
            OauthUserInfo userInfo = oauthClient.getUserInfo(requestDto.getAuthCode());
            
            // (3) 유저 검색 후, 없을 경우 새로 등록 -> 이 부분을 orElseGet()으로 바꾼다.
            User user = userRepository.findByEmail(userInfo.getEmail())
                .orElseGet(() -> registerUser(userInfo));**
    
            // (4) JWT 토큰 발급
            JwtToken accessToken = jwtProvider.createToken(user, JwtTokenType.ACCESS);
            JwtToken refreshToken = jwtProvider.createToken(user, JwtTokenType.REFRESH);
    
            return JwtResponseDto.of(accessToken, refreshToken);
        }

     

    이렇게 코드를 수정하면 일단 버그 해결!

     

     

    그런데 왜 이렇게 작동할까?

    일단 버그는 해결했는데, 여기서 ‘뭐야 메서드 이름이 뭐 이래… 이상해!’ 하고 그냥 넘어가면 뭔가 개운치가 않다. orElse()와 구분되는 orElseGet()을 따로 설계한 이유가 무엇일지 궁금했다. 그냥 별 고민없이 생각해 보면 개발자의 혼란을 일으켜서 버그만 만드는 거 아닌가?

     

    일단 두 메서드가 왜 다르게 작동하는지부터 살펴보자. Optional 클래스의 코드를 까 보면 두 메서드의 구현은 다음과 같다.

    public T orElse(T other) {
            return value != null ? value : other;
        }
    
    public T orElseGet(Supplier<? extends T> supplier) {
            return value != null ? value : supplier.get();
        }

     

    일단 orElse()의 매개변수는 그냥 T other이다. 변경 전의 Oauth 로그인 로직은 여기에 registerUser(userInfo)라는 메서드를 집어넣은 것처럼 보인다. 하지만 실제로 orElse()의 매개변수는 그냥 제네릭 객체다. 따라서 겉으로 보기엔 ‘메서드'를 인자로 넣은 것 같지만, 사실은 ‘메서드를 호출해서 반환받은 객체’를 매개변수로 집어넣은 것이다.

    사실 orElse()라는 이름 때문에 헷갈릴 뿐, 조금만 생각해 보면 아주 당연한 이치다. orElse()는 객체를 매개변수로 받는 자바 메서드일 뿐이다. 그래서orElse(registerUser(userInfo))이 실행되려면 orElse()의 매개변수를 먼저 평가해야 한다. 매개변수로 들어온 객체가 뭔지도 모르는데 메서드 내부에서 이 옵셔널에 value가 있는지 없는지 로직을 먼저 실행한다는 것 자체가 불가능하다.

     

    그렇게 매개변수를 평가하고 나서야, 해당 옵셔널의 value가 있으면 value를 리턴하고, 없으면 매개변수로 들어온(registerUser(userInfo)의 리턴값인) other를 리턴하게 되는 것이다. 이걸 ‘orElse()는 값이 있든 없든 실행되고 값이 있으면 orElse()절의 값은 버린다’ 라고 외우듯이 생각하면 어딘가 어색하게 느껴진다. 하지만 메서드 구현을 직접 보면 프로그래밍 언어의 구조상 논리적으로 그렇게 작동할 수밖에 없다는 걸 알게 된다.

     

    이와 다르게 orElseGet() 메서드의 매개변수는 단순 객체가 아닌 Supplier다. Supplier는 자바 8부터 도입된 함수형 인터페이스다. 그리고 Supplier 인터페이스에는 get()이라는 메서드 하나가 존재한다.

     

    package java.util.function;
    
    @FunctionalInterface
    public interface Supplier<T> {
    
        T get();
    }

     

     

    함수형 ‘인터페이스’와 람다 표현식

    자, 위에서 Supplier는 함수형 인터페이스라고 이야기했다. 알다시피 인터페이스는 일종의 설계도 같은 것이기 때문에, 실제로 사용하려면 이 인터페이스에 정의된 get() 메서드를 구현해야 한다. 정석대로 Supplier 인터페이스 구현체를 만든다면 대강 아래와 같은 모습일 것이다.

    @RequiredArgsConstructor
    public class RegisterUserSupplier implements Supplier<User> {
    
        private final LoginService loginService;
    
        @Override
        public User get(UserInfo userInfo) {
            loginService.registerUser(userInfo);
        }
    }

     

    그 다음에 orElse()에 메서드로 이 RegisterUserSupplier 객체를 넣으면 된다. 하지만 이런 식의 코드는 아무도 본 적이 없을 것이다. 두 가지 문제 때문이다.

    1. 일단 loginServiceregisterUser() 메서드는 private 접근 제어자라서 다른 클래스에서 접근할 수가 없다. 그래서 애초에 이 코드는 현재 상태에서 컴파일 자체가 안 된다.
    2. loginServiceregisterUser() 메서드를 public으로 바꾸든지 해서 넣었다고 치자. 근데 이렇게 구현체 클래스를 따로 정의하고, LoginService 의존관계 주입도 넣어준 다음에 orElse()에 집어넣는다니… 벌써 머리가 아프다.

    그래서 실제 코드에서는 람다식을 사용해서 아래와 같은 형태로 깔끔하게 처리한 것이다.

     

    User user = userRepository.findByEmail(userInfo.getEmail())
                    .orElseGet(() -> registerUser(userInfo));

     

    그냥 아무렇지 않게 그렇게 쓰라니까 익숙하게 써 왔던 람다식이지만, 람다식의 의미는 ‘get() 메서드를 이렇게 구현한 함수형 인터페이스 SupplierorElseGet()에 인자로 넣겠다!’ 라는 의미다. 눈치챘을 수도 있겠지만, 이게 함수형 인터페이스는 메서드가 하나밖에 없는 이유다. 그렇지 않으면 람다식이 표현하고 있는 메서드가 어떤 건지 어떻게 알아!

     

     

    이제는 진짜로 ‘메서드’를 인자로 넣을 수 있다

    여기서 다시 로그인 로직으로 돌아와 보자. 이전에 orElse() 메서드를 사용할 때에도 registerUser(userInfo)를 매개변수로 넣었지만, 이건 사실 메서드를 인자로 넣은 게 아니라 registerUser(userInfo) 메서드를 먼저 호출하고, 그 반환값을 매개변수로 넣은 것 뿐이다. 그래서 옵셔널에 값이 있는지 없는지를 탐색하기도 전에 유저 등록 로직이 먼저 실행될 수밖에 없다. 그래서 대부분의 자료에서는 orElse()에는 메서드 반환값을 넣기보다는, 이미 존재하는 다른 객체를 리턴할 때 사용하라고 권하고 있다.

     

    하지만, orElseGet() 메서드를 사용하면 우리는 비로소 유저를 먼저 찾아본 다음에 유저가 없을 때 매개변수로 들어온 Supplierget() 메서드를 통해 registerUser(userInfo) 를 호출할 수 있다. 함수형 인터페이스를 통해 드디어 ‘메서드'를 메서드의 인자로 넣은 모양새가 되었다. 이게 바로 함수형 프로그래밍 패러다임에서 말하는 고계 함수(고차 함수)다. 함수의 인자로 함수를 집어넣고, 함수 내부 로직 속에서 인자로 들어온 함수를 활용할 수 있다.

     

    기존의 자바 프로그래밍 패러다임에서는 이런 식의 구현이 불가능했지만, 함수형 패러다임을 도입함으로써 함수를 매개변수로 집어넣고 그 반환값을 지연 로딩할 수 있게 된 것이다. 물론 함수형 패러다임을 도입한 이유는 이 뿐만이 아니다. 이 글에서 다루는 건 극히 일부분에 불과하지만 아무렇지 않게 구현 코드만 찾아보고 'orElse()는 값이 있든 없든 무조건 로직이 실행된대~' 'orElseGet()에는 람다식을 넣어야 된대~’ 하고 고민 없이 사용하는 걸 넘어서, 단순한 메서드 하나에도 이런 패러다임이 숨어 있다는 걸 깨닫는 소중한 경험이었다.

     

     

     

    참고자료

    댓글