본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 03. 19:49

유지보수가 악몽이 되지 않는 Node.js 코드베이스 구조 설계 방법

요약

Node.js 프로젝트의 유지보수성을 높이기 위한 코드베이스 구조 설계 원칙을 다룹니다. 라우트 핸들러와 비즈니스 로직의 분리, 그리고 환경 변수 관리의 중앙화를 통해 구조적 부채를 방지하는 방법을 제안합니다.

핵심 포인트

  • 라우트 핸들러와 비즈니스 로직을 분리하여 결합도를 낮춰야 함
  • 비즈니스 로직은 서비스(Services) 계층에서 관리하여 재사용성을 확보함
  • process.env를 직접 호출하지 말고 설정을 중앙 집중화해야 함
  • 초기 설계 단계에서의 의도적인 구조적 결정이 유지보수성을 결정함

Node.js는 무엇을 어떻게 조직화해야 하는지 알려주지 않습니다. 그것은 Node.js의 가장 큰 장점일 수도 있고, 현재 당신이 겪고 있는 문제의 근원일 수도 있습니다.

Rails는 관습 (Conventions)을 제공합니다. Django도 관습을 제공합니다. Laravel도 관습을 제공합니다. Node.js는 모듈 시스템 (Module system)을 제공하며 행운을 빌어줄 뿐입니다. 첫 한 달 동안은 이것이 자유처럼 느껴집니다. 하지만 18개월이 지나, 6명의 개발자가 각자 조용히 자신만의 방식으로 일을 처리해 왔을 때, 그것은 고고학처럼 느껴집니다.

저는 변경하기가 진심으로 고통스러운 Node.js 코드베이스에서 일해 본 적이 있습니다. 그것은 그것을 만든 사람들이 형편없는 엔지니어였기 때문이 아닙니다. 그들은 훌륭했습니다. 문제는 초기에 명시적인 구조적 결정 (Structural decisions)을 내린 사람이 아무도 없었고, 코드베이스가 따라야 할 관습이 없을 때 각 개발자가 기본값으로 선택한 것들의 기록이 되어버렸기 때문입니다. 반대로 상당한 성장과 팀의 변화 속에서도 깨끗하게 유지된 코드베이스에서도 일해 보았습니다. 그 차이는 거의 언제나 재능이 아닌, 초기의 의도적인 결정에 있었습니다.

유지보수가 가능한 코드베이스들이 하는 방식은 다음과 같습니다.

라우트 핸들러 (Route Handlers) 내 비즈니스 로직 문제

이 지점이 대부분의 Node.js 프로젝트에서 구조적 부채 (Structural debt)가 쌓이기 시작하는 곳입니다.

라우트 핸들러 (Route handlers)는 편리합니다. 요청이 들어오고, 데이터를 가지고, 작업을 수행하고, 응답을 반환하면 됩니다. 왜 굳이 다른 계층 (Layer)을 추가해야 할까요? 그냥 여기서 로직을 작성하면 됩니다.

문제는 HTTP 요청이 아닌 다른 곳에서 동일한 비즈니스 로직 (Business logic)을 실행해야 하는 첫 번째 상황에서 나타납니다. 큐 컨슈머 (Queue consumer), 예약된 작업 (Scheduled job), 또는 CLI 명령 (CLI command) 같은 경우 말이죠. 갑자기 당신은 로직을 중복해서 작성하거나, 비-HTTP 컨텍스트 (Non-HTTP context)에서 라우트 핸들러를 임포트 (Import)하게 되는데, 이는 아직 이 문제로 고생해 보지 않은 사람에게 설명하기 어려운 방식으로 잘못된 방식입니다.

비즈니스 로직은 서비스 (Services)에 속해야 합니다. 이는 어떤 아키텍처적 순수성 원칙 때문이 아닙니다. API 계약 (API contracts)이 변경될 때 라우트 핸들러가 변경되고, 비즈니스 요구사항 (Business requirements)이 변경될 때 비즈니스 로직이 변경되기 때문입니다. 이 둘은 서로 다른 시점에 발생하는 서로 다른 이유입니다. 두 가지가 모두 동일한 함수 내에 존재하면, 모든 변경 시 두 계층을 동시에 이해해야만 합니다.

