Avatar
@deuk9

성장과 배움, 그리고 공유의 공간입니다.

📌 Recent Posts

Spring boot 에서 gRPC 간단히 사용해보기

2025-06-29

최근 회사에서 gRPC 사용 가능성에 대한 이야기가 나왔다. 그래서 gRPC를 실제로 어떻게 사용할 수 있을지 간단하게 코드로 구성해보았다.Java에서 gRPC를 사용할 때 보통은 net.devh:grpc-spring-boot-starter 기반으로 구성하는 샘플이 많지만, IntelliJ에서 Spring Boot 프로젝트를 새로 생성해보니 Spring에서 공식으로 제공하는 spring-grpc starter가 있어 이를 이용해보았다. 아직 버전은 1.0.0 미만이지만, 사용해보기에 충분했고 설정도 비교적 단순했다.

설정 관련 내용은 GitHub 샘플 코드에 정리해두었다.
이 글에서는 proto 작성부터 서비스 구현까지의 과정을 간단히 정리한다.

1. 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는 리스트 형태를 의미한다.
  • 2. 코드 생성
  • .proto 파일을 작성한 후 generateProto task를 실행하면 Java 코드가 자동으로 생성된다.
  • 코드 생성 위치는 java_package에 지정한 패키지 기준이다.
  • 3. 구현3.1 gRPC Client 정의@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으로 등록할 수 있다.
  • 3.3 서비스 구현@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 Boot에서 gRPC를 사용해보는 것은 꽤 간단했다. 공식 Spring starter인 spring-grpc 덕분에 설정도 복잡하지 않았고, proto 정의 → 코드 생성 → 서버/클라이언트 구성 → 실행까지 한 번에 정리할 수 있었다.
  • Spring gRPC
  • 샘플코드
  • html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}

    Spring 에서 kafka, REST API trace 모니터링하기(tempo + loki + grafana)

    2025-06-01

    최근 Spring I/O 발표에서 소개된 부분중 Kafka 데이터 흐름에 대한 trace정보를 저장하는 부분에 대하여 인상깊게 보았다. REST API + Kafka producer/consumer + Database 에 대한 trace 정보를 기록하는 방법에 대하여 구현해 보았다.1. 기본 구성1.1 사용한 스택
  • Spring Boot 3.x
  • Micrometer Tracing (OpenTelemetry Bridge)
  • Kafka (Producer/Consumer)
  • Tempo (trace 저장)
  • Loki (log 저장)
  • Grafana (시각화)
  • logbak loki appender
  • 1.2 흐름
    flow-chart

    flow-chart

    2. 설정2.1 gradle 설정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용 appender
  • datasource-micrometer-spring-boot:1.1.1: database 까지 trace된다면 좋을 것 같아서 찾아보니, 이런 라이브러리가 있어서 추가해 보았다. (ttddyy 이분 어디서 봤나 했더니 datasource-proxy 라이브러리 만드신분이다. )
  • 2.2 yaml 설정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
  • KafkaTemplate, KafkaListener에 대한 자동 observation 기능을 제공한다. observation-enabled: true 설정만으로 메시지 송신/수신 시 자동 적용.
  • 2.3 logback 설정<?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 기타 인프라 구성요소
  • kafka, tempo, loki, grafana 샘플은 글이 너무 길어지니 github repository로 대체하겠다.
  • docker compose 로 간단하게 구성
  • 3. 샘플 구현@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(); } }
  • REST API 로 요청을 보내면 kafka producer 에서 메시지를 보냄
  • kafka consumer에서 해당 메시지를 받고 비지니스 로직을 수행한다.
  • 4. 대시보드
    traceid-table

    traceid-table

    trace-span

    trace-span

    trace-log

    trace-log

  • 위의 예제에서는 traceId ea2cab37... 를 기준으로 데이터가 어떻게 흘러가는지 파악 할 수 있다.
  • traceId 기준으로 데이터의 흐름이 어떻게 되는지 파악할 수 있고, 해당하는 로그까지 연결하여 볼 수 있다. (로그의 대괄호의 ea2..를 참고)
  • 새로 적용해본 database trace 부분도 잘 기록된다. (간단한 쿼리 결과도 볼 수 있다.)
  • 📚 참고 자료 && 샘플 코드
  • 샘플 코드
  • html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}

    Spring Boot 3.4 + Log4j2로 Structured Logging 적용하기

    2025-05-17

    운영 환경에서 로그를 수집하고 분석하는 과정에서, 특정 키워드나 필드 값을 기준으로 로그를 필터링하거나 분류하고 싶을 때가 자주 발생한다. 예를 들어 "level": "error" 같은 값을 기준으로 로그를 검색하려면, 단순 텍스트 로그보다 구조화된 JSON 로그가 훨씬 유리하다. 보다 안정적이고 일관된 로그 구조를 만들기 위해 JSON 로그 포맷을 본격적으로 검토하였고, 그 과정에서 Spring Boot 3.4에서 새롭게 추가된 structured logging 기능을 알게 되었다.📦 사전 준비
  • Spring Boot 3.4.0 이상
  • 로깅 구현체: spring-boot-starter-log4j2
  • Java 17 이상
  • 🧱 StructuredLogFormatter란?Spring Boot 3.4부터 도입된 org.springframework.boot.logging.structured.StructuredLogFormatter<T>
    인터페이스는 구조화된 로그 출력을 위한 포맷터 역할을 한다.Spring Boot는 사용하는 로깅 구현체에 맞춰 해당 포맷터를 자동 연동하며,
    JSON 형태의 로그 생성을 쉽게 도와주는 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); } }
  • logback 으로 적용은 spring-boot-logback-샘플 을 참고한다. (제너릭 인터페이스가 다름)
  • ⚙️ 설정 방법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."} 📚 참고 자료
  • Spring Boot Structured Logging 공식 문서
  • html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}