[PJT] CodeSwamp (2) 서비스 분리 및 프로젝트 중단

Park Yeongseo·2025년 8월 24일
1

Project

목록 보기
7/7
post-thumbnail

1. 서비스 분리

(1) 공통 모듈 추출

서비스 분리를 결정하고 난 후, 우선은 기존의 모놀리식 서버를 도메인 별로 복제하고 각각을 점진적으로 변경해 나가려고 했는데, 이 과정에서 인증, 이벤트 처리 등 동일한 기능과 구조가 여러 서비스에서 반복되는 문제가 발생했다.

이러한 공통 기능들을 외부로 분리하여 모듈화했고, 나아가 아키텍처의 통일성을 유지하기 위해 기본 개발 구조와 인터페이스, 기본 구현체를 프레임워크 형태로 추출하여 관리하게 됐다.

서비스 간 코드 중복이 줄기는 했지만 외부 모듈 의존성이 커졌고, 모듈 내부의 세부 로직을 파악하기 어려워졌다. 그 결과 학습 장벽이 높아졌고, 프로젝트 진행 도중 신규 팀원을 영입을 고려했다가 보류하게 되었다.

(2) 인증 방식의 선택 (참고 문서)

서비스를 분리하면서 가장 먼저 마주하게 된 고민은 사용자 인증 처리를 어떻게 할지에 대한 것이었다. 고려했던 방식에는 크게 두 가지가 있었다.

중앙집중형분산형
설명게이트웨이에서 인증을 집중 처리하고, 이후 서비스에 요청과 함께 인증 정보를 전달각 서비스에서 독립적으로 토큰을 검증
장점인증 로직이 한 곳에 모여 있어 관리가 쉽고, 일관된 보안 정책 적용이 가능인증 부하 분산. 한 서비스에 오류가 발생해도 다른 서비스로 전파되지 않음
단점게이트웨이에 부하가 집중되고, SPOF로 작용각 서비스 별 인증 로직 중복이 발생. 일관성 있는 보안 정책 적용이 어려움

결제나 민감한 개인 정보를 다루지 않는 프로젝트의 특성상 높은 수준의 보안 정책이 필수적이지는 않을 것이라 판단했기에, 게이트웨이 단에 강력한 보안 체계를 두는 대신 각 서비스에서 독립적으로 인증을 처리하는 분산형을 선택했다.

다만 서비스별로 인증 로직이 중복되고, 일관성 있는 보안 정책 변경이 어렵다는 단점을 보완하기 위해, 인증 로직을 별도의 모듈로 분리, 각 서비스에 공통적으로 포함해 사용하는 방식으로 변경했다.

JWT 신뢰

이전에 진행했던 프로젝트들에서는 JWT를 완전히 신뢰하기보다는, JWT에서 사용자 정보를 추출한 뒤 DB 조회를 통한 권한 확인 과정을 거쳤다.

이 방식을 유지하는 경우 다음과 같은 단점들이 있었다.

  1. JWT의 장점인 무상태성을 살리지 못함.
  2. . 각 서비스에서 인증 서버에 권한 확인 요청해야하므로
    • 인증 서버가 SPOF로 작용
    • 불필요한 네트워크 I/O도 발생

따라서 이번 프로젝트에서 인증 서버는 토큰 발급 및 갱신 역할만을 담당하게 하고, JWT를 신뢰하고 토큰 내에 사용자 ID와 권한 정보 등을 포함시킴으로써 개별 서비스에서 독립적으로 인증을 수행하도록 했다.

게이트웨이 선택

분리된 서비스들로 API 요청을 보내기 위해서는 Spring Cloud Gateway 등을 사용하기보다 간단하게 Nginx의 리버스 프록시 기능을 사용하는 편을 선택했다.

  • 각 서비스가 단일 인스턴스로 동작하며,
  • 고급 라우팅 기능이 불필요하고,
  • 각 서비스에서 개별 인증을 수행하므로 중앙 집중형 필터 체인 기능을 사용하지 않으며,
  • Spring Cloud Gateway 사용 경험 부재로 인한 학습 부담이 있음.

(3) 서비스 간 커뮤니케이션

