반응형

Spring WebFlux는 Spring에서 non-blocking reactive 웹 애플리케이션을 만들기 위한 프레임워크이며 Reactor의 Mono와 Flux를 중심으로 동작한다.

WebFlux를 이해하려면 Mono, Flux, subscribe 시점, scheduler 전환, blocking 코드가 reactive 흐름을 막는 지점을 함께 봐야 한다.

 

핵심 정리

WebFlux는 요청마다 스레드를 오래 붙잡는 방식보다 이벤트 기반 non-blocking I/O를 활용하는 구조다. Mono는 0개 또는 1개의 값을, Flux는 0개 이상의 값을 비동기로 표현하며, Reactor operator를 통해 데이터 흐름을 조합한다.

  • WebFlux는 Spring MVC와 달리 reactive stream 흐름을 기반으로 요청을 처리한다.
  • Mono는 단일 결과나 빈 결과를 표현하는 Reactor 타입이다.
  • Flux는 여러 값의 흐름을 표현하는 Reactor 타입이다.
  • subscribeOn과 publishOn은 실행 scheduler를 다루지만 적용 위치와 효과가 다르다.
  • blocking 호출을 그대로 넣으면 WebFlux의 non-blocking 장점이 줄어든다.

WebFlux는 빠른 프레임워크라기보다 non-blocking 흐름을 끝까지 유지할 때 효과가 나는 구조다. Mono와 Flux의 실행 시점, scheduler, blocking 호출 여부를 같이 확인해야 한다.

이어서 볼 글

 

subscribeOn

이건 스레드풀(스케줄러) 바꿔줄때 쓰는건데 여러번 해도 코드기준 처음지정해준걸로 고정된다.

Flux.interval(Duration.ofSeconds(10))
    .map(…)
    .subscribeOn(Schedulers.elastic())     // A
    .map(…)
    .subscribeOn(Schedulers.parallel())    // B
    .subscribe();

A로 고정, B는 무시

 

만약 중간중간에 스레드풀을 바꾸고 싶다면 publishOn을 써야한다.

Flux.interval(Duration.ofSeconds(10))
    // (1) 최초 ticker 신호 발행: Reactor 내장 타이머 스케줄러 (예: parallel())
    .publishOn(Schedulers.boundedElastic())
    // (2) 이제부터 아래 연산(map, flatMap 등)을 boundedElastic 스레드에서 실행
    .map(ignore -> loadWidgetPerformanceByPcidData())
    .publishOn(Schedulers.parallel())
    // (3) 여기 아래 연산은 parallel() 스레드에서 실행
    .map(result -> {
        // 예: 결과를 DB에 저장하거나 metrics를 찍는 작업
        recordMetric(result);
        return result;
    })
    .subscribe();
반응형
반응형

WebSocket은 클라이언트가 서버와 연결을 유지한 상태에서 서버가 필요한 순간 메시지를 밀어 넣을 수 있게 해 주는 통신 방식이다. 주문, 송금, 알림처럼 처리 결과를 즉시 알려야 하는 흐름에서 HTTP 요청만으로는 부족한 부분을 보완한다.

이 글의 예시는 API 서버, Kafka 처리 서버, WebSocket 게이트웨이를 분리하고 Kafka Consumer가 받은 결과를 연결된 사용자 세션으로 전달하는 구조를 메모한 것이다.

 

핵심 정리

WebSocket push 구조에서는 클라이언트가 먼저 WebSocket 서버에 연결하고, 서버는 사용자 식별자와 세션을 함께 관리한다. 이후 Kafka 같은 메시지 브로커에서 처리 완료 이벤트를 받으면 해당 사용자에게 열린 세션을 찾아 메시지를 보낸다. 이 구조를 쓰면 API 서버가 긴 작업이 끝날 때까지 요청을 붙잡고 있을 필요가 없고, 처리 담당 서비스와 실시간 알림 담당 서비스를 분리할 수 있다. 다만 예시처럼 사용자 식별자를 쿼리 파라미터로 단순히 넘기는 방식은 설명용에 가깝고, 실제 서비스에서는 인증된 사용자 정보와 세션 수명, 중복 연결, 끊김 처리, 재연결 정책을 함께 설계해야 한다.

  • 클라이언트가 먼저 WebSocket 서버에 연결해 세션을 만든다.
  • 서버는 사용자 식별자와 WebSocket 세션을 함께 저장한다.
  • 업무 처리는 API 서버나 별도 처리 서버에서 수행한다.
  • 처리 완료 이벤트는 Kafka 같은 메시지 브로커를 통해 전달할 수 있다.
  • WebSocket 게이트웨이는 이벤트를 받아 해당 사용자 세션으로 메시지를 보낸다.
  • 세션 저장소는 동시 접근과 연결 종료 상황을 고려해야 한다.
  • 운영 환경에서는 인증, 권한, 재연결, 중복 접속 정책을 반드시 점검한다.
  • 서버가 여러 대라면 세션 위치와 메시지 라우팅 전략도 함께 설계해야 한다.

