gRPC

[MSA] SpringBoot에 gRPC 서버 구성하기: 회원 서비스 만들기

Stark97 2025. 1. 30. 17:38

시작하며


안녕하세요. 개발자 stark입니다.

최근 업무가 조금 바빠져서 블로그에 글을 작성하지 못했는데요. 설이기도 하고 정리할 시간이 생겨서 오랜만에 글을 적게 되었습니다. 제가 이번 연도에 작성할 주요 시리즈 중 하나는 gRPC였는데요. 왜냐하면 원할 때 언제든지 적용해서 사용하기에는 생각보다 관련된 정보가 부족하다고 느꼈기 때문입니다. (제 정보력이 부족한 걸 수도 있지만요 ㅎㅎ)

 

그래서 저는 Spring에 gRPC를 적용하고 MSA 서버 간 통신이 가능하도록 프로젝트를 구성했고 이것을 천천히 공유드리고자 합니다. 총 2개의 SpringBoot 프로젝트를 만들어야 하고 각 프로젝트에 gRPC 서버, gRPC 클라이언트를 구성해야 합니다. 조금 특이하다고 느낄만한 것이 있는데 일반적으로 MSA를 구축할 때 feignClient로 요청을 보낸다면 tomcat으로 띄워진 Spring서버의 api에 feign 요청을 보내면 되는데 gRPC는 Spring의 tomcat 서버와 별개로 내부에 gRPC 전용 서버(Netty)를 띄워줘야 합니다.

 

왜냐하면 gRPC는 HTTP/2 + 바이너리 프레이밍을 필요로 하고, 이는 전통적 서블릿 컨테이너(Tomcat, Jetty)와 구조가 다르기 때문입니다. 그래서 Netty 기반 서버를 별도로 구동해야만 합니다. 더군다나 ALPN, 멀티플렉싱, 양방향 스트리밍 등 gRPC 특유의 기능을 그대로 써야 하므로, 일반적인 “서블릿 API”로는 구현이 어려워서 자체 서버(Port 분리)를 띄우는 것입니다.

 

제가 구성할 gRPC MSA 프로젝트는 다음과 같습니다.

gRPC msa 서버 구성
gRPC msa 서버 구성

마지막으로 이번 시리즈의 구성을 간단히 설명드리겠습니다.

(현 게시글) 1. SpringBoot gRPC 서버 구성하기 (회원가입, 조회 api 설계)

2. SpringBoot gRPC 클라이언트 구성하기 (회원 조회 feignClient, gRPC 클라이언트 구성)

3. MSA 서버 간 인증 적용하기 (jwt 서버 토큰 구성, gRPC 인터셉터 적용)

4. locust로 http와 gRPC의 성능 비교하기

 

 

스프링 의존성 분석


gRPC 서버 프로젝트 링크입니다. (코드 사용 시 star 한 번씩만 부탁드립니다!!)

https://github.com/wlsdks/grpc-server-example

 

