서버 간의 gRPC 통신이 처음이라면 이 흐름이 복잡하고 어색하게 느껴지는 것이 아주 당연합니다
가장 최근에 만든 캐릭터 이름 수정 (4.5 API)을 예시로, 사용자의 요청이 데이터베이스에 반영될 때까지의 여정을 5개의 단계로 아주 쉽게 나누어서 설명해 드릴게요.
전체 구조는 다음과 같습니다:
[클라이언트] -(HTTP)-> [Gateway] -(gRPC)-> [Character 모듈] -> [데이터베이스(JPA)]
외부 인터넷(웹 브라우저나 모바일 앱)에서 PATCH /api/character/v1/characters/10 같은 HTTP 요청이 들어오는 곳입니다.
@org.springframework.web.bind.annotation.PatchMapping("/v1/characters/{characterId}")
public ApiResponse<UpdateCharacterNameResponse> updateCharacterName(
@PathVariable Long characterId,
@LoginUserId Long userId, // 🔐 로그인한 사용자 ID (인증 토큰에서 추출)
@RequestBody UpdateCharacterNameRequest request) { // 수정할 이름 {"name": "노바별"}
// GatewayService로 데이터를 토스합니다.
return ApiResponse.success(characterGatewayService.updateCharacterName(characterId, userId, request));
}
여기서 서버 간의 gRPC 통신이 시작됩니다. Gateway가 Character 서버에게 전화를 거는 과정이라고 생각하시면 됩니다.
public UpdateCharacterNameResponse updateCharacterName(Long characterId, Long userId, UpdateCharacterNameRequest request) {
// 1. protobuf 규격에 맞춰 전송용 "택배 박스(Request)"를 조립합니다.
var grpcRequest = com.p5laris.proto.character.v1.UpdateCharacterNameRequest.newBuilder()
.setCharacterId(characterId)
.setUserId(userId)
.setName(request.name())
.build();
// 2. characterStub(전화기)를 사용해 Character 서버의 updateCharacterName 함수를 호출합니다. (네트워크 통신)
var grpcResponse = characterStub.updateCharacterName(grpcRequest);
// 3. Character 서버에서 돌려받은 결과(택배 박스)를 다시 웹 응답용 DTO로 포장합니다.
return UpdateCharacterNameResponse.builder()
.id(grpcResponse.getId())
.name(grpcResponse.getName())
.updatedAt(java.time.Instant.parse(grpcResponse.getUpdatedAt()))
.build();
}
통신할 때 "우리는 이 구조의 데이터를 주고받을 것이다"라고 정의해 둔 규격서(약속)입니다. 이 파일을 기반으로 Java 통신 코드가 자동으로 컴파일 및 생성됩니다.
// 약속 1: 이런 함수(RPC)를 주고받을 거야.
rpc UpdateCharacterName(UpdateCharacterNameRequest) returns (UpdateCharacterNameResponse);
// 약속 2: 보낼 때는 이 데이터를 꼭 담아줘.
message UpdateCharacterNameRequest {
int64 character_id = 1;
int64 user_id = 2;
string name = 3;
}
// 약속 3: 응답할 때는 이 데이터를 돌려줄게.
message UpdateCharacterNameResponse {
int64 id = 1;
string name = 2;
string updated_at = 3;
}
Character 서버가 Gateway에서 보낸 gRPC 전화를 받는 곳입니다.
@Override
public void updateCharacterName(UpdateCharacterNameRequest request,
StreamObserver<UpdateCharacterNameResponse> responseObserver) {
// 1. gRPC 요청 박스를 열어 데이터를 꺼내고, 실제 비즈니스 로직(Service)을 실행합니다.
var result = characterService.updateCharacterName(
request.getCharacterId(),
request.getUserId(),
request.getName()
);
// 2. 돌려줄 gRPC 응답 박스를 조립합니다.
UpdateCharacterNameResponse response = UpdateCharacterNameResponse.newBuilder()
.setId(result.id())
.setName(result.name())
.setUpdatedAt(result.updatedAt().toString())
.build();
// 3. 통신망을 통해 Gateway 서버로 전송합니다.
responseObserver.onNext(response);
responseObserver.onCompleted();
}
여기서부터는 질문자님이 이미 아주 잘 알고 계시는 순수 CRUD/JPA 영역입니다!
@Transactional
public UpdateCharacterNameResponse updateCharacterName(Long characterId, Long userId, String newName) {
// 1. DB에서 캐릭터 엔티티를 꺼내옵니다.
UserCharacter userCharacter = userCharacterRepository.findById(characterId)
.orElseThrow(() -> new IllegalArgumentException("Character not found: " + characterId));
// 2. 권한 검증: 이 캐릭터가 해당 유저의 것이 맞는지 검사합니다.
if (!userCharacter.getUserId().equals(userId)) {
throw new IllegalArgumentException("User does not own this character");
}
// 3. 엔티티 내부에서 비즈니스 검증(글자 수 등)을 하며 이름을 변경합니다. (JPA Dirty Checking으로 자동 변경 감지 후 DB 반영)
userCharacter.updateName(newName);
// 4. 결과를 반환합니다.
return UpdateCharacterNameResponse.builder()
.id(userCharacter.getId())
.name(userCharacter.getName())
.updatedAt(userCharacter.getUpdatedAt())
.build();
}
이렇게 중간에 통신 규격(gRPC)을 거침으로써, Gateway와 Character 서버가 독립적인 서버로 실행되면서도 서로 함수 호출하듯이 통신할 수 있게 됩니다.