라우트 핸들러 (Route handlers)는 가벼워야 합니다. 요청을 받고, 파라미터를 추출하고, 서비스를 호출하고, 응답을 반환합니다. 그게 전부입니다. 라우트 핸들러가 비즈니스 규칙 (business rules)에 대해 결정을 내리기 시작하는 순간, 분리되어야 할 두 가지 요소가 결합 (coupled)된 것입니다.

모든 곳에서 process.env를 읽는 것을 멈추세요

전형적인 Node.js 코드베이스를 살펴보며 process.env에서 직접 값을 읽는 파일이 몇 개인지 세어보십시오. process.env.DATABASE_URL을 읽는 데이터베이스 파일. process.env.SMTP_HOST를 읽는 이메일 파일. process.env.JWT_SECRET을 읽는 인증 (Auth) 파일. 애플리케이션이 실제로 실행되는 데 무엇이 필요한지 파악할 수 있는 단 한 곳도 없이, 설정 (Configuration)이 30개의 파일에 흩어져 있습니다.

이는 사소해 보이지만 결코 사소하지 않은 두 가지 문제를 야기합니다.

첫째, 시작 시 검증 (startup validation) 단계가 없습니다. 애플리케이션은 실행되고 트래픽을 받아들이지만, 누락된 환경 변수 (environment variable)가 필요한 코드 경로에 첫 요청이 도달하는 순간 실패합니다. 에러 메시지는 모호하며, 실패는 스택 (stack) 깊숙한 곳에서 발생합니다. 만약 누군가 시작 시점에 확인만 했더라면 누락된 변수를 즉시 잡아낼 수 있었을 것입니다.

둘째, 애플리케이션의 설정 요구 사항에 대한 전체적인 그림을 파악할 수 없습니다. 로컬 환경을 설정하는 신입 개발자들은 애플리케이션을 실행해보고 무엇이 깨지는지 확인하면서 필요한 변수들을 찾아내야 합니다. 이는 코드베이스 규모에 비례하여 악화되는 끔찍한 온보딩 (onboarding) 경험입니다.

단 하나의 설정 모듈 (configuration module)을 만드세요. 이 모듈이 모든 환경 변수를 읽습니다. 시작 시점에 이를 검증하며, 필요한 항목이 누락되었다면 명확한 메시지와 함께 즉시 에러를 던집니다. 그리고 타입이 지정된 설정 객체 (typed configuration object)를 내보냅니다 (exports). 애플리케이션의 다른 모든 부분은 해당 모듈로부터 이를 가져옵니다 (imports).

단순합니다. 지루할 정도입니다. 하지만 운영하기 고통스러운 코드베이스에서는 일관되게 찾아볼 수 없는 방식입니다.

규모가 커질수록 기능별 폴더가 계층별 폴더보다 낫습니다

계층 기반 구조 (Layer-based structure) — 컨트롤러 (controllers), 서비스 (services), 모델 (models), 미들웨어 (middleware), 라우트 (routes)가 모두 별도의 최상위 폴더에 나뉘어 있는 구조 — 는 대부분의 Node.js 프로젝트가 시작할 때 사용하는 기본값입니다. 이는 조직화된 것처럼 느껴집니다. 같은 유형의 모든 것이 함께 모여 있기 때문입니다.

문제는 기능(feature)이 단 하나의 유형(type)에만 머물지 않는다는 점입니다. 사용자 등록(user registration) 기능은 컨트롤러(controller), 서비스(service), 모델(model), 검증기(validator), 그리고 라우트(routes)를 모두 건드립니다. 이 다섯 개의 파일이 서로 다른 다섯 개의 폴더에 흩어져 있다면, 해당 기능을 작업할 때 다섯 곳을 번갈아 가며 이동해야 함을 의미합니다. 모든 기능 변경은 여러 폴더를 오가는 작업이 됩니다. 이 파일들 사이의 연결 관계는 구조로 표현되는 것이 아니라 명명 규칙(naming)에 의해 암시될 뿐입니다.

기능 기반 조직화(Feature-based organization)는 사용자 컨트롤러, 사용자 서비스, 사용자 모델, 사용자 검증기, 그리고 사용자 라우트를 하나의 users 폴더 안에 유지합니다. 사용자 등록 작업을 한다는 것은 한 곳에서 작업한다는 것을 의미합니다. 관계는 명명 규칙(naming convention)보다는 근접성(proximity)에 의해 표현됩니다.