GitHub - wlsdks/grpc-server-example: SpringBoot3.x.x 버전 grpc 예제 프로젝트 (gRPC 서버 예시 코드이며 클라이

SpringBoot3.x.x 버전 grpc 예제 프로젝트 (gRPC 서버 예시 코드이며 클라이언트와 함께 봐주세요) - wlsdks/grpc-server-example

github.com

gRPC 서버 프로젝트가 필요로 하는 의존성은 다음과 같습니다.

프레임워크 spring-boot-starter-web
spring-boot-starter-security
spring-boot-starter-data-jpa
3.3.7
DB PostgreSQL  
언어 Java 21
인증 jjwt 0.12.6
grpc grpc-spring-boot-starter 3.1.0
  com.google.protobuf
com.google.protobuf:protoc
4.28.2
  io.grpc:grpc-netty-shaded
io.grpc:grpc-protobuf
io.grpc:grpc-stub
1.65.1
  com.google.protobuf (플러그인) 0.9.4
  javax.annotation-api (grpc proto 빌드 오류 해결) 1.3.2

모든 버전을 가능한 최신 버전으로 적용시키고자 많이 노력했습니다 ㅎㅎ 그래도 제가 개발하는 속도보다 버전이 올라가는 게 더 빠르더군요.. 지금 글을 작성하는 이 시점에도 벌써 스프링부트 3.4.2 버전이 안정화 버전이 되어있었습니다.

 

여기서 가장 중요한 건 grpc겠죠? 나름 열심히 알아보고 가장 최신 버전으로 맞춰보았습니다. 그러나 grpc만 최신버전으로 적용시킨다고 해서 서버가 멋지게 실행되지는 않았습니다. 왜냐하면 generateProto로 컴파일하는 과정에서 오류가 발생하기 때문입니다. 제가 대표적으로 발생하는 오류를 가져와봤습니다.

@javax.annotation.Generated(
    value = "by gRPC proto compiler (version 1.65.1)",
    comments = "Source: member.proto")
@io.grpc.stub.annotations.GrpcGenerated

## 그리고 아래와 같은 오류로그가 남게됩니다.
error: cannot find symbol
@javax.annotation.Generated(
                 ^
  symbol:   class Generated
  location: package javax.annotation

이 로그를 통해 문제를 바로 파악하신 분들도 계실 것이지만 일반적으로 바로 알아채기는 쉽지 않습니다. 저도 여러 고민을 하던 중에 javax라는 로그를 눈여겨봤고 이것을 집요하게 분석해서 문제점을 찾았습니다.

 

문제점은 바로 Java 플랫폼 이관에 따른 패키지의 변경입니다. 예전에는 썬마이크로 시스템즈가 java EE라는 플랫폼을 구축했었고 이와 관련된 코드 패키지로 javax가 사용되었습니다. 그런데 2017년에 이클립스 재단으로 플랫폼이 이관되면서 명칭이 jakarta EE로 변경되었고 패키지 또한 jakarta로 변경되었습니다.

 

그런데 저는 Spring 최신 버전(3.x.x)을 사용 중이고 자바는 21 버전을 적용했기에 jakarta 패키지와 호환이 됩니다. 그런데 gRPC로 생성된 코드에서는 @javax.annotation.Generated 이렇게 javax의 패키지의 어노테이션을 사용하고 있다는 것을 알 수 있습니다. 그래서  저는 javax 패키지의 어노테이션이 없어서 관련된 오류가 발생하고 있었던 것입니다. (실제로 개발 중에 코드를 import 하다 보면 javax, jakarta 코드가 공존하는 것을 확인하실 수 있을 것입니다.)

 

좀 더 자세히 설명드리면 Java 9 이상 환경에서는 javax.annotation.Generated 같은 일부 어노테이션이 더 이상 JDK 기본 패키지에 포함되지 않아 “cannot find symbol” 에러가 발생하는 것입니다. gRPC에서 Proto 코드를 컴파일하면 자동 생성된 클래스에 @javax.annotation.Generated가 붙는데, 해당 어노테이션을 찾지 못하는 것입니다. 그래서 저는 javax.annotation-api 의존성을 build.gradle에 추가해서 이 문제를 해결했습니다. (버전은 2018년의 1.3.2를 사용합니다)

 

참고로 mavenRepository에는 새로운 라이브러리인 jakarta.annotation-api로 이동을 권장하지만 이름을 보면 알 수 있듯이 jakarata의 annotation-api이기 때문에 지금 발생하는 문제인 javax와는 관련이 없습니다. 그러니 꼭 javax.annotation-api를 사용해 주세요.

 

저는 build.gradle을 다음과 같이 작성하였습니다.

buildscript {
    ext {
        protobufVersion = '4.28.2'
        protobufPluginVersion = '0.9.4'
        grpcVersion = '1.65.1'
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.7'
    id 'io.spring.dependency-management' version '1.1.7'

    // Protobuf 플러그인을 적용하여 .proto 파일을 컴파일할 수 있다. 여기서 버전은 ext에 정의된 protobufPluginVersion을 사용한다.
    id 'com.google.protobuf' version "${protobufPluginVersion}"
}

group = 'com.demo'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // spring
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // data jpa, h2, postgresql
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.postgresql:postgresql'
    runtimeOnly 'com.h2database:h2'

    // lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // mapstruct 설정
    implementation 'org.mapstruct:mapstruct:1.5.5.Final'
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'

    // grpc 프로토콜 버터를 사용하기 위한 핵심 라이브러리 (Protobuf 메시지의 직렬화 및 역직렬화를 지원합니다.)
    implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
    implementation "com.google.protobuf:protobuf-java:${protobufVersion}"

    // grpc 서버, 클라이언트 설정
    implementation 'net.devh:grpc-spring-boot-starter:3.1.0.RELEASE' // Spring Boot와 gRPC의 통합을 간편하게 도와주는 스타터
    implementation "io.grpc:grpc-netty-shaded:${grpcVersion}" // Netty Shaded 사용(gRPC 서버와 클라이언트의 Netty 전송 계층을 제공)
    implementation "io.grpc:grpc-protobuf:${grpcVersion}"     // Protobuf 메시지와 gRPC의 통합을 지원
    implementation "io.grpc:grpc-stub:${grpcVersion}"         // gRPC 클라이언트 스텁을 생성
    implementation 'javax.annotation:javax.annotation-api:1.3.2' // 이걸 추가해야 gRPC 컴파일시 javax 어노테이션 오류가 발생하지 않는다.

    implementation("io.jsonwebtoken:jjwt-api:0.12.6")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")

    // json 타입을 DB에 저장하기 위한 라이브러리
    implementation 'com.vladmihalcea:hibernate-types-60:2.21.1'

    // Grpc-Test-Support
    testImplementation("io.grpc:grpc-testing")
    // Spring-Test-Support (Optional)
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

protobuf {
    // Protobuf 컴파일러를 지정하여 .proto 파일을 컴파일합니다.
    protoc {
        artifact = "com.google.protobuf:protoc:${protobufVersion}"
    }
    // 생성된 파일을 정리합니다.
    clean {
        delete generatedFilesBaseDir
    }
    // gRPC 플러그인을 설정하여 Protobuf 파일로부터 gRPC 관련 코드를 생성합니다.
    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
        }
    }
    // 모든 프로토콜 버퍼 작업에 대해 gRPC 플러그인을 적용합니다.
    generateProtoTasks {
        all()*.plugins {
            grpc {}
        }
    }
}

tasks.named('test') {
    useJUnitPlatform()
}

application.yml도 작성해 줍니다. (제 github에는 최종 버전이 적용되어 있기에 조금 다를 수 있습니다.)

  • tomcat 서버 포트와 gRPC 서버 포트 설정을 각각 해줘야만 합니다. (gRPC는 tomcat이 아닌 netty를 사용하기 때문)
# 서버포트
server:
  port: 8090

spring:
  # 데이터베이스 설정
  datasource:
    url: jdbc:postgresql://localhost:5432/grpc
    username: root
    password: 1234
    driver-class-name: org.postgresql.Driver
  # JPA 설정
  jpa:
    database: POSTGRESQL
    defer-datasource-initialization: true
    hibernate:
      ddl-auto: create
    open-in-view: false
    # show-sql: true
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 10
        highlight_sql: true

# gRPC 서버포트
grpc:
  server:
    port: 50051 # gRPC 서버 포트
    maxInboundMessageSize: 10485760 # 최대 메시지 크기 (10MB)
    security:
      enabled: false

postgres를 실행하기 위한 docker-compose.yml도 올려드립니다!

version: '3.8'

services:
  postgres:
    image: postgres:13
    container_name: postgres-grpc-container
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_DB: member
      POSTGRES_USER: root
      POSTGRES_PASSWORD: 1234
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:
    driver: local

 

 

gRPC proto 파일 작성하기


.proto 파일은 Protocol Buffers(줄여서 Protobuf)라는 직렬화 형식을 정의하기 위해 사용되는 스키마 파일입니다. Protobuf는 데이터를 효율적으로 직렬화/역직렬화하는 데 사용되는 Google의 데이터 포맷인데, 이 .proto 파일은 그 데이터 구조와 RPC(Service)를 정의하는 데 쓰입니다. 쉽게 말해, 서버와 클라이언트 간 통신에서 "데이터를 어떻게 보낼지"와 "어떤 메서드를 호출할지"를 설계하는 설계도 같은 역할을 합니다.

 

.proto 파일은 크게 두 가지로 구성됩니다.

  • 메시지(Message): 교환되는 데이터 구조를 정의합니다. 예를 들어, JSON의 객체(Object)와 비슷한 개념입니다. 각 메시지는 필드와 타입으로 구성됩니다.
  • 서비스(Service): 클라이언트가 호출할 수 있는 원격 메서드를 정의합니다. 이는 서버에서 구현되고 클라이언트는 이를 호출하여 작업을 수행합니다.

제가 작성한 proto 파일은 다음과 같습니다. proto 파일을 작성하다 보면 마치 자바의 인터페이스에 메서드를 선언하는 방식과 매우 유사하다는 느낌이 들기도 합니다.

syntax = "proto3";

package com.test.member.grpc;

// 여기에 내가 원하는 패키지명을 적는다. 아래는 예시이며 실제 프로젝트 패키지를 잘 보면서 수정하자.
//option java_multiple_files = true;
option java_package = "com.test.member.grpc";
option java_outer_classname = "MemberProto";

service MemberService {
  // gRPC의 스트리밍 기능을 통해 다수의 요청과 응답을 효율적으로 처리하는 방식
  rpc StreamCreateMember (stream MemberRequest) returns (stream MemberCreateResponse);

  // 단일 회원 ID로 조회
  rpc GetMemberById (MemberIdRequest) returns (MemberResponse);

  // 이메일로 회원 조회
  rpc GetMemberByEmail (MemberEmailRequest) returns (MemberResponse);
}

// 회원 ID로 조회 요청
message MemberIdRequest {
  int64 id = 1;
}

// 이메일로 조회 요청
message MemberEmailRequest {
  string email = 1;
}

// 주소 정보
message Address {
  string street = 1;
  string city = 2;
  string country = 3;
  string postal_code = 4;
  map<string, string> additional_info = 5;
}

// 연락처 정보
message Contact {
  string phone = 1;
  string mobile = 2;
  string work_phone = 3;
  repeated string emails = 4;
  map<string, string> social_media = 5;
}

// 회원 정보 응답
message MemberResponse {
  int64 id = 1;
  string email = 2;
  string name = 3;
  string profile_image_base64 = 4;
  Address address = 5;
  Contact contact = 6;
  repeated string interests = 7;
  repeated string skills = 8;
  string metadata = 9;
}

// 멤버 생성 요청 객체
message MemberRequest {
  int64 id = 1;
  string email = 2;
  string password = 3;
  string name = 4;
  // 대용량 필드
  string profileImageBase64 = 5;
  string etcInfo = 6;
}

// 멤버 생성 응답
message MemberCreateResponse {
  int64 id = 1;
  string email = 2;
  string password = 3;
  string name = 4;
  string profileImageBase64 = 5;
  string etcInfo = 6;
}

이렇게 작성된 proto 파일은 단순히 설계도의 역할만 하는 것이 아닙니다. 이 파일을 기반으로 서버와 클라이언트의 코드를 자동으로 생성합니다. 예를 들어, gradle의 generateProto 명령어를 실행하면 위에 작성된 proto파일에 선언한 MemberService를 구현하기 위한 서버 코드와 클라이언트에서 호출할 수 있는 스텁 코드가 자동으로 생성됩니다. 이 과정을 통해 저희는 로직을 상세하게 구현하는 것에만 집중할 수 있습니다. (매우 편리하죠?)

 

자 그럼 이 proto 파일은 어디에 만들어야할까요?

  • 일반적으로 src > main 패키지 내부에 새로운 proto 패키지를 만들고 .proto 파일을 생성합니다. 아래의 tree 구조처럼 생성하시면 됩니다. 위치가 은근 중요하니 꼭 확인해주세요!
└── src
    ├── main
    │   ├── java
    │   │   └── 생략합니다!
    │   ├── proto
    │   │   └── member.proto
    │   └── resources
    │       └── application.yml

 

 

build.gradle의 protobuf 설정 이해하기


build.gradle의 맨 하단을 봅시다. protobuf 설정을 작성해 둔 것을 알 수 있습니다.

이 protobuf 블록의 전체적인 역할을 이해하면 좋습니다. 이 설정은 Protocol Buffers의 컴파일 과정을 자동화하고, gRPC 서비스 코드를 생성하는 전체 파이프라인을 구성합니다.

protobuf {
    // Protobuf 컴파일러를 지정하여 .proto 파일을 컴파일합니다.
    protoc {
        artifact = "com.google.protobuf:protoc:${protobufVersion}"
    }
    // 생성된 파일을 정리합니다.
    clean {
        delete generatedFilesBaseDir
    }
    // gRPC 플러그인을 설정하여 Protobuf 파일로부터 gRPC 관련 코드를 생성합니다.
    plugins {
        grpc {
            artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
        }
    }
    // 모든 프로토콜 버퍼 작업에 대해 gRPC 플러그인을 적용합니다.
    generateProtoTasks {
        all()*.plugins {
            grpc {}
        }
    }
}

protoc 설정부터 살펴보겠습니다.

이것은 Protocol Buffers 컴파일러를 지정하는 부분입니다. protoc은 .proto 파일을 읽어서 각 프로그래밍 언어에 맞는 코드를 생성하는 컴파일러입니다. artifact 설정은 Maven 저장소에서 다운로드할 컴파일러의 버전을 지정합니다. 저는 4.28.2 버전을 사용 중입니다.

protoc {
    artifact = "com.google.protobuf:protoc:${protobufVersion}"
}

clean 설정을 보겠습니다.

이 부분은 이전에 생성된 프로토콜 버퍼 관련 파일들을 정리하는 역할을 합니다. gradle clean 태스크가 실행될 때 generatedFilesBaseDir에 지정된 디렉토리의 파일들이 삭제됩니다. 이는 새로운 빌드를 깨끗한 상태에서 시작할 수 있게 해 줍니다. (이 설정은 추가하지 않아도 gradle의 clean 명령어 실행 시 잘 삭제되니 따로 추가하지 않으셔도 됩니다.)

clean {
    delete generatedFilesBaseDir
}

plugins 설정은 특히 중요합니다.

이 설정은 protoc 컴파일러의 플러그인을 정의합니다. protoc-gen-grpc-java는 프로토콜 버퍼 정의로부터 gRPC 서비스 관련 Java 코드를 생성하는 특별한 플러그인입니다. 기본 protoc은 메시지 클래스만 생성하지만, 이 플러그인을 통해 서비스 인터페이스와 스텁 클래스도 생성할 수 있습니다.

plugins {
    grpc {
        artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
    }
}

마지막으로 generateProtoTasks 설정입니다.

이 부분은 모든 프로토콜 버퍼 컴파일 태스크에 대해 위에서 정의한 grpc 플러그인을 적용하라고 지시합니다. all()은 프로젝트의 모든 소스셋(main, test 등)에 대해 적용됨을 의미합니다.

generateProtoTasks {
    all()*.plugins {
        grpc {}
    }
}

실제 동작 순서를 보면

  1. Gradle이 protoc 컴파일러를 다운로드합니다.
  2. 지정된 grpc 플러그인도 함께 다운로드됩니다.
  3. src/main/proto 디렉토리의 .proto 파일들을 찾습니다.
  4. protoc이 이 파일들을 컴파일하여 Java 메시지 클래스를 생성합니다.
  5. grpc 플러그인이 추가로 서비스 관련 코드를 생성합니다.
  6. 생성된 모든 코드는 build/generated/source/proto 디렉토리에 저장됩니다.

이렇게 생성된 코드들은 자동으로 Java 소스 셋에 포함되어, 프로젝트의 다른 코드에서 사용할 수 있게 됩니다.

 

 

gRPC 서비스 코드 구현하기


자 그럼 gradle에 아래의 명령어를 실행해 봅시다. (인텔리제이에서는 버튼 클릭으로도 가능합니다)

./gradlew generateProto

위의 명령어를 실행하면 아래처럼 MemberServiceGrpc 클래스가 생성됩니다.

  • 아마 querydsl 또는 mapstruct 라이브러리를 사용해 보신 분들께서는 매우 익숙한 작업일 것입니다. gradle의 명령어를 실행하면 코드가 생성됩니다. 아래의 예시코드에는 설명을 위한 AsyncService 인터페이스와 ImplBase 클래스만을 적어두었습니다.
@javax.annotation.Generated(
    value = "by gRPC proto compiler (version 1.65.1)",
    comments = "Source: member.proto")
@io.grpc.stub.annotations.GrpcGenerated
public final class MemberServiceGrpc {

      private MemberServiceGrpc() {}

      public static final java.lang.String SERVICE_NAME = "com.test.member.grpc.MemberService";

      public interface AsyncService {

        default io.grpc.stub.StreamObserver<com.test.member.grpc.MemberProto.MemberRequest> streamCreateMember(
            io.grpc.stub.StreamObserver<com.test.member.grpc.MemberProto.MemberCreateResponse> responseObserver) {
          return io.grpc.stub.ServerCalls.asyncUnimplementedStreamingCall(getStreamCreateMemberMethod(), responseObserver);
        }

        default void getMemberById(com.test.member.grpc.MemberProto.MemberIdRequest request,
            io.grpc.stub.StreamObserver<com.test.member.grpc.MemberProto.MemberResponse> responseObserver) {
          io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getGetMemberByIdMethod(), responseObserver);
        }

        default void getMemberByEmail(com.test.member.grpc.MemberProto.MemberEmailRequest request,
            io.grpc.stub.StreamObserver<com.test.member.grpc.MemberProto.MemberResponse> responseObserver) {
          io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getGetMemberByEmailMethod(), responseObserver);
        }

      }

      public static abstract class MemberServiceImplBase
          implements io.grpc.BindableService, AsyncService {

        @java.lang.Override 
        public final io.grpc.ServerServiceDefinition bindService() {
          return MemberServiceGrpc.bindService(this);
        }

      }

      // ...
  
}

