[태그:] Java

  • gRPC 인터셉터 구현 – 클라이언트 (Java)

    지난 글에서 이어서 이번 글에서 클라이언트에서의 인터셉터 구현을 살펴보겠습니다.

    gRPC 인터셉터 – 클라이언트

    클라이언트에서의 gRPC 인터셉터도 이전 글에서 살펴본 서버와 동작 원리 및 구현 방법이 비슷합니다. 마찬가지로 라이브러리의 구조를 살펴보면서 gRPC 인터셉터를 구현해보도록 하겠습니다.

    (1) ClientCall.Listener<RespT> 정적 추상 클래스를 상속받는 클래스를 생성합니다.

    ClientCall.Listener<RespT>는 ClientCall 추상 클래스 안에 선언된 정적 추상 이너 클래스로, ServerCall.Listener와 유사합니다. 라이브러리에 선언된 ClientCall.Listener<RespT> 추상 클래스는 다음과 같으며, 모두 4개의 메서드가 정의되어 있습니다. onHeaders 메서드는 모든 RPC에 대해 서버로부터 응답 메시지가 처음 수신할 때 한 번만 호출되며, onMessage 메서드는 Stream RPC인 경우에도 서버로부터 응답 메시지를 수신받을 때마다 호출됩니다.

    public abstract class ClientCall<ReqT, RespT> {
    
      public abstract static class Listener<T> {
    
        public void onHeaders(Metadata headers) {}
        public void onMessage(T message) {}
        public void onClose(Status status, Metadata trailers) {}
        public void onReady() {}
      }
      ...
    }

    ClicentCall.Listener<RespT>는 이전에 살펴본 ServerCall.Listener<ReqT>와 같이 추상 클래스이므로 모든 메서드에 대한 오버라이딩이 필요합니다. 마찬가지로, 라이브러리에서는 delegate 패턴을 이용하여 다음과 같은 상속 관계를 가지는 클래스들을 제공합니다.

    • (abstract) ClientCall.Listener<RespT>
      • (abstract) PartialForwardingClientCallListener<RespT>
        • (abstract) ForwardingClientCallListener<RespT>
          • (abstract) ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>

    따라서 ClientCall.Listener<RespT> 추상 클래스 대신 ForwardingClientCallListener<RespT> 추상 클래스를 상속받아 구현하면 됩니다. 여기서는 ForwardingClientCallListener 클래스의 이너 클래스로 구현되어 있는 SimpleForwardingClientCallListener을 이용하여 간단하게 구현합니다.

    이제 ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT> 추상 클래스를 상속받는 UserClientCallListener을 다음과 같이 구현합니다. onHeaders 메서드에 대해서 오버라이딩하고, delegate 패턴에 대한 구현도 추가합니다. 서버로부터 응답 메시지가 처음 도달하면 표준 출력이 콘솔에 나타나도록 구현했습니다.

    // UserClientCallListener.java
    package com.lattechiffon.grpc;
    
    import io.grpc.ClientCall;
    import io.grpc.ForwardingClientCallListener;
    import io.grpc.Metadata;
    
    public class UserClientCallListener<RespT> extends ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT> {
        protected UserClientCallListener(ClientCall.Listener<RespT> delegate) {
            super(delegate);
        }
    
        @Override
        public void onHeaders(Metadata headers) {
            System.out.println("[Client Call Listener] client header : " + headers);
            super.onHeaders(headers);
        }
    }

    (2) ClientCall<ReqT, RespT> 추상 클래스를 상속받는 클래스를 생성합니다.

    ClientCall<ReqT, RespT> 추상 클래스에는 gRPC 클라이언트에서의 메시지 송수신을 처리하는 메서드들이 정의되어 있습니다. 과정 (1)에서 살펴본 ClientCall.Listener<RespT> 클래스도 ClientCall<ReqT, RespT> 추상 클래스의 이너 클래스입니다. ClientCall 클래스에 선언된 sendMessage 메서드를 이용하여 서버로 요청 메시지를 전송할 수 있으며, 이 메서드를 오버라이딩하여 요청 메시지를 서버로 전달하기 전에 수행해야 할 사전 작업을 정의할 수 있습니다.

    package io.grpc;
    
    import javax.annotation.Nullable;
    
    public abstract class ClientCall<ReqT, RespT> {
    
      public abstract static class Listener<T> { ... }
    
      public abstract void start(Listener<RespT> responseListener, Metadata headers);
      public abstract void request(int numMessages);
      public abstract void cancel(@Nullable String message, @Nullable Throwable cause);
      public abstract void halfClose();
      public abstract void sendMessage(ReqT message);
    
      public boolean isReady() {
        return true;
      }
      ...
    }

    ClientCall<ReqT, RespT>는 Listener와 마찬가지로 추상 클래스이므로, 모든 메서드에 대한 오버라이딩이 필요합니다. 라이브러리에서는 delegate 패턴을 이용하여 다음과 같은 상속 관계를 가지는 클래스들을 제공합니다.

    • (abstract) ClientCall<ReqT, RespT>
      • (abstract) PartialForwardingClientCall<ReqT, RespT>
        • (abstract) ForwardingClientCall<ReqT, RespT>
          • (abstract) ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>

    따라서 ClientCall<ReqT, RespT> 추상 클래스 대신 ForwardingClientCall<ReqT, RespT> 추상 클래스를 상속받아 구현하면 됩니다. 여기서는 ForwardingClientCall 클래스의 이너 클래스로 구현되어 있는 SimpleForwardingClientCall을 이용하여 간단하게 구현합니다.

    이제 ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT> 추상 클래스를 상속받는 UserClientCall을 다음과 같이 정의합니다. start와 sendMessage 메서드에 대해서 오버라이딩하고, delegate 패턴에 대한 구현도 추가합니다. 새로운 요청 메시지를 서버로 전달하기 전에 표준 출력이 콘솔에 나타나도록 구현했습니다.

    // UserClientCall.java
    package com.lattechiffon.grpc;
    
    import io.grpc.ClientCall;
    import io.grpc.ForwardingClientCall;
    import io.grpc.Metadata;
    
    public class UserClientCall<ReqT, RespT> extends ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT> {
        protected UserClientCall(ClientCall<ReqT, RespT> delegate) {
            super(delegate);
        }
    
        @Override
        public void start(Listener<RespT> responseListener, Metadata headers) {
            super.start(new UserClientCallListener<>(responseListener), headers);
        }
    
        @Override
        public void sendMessage(ReqT message) {
            System.out.println("[Client Call] Client Send Message : " + message);
            super.sendMessage(message);
        }
    }
    

    (3) ClientInterceptor 인터페이스의 구현 클래스를 생성합니다.

    마지막으로 ClientInterceptor 인터페이스를 살펴보겠습니다. ServerInterceptor 인터페이스와 마찬가지로 인터페이스의 구조는 단순합니다. 구현 클래스는 interceptCall이라는 하나의 메서드만 구현하면 됩니다. 세 개의 파라미터와 하나의 반환은 다음의 역할을 가집니다. 서버와는 그 구성이 다소 다릅니다.

    • MethodDescriptor<ReqT, RespT> method:
    • CallOptions callOptions:
    • Channel next:
    • ClientCall<ReqT, RespT> (return):

    package io.grpc;
    
    import javax.annotation.concurrent.ThreadSafe;
    
    @ThreadSafe
    public interface ClientInterceptor {
      <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
          MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next);
    }

    // UserClientInterceptor.java
    package com.lattechiffon.grpc;
    
    import io.grpc.*;
    
    public class UserClientInterceptor implements ClientInterceptor {
        @Override
        public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
            return new UserClientCall<>(next.newCall(method, callOptions));
        }
    }

    (4) gRPC 클라이언트 채널에 구현한 Interceptor 클래스를 인터셉터로 등록합니다.

    마지막으로 구현한 인터셉터가 정상적으로 동작하도록 다음과 같이 gRPC 클라이언트에 등록해줍니다. 이제 서버로부터 응답이 도달하면 인터셉터에 의해 앞서 구현한 일련의 과정들이 먼저 수행됩니다.

    // GrpcApplication.java
    ...
    public static void main(String[] args) {
        ...
        ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 8080).usePlaintext()
                .intercept(new UserClientInterceptor()).build();
        ...
    }

    지금까지 서버와 클라이언트에서의 gRPC 인터셉터 동작 원리와 Java를 이용한 구현 과정에 대해 살펴보았습니다. 다음 글에서는 gRPC에서 제공하는 데드라인 기능에 대해 살펴보고 마찬가지로 Java로 구현하는 내용을 다루도록 하겠습니다.

    • (참고) JDK 9부터는 JAVA EE 모듈이 제거되었기 때문에 gRPC 컴파일 코드를 포함한 프로젝트 전체를 빌드하기 위해서는 다음과 같이 javax.annotation 의존성을 추가해야 합니다.
    // build.gradle
    dependencies {
        ...
        implementation 'javax.annotation:javax.annotation-api:1.3.2'
        ...
    } 

  • gRPC 인터셉터 구현 – 서버 (Java)

    지금까지 gRPC의 기본적인 특징들을 살펴보고, 핵심적인 4가지의 통신 방법을 Protocol Buffer와 Java를 이용하여 간단한 예제로 구현해보았습니다. 분산된 프로세스 사이에서 간단한 통신을 하고자 한다면 이전에 다뤘던 구현 방법만으로도 충분하지만, MSA 기반 애플리케이션처럼 복잡한 형태의 구조를 가지는 경우에는 보다 세밀하고 유연하게 통신 과정을 제어할 수 있는 방법들이 요구됩니다. 이번 글과 다음 글에서는 gRPC에서 제공하는 확장 기능들 중에서 인터셉터(Interceptor)에 대해 살펴보고, 이를 Java로 구현하는 과정을 다룹니다.

    gRPC 인터셉터 – 서버

    프로젝트의 의존성으로 추가한 io.grpc 라이브러리의 구조를 분석하지 않으면 인터셉터의 동작 원리를 이해하기가 쉽지 않습니다. 라이브러리의 구조를 하나씩 살펴보면서 gRPC 인터셉터를 구현해보도록 하겠습니다.

    (1) ServerCall.Listener<ReqT> 정적 추상 클래스를 상속받는 클래스를 생성합니다.

    ServerCall.Listener<ReqT>는 다음 단계에서 이야기 할 ServerCall 추상 클래스 안에 선언된 정적 추상 이너 클래스입니다. Listener 클래스에 선언된 onMessage 메서드를 이용하여 클라이언트로부터 수신된 메시지를 전달받을 수 있으며, 이 메서드를 오버라이딩하여 메시지를 gRPC 서비스 로직으로 전달하기 전에 수행해야 할 사전 작업을 정의할 수 있습니다. 라이브러리에 선언된 ServerCall.Listener<ReqT> 추상 클래스는 다음과 같으며, onMessage 메서드를 포함하여 5개의 메서드가 정의되어 있습니다. onMessage 메서드는 Stream RPC인 경우에도 클라이언트로부터 메시지를 수신받을 때마다 호출됩니다.

    public abstract class ServerCall<ReqT, RespT> {
    
      public abstract static class Listener<ReqT> {
    
        public void onMessage(ReqT message) {}
        public void onHalfClose() {}
        public void onCancel() {}
        public void onComplete() {}
        public void onReady() {}
      }
      ...
    }

    그러나 ServerCall.Listener<ReqT>는 추상 메서드만을 가진 추상 클래스입니다. 즉, 모든 메서드에 대한 오버라이딩이 있어야 동작을 보장할 수 있습니다. 라이브러리에서는 delegate 패턴을 이용하여 onMessage 메서드를 제외한 나머지 4개의 메서드를 구현하고 있는 PartialForwardingServerCallListener<ReqT>를 제공합니다. 그뿐만 아니라, 다시 PartialForwardingServerCallListener<ReqT> 추상 클래스를 상속받아 delegate 패턴을 이용하여 onMessage 메서드만을 정의하는 ForwardingServerCallListener<ReqT> 추상 클래스를 제공합니다.

    • (abstract) ServerCall.Listener<ReqT>
      • (abstract) PartialForwardingServerCallListener<ReqT>
        • (abstract) ForwardingServerCallListener<ReqT>
          • (abstract) ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>

    따라서 ServerCall.Listener<ReqT> 추상 클래스 대신 ForwardingServerCallListener<ReqT> 추상 클래스를 상속받아 구현하면 됩니다. ForwardingServerCallListener 클래스의 이너 클래스로 구현되어 있는 SimpleForwardingServerCallListener는 onMessage 메서드를 오버라이딩하고 있지 않으므로 여기서는 ForwardingServerCallListener를 상속받아 구현합니다.

    이제 ForwardingServerCallListener<ReqT> 추상 클래스를 상속받는 UserServerCallListener를 다음과 같이 정의합니다. onMessage 메서드에 대해서 오버라이딩하고, delegate 패턴에 대한 구현도 추가합니다. 클라이언트로부터 메시지가 전달될 때마다 표준 출력이 콘솔에 나타나도록 구현했습니다.

    // UserServerCallListener.java
    package com.lattechiffon.grpc;
    
    import io.grpc.ForwardingServerCallListener;
    import io.grpc.ServerCall;
    
    public class UserServerCallListener<ReqT> extends ForwardingServerCallListener<ReqT> {
    
        private final ServerCall.Listener<ReqT> delegate;
    
        UserServerCallListener(ServerCall.Listener<ReqT> delegate) {
            this.delegate = delegate;
        }
    
        @Override
        protected ServerCall.Listener<ReqT> delegate() {
            return delegate;
        }
    
        @Override
        public void onMessage(ReqT message) {
            System.out.println("[Server Call Listener] Client Message : " + message);
            super.onMessage(message);
        }
    }

    (2) ServerCall<ReqT, RespT> 추상 클래스를 상속받는 클래스를 생성합니다.

    ServerCall<ReqT, RespT> 추상 클래스에는 gRPC 서버사이드에서의 메시지 송수신을 처리하는 메서드들이 정의되어 있습니다. 과정 (1)에서 살펴본 ServerCall.Listener<ReqT> 클래스도 ServerCall<ReqT, RespT> 추상 클래스의 이너 클래스로 선언되어 있습니다. ServerCall 클래스에 선언된 sendMessage 메서드를 이용하여 서비스 로직으로부터 생성된 응답 메시지를 클라이언트에 전송할 수 있으며, 이 메서드를 오버라이딩하여 서비스 로직의 응답 메시지를 클라이언트로 전달하기 전에 수행해야 할 사전 작업을 정의할 수 있습니다.

    package io.grpc;
    
    import javax.annotation.Nullable;
    
    public abstract class ServerCall<ReqT, RespT> {
    
      public abstract static class Listener<ReqT> { ... }
    
      public abstract void request(int numMessages);
      public abstract void sendHeaders(Metadata headers);
      public abstract void sendMessage(RespT message);
      public boolean isReady() { return true; }
      public abstract void close(Status status, Metadata trailers);
      public abstract boolean isCancelled();
      ...
      public abstract MethodDescriptor<ReqT, RespT> getMethodDescriptor();
    }
    

    그러나 ServerCall<ReqT, RespT>는 Listener와 마찬가지로 추상 메서드만을 가진 추상 클래스입니다. 즉, 모든 메서드에 대한 오버라이딩이 있어야 동작을 보장할 수 있습니다. 라이브러리에서는 delegate 패턴을 이용하여 sendMessage 메서드를 제외한 나머지 메서드를 구현하고 있는 PartialForwardingServerCall<ReqT, RespT>를 제공합니다. 마찬가지로, 다시 PartialForwardingServerCall<ReqT, RespT> 추상 클래스를 상속받아 delegate 패턴을 이용하여 sendMessage 메서드만을 정의하는 ForwardingServerCall<ReqT, RespT> 추상 클래스를 제공합니다.

    • (abstract) ServerCall<ReqT, RespT>
      • (abstract) PartialForwardingServerCall<ReqT, RespT>
        • (abstract) ForwardingServerCall<ReqT, RespT>
          • (abstract) ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT>

    따라서 ServerCall<ReqT, RespT> 추상 클래스 대신 ForwardingServerCall<ReqT, RespT> 추상 클래스를 상속받아 구현하면 됩니다. 여기서는 ForwardingServerCall 클래스의 이너 클래스로 구현되어 있는 SimpleForwardingServerCall을 이용하여 간단하게 구현합니다.

    이제 ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT> 추상 클래스를 상속받는 UserServerCall을 다음과 같이 정의합니다. sendMessage 메서드에 대해서 오버라이딩하고, delegate 패턴에 대한 구현도 추가합니다. 서비스 로직으로부터 새로 생성된 응답 메시지가 전달될 때마다 표준 출력이 콘솔에 나타나도록 구현했습니다.

    // UserServerCall.java
    package com.lattechiffon.grpc;
    
    import io.grpc.ForwardingServerCall;
    import io.grpc.MethodDescriptor;
    import io.grpc.ServerCall;
    
    public class UserServerCall<ReqT, RespT> extends ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT> {
    
        protected UserServerCall(ServerCall<ReqT, RespT> delegate) {
            super(delegate);
        }
    
        @Override
        protected ServerCall<ReqT, RespT> delegate() {
            return super.delegate();
        }
    
        @Override
        public MethodDescriptor<ReqT, RespT> getMethodDescriptor() {
            return super.getMethodDescriptor();
        }
    
        @Override
        public void sendMessage(RespT message) {
            System.out.println("[Server Call] Service Return Message : " + message);
            super.sendMessage(message);
        }
    }

    (3) ServerInterceptor 인터페이스의 구현 클래스를 생성합니다.

    마지막으로 ServerInterceptor 인터페이스를 살펴보겠습니다. 다음과 같이 인터페이스의 구조는 굉장히 간단합니다. 구현 클래스는 interceptCall이라는 하나의 메서드만 구현하면 됩니다. 세 개의 파라미터와 하나의 반환은 다음의 역할을 가집니다.

    • ServerCall<ReqT, RespT> call: 서버가 반환한 응답(RespT) 메시지를 받을 객체입니다.
    • Metadata headers: 통신 메타데이터를 가지는 객체입니다. 새로운 사용자 정의를 추가할 수 있습니다.
    • ServerCallHandler<ReqT, RespT> next: 인터셉터 체인의 다음 처리를 수행할 객체입니다.
    • ServerCall.Listener<ReqT> (return): 클라이언트로부터 전송된 수신(ReqT) 메시지를 처리할 객체입니다.

    즉, incerceptCall 메서드를 오버라이딩함으로써 클라이언트로부터 전송된 수신 메시지를 처리할 리스너를 직접 정의할 수 있으며, 이를 인터셉터 체인에 등록할 수 있습니다. 구현한 인터셉터를 gRPC 서버의 인터셉터 체인에 등록하는 과정은 마지막 부분에서 다시 다룹니다.

    package io.grpc;
    
    import javax.annotation.concurrent.ThreadSafe;
    
    @ThreadSafe
    public interface ServerInterceptor {
      <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
          ServerCall<ReqT, RespT> call,
          Metadata headers,
          ServerCallHandler<ReqT, RespT> next);
    }

    이제 프로젝트에 ServerInterceptor 인터페이스를 구현할 UserServerInterceptor 클래스를 다음과 같이 정의합니다. 우선, 인터셉터가 동작하는지 확인하기 위해 표준 출력을 하도록 합니다. 그리고 과정 (2)에서 ServerCall를 커스텀한 UserServerCall 객체를 만듭니다. 마지막으로 과정 (1)에서 클라이언트의 메시지를 수신하도록 커스텀한 UserServerCallListener 객체를 생성하여 반환합니다. 즉, gRPC 인터셉터에 의한 메시지의 처리 과정을 정리하면 다음과 같습니다.

    1. 클라이언트로부터 요청 메시지가 전달되면 우선 UserServerCallListener에게 전달됩니다.
    2. UserServerCallListener는 메시지에 대한 전처리 작업을 수행하고, gRPC 서비스에게 이 메시지를 전달합니다.
    3. gRPC 서비스로부터 전달된 응답 메시지가 UserServerCall에게 전달됩니다.
    4. ServerCallHandler에 의해 클라이언트의 메시지 전송이 중단될 때까지 위의 과정을 반복합니다.
    package com.lattechiffon.grpc;
    
    import io.grpc.Metadata;
    import io.grpc.ServerCall;
    import io.grpc.ServerCallHandler;
    import io.grpc.ServerInterceptor;
    
    public class UserServerInterceptor implements ServerInterceptor {
        @Override
        public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
            System.out.println("[Server Interceptor] : Invoke RPC - " + call.getMethodDescriptor().getFullMethodName());
            ServerCall<ReqT, RespT> serverCall = new UserServerCall<>(call);
    
            return new UserServerCallListener<>(next.startCall(serverCall, headers));
        }
    }

    (4) gRPC 서버 빌더에 구현한 Interceptor 클래스를 인터셉터로 등록합니다.

    마지막으로 구현한 인터셉터가 정상적으로 동작하도록 다음과 같이 gRPC 서버에 등록해줍니다. 이제 UserServiceImpl 서비스가 수행되기 전에 인터셉터에 의해 앞서 구현한 일련의 과정들이 먼저 수행됩니다.

    // GrpcApplication.java
    ...
    public static void main(String[] args) {
            // Initialize gRPC Server
            int port = 8080;
            Server server = ServerBuilder.forPort(port)
                    .addService(ServerInterceptors.intercept(new UserServiceImpl(), new UserServerInterceptor())).build();
    
            try {
                server.start();
            } catch (IOException e) {
                e.printStackTrace();
                return;
            }
            ...
    }
    ...

    이제 서버 사이드에 대한 구현이 모두 끝났습니다. 다음 글에서 클라이언트에서의 인터셉터 구현을 살펴보겠습니다.

    이 예제의 전체 소스코드는 아래의 GitHub 저장소에서 확인할 수 있습니다.

    • (참고) JDK 9부터는 JAVA EE 모듈이 제거되었기 때문에 gRPC 컴파일 코드를 포함한 프로젝트 전체를 빌드하기 위해서는 다음과 같이 javax.annotation 의존성을 추가해야 합니다.
    // build.gradle
    dependencies {
        ...
        implementation 'javax.annotation:javax.annotation-api:1.3.2'
        ...
    } 

  • JUnit5와 Mockito를 이용한 Mock Test (Java)

    JUnit5, Hamcrest 및 Mockito

    • JUnit5:
    • Hamcrest: JUnit의 테스트 작성을 보다 문맥적으로 자연스럽고 우아하게 할 수 있도록 도와주는 Matcher 라이브러리입니다. JUnit5부터는 Hamcrest 관련 라이브러리가 포함되어 있지 않습니다.
    • Mockito: Java 프로그래밍에서의 단위 테스트를 위한 Mocking 오픈소스 프레임워크입니다.

    앞으로의 모든 글은 JUnit5에 맞추어 기술합니다. JUnit4에 대한 내용은 특별한 경우가 아니면 언급하지 않으며, 구현 방법이 JUnit5와 상이할 수 있으므로 구체적인 세부 구현은 다른 글이나 레퍼런스를 참조하길 권장합니다.

    Gradle에 Hamcrest, Mockito 라이브러리 Dependency 추가

    Intellij IDEA 최신버전에서 Gradle을 이용하여 프로젝트를 구성하면 기본적으로 JUnit5 의존성을 가집니다. JUnit5부터는 Hamcrest 관련 라이브러리가 분리되었으므로 이를 Gradle 의존성에 추가해야 하며, JUnit5에서 Mockito를 함께 사용하기 위한 의존성도 함께 작성해주어야 합니다. 또한, 예제에서는 개발 편의를 위해 Lombok을 함께 사용합니다. 생성한 프로젝트의 build.gradle을 다음과 같이 수정하고, ‘Gradle Reload’ 작업을 수행하도록 합니다. 다만, Spring Boot (2.2+) 프로젝트를 구성한 경우에는 ‘org.springframework.boot:spring-boot-starter-test’ 라이브러리에 이미 JUnit5, Hamcrest 및 Mockito가 모두 포함되어 있으므로 이 작업이 필요 없습니다.

    // build.gradle
    plugins {
        id 'java'
    }
    
    group 'com.lattechiffon'
    version '1.0-SNAPSHOT'
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        compileOnly 'org.projectlombok:lombok:1.18.20'
        annotationProcessor 'org.projectlombok:lombok:1.18.20'
        testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0'
        testImplementation 'org.hamcrest:hamcrest-library:2.2'
        testImplementation 'org.mockito:mockito-core:3.11.2'
        testImplementation 'org.mockito:mockito-junit-jupiter:3.11.2'
        testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0'
    }
    
    test {
        useJUnitPlatform()
    }

    이제 /src/main/java/package(예제에서는 com.lattechiffon.mockito)의 위치에 간단한 Mock 테스트를 위한 Domain(Model), Repository(DAO) 및 Service 클래스를 다음과 같이 작성합니다. Spring Boot로 프로젝트를 구성한 경우에는 어노테이션(@Repository, @Service 등)을 활용하여 Bean에 등록합니다.

    • [Domain] User: 사용자 정보를 담는 도메인 클래스입니다. 고유번호와 이름, 이메일 주소 및 권한 정보를 가집니다.
    • [Repository] UserRepository: 사용자에 대한 영속성 처리를 담당하는 인터페이스입니다. Mock Test를 위한 예제이므로 내부 구현에 대해서는 다루지 않습니다. (일반적으로 Spring Boot로 구현한 경우에는 JpaRepository<> 혹은 CrudRepository<> 인터페이스를 상속 받습니다.)
      • findByUsername: 사용자 이름을 이용하여 User 객체를 반환합니다.
      • save: User 객체를 영속화하고 해당 User 객체를 반환합니다.
      • findAll: 영속된 모든 User 객체들을 List에 넣어 반환합니다.
    • [Service] UserService: 사용자에 대한 비즈니스 로직을 가지는 클래스입니다. 클래스 내부의 인스턴스 메서드들은 각각 UserRepository 클래스의 적절한 메서드를 호출합니다. 일반적으로는 인터페이스를 상속받도록 구현(Interface: Service <- Class: ServiceImpl)하지만 여기서는 간단하게 구성했습니다.
    // User.java
    package com.lattechiffon.junit.mockito;
    
    import lombok.*;
    
    import java.util.List;
    
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class User {
    
        private long idx;
    
        @Setter
        private String username;
    
        @Setter
        private String email;
    
        @Setter
        private List<String> roles;
    
    }
    // UserRepository.java
    package com.lattechiffon.junit.mockito;
    
    import java.util.List;
    
    public interface UserRepository {
    
        User findByUsername(String username);
        User save(User user);
        List<User> findAll();
    }
    // UserService.java
    package com.lattechiffon.junit.mockito;
    
    import java.util.List;
    
    public class UserService {
        UserRepository userRepository;
    
        UserService(UserRepository userRepository) {
            this.userRepository = userRepository;
        }
    
        public User createUser(User user) {
            if (user.getUsername().isEmpty()) {
                return null;
            }
    
            return userRepository.save(user);
        }
    
        public User findUser(String username) {
            return userRepository.findByUsername(username);
        }
    
        public List<User> findAllUsers() {
            return userRepository.findAll();
        }
    }

    이제 구현한 서비스가 정상적으로 동작하는지 확인하기 위해 Mockito를 이용하여 Unit Test를 구현합니다. /src/main/test/package(예제에서는 com.lattechiffon.mockito)의 위치에 다음과 같이 UserServiceTest 클래스를 작성합니다.

    // UserServiceTest.java
    package com.lattechiffon.junit.mockito;
    
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.InjectMocks;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import java.util.Arrays;
    import java.util.List;
    
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.Mockito.*;
    import static org.hamcrest.MatcherAssert.*;
    import static org.hamcrest.CoreMatchers.*;
    
    @ExtendWith(MockitoExtension.class)
    class UserServiceTest {
    
        @Mock
        UserRepository userRepository;
    
        @InjectMocks
        UserService userService;
    }

    여기서 세 개의 어노테이션(@ExtendWith, @Mock, @InjectMocks)이 등장합니다. 각각의 어노테이션의 의미는 다음과 같습니다.

    • @ExtendWith(MockitoExtension.class): 테스트 클래스가 Mockito를 사용함을 의미합니다.
    • @Mock: 실제 구현된 객체 대신에 Mock 객체를 사용하게 될 클래스를 의미합니다. 테스트 런타임 시 해당 객체 대신 Mock 객체가 주입되어 Unit Test가 처리됩니다.
    • @InjectMocks: Mock 객체가 주입된 클래스를 사용하게 될 클래스를 의미합니다. 테스트 런타임 시 클래스 내부에 선언된 멤버 변수들 중에서 @Mock으로 등록된 클래스의 변수에 실제 객체 대신 Mock 객체가 주입되어 Unit Test가 처리됩니다.

    이를 토대로 위의 코드를 쉽게 분석할 수 있습니다. UserServiceTest는 Mockito를 사용(@ExtendWith)하고, UserRepository를 실제 객체가 아닌 Mock 객체로 바꾸어 주입(@Mock)합니다. 따라서 테스트 런타임 시 UserService의 멤버 변수로 선언된 UserRepository에 Mock 객체가 주입(InjectMocks)됩니다.

    이제 Mock을 이용하여 Unit Test를 작성해보겠습니다. UserServiceTest 클래스에 다음과 같이 새로운 사용자를 등록하는 기능을 테스트하기 위한 메서드를 추가합니다. 일반적으로 Given – When – Then 패턴을 이용하여 Mock Test를 구성합니다.

    • Given: 테스트를 위한 준비 과정입니다. 변수를 선언하고, Mock 객체에 대한 정의도 함께 작성합니다.
    • When: 테스트를 실행하는 과정입니다. 테스트하고자 하는 내용을 작성합니다.
    • Then: 테스트를 검증하는 과정입니다. 예상한 값과 결괏값이 일치하는 지 확인합니다.
    // UserServiceTest.java
    ...
    @Test
        public void saveNewUser() {
            // given
            User user = User.builder().idx(1)
                    .username("lattechiffon")
                    .email("lattechiffon@gmail.com")
                    .roles(Arrays.asList("USER", "ADMIN")).build();
    
            when(userRepository.save(any())).thenReturn(user); // Mock 객체 주입
    
            // when
            User result = userService.createUser(User.builder().username("lattechiffon").build());
    
            // then
            verify(userRepository, times(1)).save(any());
            assertThat(result, equalTo(user));
        }
    ...

    위의 코드에서 주목해야 될 부분은 Mock 객체가 주입되도록 설정하는 부분입니다. when().thenReturn()을 이용하여 테스트 과정에서 UserRepository의 save 메서드를 호출하게 되면 즉시 user 인스턴스 객체를 반환하도록 만듭니다.

    Gradle을 이용하여 Unit Test를 실행하면 정상적으로 통과되는 것을 확인할 수 있습니다. 즉, 작성한 테스트는 UserRepository의 save 메서드에 1번 접근하며, 정상적으로 사용자 등록이 처리됨이 검증되었습니다.

    다음과 같이 추가로 테스트 메서드를 구성하여 서비스의 나머지 기능들도 정상적으로 동작하는지 확인할 수 있습니다.

    // UserServiceTest.java
    ...
    @Test
        public void findUser() {
            // given
            User user = User.builder().idx(1)
                    .username("lattechiffon")
                    .email("lattechiffon@gmail.com")
                    .roles(Arrays.asList("USER", "ADMIN")).build();
    
            when(userRepository.findByUsername("lattechiffon")).thenReturn(user);
    
            // when
            User result = userService.findUser("lattechiffon");
    
            // then
            verify(userRepository, times(1)).findByUsername(any());
            assertThat(result, equalTo(user));
        }
    
        @Test
        public void findAllUsers() {
            // given
            List<User> users = Arrays.asList(User.builder().idx(1).username("lattechiffon").build(),
                    User.builder().idx(2).username("yu-gotcha").build());
    
            when(userRepository.findAll()).thenReturn(users);
    
            // when
            List<User> result = userService.findAllUsers();
    
            // then
            verify(userRepository, times(1)).findAll();
            assertThat(result, equalTo(users));
        }
    ...

    이 예제를 포함한 전체 소스코드는 아래의 GitHub 저장소에서 확인할 수 있습니다.

    다음 글부터는 JUnit5를 이용한 단위 테스트에 대해 기본적인 내용들부터 하나씩 살펴보도록 하겠습니다.