원문은 Spring WebSocket 설정, Handler, Kafka Consumer, 브라우저 클라이언트 예제를 중심으로 한 실습 메모입니다. 보강문에서는 코드가 어떤 아키텍처를 설명하는지 먼저 정리했습니다. 핵심은 WebSocket 자체보다 처리 완료 이벤트를 실시간 사용자 알림으로 바꾸는 흐름이며, 실제 운영에서는 세션 관리와 인증 설계가 코드 예제만큼 중요합니다.

가상 상황설명

[1] api-server (주문, 송금 등 HTTP 처리)
     └─ KafkaProducer 전송 (TransferRequested)

[2] transfer-processor (Kafka Consumer, 송금 처리 전용)
     └─ KafkaProducer (TransferSucceeded)

[3] socket-gateway (WebSocket 전용)
     └─ KafkaConsumer (TransferSucceeded 수신)
     └─ 사용자에게 push

WebSocket push

1. Client가 WebSocket으로 Push서버에 먼저연결

2. Push서버는 연결된 세션을 Map처럼 관리(userId -> session)

3. Kafka에서 메시지를 수신하면, 해당 userId에 연결된 세션을 찾아서 메시지 전송

코드예시

WebSocket설정

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new MyWebSocketHandler(), "/ws")
                .setAllowedOrigins("*");
    }
}

WebSocketHandler

@Component
public class MyWebSocketHandler extends TextWebSocketHandler {

    // userId -> session 저장 (concurrent주의)
    private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 쿼리 파라미터에서 userId 추출 (예시)
        String userId = getUserIdFromSession(session);
        sessions.put(userId, session);
    }

    public void sendToUser(String userId, String message) throws IOException {
        WebSocketSession session = sessions.get(userId);
        if (session != null && session.isOpen()) {
            session.sendMessage(new TextMessage(message));
        }
    }

    private String getUserIdFromSession(WebSocketSession session) {
        URI uri = session.getUri(); // 예: ws://localhost:8080/ws?userId=wonil
        return UriComponentsBuilder.fromUri(uri).build().getQueryParams().getFirst("userId");
    }
}

Kafka Consumer → WebSocket 전송 연동

@Component
public class KafkaResultConsumer {

    private final MyWebSocketHandler webSocketHandler;

    public KafkaResultConsumer(MyWebSocketHandler handler) {
        this.webSocketHandler = handler;
    }

    @KafkaListener(topics = "TransferSucceeded")
    public void onTransferSuccess(String messageJson) throws IOException {
        JSONObject obj = new JSONObject(messageJson);
        String userId = obj.getString("userId");
        String msg = obj.getString("message");

        webSocketHandler.sendToUser(userId, msg);
    }
}

클라이언트 (JavaScript)

<script>
  const userId = "wonil";  // 로그인된 사용자 ID라고 가정
  const socket = new WebSocket("ws://localhost:8080/ws?userId=" + userId);

  socket.onmessage = function(event) {
    console.log("서버로부터 수신된 메시지:", event.data);
    alert(event.data);  // 예: "송금 완료!"
  };
</script>
반응형
반응형

Spring에서 GraphQL을 적용할 때는 REST 엔드포인트를 늘리는 방식이 아니라 스키마, Query, Mutation, Resolver 또는 QueryMapping, 클라이언트 쿼리, Apollo 연동 흐름을 함께 설계해야 한다.

이 글은 Spring Boot GraphQL 서버와 React 또는 Next.js 클라이언트를 연결하며, 문제별 제출 수를 조회하는 Query 예제로 서버와 클라이언트 코드를 정리한 메모다.

 

핵심 정리

GraphQL 적용 흐름은 서버 스키마에서 어떤 Query를 제공할지 정하는 단계에서 시작한다. 원문 예시에서는 문제 ID를 받아 제출 수를 반환하는 Query를 schema.graphqls에 정의하고, 서비스 계층에 count 메서드를 추가한 뒤, Spring의 QueryMapping으로 GraphQL 요청을 처리한다. 클라이언트에서는 Apollo Client를 만들고 GraphQL 서버 URL과 캐시를 설정한 뒤, 앱 전체를 Provider로 감싸 각 페이지에서 쿼리를 사용할 수 있게 한다. 실제 화면에서는 gql로 쿼리 문자열을 만들고 useQuery로 데이터를 가져온다. GraphiQL 같은 도구로 서버 Query가 먼저 정상 동작하는지 확인한 뒤 클라이언트 연동을 붙이면 문제 범위를 줄이기 쉽다.

  • GraphQL 서버는 먼저 schema.graphqls에서 Query와 타입을 정의한다.
  • QueryMapping은 특정 GraphQL Query를 서버 메서드에 연결한다.
  • 서비스 계층에는 실제 데이터를 조회하는 메서드가 필요하다.
  • GraphiQL로 서버 Query를 먼저 테스트하면 클라이언트 문제와 분리할 수 있다.
  • Apollo Client는 JavaScript 클라이언트에서 GraphQL API를 호출할 때 사용할 수 있다.
  • Provider 설정을 해 두면 여러 페이지에서 같은 클라이언트 설정을 공유할 수 있다.
  • useQuery는 화면 컴포넌트에서 GraphQL Query 결과를 가져오는 데 사용된다.
  • Mutation은 조회가 아니라 생성이나 변경 작업을 다룰 때 별도로 설계한다.