이렇게 gRPC 클래스가 생성되었다면 서버의 구성 준비는 끝입니다. 이제 이 gRPC 클래스 내부에 있는 MemberServiceImplBase 코드를 상속받아서 제가 원하는 저만의 gRPC 서비스 코드를 구현할 수 있게 되었습니다. 다만 위의 코드를 살펴보시면 ImplBase를 상속받지 않고 AsyncService 인터페이스를 직접 구현하면 되는 거 아닌가? 이런 궁금증이 드실 수 있습니다.

 

당연히 AsyncService 인터페이스를 직접 구현하셔도 됩니다. 그러나 이 경우에는 조금 더 해주셔야 할 작업이 있습니다. 바로 gRPC 서비스 메서드 바인딩입니다. 왜냐하면 gRPC 서비스 메서드를 호출하기 위해서는 아래에 있는 BindableService 인터페이스를 구현해서 gRPC 서비스를 바인딩해 주는 bindService() 메서드를 구현해야만 하기 때문입니다.

package io.grpc;

public interface BindableService {
  ServerServiceDefinition bindService();
}

그런데 AsyncService 인터페이스 내부에는 BindableService 인터페이스를 가지지 않기에 구현하는 개발자가 같이 이 인터페이스를 상속받아서 구성해줘야만 합니다. 그리고 이렇게 직접 바인딩 메서드를 구현하는 작업은 생각보다 쉽지 않습니다.

 