서비스 간 커뮤니케이션을 위한 도구로는 다음의 두 가지를 사용했다..

  1. gRPC: 즉시 응답이 필요한 경우
  2. 카프카: 부가적인 작업 및 후속 처리. 즉시 응답이 필요하지는 않은 경우

회원가입 시 인증 담당 객체를 만들고 유저 리소스를 만드는 작업이나, 각 포스트를 불러올 때 사용자 프로필을 불러오는 등, 다른 서비스로부터 즉시 데이터를 받아와야하는 경우에는 gRPC를 사용했고, 이외의 경우 카프카를 이용했다.

Q. 왜 REST API 대신 gRPC?

처음에는 서비스 간 커뮤니케이션을 위해서 REST API 사용을 검토했으나, 다음의 이유로 REST API 대신 gRPC를 사용했다.

  1. 모놀리식 프로젝트를 분리하는 과정이므로, 이미 각 서비스가 공통 스펙을 공유함
  2. REST API를 사용하는 경우 각 서비스마다 DTO를 정의하고 관리하는 데 일관성 문제가 발생할 수 있으나, gRPC를 사용하면 하나의 모듈로 중앙에서 정의할 수 있음

보상 트랜잭션

대부분의 업데이트는 하나의 서비스 내에서 이루어졌기에 분산 트랜잭션을 크게 신경 쓸 필요가 없었지만, 유일하게 회원가입의 경우 두 개의 서비스를 거쳐야만 했다.

프로젝트에서는 사용자 정보를 인증 주체로서의 사용자(auth)와 사용자 리소스(user)로 구분하고 있으며, 회원가입은 인증 주체가 아니라 사용자 리소스를 생성하는 과정으로 판단하여, 회원가입 API 엔드포인트를 유저 서비스에 두었다.

이 때 회원가입 처리 흐름은 다음과 같은데,

  1. 유저 서비스로 회원가입 요청
  2. auth : 회원가입용 임시토큰 검증 및 사용자 ID 및 인증 객체 생성
  3. user : 인증 객체와 동일한 ID로 사용자 리소스 생성

이때 3번에 실패하는 경우, auth 서비스에는 인증 객체가 남아있고, user 서비스에는 사용자 리소스가 생성되지 않아 부정합이 발생한다. 이를 처리하기 위해 사용자 리소스 생성에 실패하는 경우, 이벤트를 발행해 auth 서비스를 통해 인증 객체까지 삭제하도록 했다.

아웃박스 패턴 적용

초기 프로젝트에서는 주 관심사와 부작용을 디커플링하거나, 이종 데이터소스를 저장하는 용도로만 이벤트를 사용했다. 이때는 동기적으로, @TransactionalEventListener를 사용해 트랜잭션 커밋 이후 이벤트를 발행하도록 했기에, 트랜잭션과 이벤트 발행 간의 일관성을 확보할 수 있었다.

하지만 서비스가 분리되고 카프카를 도입하면서 트랜잭션과 이벤트 발행 간의 일관성을 유지하기가 어려워졌다. 이를 해결하기 위해 고려했던 방식에는 다음의 세 가지가 있었는데,

  1. @Transactional 메서드 내에서 이벤트 발행 사용
    • 트랜잭션 커밋에 실패하더라도 이벤트 발행이 될 수 있음
  2. @Transactional 메서드를 따로 두고, 커밋 후 이벤트를 발행
    • 트랜잭션 커밋에 성공하더라도 이벤트 발행에는 성공할 수 있음
  3. 2PC-like하게 이전 상태를 저장해두고, 커밋 후 이벤트 발행. 실패 시 이전 상태로 롤백
    • 어떤 종류의 작업인지에 따라 처리 방식이 달라지고 유지 보수성이 떨어짐

결론적으로 세 방식 모두 트랜잭션과 이벤트 발행을 완전히 원자적으로 묶는 것이 어렵다 판단되어, 아웃박스 패턴을 도입해 도메인 객체에서 발행한 이벤트를 DB에 JSON 형식으로 저장, 주기적인 폴링을 통해 미발행 이벤트를 발행하는 방식을 선택했다.