원문은 Spring Boot GraphQL과 Apollo Client를 붙이며 만든 실습 기록입니다. 보강문에서는 서버 스키마, 서비스, QueryMapping, GraphiQL 확인, Apollo Client, 화면 조회 순서로 정리했습니다. GraphQL은 엔드포인트 개수보다 스키마 설계와 타입 일관성이 중요하므로, 작은 Query 하나를 끝까지 연결한 뒤 Mutation과 복잡한 타입으로 확장하는 편이 좋습니다.

Tutorial

여기 따라하면 기본적인건 해볼 수 있다.

설치

서버사이드(Java/Maven)

maven의존성

springframework에서 지원하는 java버전이 있고

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-graphql</artifactId>
        </dependency>

expediagroup에서 지원하는 kotlin버전이 있다.

        <dependency>
            <groupId>com.expediagroup</groupId>
            <artifactId>graphql-kotlin-spring-server</artifactId>
            <version>7.0.1</version>
        </dependency>

후자가 Resolver(리졸버)만 구현하면 스키마를 자동생성해주고 kotlin 타입과 연동되는등 kotlin을 사용한다면 더 편리한 측면이 있지만, 여기서는 전자를 사용한 기준으로 서술한다.

expediagroup 아티펙트의 경우, 내 경우엔 /graphql 404문제가 해결이 안돼서 springframework를 쓰기로 했다.
추가로 조사해보니 webmvc대신 webflux로 교체해야 호환되는 이슈도 있었다(여기)
spring-boot-starter-web 대신 spring-boot-starter-webflux의존성으로 바꿔야 하는데 WebConfig.kt등 여러곳에서 코딩방식을 바꿔야 하는 걸로 보인다.

클라이언트 사이드(React/Next.js)

관련 library설치

npm install @apollo/client graphql

아폴로?

Apollo Client는 JavaScript 어플리케이션에서 GraphQL API와 통신할 수 있게 해주는 라이브러리입니다.

Facebook에서는 GraphQL을 발명했으며, Relay라는 GraphQL 클라이언트를 만들어 공개했습니다. 그러나 Apollo가 Relay보다 더 널리 사용되는 이유는
사용자 친화적: Apollo는 사용자 친화적이고, 초보자에게 친숙하며, 설정이 상대적으로 간단합니다. 문서화도 잘 되어 있어, 개발자들이 쉽게 접근하고 사용할 수 있습니다.
커뮤니티 지원: Apollo는 강력한 커뮤니티 지원을 받고 있으며, 다양한 추가 기능과 툴이 개발되고 있습니다. 또한 꾸준한 업데이트와 개선이 이루어지고 있어, 더 많은 개발자들이 Apollo를 선호하게 되었습니다.

코드

이번 문서 에서는 특정문제에 대해서 제출된 횟수를 리턴하는 예제를 해보기로 하자.

프로젝트 구성에 대해서는 여기를 보고오자.

서버사이드

Query

1. GraphQL 스키마 정의

src/main/resources/graphql/schema.graphqls 파일에 쿼리를 추가

type Query {
  submissionCountByProblem(problemId: ID!): Int
}


2. 서비스 수정

SubmissionService에 getSubmissionCountByProblem매서드를 추가하여 문제별 제출 수를 가져옴 

@Service
class SubmissionService(private val submissionRepository: SubmissionRepository) {

    // 아래는 기존에 존재하던 매서드
    fun submitProblem(userId: Long, problemId: Int, code: String): Submission {
        val submission = Submission(
            userId = userId,
            problemId = problemId,
            code = code,
            status = "PENDING" // 초기 상태
        )
        return submissionRepository.save(submission)
    }

    // 아래 매서드 추가
    fun getSubmissionCountByProblem(problemId: Int): Int {
        return submissionRepository.countByProblemId(problemId)
    }
}

3. 컨트롤러에 로직 추가

컨트롤러에 @QueryMapping 어노테이션을 사용하여 submissionCountByProblem GraphQL쿼리를 처리하는 메서드를 추가(엔드포인트 추가는 아니지만 약간 유사)

package com.sevity.problemservice.controller

import ...

@RestController
class SubmissionController(private val submissionService: SubmissionService) {

    // 기존 코드
    // ...

    @QueryMapping
    fun submissionCountByProblem(@Argument problemId: Int): Int {
        return submissionService.getSubmissionCountByProblem(problemId)
    }
}

Mutation

//TBD

클라이언트사이드

본격적으로 클라이언트 사이드 코딩을 하기전에

https://localhost:9993/graphiql 

위 url에 접속해서 아래 처럼 날려볼 수 있다.

query  {submissionCountByProblem(problemId: 13)}

그럼 결과가 다음처럼 보일 것이다.

apollo library 사용을 위해 아래 코드를 기본적으로 넣어준다.

// src/apolloClient.js
import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: 'https://sevity.com:9993/graphql',  // GraphQL 서버 URL
  cache: new InMemoryCache(),
});

export default client;

그다음 _app.js를 수정해서 각 페이지에서 해당 기능을 사용할 수 있게 해준다.