다행히 이런 바인딩 메서드를 미리 구현해 주는 클래스가 있습니다. 그것이 바로 ImplBase 클래스입니다!! 그럼 ImplBase 코드가 어떻게 생겼는지 한번 살펴볼까요? 내부 코드는 누구나 이해할 수 있게 매우 간단히 구성되어 있습니다. BindableService 인터페이스에 선언된 bindService() 메서드를 구현하고 있으며 이 구현체는 다음과 같이 동작합니다.

  1. 서비스 디스크립터를 생성하여 gRPC 서버에 등록합니다. (서비스 이름, 메서드 이름, 입력/출력 형식 등을 포함)
  2. addMethod()를 호출하여, 서비스 내의 각 메서드와 클라이언트 요청을 매핑합니다.
  3. 등록된 메서드는 클라이언트가 호출했을 때 gRPC 프레임워크가 요청을 처리할 수 있도록 합니다.
public static abstract class MemberServiceImplBase
  implements io.grpc.BindableService, AsyncService {

    @java.lang.Override 
    public final io.grpc.ServerServiceDefinition bindService() {
      return MemberServiceGrpc.bindService(this);
    }
}

// bindService 메서드의 구현을 살펴봅시다.
public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) {
    return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
        .addMethod(
          getStreamCreateMemberMethod(), // 클라이언트 요청에 매핑될 메서드 1
          io.grpc.stub.ServerCalls.asyncBidiStreamingCall(
            new MethodHandlers<>(service, METHODID_STREAM_CREATE_MEMBER))) // 핸들러 등록

        .addMethod(
          getGetMemberByIdMethod(),      // 클라이언트 요청에 매핑될 메서드 2
          io.grpc.stub.ServerCalls.asyncUnaryCall(
            new MethodHandlers<>(service, METHODID_GET_MEMBER_BY_ID))) // 핸들러 등록

        .addMethod(
          getGetMemberByEmailMethod(),   // 클라이언트 요청에 매핑될 메서드 3
          io.grpc.stub.ServerCalls.asyncUnaryCall(
            new MethodHandlers<>(service, METHODID_GET_MEMBER_BY_EMAIL))) // 핸들러 등록
        .build();
}

