💻 뚝딱뚝딱/북북클럽

[개발일지 #037] 모놀리식 아키텍처를 MSA 아키텍처로 전환하기 (3) - MSA 환경에서의 서비스 간 통신 구축

뚜루리 2025. 6. 9. 17:47
728x90
320x100

🎯 오늘 개발 할 기능

  • 모놀리식 아키텍처를 MSA 아키텍처로 전환하기 (2)
    • Book, Feed, Like 엔티티

🛠️ 개발내용

📌 관련 코드 옮기기

  • bbc-post-service와 관련있는 엔티티(Book, Feed, Like)를 위주로 일단 코드를 옮겼다. 

옮기고 나니 패키지 구조가 위와 같아짐.

 

 


✅ 🔐 2. 분리된 서비스끼리 어떻게 통신해야 할까?

✅ MSA 간 통신 방식 (Post → User 호출)

1. RestTemplate

  • Spring에서 기본 제공하는 HTTP 통신 라이브러리.
  • 장점: 간단함.
  • 단점: boilerplate 코드 많음, 유지보수 어려움.

2. WebClient

  • Spring WebFlux 기반 비동기/논블로킹 HTTP 클라이언트.
  • 장점: 비동기 처리 가능, 유연함.
  • 단점: 학습 곡선 있음, 동기 방식에는 과함.

3. Feign Client

  • 선언형 HTTP Client. 인터페이스만 정의하면 자동으로 구현됨.
  • 장점: 코드 간결, 가독성 좋음, Spring Cloud와 통합 쉬움.
  • 단점: 설정 복잡할 수 있음, 예외 처리 신경 써야 함.

👉 MSA 구조에서 서비스 간 동기 통신할 때 가장 널리 쓰여 Feign Client을 사용 하기로함.

 

Feign Client만으로 해결이 될까?

MSA 환경에서 Feign Client을 활용해서 사용자 정보를 받아오는 방식을 사용하려고 했는데 만약 매 요청 때마다 user-service에 유저 정보를 요청하면 네트워크 비용 증가하게 됨. 그래서 JWT에 기본 정보를 담고, 자주 바뀌거나 상세한 정보만 필요할 때 Feign Client로 요청 하는 방식을 사용하기로 했다. 즉, 정적 정보는 JWT로, 동적 정보는 Feign으로.

 

[추가] 토큰(JWT)을 활용한 이유

🔒 1. 보안 및 인증

  • Post Service에서 User Service를 호출할 때, 유저의 권한이 필요한 경우
  • User Service는 인증되지 않은 요청을 거부하도록 되어있기 때문에, 토큰을 전파해야 함

🤝 2. 유저 컨텍스트 유지

  • A 사용자가 Post를 작성할 때, Post Service는 User ID를 받아서 유저 정보를 가져오고, 저장해야 함.
  • 이 때 누가 요청했는지를 인증된 토큰에서 파싱해서 확인해야 정확한 데이터 저장이 가능함.

 

[사용자 요청]
    ⬇ Authorization: Bearer accessToken
[post-service]
    ⬇ JWT 검증 (JwtAuthenticationFilter + JwtUtil)
    ⬇ 유저 정보 필요하면 Feign Client 호출
[FeignClient → user-service]
    ⬇ 다시 JWT 검증 (user-service 쪽 JwtAuthenticationFilter)
    ⬇ Internal API 호출 → 유저 정보 응답

 

 

그런데, 이렇게 결정하면 변경해야 할 것들이 몇 개 있다.

 

 


 

JWT 구조 확장 (bbc-user-service)

기존에 JWT 토큰 안에 이메일과 비밀번호? 정도 담아두었었는데 MSA 환경에서 Feign Client로 서비스 간 통신할 때는 서비스 입장에서 "유저 요약 정보"가 필요함 (닉네임, 프로필 이미지 등) 단순히 userId만 있으면 매번 UserService를 추가 호출해야 하고 성능 낭비임

JwtUtil 수정

    public String createToken(Long id, String email, String nickname, String profileImageUrl, String role) {
        return Jwts.builder()
                .setSubject(email)
                .claim("id", id)
                .claim("email", email)
                .claim("nickname", nickname)
                .claim("profileImageUrl", profileImageUrl)
                .claim("role", role)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }
  • 토큰 생성시 아이디, 이메일, 닉네임, 프로필사진, 권한까지 넣어주었다.
  • createToken을 사용하는 2곳도 같은 방식으로 수정해준다. UserController(일반 로그인시), OAuth2SuccessHandler(소셜로그인시)

 

  같지만 다른 클래스들

CustomUserDetails, JwtAuthenticationFilter, JwtUtil 클래스는 기존 모놀리식 아키텍처일 때 있던 클래스지만 분리하면서도 여전히 각 프로젝트에 남아 있다. 왜냐하면 각 서비스는 완전히 독립되기 때문에 공통으로 보이는 클래스도 서로 다른역할을 하기 때문임.

클래스명 위치 역할 차이점
CustomUserDetails 모두 JWT → 인증 객체 변환 user는 DB 인증용, post는 토큰 검증용
JwtAuthenticationFilter 모두 JWT 필터 SecurityContext에 인증 정보 저장
JwtUtil 모두 JWT 생성 / 파싱 / 검증 user는 생성 위주, post는 파싱 위주

 

 

  FeignClient 호출도 여전히 필요할까?

토큰에는 최소한의 정보만 넣는 게 일반적인대  예를 들어, PostService에서 피드 작성할 때는 토큰 정보로 충분하지만, "다른 유저의 요약 정보 조회" 같은 기능에서는 정확한 최신 유저 정보가 필요함. 그래서 "토큰 + FeignClient" 두 가지 방법을 상황에 따라 적절히 병행함

  • ✅ 본인 정보 → 토큰에서 추출
  • ✅ 다른 유저 정보 or 정확한 정보 → FeignClient 호출

 

  FeignClient로 호출하기

🧩 의존성 추가 (Gradle 기준)

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.1"
    }
  • 의존성(spring-cloud-starter-openfeign) 추가는 Feign을 사용하는 쪽, 즉 @FeignClient를 선언하는 서비스에만 해주면 됨.

 

