[백엔드 리팩토링] 백엔드 코드 리팩토링 : 책임 분리

Administrator||조회수 58


#1. 백엔드 리팩토링: 비대해진 라우터 파일을 3계층 아키텍처로 분리하기

서론

프로젝트의 백엔드는 MVP 개발부터 현재까지 Hono 프레임워크 기반의 서버리스 아키텍처(AWS Lambda, DynamoDB)로 진행해왔다. 초기에는 단일 람다 함수에서 모든 API 라우팅을 처리하는 구조로 충분했다. 트래픽이 거의 없고 기능이 단순했기 때문에 개발 속도 측면에서 효율적인 선택이었다.

하지만 MVP개발이 끝난 후 프로젝트가 성장하며, 게시물에 대한 CRUD 외 추천 기능, 좋아요, AI 요약 등 다양한 기능이 추가되면서 특정 파일의 복잡도가 급격히 증가하는 문제에 직면했다. 특히 핵심 도메인인 posts.router.ts 파일의 코드 라인이 1000줄에 육박하게 되었다. 이는 코드의 가독성을 저해하고 유지 보수를 어렵게 만드는 명백한 신호였다.

따라서 현재의 구조를 진단하고, 장기적인 관점에서 프로젝트의 확장성과 안정성을 확보하기 위해 아키텍처 리팩토링을 계획하고 실행 과정을 기록하기로 한다.

alt text

문제 진단: 하나의 파일에 집중된 책임

💡 단일 책임 원칙(Single Responsibility Principle, SRP)란?

단일 책임 원칙은 하나의 클래스는 하나의 책임만 가져야 한다는 객체 지향 설계 원칙입니다. 이는 각 클래스가 하나의 기능만을 담당하도록 하여 코드의 유지 보수성과 가독성을 높이고, 코드 변경이 다른 부분에 미치는 영향을 최소화하며, 테스트 용이성을 향상시키는 것을 목표로 합니다.

posts.router.ts 파일이 비대해진 근본적인 원인은 단일 책임 원칙 이 지켜지지 않았기 때문이다. 현재 이 파일은 다음과 같은 너무 많은 책임을 동시에 수행하고 있다.

  1. 라우팅(Routing): GET /posts, POST /posts/:postId/like 등 HTTP 요청 경로를 정의하고 해당 요청을 처리하는 핸들러를 연결한다.
  2. 요청/응답 처리: HTTP 요청의 파라미터, 쿼리, 본문을 파싱하고 유효성을 검증(zod)하며, 처리 결과를 HTTP 응답(JSON)으로 반환한다.
  3. 비즈니스 로직 수행: 게시물 생성 시 요약(summary)과 썸네일(thumbnail)을 추출하거나, 게시물 목록 조회 시 각 게시물의 댓글 수를 집계하는 등 애플리케이션의 핵심 규칙을 처리한다.
  4. 데이터 접근: DynamoDB의 GetCommand, QueryCommand, UpdateCommand 등을 직접 생성하고 ddbDocClient를 통해 실행하여 데이터베이스와 통신한다.
  5. 외부 서비스 연동: S3의 이미지를 삭제하거나, Bedrock AI 모델을 호출하여 텍스트 요약을 생성한다.

이처럼 여러 계층의 책임이 하나의 파일에 혼재하면서 다음과 같은 구체적인 문제점이 발생했다.

  • 가독성 및 유지보수성 저하: 하나의 핸들러 함수가 100줄을 넘어가면서 전체 로직을 파악하기 어려워졌다. 간단한 기능을 수정하려고 해도 수많은 컨텍스트를 이해해야 했고, 이는 잠재적인 버그 발생 가능성을 높였다.
  • 코드 중복: 관리자와 일반 사용자를 구분하여 DynamoDB 쿼리의 FilterExpression을 다르게 적용하는 로직이 여러 GET 핸들러에서 반복적으로 나타났다.
  • 성능 병목 식별의 어려움: GET / 핸들러에서는 게시물 목록을 조회한 후, Promise.all 내부에서 각 게시물의 댓글 수를 다시 조회하는 N+1 쿼리 문제가 발생하고 있었다. 이 로직이 다른 비즈니스 로직과 섞여 있어 문제점을 명확히 인지하고 개선하기 어려운 구조였다.
  • 테스트의 어려움: 비즈니스 로직을 단위 테스트하고 싶어도 HTTP 요청/응답 처리 및 데이터베이스 접근 코드와 강하게 결합되어 있어 순수한 로직만을 분리하여 테스트하기가 거의 불가능했다.