저는 이런 ImplBase코드를 상속받는 MemberServiceGrpcImpl 커스텀 클래스를 선언했습니다. 이 커스텀 클래스는 ImplBase를 상속받았으니 ImplBase가 구현 중인 AsyncService 인터페이스의 메서드를 구현할 수 있게 됩니다. 그래서 저는 회원 조회 메서드인 geMemberById()를 Override 했습니다.

 

참고로 만약 사용할 gRPC 메서드를 직접 구현하지 않으면 AsyncService 인터페이스에 선언되어 있던 default 메서드가 실행될 것이고 asyncUnimplementedUnaryCall 호출되면서 "미구현" 응답을 보냅니다. (사용하시기 전에 꼭 구현해 주세요 ㅎㅎ)

@Slf4j
@Transactional(readOnly = true) // 트랜잭션 추가하여 지연 로딩 문제 해결
@RequiredArgsConstructor
@GrpcService
public class MemberServiceGrpcImpl extends MemberServiceGrpc.MemberServiceImplBase {

    private final MemberRepository memberRepository;
    private final GrpcMemberMapper grpcMemberMapper;

    /**
     * @param request          : 클라이언트로부터 받은 요청
     * @param responseObserver : 클라이언트로부터 받은 요청에 대한 응답을 전송하는 스트림
     * @apiNote 클라이언트로부터 받은 요청에 대한 응답을 전송하는 메서드
     */
    @Override
    public void getMemberById(MemberProto.MemberIdRequest request,
                              StreamObserver<MemberProto.MemberResponse> responseObserver) {
        try {
            MemberEntity member = memberRepository.findById(request.getId())
                    .orElseThrow(() -> {
                        log.error("회원을 찾을 수 없음 - ID: {}", request.getId());
                        return new StatusRuntimeException(
                                Status.NOT_FOUND.withDescription("Member not found")
                        );
                    });

            // 기존 응답 로직
            responseObserver.onNext(grpcMemberMapper.entityToProto(member));
            responseObserver.onCompleted();
        } catch (Exception e) {
            log.error("getMemberById 메서드 실행 중 오류 발생", e);
            responseObserver.onError(
                    Status.INTERNAL
                            .withDescription(e.getMessage())
                            .asRuntimeException()
            );
        }
    }

}

자 근데 아직 한 가지 남았습니다. 코드에는 @GrpcService라는 어노테이션이 적용되어 있습니다.

  • 이 어노테이션은 gRPC 서비스 클래스를 Spring Bean으로 등록합니다. 이를 통해 Spring의 의존성 주입(DI) 기능을 사용할 수 있게 됩니다.
  • 해당 클래스가 gRPC 서비스 구현체임을 Spring에게 알립니다. Spring은 이 어노테이션이 붙은 클래스를 자동으로 감지하여 gRPC 서버에 등록합니다.

어노테이션을 확인해 봅시다. 실제 어노테이션 코드에는 관련된 주석이 굉장히 많은데 코드를 보기 좋게 적어두기 위해 주석은 제거하였습니다. 꼭 한번 직접 읽어보시는 것을 추천드립니다.

  • 주석에 의하면 이 어노테이션은 BindableService를 구현한 클래스에만 사용해야 합니다. 보통 protobuf가 생성한 GrpcService.ImplBase를 상속한 구현체에 사용하게 됩니다.
@Target({ElementType.TYPE, ElementType.METHOD})  // 클래스나 메소드에 적용 가능
@Retention(RetentionPolicy.RUNTIME)              // 런타임에도 어노테이션 정보가 유지됨
@Documented                                      // JavaDoc에 포함됨
@Service                                         // Spring의 서비스 컴포넌트로 등록
@Bean                                            // Spring Bean으로 등록
public @interface GrpcService {

    // 1. 클래스 기반 인터셉터 설정
    Class<? extends ServerInterceptor>[] interceptors() default {};

    // 2. Bean 이름 기반 인터셉터 설정
    String[] interceptorNames() default {};

    // 3. 인터셉터 정렬 옵션
    boolean sortInterceptors() default false;

}

저는 이미 클래스에 어노테이션을 적용했는데 만약 인터셉터 관련 옵션을 적용시키고자 한다면 다음과 같이 적용해 주시면 됩니다.

@GrpcService(
    interceptors = {LogInterceptor.class},   // 특정 인터셉터 클래스 지정
    interceptorNames = {"authInterceptor"},  // Bean으로 등록된 인터셉터 지정
    sortInterceptors = true                  // 전역 인터셉터와 함께 정렬
)
public class MyServiceImpl extends MyServiceGrpc.MyServiceImplBase {
    // 서비스 구현
}

참 쉽죠? gRPC 서버 구성은 이렇게 끝났습니다. 제가 한 것은 몇 개 없습니다. 되돌아보면 저는 proto 파일에 제가 원하는 서비스 메서드를 선언했고 gradle에서 generateProto 명령어를 실행해서 gRPC 전용 클래스를 만들었습니다.

 

이후 커스텀 클래스를 선언했고 이 클래스는 gRPC 전용 클래스 내부에 선언된 ImplBase 클래스를 상속받도록 했습니다. 이후 클래스 내부에 제가 원하는 gRPC 메서드를 구현(Override)했습니다. 이렇게 하나의 gRPC 서버 메서드가 완성되었습니다.

 

추후 gRPC 클라이언트 서버(다른 SpringBoot)를 구성하고 지금 구성한 gRPC 서비스의 회원조회 메서드를 호출하면 ImplBase 클래스 내부에 구현되어 있는 bindService() 메서드에서 등록된 gRPC 메서드를 찾아서 실행해 줍니다. 바로 이렇게 제가 지금까지 열심히 구현한 gRPC 메서드가 실행되어 서버 간의 gRPC 통신이 처리됩니다.

 

 

gRPC 메서드는 반환을 하지 않는다?


저는 gRPC서비스 메서드를 구현하면서 가장 먼저 든 생각이 있습니다. 바로 일반적인 API와는 다르게 void 타입으로 선언되어 있다는 것입니다. 기존의 REST API에서는 요청에 대한 결과를 항상 리턴값으로 반환하는 것이 일반적인데, gRPC는 이와 다른 방식이 적용되어 있었습니다. 처음에는 다소 낯설게 느껴질 수 있지만, gRPC가 이러한 구조를 채택한 이유는 responseObserver라는 특별한 메커니즘에 있습니다.