import ...
import { ApolloProvider } from '@apollo/client';
import client from '../apolloClient';  // Adjust the path if necessary

function MyApp({ Component, pageProps }) {
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  )
}

export default MyApp;

실제 조회하는 코드를 [id].js에 넣어준다.(제출 횟수 및 관련 부분)

...
import { useQuery } from '@apollo/client';
import gql from 'graphql-tag';

// 아래 스트링 방식이 후진적으로 보일 수 있는데, 간접적으로 문법검사나, 자동생성 툴이 존재한다.
// 하지만, 결국 서버측에 문자열로 graphQL을 보내야한다. 현재 graphQL의 한계점.
const GET_PROBLEM_SUBMISSION_COUNT = gql`
  query GetProblemSubmissionCount($problemId: Int!) {
    submissionCountByProblem(problemId: $problemId)
  }
`;

const Problem = () => {
  ...
  const { data, loading, error } = useQuery(GET_PROBLEM_SUBMISSION_COUNT, {
    variables: { problemId: Number(id) },
    skip: !id  // id가 없는 경우 쿼리를 건너뜁니다.
  });

  // JavaScript의 옵셔널 체이닝(Optional Chaining)사용('?.'부분들)
  const submissionCount = data?.submissionCountByProblem;
  if(data)
    console.log('GraphQL response:', JSON.stringify(data, null, 2));

...
  
  
  return (
    <div className="container">
      <div className="alert alert-success mt-3">username: {username} </div>
      {problem ? (
        <>
          <h1>{problem.title}</h1>
          <p>{problem.description}</p>
          <p><strong>예제 입력:</strong> {problem.exampleInput}</p>
          <p><strong>예제 출력:</strong> {problem.exampleOutput}</p>
          <p><strong>실제 입력:</strong> {problem.realInput}</p>
          <p><strong>실제 출력:</strong> {problem.realOutput}</p>
          {submissionCount && (
            <p><strong>제출 횟수:</strong> {submissionCount}</p>
          )}
          <textarea
            className="form-control"
            rows="10"
            value={sourceCode}
            onChange={(e) => setSourceCode(e.target.value)}
          ></textarea>
          <button className="btn btn-primary" onClick={handleSubmit}>
            제출
          </button>
        </>
      ) : (
        <p>Loading...</p>
      )}
    </div>
  );
};

export default Problem;

트러블슈팅

/graphql 404

src/main/resources/graphql 폴더와 그안에 anyname.graphqls 파일을 생성안하면 /graphql 경로접근시 404뜨는 문제가 있어서 반나절 이상 소모했다 ㅠ

여기에도 기록함

expediagroup 아티펙트의 경우, 위의 해결책을 적용해도 여전히 404가 떴고, 추가로 조사해보니 webmvc대신 webflux로 교체해야 하는 이슈가 있었다(여기)
spring-boot-starter-web 대신 spring-boot-starter-webflux의존성으로 바꿔야 하는데 WebConfig.kt등 여러곳에서 코딩방식을 바꿔야 하는 걸로 보인다.

반응형
반응형

Spring 서비스 사이에서 gRPC를 연동하려면 REST 호출과 달리 proto 파일, protobuf 컴파일, gRPC stub 생성, 서버 구현, 클라이언트 호출, Maven 플러그인 설정을 함께 맞춰야 한다.

이 글은 Spring 기반 MSA에서 gRPC를 붙이며 겪은 Maven 의존성, protoc 버전, proto package, 서버 구현, 클라이언트 설정, 빌드 오류를 정리한 실습 메모다.

 

핵심 정리

Spring에서 gRPC를 붙일 때 첫 단계는 서버와 클라이언트가 공유할 proto 파일을 정의하는 것이다. proto 파일에는 service, rpc 메서드, 요청 메시지, 응답 메시지가 들어가며, package 이름이 서버와 클라이언트에서 일치해야 생성 코드 참조 오류를 줄일 수 있다. Maven에서는 protobuf와 gRPC 관련 의존성, protoc 실행 파일, grpc-java 플러그인, 생성 코드 출력 위치를 맞춰야 한다. 빌드를 돌리면 proto 파일에서 Java 코드와 gRPC stub 코드가 생성되고, 서버는 생성된 base 클래스를 상속해 실제 메서드를 구현한다. 클라이언트는 채널과 stub을 통해 서버 메서드를 호출한다. 원문처럼 protobuf와 protoc, grpc-java 버전 조합이 맞지 않으면 빌드 단계에서 오류가 날 수 있으므로 의존성 버전은 한 묶음으로 관리하는 편이 좋다.

  • Spring 서비스 간 통신은 REST 대신 gRPC로도 구성할 수 있다.
  • gRPC는 proto 파일을 기준으로 요청과 응답 타입을 먼저 정의한다.
  • 서버와 클라이언트는 같은 proto package와 메시지 정의를 공유해야 한다.
  • Maven protobuf 플러그인은 proto 파일에서 Java 코드를 생성한다.
  • protoc와 grpc-java 플러그인 버전 조합이 맞지 않으면 빌드 오류가 날 수 있다.
  • 서버는 생성된 base 클래스를 상속해 rpc 메서드를 구현한다.
  • 클라이언트는 채널과 stub을 통해 서버 메서드를 호출한다.
  • 생성 코드 출력 위치는 Java와 Kotlin 프로젝트 구조에 맞춰 확인해야 한다.