해결 전략: 3계층 아키텍처 도입

이러한 문제들을 해결하기 위해 라우터(Router) - 서비스(Service) - 리포지토리(Repository) 로 책임을 명확하게 나누는 3계층 아키텍처를 도입하기로 결정했다.

[Client] <-> [Router] <-> [Service] <-> [Repository] <-> [Database]

각 계층의 역할은 다음과 같이 정의한다.

  1. Router (Controller) Layer (*.router.ts)

    • 책임: 오직 HTTP 와의 소통을 담당한다.
    • 역할: HTTP 요청을 받고 유효성을 검증한 후, 서비스 계층으로 작업을 위임하고 그 결과를 받아 클라이언트에 반환한다. 비즈니스 로직이나 데이터베이스에 대해서는 알지 못한다.
  2. Service Layer (*.service.ts)

    • 책임: 애플리케이션의 핵심 비즈니스 로직을 총괄한다.
    • 역할: 하나의 비즈니스 요구사항을 완수하기 위해 여러 리포지토리를 호출하여 데이터를 가공하고 비즈니스 규칙을 적용한다.
  3. Repository Layer (*.repository.ts)

    • 책임: 데이터 영속성(Persistence), 즉 데이터베이스와의 통신만을 전담한다.
    • 역할: DynamoDB SDK를 사용하여 CRUD를 수행하는 함수를 제공한다. 서비스 계층은 복잡한 DynamoDB 쿼리 구조를 알 필요가 없어진다.

현재 아키텍처 진단 및 개선 방향

3계층 아키텍처 도입을 결정했지만, 그렇다면 현재 구조는 무엇이라고 정의할 수 있을까? 현재 나의 백엔드 구조는 '계층 없는(Layerless) 아키텍처' 또는 '암묵적인 2계층 구조' 에 가깝다.

  • 라우터(Router) 계층: Hono의 라우터가 HTTP 요청을 처리하는 명시적인 계층.
  • 데이터(Data) 계층: DynamoDB가 데이터를 저장하는 물리적인 계층.

문제는 이 두 계층 사이를 연결하는 비즈니스 로직(Service) 계층이 라우터 안에 뒤섞여 있다는 점이다. 즉, 코드의 논리적 흐름이 아래와 같다.

alt text

이 구조를 리팩토링을 통해 각 계층의 책임이 명확히 분리된 3계층 구조로 전환하는 것이 이번 프로젝트의 핵심 목표이다.

alt text

인프라 변경 없는 논리적 구조 개선

여기서 중요한 점은 이번 리팩토링이 코드의 논리적 구조(Logical Structure) 를 개선하는 것이지, 인프라의 물리적 구조(Physical Structure) 를 변경하는 것이 아니라는 점이다.

배포 관점에서 보면, 분리된 모든 TypeScript 파일들은 빌드 시점에 결국 하나의 JavaScript 번들 파일로 컴파일되어 단일 AWS Lambda 함수에서 실행된다. 즉, "Lambda Monolith" 또는 "Monolambda"라 불리는 현재의 효율적인 인프라 구조는 그대로 유지된다.

이 접근 방식은 현재 트래픽 수준에서 단일 람다가 주는 관리의 용이성과 비용 효율성을 그대로 누리면서, 코드 품질과 확장성은 마이크로서비스 수준으로 향상시키는 최적의 전략이다.

아키텍처 변경 전후 비교

아키텍처 변경으로 인해 코드의 어떤 부분이 어떻게 달라지는지 표로 정리하면 다음과 같다.