이 기능 또한 여러 서비스에서 반복적으로 나타나기 때문에, 별도의 모듈로 묶어 인터페이스와 기본 구현체를 제공하도록 했다. (이벤트 메시징 시스템)

남은 문제 (1) 5초 주기로 100개의 이벤트를 폴링하는 방식이기에, 이벤트 체인이 길어질 경우 눈에 띄는 지연이 발생할 수 있다. Debezium 같은 CDC를 써야하나 고민을 하기는 했지만, 전환 비용이 상당할 것이라 예상되어 보류.

남은 문제 (2) 현재 방식에서는 DB 조회→발행→DB에 변경된 상태 반영의 순서로 작업이 이루어지고 있다. 이떄 발행 성공 후 상태 갱신이 제대로 되지 않는 경우, 중복 발행이 일어날 수 있다는 문제가 남아있고, 메시지 멱등성 보장을 위한 처리가 필요.

(4) 스펙 변경

Netty + Coroutine + R2DBC로의 전환

단일 인스턴스에서 다수의 스프링 애플리케이션을 구동하는 구조에서 톰캣 기반의 애플리케이션은 그만큼 많은 스레드를 생성해 불필요한 메모리·컨텍스트 스위칭 오버헤드 발생 가능성이 있을 것이라 판단해, Netty와 Coroutine을 사용하는 방향으로 전환했다.

이때 JPA 또한 JDBC에 바탕하므로 블로킹으로 동작하고, 이 부분이 병목이 될 것을 우려해 R2DBC로 전환했고, 드라이버 안정성 문제에 따라 RDB 또한 MySQL에서 PostgreSQL로 변경했다.

GraphQL 도입 (참고 문서)

블로그 포스트의 경우 다양한 형태로 사용자에게 제공될 수 있다..

예시

  • 리스트 조회에서는 간단한 메타데이터들만을 제공해도 됨
  • 상세 조회에서는 본문, 폴더 Breadcrumb 등을 포함한 전체 데이터를 조합해 제공해야 함

이때 매번 포맷에 따라 응답용 DTO와 엔드포인트를 만드는 건 유연하지 않고, 항상 전체 데이터를 내보내고, 프론트엔드에서 필요한 것들을 선택적으로 사용하는 방법의 경우 글 본문이 그 외 모든 메타데이터들보다 크기가 훨씬 클 가능성이 높기에 비효율적이라 생각했다.

이에 따라 프론트엔드에서는 필요한 데이터만을 선택적으로 요청할 수 있게 하고, 백엔드에서도 요청된 필드만을 선택적으로 제공할 수 있게 하고자 GraphQL을 도입했다.

infra-database-query 모듈
GraphQL을 통해 단일 엔드 포인트로 필요한 필드를 요청받을 수 있게 되었지만, 마찬가지로,어떻게 DB에서 데이터를 가져올지를 다뤄야 했다. 고려한 방식에는 두 가지가 있었는데,

  1. 모든 데이터를 가져오고 요청 받은대로 조합
  2. 요청받은 것만 선택적으로 조회

1번의 경우 구현이 쉽고 안정적이지만, 불필요하게 큰 필드(글 본문)를 함께 조회해야 하거나, 여러 번의 DB I/O가 발생할 수 있어 비효율적이라 판단했다. 따라서 2번, 선택적으로 필요한 것들만을 조회/요청하는 방식을 선택했는데, R2DBC를 사용하게 되다 보니 다음과 같은 번거로움이 있었다.

  1. GraphQL로 요청된 필드와 실제 DB 컬럼 간의 매핑이 필요
  2. 필요한 컬럼만을 선택해 쿼리를 보낼 수 있어야 하지만, 매번 SQL을 직접 작성하기는 번거로움
  3. 쿼리 결과의 필드-컬럼을 매번 수동으로 매핑하는 방식은 유지보수에 취약하고 비효율적임

위와 같은 문제를 해결하기 위해 부분 쿼리 지원을 위한 클래스들을 만들고 별도 모듈로 분리했다.

트레이드오프
반복되는 로직이 줄었고, 선택적 데이터 페칭이 가능해짐에 따라 유연성이 좋아졌다. 하지만 선택적으로 컬럼을 가져오기 위해 DTO의 모든 필드를 nullable하게 두면서, 코틀린의 null-safety를 살리지 못하게 됐고, 각 유즈케이스마다 어떤 컬럼이 내부 로직 처리에 필요한지 더 신경을 써줘야 하게 됨에 따라 개발 부담이 증가했다.