원문은 특정 Spring 프로젝트에서 gRPC 서버와 클라이언트를 연결하며 작성한 설정 기록입니다. 보강문에서는 의존성 전체를 새로 늘리지 않고, proto 정의와 코드 생성, 서버 구현, 클라이언트 호출이라는 흐름을 먼저 정리했습니다. gRPC 연동은 코드보다 빌드 설정과 생성 파일 위치에서 자주 막히므로, 작은 proto 하나로 먼저 끝까지 빌드해 보는 것이 좋습니다.

이어서 볼 글

 

MSA구조에서 Spring Service간 통신에 REST를 써도되지만 gRPC를 쓸 수도 있다.

이경우 설정방법에 대해서 경험한 바를 여기 적는다.

pom.xml 에 의존성 추가

서버/클라이언트 공통

아래 내용을 서버/클라이언트 pom.xml에 공히 추가하면 되고,

src/main/kotlin/이냐 src/main/java/냐 이부분만 서로 다르게 수정해주면 된다.

protobuf-javacom.google.protobuf:protoc:3.12.4 이부분등 버전을 맞춰주지 않으면 빌드과정에서 오류가 나는 경우가 있었으니 주의.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <dependencies>

        <!-- gRPC -->
        <dependency>
            <groupId>com.google.protobuf</groupId>
            <artifactId>protobuf-java</artifactId>
            <version>3.12.4</version>  <!--이 버전을 아래쪽 ptoroc버전과 맞춰야 함-->
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-netty-shaded</artifactId>
            <version>1.41.0</version>
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-protobuf</artifactId>
            <version>1.41.0</version>
        </dependency>
        <dependency>
            <groupId>io.grpc</groupId>
            <artifactId>grpc-stub</artifactId>
            <version>1.41.0</version>
        </dependency>
    </dependencies>

    <build>
        <extensions>
            <extension>
                <!--Maven 빌드 과정 중에 운영 체제(OS)에 관한 정보를 제공하고 설정하는 데 도움을 줍니다. -->
                <groupId>kr.motd.maven</groupId>
                <artifactId>os-maven-plugin</artifactId>
                <version>1.7.0</version>
            </extension>
        </extensions>
        <plugins>
            <plugin>
                <groupId>org.xolstice.maven.plugins</groupId>
                <artifactId>protobuf-maven-plugin</artifactId>
                <version>0.6.1</version>
                <configuration>
                    <protocArtifact>com.google.protobuf:protoc:3.12.4:exe:${os.detected.classifier}</protocArtifact>
                    <pluginId>grpc-java</pluginId>
                    <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.51.0:exe:${os.detected.classifier}</pluginArtifact>
                    <outputBaseDirectory>src/main/kotlin/</outputBaseDirectory>
                    <outputDirectory>src/main/kotlin/</outputDirectory>
                    <clearOutputDirectory>false</clearOutputDirectory>
                </configuration>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>compile-custom</goal>
                        </goals>
                        <configuration>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

클라이언트 전용

<dependency>
    <groupId>net.devh</groupId>
    <artifactId>grpc-client-spring-boot-autoconfigure</artifactId>
    <version>2.15.0.RELEASE</version>
</dependency>

위의 내용은 필수는 아니나 코드가 좀 더 깔끔해지게 도와준다.

protobuf 파일추가

아래와 같이 .proto 파일을 제작해서 src/main/proto 폴더안에 둔다(다른곳에 두어도 되며, 프로젝트간 공용위치에 두어도 된다)

//session_service.proto
// Version: 1.0.0

syntax = "proto3";

package com.sevity.authservice.grpc;

service SesseionService {
    rpc GetUserId (SessionRequest) returns (UserResponse) {};
}

message SessionRequest {
    string sessionId = 1;
}

message UserResponse {
    int32 userId = 1;
}

중요한건 아래 트러블슈팅에서도 나오지만 package명을 서버/클라이언트가 다르게 하면 못찾는다는 오류가 떴다.

빌드

mvn clean install 등을 수행하면 .proto 파일을 컴파일해서 다음과 같은 2개의 java파일을 자동으로 생성해준다.

위치또한 pom.xml파일에 지정된 대로 생성된다.

파일2개중 하나만 생성된적도 있었는데 여기보고 해결했던것 같다.

protoc를 터미널에서 직접 사용해서 .proto파일을 컴파일하는 것도 가능하긴하지만, mvn에 통합해서 운용하는게 훨씬 편하고, protoc 사용과정에서 직접빌드해야하는등 우여곡절도 발생했다.

서버코드 제작

아래처럼 생성된 java파일들 (grpc패키지)을 import해주고,

gRPC서버측 구현을 해준다(SessionService의 getUserId 함수)

package com.sevity.authservice.service;

import com.sevity.authservice.grpc.SesseionServiceGrpc;
import com.sevity.authservice.grpc.SessionService.SessionRequest;
import com.sevity.authservice.grpc.SessionService.UserResponse;