/**
 * @param request          : 클라이언트로부터 받은 요청
 * @param responseObserver : 클라이언트로부터 받은 요청에 대한 응답을 전송하는 스트림
 * @apiNote 클라이언트로부터 받은 요청에 대한 응답을 전송하는 메서드
 */
@Override
public void getMemberById(MemberProto.MemberIdRequest request,
                          StreamObserver<MemberProto.MemberResponse> responseObserver) {
    try {
        MemberEntity member = memberRepository.findById(request.getId())
                .orElseThrow(() -> {
                    log.error("회원을 찾을 수 없음 - ID: {}", request.getId());
                    return new StatusRuntimeException(
                            Status.NOT_FOUND.withDescription("Member not found")
                    );
                });

        // 기존 응답 로직
        responseObserver.onNext(grpcMemberMapper.entityToProto(member));
        responseObserver.onCompleted();
    } catch (Exception e) {
        log.error("getMemberById 메서드 실행 중 오류 발생", e);
        responseObserver.onError(
                Status.INTERNAL
                        .withDescription(e.getMessage())
                        .asRuntimeException()
        );
    }
}

responseObserver는 gRPC가 제공하는 비동기 스트림 객체입니다. 이 객체는 서버에서 클라이언트로 데이터를 전송하는 통로 역할을 하며, gRPC의 핵심적인 동작 방식 중 하나입니다. 서버는 데이터를 준비한 후, 이 responseObserver를 사용하여 클라이언트로 데이터를 비동기적으로 전달합니다.

 

따라서, gRPC 메서드의 반환값이 void로 선언된 이유는 명확합니다. 응답 데이터는 메서드의 리턴값을 통해 직접 반환되지 않고, responseObserver를 통해 전달되기 때문입니다. 이는 서버와 클라이언트가 보다 유연하고 효율적으로 데이터를 주고받을 수 있도록 하는 중요한 설계입니다.

 

gRPC 메서드의 동작은 다음과 같은 흐름으로 진행됩니다.

  1. 클라이언트가 서버에 요청을 보냅니다.
  2. 서버는 요청 데이터를 처리한 후, 결과를 responseObserver를 통해 클라이언트로 전달합니다.
    • 예: responseObserver.onNext(response)
  3. 데이터 전송이 완료되면 responseObserver.onCompleted()를 호출하여 스트림을 종료합니다.
  4. 만약 서버에서 예외가 발생하면 responseObserver.onError()를 호출하여 클라이언트로 에러 정보를 보냅니다.

 

 

회원 엔티티 구성 및 api 설계


gRPC 관련 코드는 작성이 완료되었습니다. 생각보다 너무 간단하지 않나요? 근데 너무 gRPC에만 집중하다 보니 회원 관련 엔티티가 어떻게 구성되었는지는 확인조차 하지 않았습니다. 이제부터라도 테이블 구성을 확인해 봅시다.

 

아래의 이미지는 제가 구성한 MemberEntity의 테이블 구조를 diagram으로 캡처한 것입니다. 저와 동일한 테이블 구성으로 생성 및 테스트를 원하신다면 최상단의 github 프로젝트에 들어가셔서 엔티티 코드를 그대로 사용하시면 됩니다.

grpc-entity
grpc-entity

일반적으로 회원 테이블은 1개면 충분하지만 저는 여러 개의 테이블 구조를 가지도록 했습니다. 왜냐하면 제 목적은 단순히 gRPC 관련 프로젝트를 생성하고 잘 동작하는지만 확인하는 게 아니라 http 요청과의 성능 비교를 하기 위함이었기 때문에 2가지 상황을 고려했습니다.

  1. 회원 조회 시 테이블 join을 많이 걸어서 조회하도록 하여 일반적인 조회보다 복잡하게 만든다.
  2. api 또는 gRPC 응답 구조를 복잡하게 만들어서 serialize를 복잡하게 만든다.

 

엔티티만 잘 구성한다면 api를 만드는 것은 정말 쉽습니다. 저는 가장 먼저 회원 생성, 조회 이렇게 2가지 api를 생성하였습니다. 여기서 만든 조회 api는 추후 feign요청에서도 사용될 예정입니다.

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class MemberRestController {

    private final MemberService memberService;

    @PostMapping("/members")
    public ResponseEntity<ResponseMemberDTO> createMember(@RequestBody MemberSignUpRequestDTO dto) {
        // DB에 저장
        ResponseMemberDTO saved = memberService.createMember(dto);
        return ResponseEntity.ok(saved);
    }

    @GetMapping("/members/{memberId}")
    public ResponseEntity<ResponseMemberDTO> getMemberById(@PathVariable Long memberId) {
        // DB에서 조회
        ResponseMemberDTO member = memberService.getMemberById(memberId);
        return ResponseEntity.ok(member);
    }

}

회원을 생성하는 서비스 로직은 조금 독특한데요. 제가 일부로 대용량 데이터를 저장하도록 하기 위해 generateLargeDummyBase64() 메서드를 만들었습니다. 그리고 이것을 통해 1MB의 데이터를 생성해서 db에 저장하도록 했습니다. 그러면 나중에 조회를 할 때마다 대용량 데이터를 가져오면서 응답 객체로 변환하게 될 테니 대용량 데이터 조회 테스트를 할 수 있겠죠?

@RequiredArgsConstructor
@Service
public class MemberService {

    private final MemberRepository memberRepository;
    private final MemberMapper memberMapper;

    /**
     * @param memberDTO 회원 가입 요청
     * @return 회원 가입 응답
     * @apiNote 회원을 생성합니다.
     */
    public ResponseMemberDTO createMember(MemberSignUpRequestDTO memberDTO) {
        // 1) DTO -> Entity 매핑
        MemberEntity memberEntity = memberMapper.dtoToEntity(memberDTO);

        // 2) 요청으로 넘어온 profileImageBase64를 무시하고,
        //    새로 가짜(랜덤) Base64 문자열을 만들거나, 필요한 로직을 수행
        String dummyBase64 = generateLargeDummyBase64();
        memberEntity.changeProfileImageBase64(dummyBase64);

        // 3) DB에 저장
        MemberEntity savedMemberEntity = memberRepository.save(memberEntity);
        return memberMapper.entityToDto(savedMemberEntity);
    }