Neo4j 삭제

초기 프로젝트에서는 계층적 데이터 탐색을 위해 Neo4j를 도입하고, 이벤트를 통해 RDB와 Neo4j에 데이터를 중복 저장하도록 했다.

Neo4j 그래프 DB를 사용했을 때 얻었던 이점에는 다음과 같은 것들이 있었다.

  1. 디렉토리 구조 표현에 용이
    • 폴더 생성, 삭제, 이동, 폴더명 변경 등의 CUD 작업을 단일 노드 이동만으로도 cascade 처리할 수 있어 구현이 단순함
  2. 글의 변경 이력 관리
    • 초기 프로젝트의 경우 포스트의 쓰기 읽기 작업이 하나의 애플리케이션에서 이루어졌기에, 매번 diff를 조합해 본문을 재구성하는 경우 읽기 작업에서의 오버헤드가 발생할 수 있음.
    • 주기적으로 글의 스냅샷 버전을 저장하고, 본문 재구성 시 가장 가까운 스냅샷 버전을 찾아 원하는 버전까지의 diff 체인을 만듦으로써 경로 길이를 줄이도록 했고, Neo4j를 사용해 RDB 재귀쿼리 없이도 빠른 탐색이 가능하도록 함.

하지만 프로젝트를 진행하면서 다음과 같은 문제들이 떠올랐다.

  1. RDB와 Neo4j 간의 일관성 유지가 어려움
    • 주기적 폴링 방식의 아웃박스 패턴을 사용하면서 이벤트 처리에 지연이 발생하게 되었고, 잦은 변경이 일어나는 경우 RDB와 Neo4j 간의 일관성을 유지하기 어려워졌음
  2. 장애 전파
    • RDB와 Neo4j가 동시에 실패 지점으로 작용해, 둘 중 하나에 문제가 생기면 전체 실패로 이어지게 됨

결국 Neo4j에는 정합성이 중요한, 도메인의 주요 엔티티 데이터를 담기보다는, 어느 정도 장애가 허용되는 관계 데이터(ex. 추천을 위한 관계 정보 등)를 담는 것이 적합할 것이라 판단해 Neo4j를 사용하지 않기로 했다.

대안 1: 폴더 테이블의 비정규화
프론트엔드에서는 @username/dir1/dir2/.../dirn/slug 의 꼴로 발행된 글에 대한 요청을 보내도록 하고 있다. Neo4j를 사용했을 때에는 이러한 계층 구조 탐색이 용이했으나, Neo4j를 제거하게 되면서 폴더 삭제, 변경, 이동이 일어나더라도 경로를 빠르고 일관적이게 제공할 수 있는 방안이 필요해졌다.

R2DBC로 전환하게 되었기에 JPA 식 객체 그래프 탐색는 불가능했고. RDB 재귀 쿼리 역시 성능 및 복잡도의 측면에서 적절하지 않다고 판단했기에, 다음과 같은 비정규화 전략을 채택했다.

  1. 폴더 경로 full path를 DB 컬럼에 저장
  2. 경로 변경 시, LIKE 'path%' 를 통해 해당 경로 및 하위 경로를 전체 업데이트

대안 2: 애그리거트 루트의 확대
초기에는 ‘모든 블로그 포스트는 특정 버전의 글’이라는 가정 아래, 애그리거트 루트를 또한 VersionedArticle (특정 버전의 글)로 설정했고, 이에 따라 Neo4j에서 가장 가까운 스냅샷을 찾고 diff 체인을 순서대로 적용해 본문을 재구서하는 방식을 선택했다.

스냅샷
매번 최초 버전에서 타겟 버전까지의 diff 체인을 적용하는 경우 성능 오버헤드가 있을 것이라 생각해, 스토리지를 조금 더 쓰는 대신, 읽기/롤백 성능을 확보하고자 특정 버전을 스냅샷으로 저장하도록 했다.