import io.grpc.StatusRuntimeException;
import io.grpc.stub.StreamObserver;
import io.grpc.Status;

@Service
public class SessionServiceImpl extends SesseionServiceGrpc.SesseionServiceImplBase {
    @Override
    public void getUserId(SessionRequest request, StreamObserver<UserResponse> responseObserver) {
        String sessionId = request.getSessionId();

        UserResponse response = UserResponse.newBuilder().setUserId(sessionId).build();
        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
}

application.properties에서 포트설정해주고(이때 src/main/resources뿐 아니라 src/test/resources에 있는 파일도 해줘야함에 주의)

# in application.properties
grpc.server.port = 50051

다음처럼 gRPC서버 띄욱 listen작업도 해줘야 했다.

package com.sevity.authservice.config;

import ...
@Configuration
public class GrpcServerConfig {
    private static final Logger logger = LoggerFactory.getLogger(GrpcServerConfig.class);

    @Autowired
    private SessionServiceImpl sessionService;
    
    private Server server;
    
    @Value("${grpc.server.port}")
    private int port;

    @PostConstruct
    public void startServer() throws IOException {
        server = ServerBuilder
            .forPort(port)
            .addService(sessionService)  // Your gRPC service implementation
            .build();

        server.start();
        logger.info("sevity gRPC server started on port {}", port);
        logger.info("sevity gRPC service name: {}", sessionService.getClass().getSimpleName());        
    }

    @PreDestroy
    public void stopServer() {
        if (server != null) {
            server.shutdown();
        }
        logger.info("sevity gRPC server stopped");
    }
}

클라이언트코드 제작

application.properties에 아래줄 추가

# GRPC
grpc.client.authService.address=static://sevity.com:50051
grpc.client.authService.negotiationType=PLAINTEXT

빌드

maven,kotlin환경이었는데, 빌드과정은 서버측과 큰차이가 없다.(같은 방법으로 빌드하면 된다)

(생성되는 파일도 여전히 .java이며 kotlin과 통합에 문제가 없었다)

호출하는 코드는 아래와 같다(코틀린임에 주의)

/submit 매핑과, cookie, session관련 처리때문에 복잡하나 그 부분은 제외하고 gRPC stub에 대한 내용만 눈여겨보자.

(서버측에 비해서 필요할때 문맥중간에서 요청하게된다)

@RestController
class SubmissionController {

    @GrpcClient("authService")
    private lateinit var sessionServiceStub: SesseionServiceGrpc.SesseionServiceBlockingStub

    @PostMapping("/submit")
    fun submitCode(request: HttpServletRequest): ResponseEntity<String> {
        val cookies = request.cookies
        val sessionId = cookies?.find { it.name == "SESSION" }?.value
            ?: return ResponseEntity("Session ID not found", HttpStatus.UNAUTHORIZED)

        val request2 = SessionRequest.newBuilder().setSessionId(sessionId).build()
        try {
            val response = sessionServiceStub.getUserId(request2)
            println("Received user ID: ${response.userId}")
            return ResponseEntity("Code submitted", HttpStatus.OK)
        } catch (e: InvocationTargetException) {
            e.targetException.printStackTrace()
        }

        // 나머지 로직
        return ResponseEntity("Code submitted", HttpStatus.OK)
    }
}

트러블슈팅

io.grpc.StatusRuntimeException: UNIMPLEMENTED: Method not found

.proto 파일은 파일내 package경로까지 완전히 동일한 파일을 사용하지 않으면 못찾는다고 에러가 났다.

이거때문에 한참헤멤 ㅠ 여기참조. 유일한 솔루션인지는 잘 모르겠으나,

서버기준으로 package명까지 동일하게 맞춰주니 해결됨.

반응형
반응형

Maven dependency가 갱신되지 않을 때는 pom.xml 변경 내용, 로컬 저장소 캐시, IDE의 Maven reload 상태를 순서대로 확인해야 합니다.

사용자 홈 디렉터리의 .m2는 Maven 로컬 저장소입니다. 이 폴더를 지우면 의존성을 다시 내려받지만, 전체 삭제는 시간이 오래 걸리고 다른 프로젝트에도 영향을 줄 수 있어 마지막 수단으로 보는 편이 안전합니다.

 

핵심 정리

Maven dependency가 갱신되지 않을 때는 pom.xml 변경 내용, 로컬 .m2 캐시, IDE의 Maven reload 상태를 순서대로 확인해야 합니다. 전체 .m2 삭제는 마지막 수단으로 보는 편이 안전합니다.