    /**
     * @param memberId 회원 ID
     * @return 회원 조회 응답
     * @apiNote 회원 ID로 회원을 조회합니다.
     */
    public ResponseMemberDTO getMemberById(Long memberId) {
        MemberEntity memberEntity = memberRepository.findById(memberId)
                .orElseThrow(() -> new IllegalArgumentException("Invalid member ID"));

        return memberMapper.entityToDto(memberEntity);
    }


    // 테스트용으로 1MB 크기의 난수 데이터를 Base64로 만든 예시
    private String generateLargeDummyBase64() {
//        byte[] dummyBytes = new byte[1024 * 1024]; // 1MB
        byte[] dummyBytes = new byte[1024 * 10]; // 10KB
        new SecureRandom().nextBytes(dummyBytes);
        return Base64.getEncoder().encodeToString(dummyBytes);
    }

}

mapper는 mapstruct 라이브러리를 사용해서 아래와 같이 코드를 선언했습니다.

/**
 * componentModel="spring"을 통해서 spring container에 Bean으로 등록 해 준다. (외부에서 주입받아서 사용하면 된다.)
 * unmappedTargetPolicy IGNORE 만약, target class에 매핑되지 않는 필드가 있으면, null로 넣게 되고, 따로 report하지 않는다.
 */
@Mapper(componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface MemberMapper {

    @Mapping(target = "etcInfo.address", source = "address")
    @Mapping(target = "etcInfo.contact", source = "contact")
    @Mapping(target = "etcInfo.interests", source = "interests")
    @Mapping(target = "etcInfo.skills", source = "skills")
    @Mapping(target = "etcInfo.metadata", source = "metadata")
    MemberEntity dtoToEntity(MemberSignUpRequestDTO dto);

    @Mapping(target = "address", source = "etcInfo.address")
    @Mapping(target = "contact", source = "etcInfo.contact")
    @Mapping(target = "interests", source = "etcInfo.interests")
    @Mapping(target = "skills", source = "etcInfo.skills")
    @Mapping(target = "metadata", source = "etcInfo.metadata")
    ResponseMemberDTO entityToDto(MemberEntity entity);

}

회원 생성 api를 호출할 때는 아래와 같이 매우 복잡한 requestBody를 담아주셔야 합니다.

{
 "email": "test@test.com",
 "password": "password",
 "name": "test",
 "profileImageBase64": "base64EncodedLongString...",
 "address": {
   "street": "123 Test St",
   "city": "Test City",
   "country": "Test Country",
   "postalCode": "12345",
   "additionalInfo": {
     "building": "A",
     "floor": "5",
     "doorCode": "1234"
   }
 },
 "contact": {
   "phone": "123-456-7890",
   "mobile": "098-765-4321",
   "workPhone": "111-222-3333",
   "emails": ["work@test.com", "personal@test.com"],
   "socialMedia": {
     "twitter": "@testuser",
     "linkedin": "linkedin.com/testuser",
     "facebook": "fb.com/testuser"
   }
 },
 "interests": ["coding", "testing", "debugging"],
 "skills": ["java", "spring", "grpc"],
 "metadata": "{\"lastLogin\":\"2024-01-23\",\"status\":\"active\",\"rank\":\"senior\"}"
}

 

 

이제 스프링 시큐리티를 적용해 봅시다.


회원 생성 api까지 완성했습니다. 그럼 로그인을 해야 할 것이고 저는 jwt를 사용해서 인증을 적용할 생각입니다. 그래서 JJwt 라이브러리를 사용했으며 jwt 전용 유틸 클래스를 선언하였습니다. 참고로 코드 내부의 필드에 선언된 값들은 실제로는 yml에서 관리하는 것이 좋지만 저는 상용 프로젝트가 아닌 테스트를 위한 프로젝트이므로 클래스 내부에 선언해 두었습니다.

@Component
public class JwtUtil {

    // 상수로 시크릿 키 문자열 정의
    private static final String SECRET_KEY_STRING = "your_very_long_and_secure_secret_key_at_least_256_bits_long_for_hs256_algorithm";
    private SecretKey secretKey;
    private final long expiration = 3600000; // 1시간

    @PostConstruct
    public void init() {
        // 문자열에서 SecretKey 생성
        secretKey = Keys.hmacShaKeyFor(SECRET_KEY_STRING.getBytes());
    }

    // JWT 토큰 생성
    public String generateToken(String username) {
        return Jwts.builder()
                .subject(username)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    // 토큰에서 사용자 이름 추출
    public String extractUsername(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }

    // 토큰 유효성 검증
    public boolean isTokenValid(String token) {
        try {
            Jwts.parser()
                    .verifyWith(secretKey)
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

}

그리고 토큰을 헤더에서 추출하는 서비스 클래스를 선언했습니다.

  • 클래스 내부에는 http요청과 gRPC요청을 처리하는 메서드를 각각 선언했습니다.
@RequiredArgsConstructor
@Component
public class TokenExtractor {

    private static final String BEARER_PREFIX = "Bearer ";
    private static final Metadata.Key<String> AUTHORIZATION_METADATA_KEY =
            Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER);

    // HTTP 요청에서 토큰 추출
    public Optional<String> extractFromHeader(String authorizationHeader) {
        if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) {
            return Optional.of(authorizationHeader.substring(BEARER_PREFIX.length()));
        }
        return Optional.empty();
    }

    // gRPC 메타데이터에서 토큰 추출
    public Optional<String> extractFromMetadata(Metadata metadata) {
        String authorizationHeader = metadata.get(AUTHORIZATION_METADATA_KEY);
        return extractFromHeader(authorizationHeader);
    }

}

자 이제 Spring Security에 등록할 jwt필터 코드를 선언합니다. try문 내부에서 위에 있는 tokenExtractor를 사용해서 토큰을 추출하고 있는 것을 확인할 수 있습니다.

@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtAuthenticationService jwtAuthenticationService;
    private final TokenExtractor tokenExtractor;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");

        try {
            tokenExtractor.extractFromHeader(authHeader)
                    .ifPresent(token -> {
                        Authentication authentication = jwtAuthenticationService.authenticateToken(token);
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    });

            filterChain.doFilter(request, response);
        } catch (JwtAuthenticationException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write(e.getMessage());
        }
    }

}

그리고 저는 jwt 인증 전용 서비스 코드를 선언했습니다. 여기서 Spring Security에서 사용하는 Authentication 객체를 만들어줍니다.

@Slf4j
@RequiredArgsConstructor
@Service
public class JwtAuthenticationService {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