그렇다고 공유 폴더(shared folders)가 없어야 한다는 뜻은 아닙니다. 미들웨어(Middleware), 유틸리티(utilities), 설정(configuration), 데이터베이스 설정(database setup) 등 기능 전반에 걸쳐 진정으로 공유되는 것들은 거처가 필요합니다. 보통 shared 또는 common 폴더를 사용합니다. 여기서 중요한 절제력은 해당 폴더가 다른 곳에 명확히 속하지 않는 모든 것을 쏟아붓는 쓰레기통(dumping ground)이 되지 않도록 관리하는 것입니다. 모든 것이

레포지토리(Repository)는 서비스와 데이터 액세스(data access) 사이에 얇은 명명된 인터페이스(named interface)를 둡니다. 예: userRepository.findByEmail(email). 서비스는 이것이 Postgres, Redis, 또는 외부 API를 호출하는지 알 필요가 없습니다. 레포지토리는 알고 있지만, 서비스는 알 필요가 없습니다.

테스트 측면에서의 차이가 진짜 핵심적인 논거입니다. 레포지토리 인터페이스에 의존하는 서비스는 단순한 인메모리(in-memory) 구현체, 즉 테스트 데이터를 반환하는 동일한 메서드 이름을 가진 일반 객체(plain object)를 사용하여 테스트할 수 있습니다. 테스트가 빠르고, 데이터베이스나 외부 의존성이 필요 없습니다. 반면 Prisma를 직접 호출하는 서비스는 실제 데이터베이스를 사용하거나, Prisma 버전마다 변경되는 내부 구현을 모킹(mocking)해야 합니다. 첫 번째 방식은 안정적이지만, 두 번째 방식은 테스트로 위장한 유지보수 작업에 불과합니다.

불일치가 쌓이지 않는 에러 핸들링 (Error Handling)

의도적인 에러 핸들링(error handling) 전략이 없을 때 발생하는 상황은 다음과 같습니다.

개발자 A는 모든 곳에서 try-catch를 사용하며 에러 객체를 반환합니다. 개발자 B는 문자열을 던집니다(throw). 개발자 C는 타입 지정이 없는 Error 인스턴스를 던집니다. 개발자 D는 자신이 직접 만든 커스텀 에러 클래스를 사용합니다. 6개월이 지나면, 에러가 애플리케이션을 통해 어떻게 흐르는지 파악하기 위해 컨벤션(convention)을 이해하는 대신 모든 함수를 일일이 읽어야만 합니다.

확장 가능한 전략은 다음과 같습니다: 비즈니스 로직은 타입이 지정된 커스텀 에러(custom error)를 던집니다. ValidationError, NotFoundError, AuthorizationError와 같은 것들입니다. 이들은 상태 코드(status code)와 사용자용 메시지를 포함하는 기본 AppError 클래스를 상속받습니다. 서비스는 문제가 발생했을 때 이 에러들을 던집니다. 라우트 핸들러(Route handler)는 이를 잡지(catch) 않고, 에러 타입을 HTTP 응답으로 매핑하는 중앙 집중식 에러 핸들링 미들웨어(error handling middleware)로 전파(propagate)합니다.

단 한 곳에서 모든 에러-상태 코드 매핑을 관리합니다. 새로운 에러 타입을 추가한다는 것은 커스텀 에러 클래스에 추가하고 중앙 미들웨어에 핸들러를 추가하는 것을 의미합니다. 이는 새로운 에러를 만날 수 있는 모든 라우트 핸들러를 찾아 각각 업데이트해야 함을 의미하지 않습니다.

unhandledRejectionuncaughtException 프로세스 핸들러는 한 번만 설정됩니다. 이 핸들러들은 전체 에러, 스택 트레이스 (stack trace), 컨텍스트 (context) 등 유용한 모든 정보를 로그로 남긴 후, 프로세스를 우아하게 종료 (graceful shutdown)합니다. 조용히 에러를 삼키는 것도 아니고, 로그 없이 충돌하는 것도 아닙니다. 로그를 남기고 종료함으로써 프로세스 매니저 (process manager)가 깔끔하게 재시작할 수 있도록 합니다.

사람들이 실제로 작성하는 테스트

Node.js 프로젝트에서 테스트 작성이 어려워지면 테스트 커버리지 (test coverage)는 저하됩니다.

