2025-06-29
net.devh:grpc-spring-boot-starter
기반으로 구성하는 샘플이 많지만, IntelliJ에서 Spring Boot 프로젝트를 새로 생성해보니 Spring에서 공식으로 제공하는 spring-grpc
starter가 있어 이를 이용해보았다. 아직 버전은 1.0.0 미만이지만, 사용해보기에 충분했고 설정도 비교적 단순했다.설정 관련 내용은 GitHub 샘플 코드에 정리해두었다.
이 글에서는 proto 작성부터 서비스 구현까지의 과정을 간단히 정리한다.
syntax = "proto3";
package hello;
option java_multiple_files = true;
option java_package = "org.example.springgrpc.helloservice.proto";
service HelloService {
rpc SayHello(HelloRequest) returns (HelloReply);
rpc StreamHello(HelloRequest) returns (stream StreamHelloReply);
rpc SayBye(ByeRequest) returns (ByeReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
repeated string message = 1;
string byeMessage = 2;
}
message ByeRequest {
string name = 1;
}
message ByeReply {
string message = 1;
}
message StreamHelloReply {
string message = 1;
}
QueryDSL
과 마찬가지로 .proto
파일을 기반으로 Java 코드가 자동 생성된다.package
는 네임스페이스처럼 동작하며, 실제 호출 시 hello.HelloService.SayHello
처럼 전체 경로가 붙는다.service
는 gRPC에서 제공하는 함수 목록이고, 각 함수의 입력과 반환 타입을 지정한다.stream
키워드는 스트리밍 응답을 의미한다. 연결이 유지된 채로 여러 데이터를 연속으로 받을 수 있다.message
는 요청/응답 데이터 구조이며, repeated
는 리스트 형태를 의미한다..proto
파일을 작성한 후 generateProto
task를 실행하면 Java 코드가 자동으로 생성된다.java_package
에 지정한 패키지 기준이다.@Configuration
public class GrpcClientsConfig {
@Bean
HelloServiceGrpc.HelloServiceBlockingStub stub(GrpcChannelFactory channels) {
return HelloServiceGrpc.newBlockingStub(channels.createChannel("local"));
}
@Bean
HelloServiceGrpc.HelloServiceFutureStub asyncStub(GrpcChannelFactory channels) {
return HelloServiceGrpc.newFutureStub(channels.createChannel("local"));
}
}
GrpcChannelFactory
를 통해 설정한 채널 이름(local
)을 기준으로 채널을 생성한다. (application.yml 참고)BlockingStub
, FutureStub
중 하나를 선택해 Bean으로 등록할 수 있다.@Slf4j
@GrpcService
@RequiredArgsConstructor
public class HelloService extends HelloServiceGrpc.HelloServiceImplBase {
private final HelloServiceBlockingStub stub;
private final HelloServiceFutureStub asyncStub;
@Override
public void sayHello(HelloRequest request, StreamObserver<HelloReply> responseObserver) {
ListenableFuture<ByeReply> byeFuture = asyncStub.sayBye(
ByeRequest.newBuilder()
.setName(request.getName())
.build());
log.info("stream request");
Iterator<StreamHelloReply> streamHello = stub.streamHello(request);
log.info("stream response");
try {
ByeReply byeReply = byeFuture.get();
Builder builder = HelloReply.newBuilder()
.setByeMessage(byeReply.getMessage());
// streamHello Iterator의 각 StreamHelloReply에서 message를 추출하여 추가
while (streamHello.hasNext()) {
log.info("streamHello.hasNext()");
StreamHelloReply reply = streamHello.next();
builder.addMessage(reply.getMessage());
}
log.info("test");
HelloReply helloReply = builder.build();
log.info("HelloReply: {}", helloReply);
responseObserver.onNext(helloReply);
responseObserver.onCompleted();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
@Override
public void streamHello(HelloRequest request,
StreamObserver<StreamHelloReply> responseObserver) {
for (int i = 0; i < 10; i++) {
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
responseObserver.onNext(StreamHelloReply.newBuilder()
.setMessage("Hello " + request.getName() + " " + i)
.build());
}
try {
Thread.sleep(5000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
responseObserver.onCompleted();
}
@Override
public void sayBye(ByeRequest request, StreamObserver<ByeReply> responseObserver) {
responseObserver.onNext(ByeReply.newBuilder()
.setMessage("Bye " + request.getName())
.build());
responseObserver.onCompleted();
log.info("bye");
}
}
@GrpcService
는 gRPC 서버로 등록되기 위한 어노테이션이다.
(@Service
만 써도 되지만, 명시적으로 @GrpcService
를 쓰면 가독성 측면에서 더 낫다.)sayHello
메서드에서는 FutureStub
으로 sayBye
를 비동기 호출하고, BlockingStub
으로 streamHello
를 호출하여 응답을 조합한다.stub.streamHello(request)
에서 blocking이 걸릴 줄 알았는데, 실제로는 채널만 열리고, .next()
로 값을 꺼낼 때 blocking이 발생한다. (다시 생각해보면 당연한 흐름이다.streamHello
는 2초 간격으로 메시지를 보내는 서버 스트리밍 처리 예제다.sayBye
는 단순한 unary 응답을 처리하며, 비동기로 구현했다.spring-grpc
덕분에 설정도 복잡하지 않았고, proto 정의 → 코드 생성 → 서버/클라이언트 구성 → 실행까지 한 번에 정리할 수 있었다.2025-06-01
flow-chart
implementation("org.springframework.kafka:spring-kafka")
implementation("org.springframework.boot:spring-boot-starter-actuator")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
implementation("com.github.loki4j:loki-logback-appender:1.6.0")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
actuator
: 메트릭 및 tracing endpoint 노출micrometer-registry-prometheus
: Prometheus 메트릭 export 지원micrometer-tracing-bridge-otel
: Micrometer 트레이싱 API와 OpenTelemetry SDK 연결opentelemetry-exporter-otlp
: 수집된 trace 데이터를 OTLP 포맷으로 Collector/Tempo로 전송loki-logback-appender
: 로그를 Loki로 전송할 수 있는 Logback용 appenderdatasource-micrometer-spring-boot:1.1.1
: database 까지 trace된다면 좋을 것 같아서 찾아보니, 이런 라이브러리가 있어서 추가해 보았다. (ttddyy 이분 어디서 봤나 했더니 datasource-proxy 라이브러리 만드신분이다. )spring:
application:
name: order-service
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
consumer:
group-id: order-consumer
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
template:
observation-enabled: true
listener:
observation-enabled: true
management:
tracing:
sampling:
probability: 1.0
enabled: true
otlp:
tracing:
endpoint: http://localhost:4317
transport: grpc
observation-enabled: true
설정만으로 메시지 송신/수신 시 자동 적용.<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<springProperty scope="context" name="application" source="spring.application.name"/>
<appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
<batchTimeoutMs>1000</batchTimeoutMs>
<http>
<url>http://${LOKI_HOST:-localhost}:${LOKI_PORT:-3100}/loki/api/v1/push</url>
</http>
<format>
<label>
<pattern>service_name=${application},host=${HOSTNAME},level=%level</pattern>
<structuredMetadataPattern>
level = %level,
thread = %thread,
class = %logger,
traceId = %mdc{traceId:-none},
spanId = %mdc{spanId:-none}
</structuredMetadataPattern>
</label>
<message>
<pattern>${FILE_LOG_PATTERN}</pattern>
</message>
<sortByTime>true</sortByTime>
</format>
</appender>
<root level="INFO">
<appender-ref ref="LOKI" />
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
2.4 기타 인프라 구성요소@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {
private final KafkaTemplate<String, String> kafkaTemplate;
private final TeamRepository teamRepository;
@GetMapping("/test")
public void sendTestMessage() {
kafkaTemplate.send("my-topic", "Hello World!")
.thenAccept(o -> log.info("Sent message:{}", o.toString()));
}
}
@Component
@Slf4j
@RequiredArgsConstructor
public class MyConsumer {
private final TeamService teamService;
@KafkaListener(topics = "my-topic")
public void listen(String message, Acknowledgment acknowledgment) {
log.info("Received Message: {}", message);
teamService.createTeam(new Team(message));
acknowledgment.acknowledge();
}
}
traceid-table
trace-span
trace-log
ea2cab37...
를 기준으로 데이터가 어떻게 흘러가는지 파악 할 수 있다.2025-05-17
"level": "error"
같은 값을 기준으로 로그를 검색하려면, 단순 텍스트 로그보다 구조화된 JSON 로그가 훨씬 유리하다.
보다 안정적이고 일관된 로그 구조를 만들기 위해 JSON 로그 포맷을 본격적으로 검토하였고, 그 과정에서 Spring Boot 3.4에서 새롭게 추가된 structured logging
기능을 알게 되었다.📦 사전 준비3.4.0
이상spring-boot-starter-log4j2
org.springframework.boot.logging.structured.StructuredLogFormatter<T>
JsonWriter<T>
유틸리티도 함께 제공한다.🛠 Log4j2 기반 커스텀 포맷터 구현다음은 LogEvent
를 기반으로 JSON 로그를 생성하는 커스텀 포맷터 클래스 구현 예시다:package org.example.jsonloggingsmple.config.log;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import org.apache.logging.log4j.core.LogEvent;
import org.springframework.boot.json.JsonWriter;
import org.springframework.boot.logging.structured.StructuredLogFormatter;
public class MyStructuredLoggingFormatter implements StructuredLogFormatter<LogEvent> {
private final JsonWriter<LogEvent> writer = JsonWriter.<LogEvent>of((members) -> {
members.add("time", event -> {
Instant javaInstant = Instant.ofEpochMilli(event.getInstant().getEpochMillisecond());
return LocalDateTime.ofInstant(javaInstant, ZoneId.systemDefault());
});
members.add("level", LogEvent::getLevel);
members.add("marker", LogEvent::getMarker).whenNotNull();
members.add("thread", LogEvent::getThreadName);
members.add("message", (event) -> event.getMessage().getFormattedMessage());
}).withNewLineAtEnd();
@Override
public String format(LogEvent event) {
return this.writer.writeToString(event);
}
}
logging:
structured:
format:
console: org.example.jsonloggingsmple.config.log.MyStructuredLoggingFormatter
config: classpath:log4j2-spring.xml
📄 로그 출력 예시{"time":"2025-05-21T23:54:07.368","level":"WARN","thread":"http-nio-8080-exec-1","message": "hi."}
📚 참고 자료