시작하며
안녕하세요. 개발자 stark입니다.
이전 포스팅에서는 gRPC 서버를 구성해 봤습니다. 이번에는 gRPC 클라이언트 서버를 구성해 봅시다.
지금 구성중인 프로젝트는 MSA이기 때문에 최소 2개의 서버가 필요합니다. 그래서 저는 어떤 도메인을 예시로 들지 고민하다 가장 예시로 설명하기 쉬운 회원 도메인을 적용해서 메인 서버를 구성했고 내부에 gRPC 서버를 구성하였습니다.
아래 다이어그램을 보시면 gRPC 서버는 SpringBoot 내부에 들어가 있는 것을 확인하실 수 있습니다. 그 이유는 gRPC는 SpringBoot에서 일반적으로 사용하는 tomcat과 별개로 netty 서버를 띄워줘야 하기 때문입니다. 반면 클라이언트 서버들은 netty 서버 구성을 하지 않고 SpringBoot의 기본 tomcat을 사용하고 있다는 것도 확인하실 수 있습니다.
이렇게 구성된 이유는 클라이언트 서버는 netty를 구성할 필요가 없기 때문입니다. 왜냐하면 gRPC 클라이언트는 단지 gRPC 서버에 정의된 Netty 기반의 주소(호스트 및 포트 등)를 사용하여 통신만 하면 사용할 수 있기 때문입니다. 그러니 클라이언트 애플리케이션 본인은 Netty를 직접 구동할 필요 없이, gRPC 클라이언트 라이브러리를 통해 gRPC 서버에 요청을 보내기만 하면 됩니다.
마지막으로 이번 시리즈의 구성을 간단히 설명드리겠습니다.
1. SpringBoot gRPC 서버 구성하기 (회원가입, 조회 api 설계)
(현 게시글) 2. SpringBoot gRPC 클라이언트 구성하기 (회원 조회 feignClient, gRPC 클라이언트 구성)
3. MSA 서버 간 인증 적용하기 (jwt 서버 토큰 구성, gRPC 인터셉터 적용)
4. locust로 http와 gRPC의 성능 비교하기
자 그럼 이제 이 클라이언트 서버를 구성해 봅시다. Let's go~
SpringBoot 서버 구성 (의존성 분석)
gRPC 클라이언트 코드는 아래 깃허브에 있습니다.
https://github.com/wlsdks/grpc-client-example
GitHub - wlsdks/grpc-client-example: SpringBoot3.x.x 이상 버전의 grpc 예제 프로젝트 (gRPC 서버 코드와 함께 확
SpringBoot3.x.x 이상 버전의 grpc 예제 프로젝트 (gRPC 서버 코드와 함께 확인해주세요) - wlsdks/grpc-client-example
github.com
이전에 gRPC 서버를 구성했을 때와 완전히 동일하지는 않습니다.
- grpc client 전용 starter를 적용해주셔야 합니다. 만약 grpc 서버와 같은 의존성을 받으신다면 잘 동작하지 않으니 주의해 주세요! 그리고 저는 feign 요청과의 성능 테스트를 할 것이므로 openfeign 또한 의존성을 추가하였습니다.
프레임워크 | spring-boot-starter-web spring-boot-starter-security spring-cloud-starter-openfeign |
3.3.7 |
DB | 필요에 의해 자율적으로 선택 | |
언어 | Java | 21 |
인증 | jjwt | 0.12.6 |
grpc | grpc-client-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 |
제가 작성한 build.gradle 파일은 다음과 같습니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.7'
id 'io.spring.dependency-management' version '1.1.7'
id 'com.google.protobuf' version '0.9.4'
}
group = 'com.demo'
version = '0.0.1'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
jar {
archiveBaseName.set('grpc-client') // 일반 JAR 이름
archiveVersion.set(version)
}
bootJar {
archiveBaseName.set('grpc-client') // 실행 가능한 JAR 이름
archiveVersion.set(version)
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
protobufVersion = '4.28.2'
grpcVersion = '1.65.1'
set('springCloudVersion', "2023.0.4")
}
dependencies {
// spring
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// feign client
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
// grpc 프로토콜 버터를 사용하기 위한 핵심 라이브러리 (Protobuf 메시지의 직렬화 및 역직렬화를 지원합니다.)
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
implementation "com.google.protobuf:protobuf-java:${protobufVersion}"
// grpc 서버, 클라이언트 설정
implementation 'net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE'
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 어노테이션 오류가 발생하지 않는다.
// jwt
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:${protobufVersion}"
}
plugins {
grpc {
artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}"
}
}
generateProtoTasks {
all()*.plugins {
grpc {}
}
}
}
tasks.named('test') {
useJUnitPlatform()
}
마지막으로 application.yml을 살펴봅시다.
- yml 설정에서는 클라이언트 서버의 port를 지정한 다음 gRPC 서버의 netty address를 연결시켜줘야 합니다. 그리고 저는 테스트를 위해 feignClient를 사용할 것이므로 feign 설정도 해줬습니다. (생략하셔도 됩니다)
server:
port: 8091 # 클라이언트 포트
spring:
application:
name: grpc-client
grpc:
client:
default:
address: static://localhost:50051
negotiationType: PLAINTEXT
member-service:
address: static://localhost:50051
negotiationType: PLAINTEXT
server:
enabled: false # 서버 기능을 비활성화
feign:
client:
config:
default:
connectTimeout: 5000
readTimeout: 5000
proto 파일 작성 및 실행
이 부분에 대한 설명은 이전 포스팅에 적어둔 것과 완전히 동일하기 때문에 링크로 대체하도록 하겠습니다.
2025.01.30 - [gRPC] - [MSA] SpringBoot에 gRPC 서버 구성하기: 회원 서비스 만들기
[MSA] SpringBoot에 gRPC 서버 구성하기: 회원 서비스 만들기
시작하며안녕하세요. 개발자 stark입니다.최근 업무가 조금 바빠져서 블로그에 글을 작성하지 못했는데요. 설이기도 하고 정리할 시간이 생겨서 오랜만에 글을 적게 되었습니다. 제가 이번 연도
curiousjinan.tistory.com
위의 글에서 확인하실 목차는 "gRPC proto 파일 작성하기 ~ build.gradle의 protobuf 설정 이해하기"까지입니다. (2개뿐입니다 ㅎㅎ) gRPC 서버와 gRPC 클라이언트에서는 동일한 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;
}
이후 우리는 gradle에 아래의 명령어를 입력하여 proto 파일을 java 클래스로 만들어줍니다.
./gradlew generateProto
gRPC 클라이언트 구성하기
클라이언트 구성은 정말 너무 간단합니다.
- 먼저 컨트롤러를 작성합니다. 지금 작성할 컨트롤러는 아래 다이어그램에서 회원 조회 api에 해당합니다.
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/test")
public class GrpcController {
private final GrpcMemberClient grpcMemberClient;
private final GrpcMemberMapper grpcMemberMapper;
/**
* [gRPC] HTTP 요청 -> gRPC 호출 -> 원격 gRPC 서버 -> 응답
*
* @param memberId 조회할 회원 ID
* @return ResponseEntity<ResponseMemberDTO>
*/
@GetMapping("/grpc")
public ResponseEntity<ResponseMemberDTO> grpcTest(@RequestParam Long memberId) {
log.trace("[gRPC TEST] 들어온 HTTP 요청 - memberId={}", memberId);
// 1) gRPC 클라이언트 호출
try {
MemberProto.MemberResponse response = grpcMemberClient.getMemberById(memberId);
ResponseMemberDTO responseMemberDTO = grpcMemberMapper.protoToDto(response);
log.trace("[gRPC TEST] 응답 - ID={}, email={}", responseMemberDTO.getId(), responseMemberDTO.getEmail());
return ResponseEntity.ok(responseMemberDTO);
} catch (Exception e) {
log.error("[gRPC TEST] 요청 중 예외 발생 - {}", e.getMessage());
return ResponseEntity.internalServerError().build();
}
}
}
컨트롤러 메서드가 호출할 gRPC 클라이언트 코드를 작성해 봅시다.
- 아래 코드를 보면 필드에 선언된 Channel 클래스에 @GrpcClient를 통해 무언가 설정을 하고 있으며 getMemberById() 메서드에서 이 channel을 사용하고 있습니다. 근데 @GrpcClient가 뭘까요? 다음 목차에서 자세히 알아봅시다.
@Slf4j
@RequiredArgsConstructor
@Component
public class GrpcMemberClient {
@GrpcClient("member-service")
private Channel channel; // gRPC 채널 재사용
/**
* 회원 ID로 회원 조회
*
* @param memberId 조회할 회원의 ID
* @return 조회된 회원 정보 DTO
*/
public MemberProto.MemberResponse getMemberById(Long memberId) {
log.trace("getMemberById 메서드 진입 - 요청 ID: {}", memberId);
// 1. 블로킹 Stub 생성 (동기 호출)
MemberServiceGrpc.MemberServiceBlockingStub stub =
MemberServiceGrpc.newBlockingStub(channel);
// 2. gRPC 요청 객체 생성
MemberProto.MemberIdRequest request = MemberProto.MemberIdRequest.newBuilder()
.setId(memberId)
.build();
try {
return stub.getMemberById(request);
} catch (StatusRuntimeException e) {
log.error("gRPC 호출 실패 - 상태: {}, 설명: {}, 원인: {}",
e.getStatus(),
e.getStatus().getDescription(),
e.getCause());
throw e;
}
}
}
@GrpcClient 어노테이션 이해하기
이 어노테이션은 gRPC Channel 또는 AbstractStub(gRPC 클라이언트 스텁) 타입의 필드나 메서드 파라미터에 사용됩니다. 이를 통해 Spring이 자동으로 해당 객체를 생성 및 주입하도록 합니다. 즉 필드에 선언된 Channel 인터페이스에 구현체를 주입해 주는 역할을 하는 것입니다.
어노테이션 코드를 살펴봅시다.
@Target({ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface GrpcClient {
String value();
Class<? extends ClientInterceptor>[] interceptors() default {};
String[] interceptorNames() default {};
boolean sortInterceptors() default false;
}
주석이 정말 길어서 제가 코드만 보기 좋게 발라내어 올려뒀는데 코드 주석을 조금 설명드리자면 이 어노테이션이 붙은 필드나 메서드는 Spring 컨텍스트에서 자동으로 값을 주입받게 됩니다. 단, @Autowired나 @Inject와 함께 사용하면 충돌이 발생할 수 있으므로 단독으로 사용해야 합니다. 또한 생성자 및 Bean Factory 주입 방식은 실험적이라고 명시되어 있어, 아직 완전히 안정화된 기능은 아니라는 점을 염두에 두어야 합니다.
참고: 주석에는 "Fields/Set-Methods that are annotated with this annotation should NOT be annotated with @Autowired or @Inject (conflict)."라고 명시되어 있습니다.
내부 구성을 하나씩 알아봅시다. 먼저 value()는 채널 이름을 지정하는 데 사용됩니다.
- 이 속성은 gRPC 클라이언트의 이름을 지정합니다.
- 이 이름은 GrpcChannelProperties 설정에서 해당 클라이언트에 대한 설정을 가져올 때 사용됩니다.
- 예시: @GrpcClient("myClient") → grpc.client.myClient.address=... 과 같이 프로퍼티 설정을 통해 연결 주소를 지정할 수 있습니다.
String value();
interceptors()는 클래스 배열로 인터셉터를 지정하는 데 사용됩니다.
- 해당 gRPC 클라이언트에 추가할 인터셉터 클래스를 지정할 수 있으며, 만약 스프링 컨텍스트에 해당 타입의 Bean이 존재하면 그 Bean을 사용합니다.
Class<? extends ClientInterceptor>[] interceptors() default {};
interceptorNames()는 빈 이름 배열로 인터셉터를 지정하는 옵션입니다.
- 인터셉터 Bean의 이름을 배열로 지정하여 사용할 수도 있습니다.
String[] interceptorNames() default {};
마지막으로 sortInterceptors()는 정렬 여부를 선택하는 옵션입니다.
- 글로벌 인터셉터와 지정된 커스텀 인터셉터를 함께 사용할 때, 정렬할지 여부를 결정합니다. (예를 들어, 글로벌 인터셉터 사이에 커스텀 인터셉터를 삽입하고자 할 때 유용합니다.)
boolean sortInterceptors() default false;
@GrpcClient의 gRPC 채널 생성 방법
gRPC 채널은 다음과 같이 생성됩니다. 이 과정을 이해해야만 @GrpcClient의 동작을 알 수 있습니다.
애플리케이션이 시작될 때, gRPC-Client-Spring Boot Starter 라이브러리는 application.yml 파일에 정의된 gRPC 클라이언트 정보를 읽어 들입니다. 예를 들어 아래와 같은 설정이 있다면 라이브러리는 "member-service"라는 이름으로 채널을 생성합니다. 이때, 해당 설정 정보를 바탕으로 채널의 주소, 통신 방식(여기서는 PLAINTEXT) 등을 구성하게 됩니다. 그리고 이 채널은 Spring Bean으로 등록되어 싱글톤으로 관리됩니다.
grpc:
client:
member-service:
address: static://localhost:50051
negotiationType: PLAINTEXT
자 근데 채널이라는 단어가 계속해서 나옵니다. 그럼 채널이 뭔지 알아야겠죠?
- Channel은 gRPC 통신의 핵심입니다. Channel은 gRPC에서 정의한 추상 타입(인터페이스 혹은 추상 클래스)이며, 실제 통신은 내부적으로 Netty 기반의 채널 등 다양한 구현체가 담당합니다. 즉, 개발자가 단순히 Channel 타입의 필드를 선언하면, 라이브러리가 적절한 설정 정보를 바탕으로 구체적인 구현체를 생성하여 주입해 줍니다.
예를 들어, 아래 코드는 gRPC에서 Channel이 어떻게 정의되어 있는지를 보여줍니다.
package io.grpc;
import javax.annotation.concurrent.ThreadSafe;
@ThreadSafe
public abstract class Channel {
// 클라이언트 호출을 위한 메서드
public abstract <RequestT, ResponseT> ClientCall<RequestT, ResponseT> newCall(
MethodDescriptor<RequestT, ResponseT> methodDescriptor, CallOptions callOptions);
// 채널의 권한(예: 서버 호스트 이름) 반환
public abstract String authority();
}
이처럼 Channel은 추상 타입으로 선언되어 있으며, 실제 주입되는 객체는 내부 로직에 따라 NettyChannelBuilder 등의 빌더를 통해 생성된 구체적인 구현체입니다.
그럼 이제 @GrpcClient 어노테이션을 통한 채널 주입을 살펴봅시다.
- @GrpcClient("member-service") 어노테이션을 사용하면, Spring 컨텍스트가 자동으로 "member-service"에 해당하는 gRPC 클라이언트 채널을 생성하여 필드에 주입합니다. 즉, 개발자는 아래와 같이 단순하게 Channel 타입의 필드만 선언하면 됩니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class GrpcMemberClient {
@GrpcClient("member-service")
private Channel channel; // gRPC 채널 재사용
// gRPC 호출 메서드 구현 ...
}
이 방식은 gRPC 채널 생성과 관련된 복잡한 설정을 라이브러리에게 위임함으로써, 개발자는 비즈니스 로직에 집중할 수 있도록 돕습니다. 근데 한 가지 헷갈리는 부분이 있습니다. 제 설명을 보시면 @GrpcClient 어노테이션을 사용할 때마다 매번 구현체를 새롭게 생성해서 주입한다고 적어뒀습니다. 그럼 왜 빈 등록을 하는 것인지가 궁금할 수 있습니다.
클라이언트 채널을 "생성"하여 필드에 주입한다?
개인적으로 이 부분이 이해가 가지 않을 것이라고 생각해서 조금 더 정리해 봤습니다.
Spring Boot에서 gRPC 클라이언트 채널은 애플리케이션 시작 시 설정 파일(application.yml)에 정의된 내용을 기반으로 한 번 생성되며, 보통 싱글톤으로 관리됩니다. 그래서 @GrpcClient("member-service") 어노테이션이 붙은 필드는 이미 생성된 "member-service" 전용 채널을 재사용하게 됩니다.
한 번 생성된 채널 재사용
- 애플리케이션이 실행되면서 grpc.client.member-service 설정에 따라 채널이 생성되고, 해당 채널은 Spring 컨텍스트에 Bean으로 등록됩니다.
@GrpcClient 어노테이션의 역할
- @GrpcClient("member-service") 어노테이션은 위에서 등록된 Bean을 주입받기 위한 역할을 하며, 새롭게 매번 채널을 생성하는 것이 아니라 이미 생성된 "member-service" 채널을 필드에 주입합니다.
따라서 개발자는 단순히 Channel 타입의 필드만 선언해 두면 되고, 별도로 채널 생성 로직을 구현할 필요 없이 재사용되는 채널 인스턴스를 받아서 사용할 수 있습니다. 추가로, 동일한 이름의 gRPC 클라이언트가 여러 군데에서 주입된다면, 모두 동일한 "member-service" 채널 인스턴스를 공유하게 됩니다. 이 방식은 리소스 효율성 및 연결 관리 측면에서도 유리합니다.
즉, 지금처럼 member-service라는 채널에 대한 빈이 생성되어 있을 때는 기존 빈을 그대로 가져다 사용하지만 content-service 이런 식으로 빈 등록되지 않은 것을 @GrpcClient에 지정하면 새롭게 생성한다고 이해해 주시면 됩니다.
- 존재하는 경우: 이미 생성되어 등록된 "member-service" 채널 빈이 주입됩니다.
- 설정이 없는 경우: 라이브러리가 기본 설정을 적용하거나, (일부는 에러를 발생시킬 수 있으므로) 기본 클라이언트 설정이 존재하는 경우 그 설정을 사용하여 새로운 채널 빈이 생성됩니다.
근데 기본 클라이언트 설정이 뭘까요? yidongnan의 grpc-spring-boot-starter는 기본적으로 application.yml 또는 application.properties 파일에서 grpc.client 하위로 각 gRPC 클라이언트의 설정을 관리합니다. 예를 들어 아래와 같이 설정할 수 있으며, 개별 클라이언트에 대한 설정이 없는 경우에는 default에 정의된 설정을 사용하도록 설계되어 있습니다.
grpc:
client:
default:
address: "static://localhost:9090" # 기본 주소
negotiationType: plaintext # 기본 네고시에이션 타입
member-service:
address: "static://member.service.host:6565"
negotiationType: plaintext
마지막으로 정리하고 넘어가겠습니다. 만약 @GrpcClient("member-service")와 같이 어노테이션에 값을 지정하면, 라이브러리는 먼저 grpc.client.member-service에 해당하는 설정이 있는지 확인합니다.
- 설정이 존재하는 경우: 해당 설정을 사용하여 gRPC 채널(Bean)을 생성한 후, 동일한 이름의 클라이언트에 대해 생성된 채널 빈을 재사용합니다.
- 설정이 없는 경우: 라이브러리는 grpc.client.default에 정의된 기본 설정을 활용하여 채널 빈을 생성합니다.
자 그럼 이제 다시 클라이언트 코드를 이해하러 가봅시다.
gRPC 클라이언트 코드 자세히 이해하기
위에서 열심히 @GrpcClient를 사용한 이유를 분석했습니다. 그러니 이제는 실제 내부 코드의 동작을 살펴봅시다.
- 아래 선언된 getMemberById() 메서드는 먼저 MemberServiceGrpc라는 클래스 (proto파일을 generateProto 하면 생성됩니다)에 있는 MemberServiceBlockingStub을 생성하는 것으로부터 시작됩니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class GrpcMemberClient {
@GrpcClient("member-service")
private Channel channel; // gRPC 채널 재사용
/**
* 회원 ID로 회원 조회
*
* @param memberId 조회할 회원의 ID
* @return 조회된 회원 정보 DTO
*/
public MemberProto.MemberResponse getMemberById(Long memberId) {
log.trace("getMemberById 메서드 진입 - 요청 ID: {}", memberId);
// 1. 블로킹 Stub 생성 (동기 호출)
MemberServiceGrpc.MemberServiceBlockingStub stub =
MemberServiceGrpc.newBlockingStub(channel);
// 2. gRPC 요청 객체 생성
MemberProto.MemberIdRequest request = MemberProto.MemberIdRequest.newBuilder()
.setId(memberId)
.build();
try {
return stub.getMemberById(request);
} catch (StatusRuntimeException e) {
log.error("gRPC 호출 실패 - 상태: {}, 설명: {}, 원인: {}",
e.getStatus(),
e.getStatus().getDescription(),
e.getCause());
throw e;
}
}
}
MemberServiceGrpc 클래스는 아래와 같이 생성됩니다.
- MemberServiceGrpc는 내부에 static class로 MemberServiceBlockingStub 클래스를 가지고 있습니다. 그리고 이 클래스에는 제가 필요로 하는 gRPC 메서드인 getMemberById()가 선언되어 있습니다. 그래서 이 타입의 인스턴스를 생성해야만 합니다.
@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 {
// ...
public static final class MemberServiceBlockingStub
extends io.grpc.stub.AbstractBlockingStub<MemberServiceBlockingStub> {
private MemberServiceBlockingStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected MemberServiceBlockingStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MemberServiceBlockingStub(channel, callOptions);
}
/**
* <pre>
* 단일 회원 ID로 조회
* </pre>
*/
public com.test.member.grpc.MemberProto.MemberResponse getMemberById(com.test.member.grpc.MemberProto.MemberIdRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getGetMemberByIdMethod(), getCallOptions(), request);
}
/**
* <pre>
* 이메일로 회원 조회
* </pre>
*/
public com.test.member.grpc.MemberProto.MemberResponse getMemberByEmail(com.test.member.grpc.MemberProto.MemberEmailRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getGetMemberByEmailMethod(), getCallOptions(), request);
}
}
}
그럼 MemberServiceBlockingStub타입의 인스턴스는 어떻게 만들까요?
- 저는 구현 중인 getMemberById() 메서드 내부에 아래와 같이 코드를 선언했습니다. MemberServiceGrpc클래스에 선언된 newBlockingStub() 메서드를 호출해서 인스턴스 생성을 할 수 있습니다.
// 1. 블로킹 Stub 생성 (동기 호출)
MemberServiceGrpc.MemberServiceBlockingStub stub =
MemberServiceGrpc.newBlockingStub(channel);
호출되는 newBlockingStub() 메서드를 살펴봅시다.
- 아래의 newBlockingStub() 코드를 살펴보면 반환 타입이 제가 필요로 하는 MemberServiceBlockingStub인 것을 확인하실 수 있습니다. 그리고 내부에서는 newStub() 메서드를 통해 MemberServiceBlockingStub를 생성합니다.
/**
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
*/
public static MemberServiceBlockingStub newBlockingStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<MemberServiceBlockingStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<MemberServiceBlockingStub>() {
@java.lang.Override
public MemberServiceBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new MemberServiceBlockingStub(channel, callOptions);
}
};
return MemberServiceBlockingStub.newStub(factory, channel);
}
이렇게 gRPC 서버에 요청을 보낼 stub 인스턴스가 생성됩니다. 추후 우리는 이 stub으로 gRPC 서버에 getMemberById() 메서드를 호출할 것입니다. 근데 잘 생각해 보면 메서드를 호출하려면 매개변수가 필요합니다. 그러니 이제 gRPC 전용 request 객체를 만들어봅시다.
저는 아래와 같이 gRPC 요청 객체를 생성해 주었습니다.
// 2. gRPC 요청 객체 생성
MemberProto.MemberIdRequest request = MemberProto.MemberIdRequest.newBuilder()
.setId(memberId)
.build();
gRPC 전용 request 객체를 생성하기 위해서는 proto 파일을 다시 보는 것부터 시작해야 합니다.
- 저는 proto 파일에 아래와 같이 MemberIdRequest라는 message를 선언해 두었는데요. 그러면 코드에서는 newBuilder()를 사용해서 setter로 제가 선언한 int64에 맞는 타입의 데이터를 담을 수 있게 됩니다. 마치 Lombok의 builder와 비슷합니다.
// 회원 ID로 조회 요청
message MemberIdRequest {
int64 id = 1;
}
그럼 이제 어떻게 빌더가 구성되어 있는지 살펴봅시다.
- gradle의 generateProto 명령어를 실행하면 proto 파일 내부의 코드가 자바 코드로 컴파일되면서 MemberProto라는 이름의 클래스가 생깁니다. 아래에 제가 코드를 필요한 내용만 잘라서 적어봤는데요. 이 클래스 내부에는 MemberIdRequest 클래스가 static class로 선언되어 있습니다. 여기서 자세히 봐야 할 건 이 클래스 내부에 있는 newBuilder() 메서드입니다. 지금 저는 이 메서드를 사용해서 request 객체를 만들고 있기 때문입니다. 그런데 newBuilder()를 보면 내부에서 또 static class로 선언된 Builder 클래스의 생성자를 호출해서 빌더 인스턴스를 생성하고 있습니다. 생각보다 복잡하죠?
public final class MemberProto {
public static final class MemberIdRequest extends
com.google.protobuf.GeneratedMessage implements MemberIdRequestOrBuilder {
@java.lang.Override
public Builder newBuilderForType() { return newBuilder(); }
public static Builder newBuilder() {
return DEFAULT_INSTANCE.toBuilder();
}
public static Builder newBuilder(com.test.member.grpc.MemberProto.MemberIdRequest prototype) {
return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype);
}
@java.lang.Override
public Builder toBuilder() {
return this == DEFAULT_INSTANCE
? new Builder() : new Builder().mergeFrom(this);
}
/**
* <pre>
* 회원 ID로 조회 요청
* </pre>
*
* Protobuf type {@code com.test.member.grpc.MemberIdRequest}
*/
public static final class Builder extends
com.google.protobuf.GeneratedMessage.Builder<Builder> implements
// @@protoc_insertion_point(builder_implements:com.test.member.grpc.MemberIdRequest)
com.test.member.grpc.MemberProto.MemberIdRequestOrBuilder {
// Construct using com.test.member.grpc.MemberProto.MemberIdRequest.newBuilder()
private Builder() {
}
private Builder(
com.google.protobuf.GeneratedMessage.BuilderParent parent) {
super(parent);
}
// 생략
/**
* <code>int64 id = 1;</code>
* @param value The id to set.
* @return This builder for chaining.
*/
public Builder setId(long value) {
id_ = value;
bitField0_ |= 0x00000001;
onChanged();
return this;
}
}
}
}
이 과정은 자동으로 코드가 해줄 것이고 우리는 코드에서 간단하게 newBuilder() 메서드를 호출해서 사용하기만 하면 됩니다. 그러면 그 내부에서는 Builder 클래스를 생성하고 내부에 있는 setId() 메서드를 사용할 수 있게 됩니다. 이 메서드가 바로 제가 proto 파일에 작성했던 int64 타입의 id값을 세팅할 수 있도록 해주는 메서드입니다. (proto 파일의 필드값은 이렇게 자바 코드로 선언되고 값을 세팅합니다)
여기서 알아두시면 좋은 정보가 있는데요. Builder 클래스는 한 개만 생성되는 것이 아니라는 것입니다. proto 파일에 선언한 message들 (각 Request 또는 Response 객체)마다 본인의 전용 Builder 클래스가 생성됩니다. 그렇다 보니 여러 개의 Builder클래스가 생성되게 됩니다. 그래도 다행인 건 이것들은 각자 구분이 잘 되어 있으니 빠르게 구분할 수 있습니다.
어떤 message의 구현체인지 확인하는 방법은 Builder 클래스의 implements를 살펴보시면 됩니다. 왜냐하면 각 Builder 클래스는 자신이 구현하고자 하는 객체를 implements 하고 있기 때문입니다. 지금 상황을 예로 들면 implements MemberIdRequestOrBuilder 이렇게 선언되어 있다는 것을 알 수 있습니다.
자 이제 끝입니다. 이제는 아래와 같이 try-catch문을 사용하고 stub의 getMemberById() 메서드를 호출해주기만 하면 됩니다. 참고로 try-catch를 사용하지 않고 @GrpcAdvice와 @GrpcExceptionHandler를 사용해서 전역 예외처리를 구성하는 방법도 있습니다.
try {
return stub.getMemberById(request);
} catch (StatusRuntimeException e) {
log.error("gRPC 호출 실패 - 상태: {}, 설명: {}, 원인: {}",
e.getStatus(),
e.getStatus().getDescription(),
e.getCause());
throw e;
}
간단하지 않나요? 원래 서버 구성이 복잡하지 요청하는 쪽에서는 제대로 규격에 맞는 요청을 구성해서 보내기만 하면 되니까요 ㅎㅎ
Proto to DTO (커스텀 매퍼 작성하기)
마지막으로 컨트롤러에서는 gRPC로 받아온 응답객체를 DTO로 변환해주고 있습니다.
- proto 파일에 작성된 객체는 mapstruct를 사용하기엔 복잡해서 저는 커스텀 mapper를 작성했습니다.
@Component
public class GrpcMemberMapper {
public ResponseMemberDTO protoToDto(MemberProto.MemberResponse proto) {
return ResponseMemberDTO.builder()
.id(proto.getId())
.email(proto.getEmail())
.name(proto.getName())
.profileImageBase64(proto.getProfileImageBase64())
.address(mapAddress(proto.getAddress()))
.contact(mapContact(proto.getContact()))
.interests(new HashSet<>(proto.getInterestsList()))
.skills(new HashSet<>(proto.getSkillsList()))
.metadata(proto.getMetadata())
.build();
}
private AddressDTO mapAddress(MemberProto.Address proto) {
return AddressDTO.builder()
.street(proto.getStreet())
.city(proto.getCity())
.country(proto.getCountry())
.postalCode(proto.getPostalCode())
.additionalInfo(proto.getAdditionalInfoMap())
.build();
}
private ContactDTO mapContact(MemberProto.Contact proto) {
return ContactDTO.builder()
.phone(proto.getPhone())
.mobile(proto.getMobile())
.workPhone(proto.getWorkPhone())
.emails(proto.getEmailsList())
.socialMedia(proto.getSocialMediaMap())
.build();
}
}
스프링 시큐리티 적용하기
시큐리티 설정코드를 작성합니다.
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationService jwtAuthenticationService;
@Bean
public AuthenticationManager authenticationManager() {
return authentication -> {
// 사용자 JWT 토큰인 경우
if (authentication.getPrincipal() instanceof String token) {
return jwtAuthenticationService.authenticateToken(token);
}
throw new AuthenticationServiceException("Unsupported authentication type");
};
}
@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("/api/auth/login").permitAll()
.requestMatchers("/api/auth/register").permitAll()
.requestMatchers("/api/members/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(e -> e
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write(authException.getMessage());
})
)
.build();
}
}
jwt 인증 필터 작성
@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 인증 서비스를 작성합니다.
@RequiredArgsConstructor
@Service
public class JwtAuthenticationService {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
/**
* @param token JWT 토큰
* @return Authentication 객체
* @apiNote JWT 토큰을 검증하고 Authentication 객체를 생성합니다.
*/
public Authentication authenticateToken(String token) {
if (!jwtUtil.isTokenValid(token)) {
throw new JwtAuthenticationException("Invalid JWT token");
}
String username = jwtUtil.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 토큰을 credentials에 포함시킵니다
return new UsernamePasswordAuthenticationToken(
userDetails,
token, // 토큰을 credentials로 저장
userDetails.getAuthorities()
);
}
}
시큐리티 인증 과정에서 사용되는 UserDetailsService 인터페이스를 구현합니다.
- 이 인터페이스에는 UserDetails를 반환하는 메서드가 하나 선언되어 있습니다. 그리고 이 메서드가 반환하는 UserDetails는 시큐리티 인증 토큰인 UsernamePasswordAuthenticationToken을 생성할 때 principal 매개변수에 담아지게 됩니다. 그래서 저는 이 UserDetails 타입의 인스턴스를 만들어줘야만 했고 이것을 쉽게 할 수 있도록 도와주는 UserDetailsService 인터페이스를 구현하는 CustomUserDetailsService 클래스를 만들어 줬습니다. (이후 내부의 메서드를 구현할 때 로직은 제가 원하는 대로 작성할 수 있습니다)
@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 클라이언트에서는 실제 DB 조회 없이 토큰에서 추출한 username으로 UserDetails 생성
return createUserDetails(username);
}
/**
* 사용자 정보를 기반으로 UserDetails 객체를 생성합니다.
* 클라이언트에서는 최소한의 정보만 포함하여 생성합니다.
*
* @param username JWT 토큰에서 추출한 사용자 식별자
* @return UserDetails 구현체
*/
private UserDetails createUserDetails(String username) {
return User.builder()
.username(username)
.password("") // 클라이언트에서는 비밀번호 검증이 필요 없음
.authorities(Set.of(new SimpleGrantedAuthority("ROLE_USER")))
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.disabled(false)
.build();
}
}
그리고 UserDetails 인스턴스는 제가 커스텀해서 만들수가 있습니다.
- 시큐리티는 참 많은것들을 커스텀할 수 있어서 좋습니다. 아래 UserDetails 인터페이스 코드를 가져와봤는데 내부에는 기본적으로 필요로 하는 메서드만 구성되어 있습니다. 이 메서드 구성에 맞게 커스텀 코드를 구현해 주시면 됩니다.
package org.springframework.security.core.userdetails;
import java.io.Serializable;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
default boolean isAccountNonExpired() {
return true;
}
default boolean isAccountNonLocked() {
return true;
}
default boolean isCredentialsNonExpired() {
return true;
}
default boolean isEnabled() {
return true;
}
}
저는 이렇게 구현했습니다.
@RequiredArgsConstructor
@Builder
@Getter
public class User implements UserDetails {
private final String username;
private final String password;
private final Set<GrantedAuthority> authorities;
private final boolean enabled;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled;
}
}
jwt 유틸 클래스 작성
@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;
}
}
}
토큰 추출 코드 작성
@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);
}
}
시큐리티 구성은 이렇게 마무리되었습니다.
회원 조회 요청 보내기
자 mapper까지 구성했다면 정말 끝났습니다. 이제 다시 컨트롤러를 봅시다.
- 컨트롤러 메서드는 grpcMemberClient의 getMemberById() 메서드를 호출해서 gRPC 서버에 회원 정보를 요청해서 받아옵니다. 그리고 받아온 회원정보 response(proto 전용객체)를 제가 만든 커스텀 DTO 객체로 변환합니다. 그리고 이 DTO를 반환합니다.
@GetMapping("/grpc")
public ResponseEntity<ResponseMemberDTO> grpcTest(@RequestParam Long memberId) {
log.trace("[gRPC TEST] 들어온 HTTP 요청 - memberId={}", memberId);
// 1) gRPC 클라이언트 호출
try {
MemberProto.MemberResponse response = grpcMemberClient.getMemberById(memberId);
ResponseMemberDTO responseMemberDTO = grpcMemberMapper.protoToDto(response);
log.trace("[gRPC TEST] 응답 - ID={}, email={}", responseMemberDTO.getId(), responseMemberDTO.getEmail());
return ResponseEntity.ok(responseMemberDTO);
} catch (Exception e) {
log.error("[gRPC TEST] 요청 중 예외 발생 - {}", e.getMessage());
return ResponseEntity.internalServerError().build();
}
}
이렇게 하나의 gRPC 클라이언트가 완성되었습니다. 모든 gRPC 클라이언트 구성은 이런 식으로 진행됩니다.
그럼 이제 요청을 보내볼까요?
- 저번 포스팅에서 회원 서버를 구성했고 회원 가입, 로그인 api를 만들었습니다. 그러니 일단 회원가입을 진행해서 1번 id를 가지는 회원을 생성해 줍시다. 그래야만 아래에 있는 회원 조회 api를 호출할 수 있게 됩니다.
http://localhost:8091/api/test/grpc?memberId=1
헤더에 authorization 추가 -> 아래와 같은 방식
Bearer token : eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0QHRlc3QuY29tIiwiaWF0IjoxNzM4NDk5NjQ5LCJleHAiOjE3Mzg1MDMyNDl9.9_3vP1LXQfUEY761YdmZa3GP4k1j1jyfl0UyOuUcfvg
위의 회원 조회 api를 호출하게 되면 이런 회원정보 응답을 받게 됩니다.
{
"id": 1,
"email": "test@test.com",
"name": "test",
"profileImageBase64": "데이터 생략",
"address": {
"street": "123 Test St",
"city": "Test City",
"country": "Test Country",
"postalCode": "12345",
"additionalInfo": {
"doorCode": "1234",
"floor": "5",
"building": "A"
}
},
"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": [
"spring",
"java",
"grpc"
],
"metadata": "{\"lastLogin\":\"2024-01-23\",\"status\":\"active\",\"rank\":\"senior\"}"
}
마무리하며
gRPC 클라이언트 적용은 이렇게 마무리되었습니다. 클라이언트 또한 security와 서버 인증을 추가하는 작업이 남았습니다. 인증을 위해서는 gRPC 전용 인터셉터를 작성해줘야 해서 아직 해야 할 작업이 많습니다. 그러니 꼭 다음 포스팅까지 확인해 주세요 ㅎㅎ
긴 글 읽어주셔서 감사합니다. 다음 포스팅에서 만나요!
다음 포스팅이 나왔습니다!
[MSA] SpringBoot에서 gRPC 인터셉터로 서버간 jwt 인증 구현
시작하며안녕하세요. 개발자 stark입니다!이번 포스팅에서는 gRPC를 사용한 MSA 프로젝트에 서버 간 인증 기능을 구현해 볼 것입니다. MSA 프로젝트에서는 각 서버 간의 통신이 매우 빈번하게 발생
curiousjinan.tistory.com
'gRPC' 카테고리의 다른 글
[MSA] SpringBoot에서 gRPC 인터셉터로 서버간 jwt 인증 구현 (0) | 2025.02.10 |
---|---|
[MSA] SpringBoot에 gRPC 서버 구성하기: 회원 서비스 만들기 (0) | 2025.01.30 |
gRPC 인터셉터를 사용한 JWT 인증과 Spring Security 연동하기 (0) | 2025.01.12 |
개발자를 위한 gRPC 기본 개념 (0) | 2024.10.31 |
[gRPC] SpringBoot3 gRPC 예외 인터셉터 적용 (0) | 2024.07.27 |