개발이군고구마

[MSA] 공통으로 사용하는 컴포넌트의 API 설계 방법 본문

SERVER/Architecture

[MSA] 공통으로 사용하는 컴포넌트의 API 설계 방법

김구황 2025. 7. 7. 08:50
728x90

각 모듈별 개발이 얼추 완료가 된 이후 놓치고 있었던 부분이 떠올랐다. 바로 마이페이지. 

 

해당 컴포넌트를 생각하다보니

겹쳐지는 모듈 (여기서는 Point) 과 각각의 컨텍스트에 대한 조회를 어떻게 마이페이지 api 와 연결할 것인가 고민이 되었다. 

 

1. 많은 컨텍스트가 침범하는 컨텍스트 

우선 아래는 많은 모듈들이 함께 사용하고 있는 "포인트" 컨텍스트이다. 

다행이 해당 컨텍스트는 각각의 컨텍스트와 분리되어 운용하여 독립적으로 사용이 가능하다. 

컨텍스트가 겹치는 마이페이지

 

그렇다면 각 컨텍스트는 트랜잭션이 있을 때 포인트 컨텍스트 트랜잭션 처리를 해줘야한다. 

이때 중요한 것이 각 컨텍스트의 침범 없이 느슨하게 결합도를 최대한 줄여야 한다는 것인데, 어떻게 설계하면 좋을까?

느슨한 조합
interface

 

인터페이스는 최종적으로 실제 구현 서비스에서 작성된다

 

 

이렇게 설계를 잡게 되면 

  • 실제 Point 를 사용해야하는 컨텍스트에서 (Order)
  • Client를 인터페이스를 주입 받고 (인터페이스 느슨한 결합) outport? 해당 클라이언트를 호출하게 되면

메인 컨텍스트와 경계가 겹쳐진 컨텍스트 사이가 느슨하게 조합되게 된다. 

✅ 즉 챌린지/주문은 포인트에서 무슨 일이 일어나는지 모름! 
private final PointSpendClient pointSpendClient;

private void processSideEffect(SingleOrderCommand command, Order savedOrder) {
    pointProductService.decreaseSingleItemStock(command.orderItemId(), command.quantity());
    String itemName = savedOrder.getOrderItems().getFirst().getItemSnapshot().getItemName();
    pointSpendClient.spendPoints(
       new PointSpendRequest(command.memberId(), savedOrder.getTotalPrice(), savedOrder.getId(), itemName + "교환")
    );
}

 

 

[본격 비교 - 트랜잭션 스크립트를 사용하고 있는 현 사이트] 

예를 들어 주문이 들어간다고 해보자. 

주문 서비스 로직 

  • 상품 재고 조회 -> 감소 
  • 회원에 대한 포인트 조회 -> 포인트 추가 

단순 명료, 빠르게 기능 구현이 가능함. 

 

💥 이게 왜 문제가 되나?  높은 결합도 

  • DIP 는 지켜지긴 하지만, 서비스 인터페이스의 범위가 넓어지면 컨텍스트 경계가 느슨해짐 
  • 모든 클라이언트가 동일한 서비스 인터페이스를 이용하기 때문에 하나의 도메인에 다양한 요구사항이 섞임 

2. 많은 컨텍스트가 모이는 마이페이지 컴포넌트 설계 

 

1. 단일 API - facade 패턴 

ㄴ 모놀리식 또는 단일 마이크로 서비스 환경

 

1) 방법

  • 하나의 백엔드 애플리케이션 안에 “조합(혹은 Facade) 서비스” 계층을 두고, Point/Challenge/Member 모듈을 내부에서 직접 호출
  • 프론트엔드 → /api/user/mypage 단일 엔드포인트
public class MypageController {
    private final MypageService mypageService;

    마이페이지 메인 조회 API
    @GetMapping("/api/user/mypage")
    public ResponseEntity<MypageMainDto> getMypageMain() {
        MypageMainDto mypageMainDto = mypageService.getMypageMain();
        return ResponseEntity.ok(mypageMainDto);
    }
}

 

@Service
@RequiredArgsConstructor
public class MypageService {

    private final ChallengeService challengeService;
    private final PointService pointService;

    public MypageMainDto getMypageMain() {
       List<ChallengeDto> challengeMypageResponseDto = challengeService.getChallendgesByUserIdForMypage(user.getId());
       List<PointDto> pointMypageResponseDto = pointService.getPointByUserIdForMypage(user.getId());

       MypageMainDto mypageMainDto = new MypageMainDto(challengeMypageResponseDto, pointMypageResponseDto);

       return new MypageMainDto(); // 임시 반환
    }
}

조합 (다만 서비스가 직접 의존되어 있음) 

 

 

 

2. 얇은 (모듈별) API 프리젠테이션 레이어 - BFF 패턴 

ㄴ 다중 마이크로 서비스 환경 

인터페이스에서 API를 호출, 컨텍스트의 완전한 분리

 