구분변경 전 (Layerless Architecture)변경 후 (3-Layer Architecture)
핵심 파일posts.router.tsposts.router.ts, posts.service.ts, posts.repository.ts
책임 소재posts.router.ts에 모든 책임이 집중각 파일이 라우팅, 비즈니스 로직, 데이터 접근의 명확한 책임을 가짐
데이터 흐름Router가 직접 DynamoDB와 통신Router -> Service -> Repository -> DynamoDB
코드 재사용성낮음 (유사 로직 복사/붙여넣기)높음 (Service, Repository의 함수 재사용)
의존성라우터가 비즈니스 로직과 DB에 강하게 의존[의존성 역전] 라우터는 서비스에만, 서비스는 리포지토리에만 의존
테스트엔드포인트 통합 테스트만 가능단위 테스트 (Service, Repository) 및 통합 테스트 모두 가능
예시: 게시물 생성POST / 핸들러가 summary 생성, ddbDocClient.send(new BatchWriteCommand(...)) 직접 실행RouterService 호출 -> Servicesummary 생성 -> RepositoryBatchWriteCommand 실행

이러한 구조적 변화는 단순히 파일을 나누는 것 이상의 의미를 가진다. 각 코드가 있어야 할 위치를 찾아가면서 전체 시스템의 예측 가능성과 안정성이 크게 향상된다.

단계별 실행 계획

리팩토링은 한 번에 모든 것을 바꾸는 대신, 안정성을 최우선으로 고려하여 점진적으로 진행한다.

  • 1단계: 리팩토링을 위한 기반 환경 구성: 실제 로직을 이전하기 전에 repositories 폴더와 posts.service.ts, posts.repository.ts 파일을 생성하여 기본적인 구조를 마련한다.
  • 2단계: 데이터 조회(Read) 로직 분리: 데이터 변경이 없는 GET 요청부터 분리를 시작한다. GET /:postId, GET / 등 복잡도가 높은 읽기 로직을 먼저 분리하여 리팩토링의 효과를 빠르게 검증한다.
  • 3단계: 데이터 변경(Write) 로직 분리: 데이터의 생성, 수정, 삭제를 담당하는 POST, PUT, DELETE 요청 로직을 분리한다. 데이터 정합성이 중요한 만큼, 각 핸들러를 신중하게 하나씩 전환한다.
  • 4단계: 주변 기능 분리 및 고도화: posts와 직접 관련이 적었던 좋아요(likes), AI 요약(summary) 기능도 각각의 서비스 파일로 완전히 분리하여 posts.router.ts를 최대한 가볍게 만든다.

리팩토링을 통한 코드 최적화 효과

이번에 계획한 리팩토링의 기대 효과는 단순히 '코드가 깔끔해진다'는 추상적인 수준에 머무르지 않고, 유지 보수성, 확장성, 성능이라는 세 가지 구체적인 관점에서 코드가 어떻게 최적화될 수 있는지 구체적인 예시를 통해 깊이 있게 살펴봤다.

1. 유지보수성 최적화: 변경의 비용을 낮추는 것

소프트웨어는 끊임없이 변화한다. 유지보수성이란 이러한 변화의 요구사항에 얼마나 빠르고 안전하게 대응할 수 있는지를 의미한다.

  • 변경 전 시나리오: "게시물 제목(title)의 최대 길이를 100자에서 120자로 변경"해야 한다는 요구사항이 발생했다고 가정해보자. 현재 구조에서는 z.string().max(100)가 정의된 CreatePostSchemaUpdatePostSchema를 1000줄에 달하는 posts.router.ts 파일 내에서 직접 찾아 수정해야 했다. 지금은 위치를 기억하지만, 프로젝트가 더 복잡해지면 이 간단한 작업조차 위험 부담을 갖게 된다.

  • 변경 후 기대 모습: 리팩토링 과정에서 Zod 스키마들을 별도의 lib/schemas/post.schema.ts 파일로 분리할 수 있다. 이렇게 하면 게시물 데이터의 유효성 검증 규칙이 모두 한곳에 모이게 된다. 이제 "게시물 정책 변경"은 post.schema.ts 파일 하나만 수정하면 되는 명확하고 예측 가능한 작업이 된다. 변경 지점이 명확하게 격리되므로, 다른 로직에 미칠 영향(사이드 이펙트)에 대한 걱정 없이 코드를 수정할 수 있다. 이것이 유지보수성 최적화의 핵심이다.