🧩  @EnableFeignClients 활성화

  • @FeignClient를 쓰려면 @EnableFeignClients로 기능을 활성화
  • 보통 Application.java 또는 config 파일에 아래처럼 붙여줌
@SpringBootApplication
@EnableFeignClients(basePackages = "com.bookbookclub.bbc_post_service.global.client")
public class PostServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(PostServiceApplication.class, args);
    }
}

Feign Client를 사용하는 쪽에만 @EnableFeignClients 붙이면 됨.

 

 

🧩 InternalUserController (user-service에 존재)

package com.bookbookclub.bbc_user_service.user.controller.internal;

import com.bookbookclub.bbc_user_service.user.dto.UserSummaryResponse;
import com.bookbookclub.bbc_user_service.user.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 내부 시스템 통신용 사용자 API (FeignClient 대응용)
 */
@RestController
@RequestMapping("/api/internal/users")
@RequiredArgsConstructor
public class InternalUserController {

    private final UserService userService;

    @GetMapping("/{userId}")
    public UserSummaryResponse getUserById(@PathVariable Long userId) {
        return userService.getUserById(userId);
    }

}

 

🧩 UserClient (post-service에 존재)

package com.bookbookclub.bbc_post_service.global.client;

import com.bookbookclub.bbc_post_service.like.dto.UserSummaryResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@FeignClient(name = "userClient", url = "${bbc-user-service.url}")
public interface UserClient {

    @GetMapping("/api/internal/users/{userId}")
    UserSummaryResponse getUserById(@PathVariable Long userId);

}
  • FeignClient는 user-service의 InternalUserController를 호출하는 인터페이스

 

  FeignClient로 호출 후, FeedService에서 사용하기

 
    @Transactional(readOnly = true)
    public List<FeedResponse> getFeedsByUserId(Long targetUserId, Long viewerId) {
        List<Feed> feeds = feedRepository.findByUserIdAndIsBlindedFalse(targetUserId);

        Set<Long> bookIds = feeds.stream().map(Feed::getBookId).collect(Collectors.toSet());

        // Book 정보 조회
        Map<Long, Book> bookMap = bookRepository.findAllById(bookIds).stream()
                .collect(Collectors.toMap(Book::getId, b -> b));

        // User 정보는 단일 조회 (getUserById 사용)
        UserSummaryResponse writer = userClient.getUserById(targetUserId);

        return feeds.stream()
                .map(feed -> {
                    Book book = bookMap.get(feed.getBookId());
                    if (book == null || writer == null) return null;
                    return new FeedResponse(feed, book, writer, 0, false);
                })
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
    }

 

  추가적으로 만들어줘야 하는 것

✅ FeignClientInterceptor

Feign 요청 보낼 때 JWT를 자동으로 헤더에 붙여주는 역할 (post-service에 생성)

  • Feign은 그냥 호출만 하지, JWT를 헤더에 붙여주지 않기 때문에 user-service는 인증 필요하니까, 이걸 붙여줘야 401 안 뜸.
package com.bookbookclub.bbc_post_service.global.config;

import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

/**
 * Feign 요청 시 JWT 토큰을 Authorization 헤더에 자동 추가하는 인터셉터
 */
@Component
public class FeignClientInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication != null && authentication.getCredentials() instanceof String token) {
            template.header("Authorization", "Bearer " + token);
        }
    }
}

 

 

 

 

  최종 흐름

[Client]
  ↓ JWT 발급받아 요청
[PostService]
  → JwtAuthenticationFilter 통해 인증
  → CustomUserDetails 로 userId, nickname 등 추출
  → 본인 정보 필요 시: 토큰에서 바로 사용
  → 다른 유저 정보 필요 시: FeignClient로 UserService에 요청
[UserService]
  → JWT 검증 후 유저 정보 응답

 

728x90
320x100