  • Maven은 내려받은 dependency를 사용자 홈의 .m2 저장소에 캐시합니다.
  • pom.xml을 바꾼 뒤에는 IDE에서 Maven reload 또는 reimport가 필요할 수 있습니다.
  • 실제 선택된 버전이 궁금하면 dependency tree로 충돌 여부를 확인합니다.
  • 문제가 되는 artifact 폴더만 지우면 전체 캐시 삭제보다 영향이 작습니다.
  • 강제 업데이트 옵션을 써도 안 되면 로컬 저장소와 원격 저장소 설정을 함께 확인합니다.

의존성 문제는 캐시를 지우기 전에 실제로 어떤 버전이 선택되었는지 확인하는 것이 좋습니다. 전체 .m2 삭제는 모든 프로젝트에 영향을 주므로 원인 범위를 좁힌 뒤 사용하는 편이 안전합니다.

 

pom.xml 변경하고 (버전변경등) 디펜던시 업데이트가 잘 안되면,

~/.m2 폴더안의 내용을 지워주면 다시 다운받는다.

(프로젝트 root가 아닌 user home디렉토리임에 주의)

반응형
반응형

Spring Boot에서 HTTPS를 직접 띄우려면 인증서 파일을 준비하고 server.ssl 관련 프로퍼티를 애플리케이션 설정에 맞춰야 한다.

개발 환경에서는 keytool로 만든 자체 서명 인증서를 쓸 수 있지만, 운영에서는 정식 인증서나 리버스 프록시의 TLS termination 구성을 함께 검토해야 한다.

 

핵심 정리

Spring Boot HTTPS 설정은 인증서 생성, keystore 배치, server.ssl 프로퍼티 설정, 포트 확인 순서로 진행한다. 자체 서명 인증서는 로컬 개발과 테스트에는 유용하지만 브라우저 경고가 나오므로 운영용 신뢰 인증서와는 구분해야 한다.

  • keytool로 JKS나 PKCS12 형식의 keystore를 만들 수 있다.
  • keystore 파일은 애플리케이션이 읽을 수 있는 경로에 배치해야 한다.
  • server.ssl.key-store, password, type, alias 값이 실제 인증서와 맞아야 한다.
  • HTTPS 포트와 기존 HTTP 포트를 혼동하지 않도록 server.port를 확인한다.
  • 운영에서는 Spring Boot가 직접 TLS를 처리할지 Nginx 같은 앞단에서 종료할지 결정해야 한다.

Spring Boot HTTPS 오류는 인증서 파일 경로, 비밀번호, alias, 포트 충돌을 먼저 확인하고, 운영 구조에서는 리버스 프록시와의 역할 분담까지 함께 봐야 한다.

이어서 볼 글

 

 

아래는 개발과정에서 쓰는 임시 인증서를 사용하여 https로 서비스를 운용하는 방법에 대한 설명이다.

세션-쿠키관련해서 https로 해야할일이 생겨서 적용해봤다.

 

1. 자체 서명된 인증서 생성:

keytool -genkey -alias selfsigned_localhost_sslserver -keyalg RSA -keysize 2048 -validity 3650 -keystore ssl-server.jks -keypass your_password -storepass your_password

생성된 ssl-server.jks 파일을 src/main/resources 폴더에 복사한다.

 

2. Spring Boot 설정:

application.properties 파일에 아래 설정을 추가

server.port=8443
server.ssl.key-store=classpath:ssl-server.jks
server.ssl.key-store-password=your_password
server.ssl.key-store-type=JKS
server.ssl.key-alias=selfsigned_localhost_sslserver

 

반응형
반응형

Spring Boot에서 세션 관리는 서버가 세션을 만들고 브라우저가 쿠키로 세션 ID를 보관하며 이후 요청에 다시 보내는 흐름으로 이해할 수 있다. 로그인, 로그아웃, 쿠키 속성, SameSite, credentials 설정이 함께 얽힌다.

이 글은 온라인 저지 프로젝트의 인증 서비스에서 Spring Boot 세션, set-cookie, 브라우저 쿠키 저장, 세션 타임아웃, cross-origin 요청, getSession 호출을 디버깅하며 남긴 메모다.

 

핵심 정리

서버 세션 방식에서는 로그인 성공 시 서버가 세션을 생성하고, 브라우저에는 세션 ID를 담은 쿠키가 내려간다. 브라우저는 이후 요청마다 조건에 맞는 쿠키를 다시 보내고, 서버는 그 ID로 세션 정보를 찾는다. 세션쿠키는 브라우저 종료와 함께 사라질 수 있고, 지속쿠키는 만료 시점을 별도로 가질 수 있다. Spring Boot에서는 세션 타임아웃을 설정으로 조정할 수 있지만, 실제 체감은 로그인 흐름, Spring Security 설정, 브라우저 쿠키 정책에 따라 달라질 수 있다. 서로 다른 포트나 도메인에서 인증 요청을 보낼 때는 서버의 쿠키 속성과 클라이언트의 credentials 설정이 맞아야 쿠키가 저장되고 다시 전송된다. getSession 호출은 false와 true의 의미를 구분하고, 멀티스레드나 중복 생성 가능성을 고려해 사용하는 것이 좋다.