    public Authentication authenticateToken(String token) {
        try {
            log.info("JWT 인증 시작 - 토큰: {}", token);

            // 토큰 유효성 검사
            if (!jwtUtil.isTokenValid(token)) {
                log.warn("JWT 토큰이 유효하지 않음");
                throw new JwtAuthenticationException("유효하지 않은 JWT 토큰");
            }

            // 사용자 이름 추출
            String username = jwtUtil.extractUsername(token);
            log.info("JWT에서 추출한 사용자 이름: {}", username);

            // 사용자 정보 로드
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            log.info("사용자 정보 로드 완료 - 권한: {}", userDetails.getAuthorities());

            return new GrpcAuthenticationToken(
                    userDetails,
                    token,
                    userDetails.getAuthorities()
            );
        } catch (JwtAuthenticationException e) {
            log.error("JWT 인증 실패: {}", e.getMessage());
            throw e;
        } catch (Exception e) {
            log.error("JWT 인증 중 알 수 없는 오류 발생: {}", e.getMessage(), e);
            throw new RuntimeException("JWT 인증 중 오류 발생", e);
        }
    }

}

마지막으로 SecurityConfig 클래스를 작성했습니다. 여기서 위에서 선언한 jwt인증 필터를 등록해 주었습니다.

@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/**").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/members/**").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(e -> e
                        .authenticationEntryPoint((request, response, authException) ->
                                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()))
                )
                .build();
    }

}

 

 

마지막으로 로그인 api를 만들어봅시다.


회원가입 api를 통해 회원 생성이 가능하게 되었고 security의 필터를 통해 jwt를 사용하여 회원 인증도 할 수 있게 되었습니다. 그럼 이제는 회원이 로그인을 해서 jwt 토큰을 발급받을 수 있도록 해야 합니다. 이것을 위해 지금부터는 로그인 api를 설계해 봅시다.

 

인증 흐름을 이해하고 진행해 봅시다. 우리가 jwt를 사용하여 인증을 구성할 때는 로그인을 해서 accessToken(jwt)을 발급받도록 합니다. 그리고 이후의 모든 api 요청의 header에는 accessToken을 담아서 보냅니다. 그러면 Spring Security의 jwt 필터에서는 매번 토큰 인증을 처리하여 시큐리티 인증을 통과하면 api가 제대로 호출됩니다.

 

그래서 회원가입, 로그인 등의 기능에는 시큐리티의 filter를 통과하도록 설정하는 경우가 많습니다. 만약 인증을 하게 되면 accessToken을 담아서 보내야 하는데 회원이 없거나 로그인을 하기 전에는 당연히 토큰이 없는 것이 정상이기 때문입니다. 그래서 저도 /auth, /members 경로는 인증을 통과하도록 SecurityConfig에서 설정해 주었습니다.

 

설명이 길어졌는데 로그인 api를 살펴봅시다.

저는 그래도 구색을 갖추기 위해 2가지의 api를 만들어 주었습니다. 일단 login api로 인증을 진행하고 accessToken을 발급받을 수 있도록 했고 refresh api로 access 토큰이 만료되면 새로운 accessToken을 발급받는 api를 만들어 주었습니다.

@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final JwtUtil jwtUtil;
    private final MemberService memberService;

    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
        LoginResponse loginResponse = memberService.login(request.getEmail(), request.getPassword());
        return ResponseEntity.ok(loginResponse);
    }

    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refreshToken(@RequestBody TokenRefreshRequest request) {
        String refreshToken = request.getRefreshToken();

        if (jwtUtil.isTokenValid(refreshToken)) {
            String username = jwtUtil.extractUsername(refreshToken);
            String newAccessToken = jwtUtil.generateToken(username);
            return ResponseEntity.ok(new TokenResponse(newAccessToken, refreshToken));
        }

        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

}

인증 서비스의 login() 메서드는 실제 회원을 조회한 다음 이 회원정보를 기반으로 accessToken(jwt)를 생성해서 반환합니다.

@RequiredArgsConstructor
@Service
public class AuthService {

    private final MemberRepository memberRepository;
    private final JwtUtil jwtUtil;

    /**
     * @param email    이메일
     * @param password 비밀번호
     * @return 로그인 응답
     * @apiNote 이메일과 비밀번호로 로그인합니다.
     */
    public LoginResponse login(String email, String password) {
        // 회원 조회
        MemberEntity memberEntity = memberRepository.findByEmail(email)
                .filter(member -> member.getPassword().equals(password))
                .orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));

        // JWT 토큰 생성
        String token = jwtUtil.generateToken(memberEntity.getEmail());

        return LoginResponse.of(token, "Bearer");
    }

}

이제 로그인 요청을 보내봅시다.

requestBody에 아래와 같이 로그인 정보를 추가합니다. 위에 있던 회원가입 api로 로그인한 정보를 담아줍니다.

{
  "email": "test@test.com",
  "password": "password"
}

다음과 같은 응답이 나올 것이고 accessToken을 응답받게 될 것입니다.

{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QHRlc3QuY29tIiwiaWF0IjoxNzM4MDQ1MzE0LCJleHAiOjE3MzgwNDg5MTR9.plfe0hktaB1BobHUqEOJS3AVvl7ABB2EGRHjub8Pl-o",
  "tokenType": "Bearer"
}

 

 

마무리하며


이번 포스팅은 이렇게 마무리되었습니다. 아직 서버 설정이 완벽하게 끝나지는 않았습니다. 추후 gRPC에도 인증을 적용하기 위해 인터셉터를 설정하는 부분이 남았는데 이것은 gRPC 클라이언트까지 구성을 마친 뒤 테스트를 진행하고 이후 서버 토큰을 추가하는 과정에서 작성하도록 하겠습니다. 이번 시리즈를 통해 gRPC를 사용해보고자 하는 많은 분들께 조금이라도 도움이 되었으면 좋겠습니다.

 

긴 글 읽어주셔서 감사합니다 :)

 

 

이제 gRPC 클라이언트를 구성하러 가봅시다!!

2025.02.02 - [gRPC] - [MSA] SpringBoot에 gRPC 클라이언트 구성하기

 

[MSA] SpringBoot에 gRPC 클라이언트 구성하기

시작하며안녕하세요. 개발자 stark입니다. 이전 포스팅에서는 gRPC 서버를 구성해 봤습니다. 이번에는 gRPC 클라이언트 서버를 구성해 봅시다.지금 구성중인 프로젝트는 MSA이기 때문에 최소 2개의

curiousjinan.tistory.com

 

반응형