1) 방법

  • 도메인별 서비스는 독립 마이크로서비스(또는 모듈)로 두고, 별도의 BFF 레이어
  • 클라이언트 → BFF 서비스 (도메인 API호출 인터페이스 = 프리젠테이션 레이어)  → 도메인 서비스(Point/Challenge/Member)
@RestController
@RequestMapping("/api/mypage")   
public class MypageBffController {
    private final MypageBffService svc;

    public MypageBffController(MypageBffService svc) {
        this.svc = svc;
    }

    @GetMapping
    public ResponseEntity<MypageResponseDto> get(@AuthenticationPrincipal User user) {
        return ResponseEntity.ok(svc.fetchForUser(user.getId()));
    }
}


@Service
public class MypageBffService {
    private final PointClient pointClient;
    private final ChallengeClient challengeClient;
    private final MemberClient memberClient;

    public MypageBffService(PointClient p, ChallengeClient c, MemberClient m) {
        this.pointClient = p;
        this.challengeClient = c;
        this.memberClient = m;
    }

    public MypageResponseDto fetchForUser(Long memberId) {
        // (1) 비동기 병렬 호출 고려 가능
        PointSummaryDto point = pointClient.getSummary(memberId);
        List<ChallengeDto> challenges = challengeClient.getParticipations(memberId);
        MemberInfoDto info = memberClient.getInfo(memberId);

        // (2) 클라이언트별 가공 로직—예: 모바일은 포인트 요약만, 웹은 세부내역까지
        return new MypageResponseDto(point, challenges, info);
    }
}

@FeignClient(name = "point-service", url = "${services.point.url}")
public interface PointClient {
    @GetMapping("/api/points/{memberId}/summary")
    PointSummaryDto getSummary(@PathVariable("memberId") Long memberId);
}

@FeignClient(name = "challenge-service", url = "${services.challenge.url}")
public interface ChallengeClient {
    @GetMapping("/api/challenges/{memberId}/participations")
    List<ChallengeDto> getParticipations(@PathVariable("memberId") Long memberId);
}

@FeignClient(name = "member-service", url = "${services.member.url}")
public interface MemberClient {
    @GetMapping("/api/members/{memberId}")
    MemberInfoDto getInfo(@PathVariable("memberId") Long memberId);
}

인터페이스에 삽입된 api 를 각각 모듈별로 호출하게 함 

 

 

 

3. 단일 API + (api 호출 없는) BFF 패턴  

모놀리식 아키텍처 설계를 기반 - interface에 api 호출 없음

 

1) WHAT (고려한 사항)

 

  • Point/Challenge/Member 각각은 엄격한 바운디드 컨텍스트로 유지하고 싶다. (즉, 서비스 객체의 침범도 하지 않게) 
  • MyPage는 애플리케이션 레이어의 Use‐Case(조합)로 구현
  • 프론트엔드엔 단일 API(/api/user/mypage)만 제공
  • 그러나 도메인 별로 독립 모듈로 둘 정도로 서비스의 규모가 크지 않다. 현재 모놀리식 아키텍처 따르고 있음 

 

2) WHAT & WHY (왜 문제인가) 

서비스를 구체 클래스로 만들어서 쓰고 있는 컨텍스트가 많다.

 

 

3) HOW (어떻게 해결할 것인가) 

  • BFF 패턴 인터페이스를 만들되 (마이페이지용) API는 연결하지 않는다
  • 마이페이지는 조합facade 계층이지만 서비스를 의존받지 않는다. 대신 인터페이스를 의존 받고 거기서 서비스를 호출한다. 
  • 그 외 모듈별 상세 화면은 컨텍스트 api를 호출한다. 즉, 마이페이지는 모듈의 기능을 "조합" 하는 것에만 집중한다.  

 

4) HOW & WHY (이게 최선인가)

 

✅ PointService를 직접 주입하는 것과 비교하여 

  • DIP  원칙 준수 
    • 고수준 모듈 ( 무엇을 할지 - 챌린지/상품) -> 저수준 모듈(어떻게 할지 - 포인트 비즈니스 로직) 의존이 아니라 둘 다 "추상(인터페이스)" 에 의존하고, 구현체는 런타임에 주입하는 방식 
    • 외부/내부 의 경계가 명확하게 유지됨 
  • 컨텍스트 경계가 명확
    • 챌린지와 상품은 포인트의 세부를 전혀 몰라도 됨 
    • PointEarnClient, PointSpendClient 등 역할별 인터페이스로 책임을 분리 -> SRP  
  • 교체 확장 용이 
    • 나중에 포인트 적립 정책이 바뀌면 adaptor만 수정하면 됨 -> OCP 
  • 테스트 편의성 
    • mock 인터페이스를 주입하게 되면 챌린지/상품 비즈니스 로직 단위 테스트 깔끔해짐 

 

❎ BFF MSA 구조와 비교하여 

  • 완전히 API를 분리하여 마이페이지는 단순히 마이페이지에 필요한 API만 모아놓은 Facade는 아니긴 함 
  • 결국 인터페이스도 -> 서비스를 구현하기 때문 (완전한 web 레이어가 아니라)