  • 세션은 서버에 저장되고 브라우저는 세션 ID를 쿠키로 보관한다.
  • set-cookie 응답이 내려와도 브라우저 정책에 맞지 않으면 쿠키가 저장되지 않을 수 있다.
  • 세션쿠키와 지속쿠키는 만료 방식이 다르다.
  • Spring Boot의 세션 타임아웃 설정은 서버 세션 유지 시간과 관련된다.
  • cross-origin 요청에서는 쿠키 속성과 클라이언트 credentials 설정을 함께 확인한다.
  • SameSite와 Secure 설정은 브라우저의 쿠키 전송 조건에 영향을 준다.
  • getSession false는 기존 세션 조회, true는 없을 때 생성 의미로 이해할 수 있다.
  • 세션 디버깅은 서버 로그와 브라우저 개발자 도구의 쿠키 상태를 함께 봐야 한다.

원문은 실제 프로젝트에서 Spring Boot와 프론트엔드 인증 연동을 디버깅하며 적은 세션 관리 메모입니다. 보강문에서는 서버 세션, 브라우저 쿠키, cross-origin 요청, getSession 동작을 분리했습니다. 브라우저 쿠키 정책과 프레임워크 설정은 버전에 따라 달라질 수 있으므로, 실제 배포 전에는 사용하는 Spring Security와 브라우저 조건을 함께 확인해야 합니다.

현재 만들어 보고 있는 online judge 프로젝트의 서비스 구성은 다음과 같다.(관련 있는 2개만 표시. 실제로는 7개)

인증 서비스 (Backend): 사용자의 회원 가입, 로그인, 로그아웃, 세션 관리 등을 담당
인증 서비스 (Frontend): 사용자 인터페이스를 제공 (로그인 폼, 회원가입 폼 등)

한가지 알아두면 좋은점은 Spring Boot의 경우 /login, /logout endpoint의 경우 직접 정의하지 않아도 자동으로 처리한다는 점이다.(이점 때문에 디버깅시 많이 헷갈렸다ㅠ)

세션은 서버에서 브라우저로 set-cookie 헤더를 통해서 세션아이디를 부여한다.

아래처럼 브라우저 개발자 도구에서 확인가능하다(애플리케이션탭 > 쿠키섹션)

서버의 세션과 브라우저(클라이언트)의 쿠키 개념

세션을 서버에서 생성하고 세션id를 set-cookie를 통해서 브라우저(클라이언트)로 전달한다.

이때 쿠키는 브라우저를 종료해도 유지되는 지속쿠키를 쓰고 만료시점을 정의할수도 있고, 세션쿠키를 쓰면 브라우저 종료시 자동으로 쿠키도 삭제된다.

Expres/Max-Age 컬럼을 보면 세션쿠키와 지속쿠키의 차이를 볼 수 있다.

서버의 세션의 경우 지속시간을 application.properties에 다음과 같이 지정할 수 있다.

server.servlet.session.timeout=30m

현재 내가 테스트중인 프로젝트에서는 /login 성공시 세션쿠키가 발급되며, 세션 타임아웃의 효과는 확인이 안되었다.

일단은 이정도에서 더 깊이 안파고 넘어가기로 한다.

트러블슈팅

http에서 포트가 서로다른 서비스(MSA)간 연동하기

아래 크롬 설명에 따르면 https를 쓰고 secure 옵션을 주어야 SameSite=None으로 지정하면서 포트가 달라도 쿠키저장이 된다는것 같다.(관련글, 관련글2)

set-cookie로 응답을 제대로 했음에도 브라우저에 쿠키저장이 안될때

클라이언트에서 서버로 요청할때 credential을 보내줘야 했다(이것땜에 한참헤맴 ㅠ)

      const response = await axios.post('https://sevity.com:9991/login', `username=${username}&password=${password}`, {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        withCredentials: true,  // 여기, 이 줄 없으면 브라우저에 쿠키저장 안된다!!
      });

getSession(true)의 안전성

HttpSession session = request.getSession(false); 를 하면 존재하는 세션정보를 가져오고, false자리에 true를 넣으면 없으면 생성하라는 의미인데, 이렇게 하면 (내가만든세션), (SpringSecurity에 의해 아직 없지만 생성될 세션) 이렇게 두벌이 될까봐 우려했는데, 나중에 확인해보니 그렇지는 않았다. 따라서 항상 true로 호출해도 무방한 것 같다.

단 여기를 보면 멀티스레드 환경에서 레이스 컨디션에 의해 중복세션과 중복쿠키가 생성되는 경우는 있는 것 같다. 해결책은 아래처럼 동기화블록내에 생성과 처리를 묶어주면 되긴할듯(현재 내 구현에서는 그렇게 까지 하진 않았다)

synchronized (request) {
    HttpSession session = request.getSession(true);
    // ... 세션 사용 코드 ...
}

TMI

/login에서 반환되는 response.data 값과 SESSION쿠키의 값은 동일하나, SESSION쿠키의 경우 base64로 인코딩 되어 있다.

//response.data: c8545bb7-c90b-4d69-9128-08efd0a73866
//SESSION(쿠키): Yzg1NDViYjctYzkwYi00ZDY5LTkxMjgtMDhlZmQwYTczODY2

let encodedSessionId = 'Yzg1NDViYjctYzkwYi00ZDY5LTkxMjgtMDhlZmQwYTczODY2';
let decodedSessionId = atob(encodedSessionId);
console.log(decodedSessionId);  // 출력: c8545bb7-c90b-4d69-9128-08efd0a73866
반응형

+ Recent posts