이는 당연한 소리처럼 들립니다. 하지만 그 함의는 덜 명확합니다. 사람들이 테스트를 작성하느냐의 여부는 팀의 의지보다는 코드 구조가 어떻게 설계되었는지에 따라 크게 좌우된다는 점입니다. HTTP 및 데이터베이스 레이어 (database layer)로부터 분리된 서비스 (services)는 유닛 테스트 (unit test)를 수행하기가 진정으로 쉽습니다. 입력을 전달하고, 출력을 단언 (assert)하며, 단 다섯 줄의 코드로 레포지토리 (repository)를 모킹 (mock)할 수 있습니다. 서비스의 얇은 래퍼 (thin wrapper) 역할을 하는 라우트 핸들러 (route handlers)는 HTTP 레벨에서 통합 테스트 (integration test)를 수행하기 쉽습니다. 각 레이어는 독립적으로 테스트됩니다.

비즈니스 로직 (business logic)이 라우트 핸들러에 거주할 때, 테스트를 하려면 HTTP 레이어와 비즈니스 규칙을 동시에 이해해야 합니다. 테스트 설정이 더 복잡해집니다. 어느 한 레이어만 변경되어도 테스트가 깨집니다. 결국 사람들은 테스트 작성을 중단하게 됩니다.

유지되는 테스트 전략은 다음과 같습니다: 서비스 로직을 위한 유닛 테스트, 테스트용 데이터베이스를 사용하는 API 엔드포인트 (endpoints)를 위한 통합 테스트, 그리고 그 사이에는 아무것도 두지 않는 것입니다. 단순히 서비스로 위임만 하는 라우트 핸들러를 위한 유닛 테스트는 만들지 마십시오. 얇은 라우트 핸들러에는 유닛 테스트를 할 가치가 있는 로직이 없습니다. HTTP를 통해 비즈니스 규칙을 테스트하려는 통합 테스트도 만들지 마십시오. 그것은 서비스 유닛 테스트가 담당해야 할 역할입니다.

구조가 테스트를 작성하지 않는 것보다 작성하는 것을 더 쉽게 만들 때, 커버리지는 유지됩니다.

채용 시의 모습

구조 (structure)는 Node.js 후보자와 대화할 때 가장 유용한 주제 중 하나이지만, 가장 많이 건너뛰는 주제이기도 합니다.

후보자에게 그들이 작업했던 Node.js 프로젝트와 그것이 어떻게 구성되었는지 설명해 달라고 요청하십시오. 지금 다시 한다면 무엇을 다르게 하겠습니까? 잘못된 구조의 코드베이스에서 작업해 본 적이 있는지, 무엇이 어려웠으며 무엇을 변경했는지 물어보십시오. 비즈니스 로직이 어디에 위치해야 하는지 어떻게 결정하는지도 물어보십시오.

잘못된 구조의 결과물을 직접 겪어본 개발자들은 확고한 의견을 가지고 있습니다. 그들은 테스트가 불가능한 라우트 핸들러 (route handlers)를 경험해 보았고, 30개의 파일에 흩어져 있는 설정 (configuration)을 경험해 보았습니다. 또한 기능 하나를 변경하기 위해 7개의 폴더를 수정해야 하는 순간을 경험해 보았습니다. 이러한 경험들은 구체적인 답변을 만들어냅니다.

반면, 이를 경험하지 못한 개발자들은 코드가 어떻게 조직되었는지보다는 무엇을 만들었는지에 대해 설명하는 경향이 있습니다.

장기적으로 프로덕션 코드베이스 (production codebase)를 책임질 Node.js 개발자를 채용할 때, 구조적 판단력은 1년 뒤에 이 코드베이스가 작업하기 쉬운 곳이 될지 아니면 어려운 곳이 될지를 결정하는 요소입니다. 기능은 어떻게든 구축되기 마련입니다. 문제는 그 기능들이 일관성 있는 무언가로 축적될 것인지, 아니면 고고학적 유물처럼 복잡하게 얽힌 무언가로 변할 것인지의 문제입니다.

Hyperlink InfoSystem은 바로 이 점을 위해 Node.js 개발자를 전문적으로 선별합니다. 단순히 기술적인 능력뿐만 아니라, 구조적 본능을 만들어내는 프로덕션 경험을 검증합니다. 팀이 향후 3년 동안 머물게 될 코드베이스는 초기 3개월 동안 내려진 결정들에 의해 형성됩니다. 적합한 엔지니어를 초기에 확보하는 것이 가장 중요한 시점입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0