2. 확장성 최적화: 새로운 기능을 쉽게 추가하는 것

확장성은 새로운 기능 요구사항에 기존 코드를 얼마나 잘 재사용하여 대응할 수 있는지에 대한 척도이다.

  • 변경 전 시나리오: "특정 사용자가 작성한 모든 게시물 목록을 보여주는 API (GET /users/:userId/posts)"를 추가해야 한다고 가정해보자. 현재 구조에서는 users.router.ts에 새로운 핸들러를 만들고, posts.router.ts에 있는 게시물 목록 조회 로직(권한 필터링, 페이지네이션, 데이터 보강 등)을 거의 그대로 복사-붙여넣기 해야 할 가능성이 높다. 이는 코드 중복을 야기하고, 향후 목록 조회 로직 변경 시 두 군데를 모두 수정해야 하는 문제를 낳는다.

  • 변경 후 기대 모습: 리팩토링 후에는 posts.repository.tsfindAll(options)이라는 유연한 함수가 존재하게 된다. 이 함수에 authorId를 조회 조건으로 추가하는 것은 매우 간단한 일이다. users.service.ts라는 새로운 서비스 파일은 이 posts.repository.ts의 함수를 재사용하여 손쉽게 원하는 데이터를 얻을 수 있다.

    // users.service.ts (신규 파일 예시) import { postsRepository } from '../repositories/posts.repository'; async function getPostsByAuthor(authorId: string, options) { // 기존에 만들어 둔 postsRepository의 함수를 '재사용' return postsRepository.findAll({ ...options, authorId }); }

    이처럼 기존 코드를 복사하는 대신 재사용함으로써, 코드 중복을 피하고 새로운 기능을 매우 빠르고 안전하게 추가할 수 있다. 이것이 확장성 최적화이다.

3. 성능 최적화: 응답 시간을 줄이고 비용을 절감하는 것

좋은 구조는 성능 병목 지점을 명확하게 드러내고, 개선을 위한 실마리를 제공한다.

  • 변경 전 시나리오: GET /:postId 핸들러는 게시물 상세 정보를 가져온 뒤, 이전/다음 글을 찾기 위해 모든 게시물 목록을 DB에서 다시 조회한다. 게시물이 1,000개라면, 단일 게시물 하나를 보기 위해 불필요하게 1,000개 게시물의 메타데이터를 읽어오는 심각한 비효율이 발생하고 있었다. 이는 사용자 응답 시간을 지연시키고 불필요한 DynamoDB 읽기 비용을 발생시킨다.

  • 변경 후 기대 모습: 리팩토링 과정에서 이 로직은 posts.service.ts로 이동하고, 데이터 조회 책임은 posts.repository.ts로 위임된다. 이 과정에서 우리는 비효율을 명확히 인지하고 개선할 기회를 갖게 된다. 예를 들어, DynamoDB의 GSI(GSI3)를 활용하여 현재 게시물의 정렬 키(SK)를 기준으로 limit: 1ScanIndexForward 옵션을 조합하면, 전체를 스캔하는 대신 정확히 이전 글 1개와 다음 글 1개만 조회하는 두 번의 정밀한 쿼리를 실행할 수 있다. 이러한 성능 개선 로직은 posts.repository.ts 내에 캡슐화되고, 서비스 계층은 repository.findAdjacentPosts(currentPost)와 같이 간단하게 호출할 수 있다. 구조가 분리되었기 때문에 이러한 세밀한 성능 튜닝이 가능해진다.

결론

현재의 코드가 '잘못'되었다기보다는, 프로젝트가 성장함에 따라 자연스럽게 마주하는 상황으로 받아들이고 있다. 결론적으로 지금이 리팩토링하기 적절한 시기라고 판단된다. 이번 백엔드 코드 수준 리팩토링 과정을 통해 더 견고하고 유연한 백엔드 시스템의 토대를 마련하고자 한다. 앞으로 위에서 제시한 실행 계획을 바탕으로, 실제 코드를 변경하는 과정을 단계별로 기록할 예정이다.


Administrator
Written by

Administrator

안녕하세요! Deep Dive! 블로그 제작자 입니다.

댓글을 불러오는 중...