가까운 스냅샷을 찾은 후 타겟 버전까지의 diff 체인을 차례로 적용해 본문을 재구성하는 것은 도메인 로직으로 두되, 어떤 버전을 스냅샷으로 저장할 것인지는 최적화의 영역이라 생각되어 애플리케이션 계층에 스냅샷 저장 정책 클래스를 만들고, 해당 정책에 따라 저장 여부를 결정하도록 했다.

하지만 Neo4j를 제거하게 되면서 그래프 기반 롤백/탐색의 장점이 사라졌고, 재귀/조합 로직이 RDB/R2DBC 환경에서는 유지보수 난이도를 높였다.

이 문제를 해결하기 위해, 읽기 작업이 쓰기 작업보다 월등히 많을 것이라는 가정 아래 “발행본은 별도 서비스로 분리하고, diff 조합은 쓰기에서만 수행”하는 방향으로 전환해, 쓰기 성능을 일부 희생하고 도메인 완전성과 유지보수성을 우선하도록 했다.

(기존) VersionedArticle : 특정 버전의 글.
→ (변경 후) Article: 게시글(버전 집합)을 하나의 애그리거트로 보고, 모든 버전을 로드해 메모리 상에서 버전 트리를 유지/조합

이렇게 애그리거트 루트를 확장함으로써, VersionedArticle을 애그리거트 루트로 정의할 때 느꼈던, “하나의 게시글은 여러 버전의 집합”이라는 도메인 본질을 온전히 담아내지 못한다는 문제를 해소할 수 있게 됐다.

2. 전체 회고

이번 프로젝트를 돌아보면서 몇 가지 중요한 교훈들을 얻었다.

  • 모놀리식에서 결합도를 낮추는 것과 서비스 분리는 다르다
    처음에는 단순히 결합도를 낮추면 서비스 분리가 쉬워질 것이라 생각했지만, 실제로는 데이터 정합성, 이벤트 흐름 등 고려해야 할 사항이 훨씬 많았다. 각 도메인을 독립 서비스로 나누는 것이 간단해 보였지만, 사실은 공통 모듈, 서비스 간 통신, 운영 편의성 등 다양한 문제가 얽혀 있었다.

  • 기능 구현과 분리의 트레이드오프
    분리를 시도해보기로 결정한 후 분리 작업에 많은 시간을 투자하다 보니, 모놀리식으로 진행했다면 구현했을 기능들을 끝내 구현하지 못하게 됐고, 기술 부채가 쌓여 프로젝트를 잠정 중단하게 됐다. 학습 효과는 컸지만 프로젝트 완성도 측면에서는 아쉬움이 남았다.

  • 성능 근거 없는 성급한 최적화
    실제 성능 지표를 바탕으로 병목 구간을 확인하기보다는 “아마 이 부분이 느릴 것이다”라는 추측에 따라 스펙을 바꾸는 선택을 했고, 결과적으로 성능 최적화보다는 유지보수 복잡성을 높이는 결과가 나온 부분도 있었다.
    초기 모놀리식 프로젝트에서 K6, Grafana, Prometheus, Spring Boot Actuator를 이용해 간단한 부하테스트를 진행해보기는 했지만, SEO를 위해 적용했던 Next.js SSR이 CPU 병목으로 작용해 스프링 애플리케이션에 대한 성능 검증이 되지는 못했다.

  • 어중간한 아키텍처
    이번 프로젝트에서는 단일 인스턴스 내에서 여러 애플리케이션을 운영했다.
    처음에는 ‘각 애플리케이션을 별도의 컨테이너로 띄우되, 각각 별도의 클라우드 인스턴스로 간주해보자’고 구상했지만, 실제로는 여러 시각이 혼재되어 있었다.

    • 일부는 여전히 단일 인스턴스에서 여러 애플리케이션이 돌아간다는 관점으로 접근
    • 일부는 각 애플리케이션이 별도의 인스턴스에서 돌아간다면 괜찮을 것이라는 가정으로 접근

    그 결과 설계 과정에서 일관성이 떨어지는 부분이 있었고, 초기에 어떤 방식으로 접근할 것인지를 명확히 하는 것이 중요함을 다시 한 번 깨닫게 됐다.

0개의 댓글