본문 바로가기

프로그래밍 일반

모듈과 컴포넌트의 차이 이해하기 (정적 소스 vs 런타임)

컴포넌트와 모듈은 소스 코드를 리팩토링하고 소프트웨어 아키텍처를 설계할 때 범용적으로 많이 사용되는 용어다. 나는 이 두가지가 표면 상 비슷하여 지금까지 구분 없이 사용했다. 하지만, 최근 설계 업무에 들어가며 이 둘을 구분해 사용하는 글들을 접했다. 그래서 이번 기회에 이 둘이 어떻게 다른지 확인해보려 한다. 인터넷에서 둘의 차이를 설명한 몇 가지 글을 접했는데, 그럼에도 둘의 차이에 대한 막연함을 느꼈다. 그래서 내용을 보충해서 적었다.

 

모듈과 컴포넌트의 공통점


표면 상으로 둘은 코드를 역할, 기능, 의존성에 따라 묶은 것으로 동일해 보인다.

여기서 응집성 있는 로직들은 하나로 묶어주고, 결합도를 낮춰야 하는 로직들은 서로 분리한다.

모듈이나 컴포넌트나 기능을 추상화하고 분할하는 설계에서의 최소 단위다. 

 

아래에서는 잠시 설계에서 중요시 하는 주된 측면에 대해 생각해보자.

모듈과 컴포넌트를 이해하는 데 도움이 될 것이다.

 

1. 독립성과 명시성

잘 알려져 있듯이, 대규모 프로그램을 만드는 방법은 수많은 더 작은 프로그램들로 조립하는 것이다. 대규모 프로그램은 절대 한번에 작성되지 않는다. 만약 그렇게 된다면, 얼기설기 얽힌 복잡한 의존성과 실행 순서의 지옥 속에서 프로그램은 점점 제대로 작동하지 않게 되고, 개발자는 기능을 구현하는 것 보다 에러를 잡는 데 더 많은 시간을 보내게 된다.

 

작고 조립 가능한 프로그램이란, 각자의 작동 흐름이 서로 간에 독립적인 프로그램을 말한다. 이들은 입력과 출력을 통해서만 서로에게 의존한다. I/O 작업, 네트워크 통신, 예상치 못한 에러 등 외부의 사이드 이펙트는 철저히 격리하여 처리함으로써 예상치 못한 경우의 수를 차단한다. 이처럼 서로 간에 결합을 분리하는 독립성, 필수적인 의존성을 입력과 출력으로 드러내는 명시성은 버그의 주요 원인인 "보이지 않는 복잡성과 상태 흐름의 꼬임"을 방지한다.

 

2. 예측 가능성

좋은 설계는 내부의 흐름을 파악하지 않고도 기능의 역할과 실행 결과를 예측할 수 있다. 예측 가능성이 올라갈수록 개발자가 읽어야할 코드는 줄어들고 더 빠르게 구조를 파악할 수 있다.

 

그러기 위해 설계자는 요소와 요소 간 관계에 정형화된 연결 방식을 약속하거나 강제한다.

이는 물리적 설계 차원에서 제약을 걸 수도 있고(e.g. gateway, 방화벽) 로직 코드를 작성할 때 어떤 경우 함수로 작성할 것이냐, 클래스로 작성할 것이냐, 이름은 무엇으로 하고 그 이름은 어떤 역할, 인풋/아웃풋을 할 것이냐, 에러는 어떻게 처리할 것이냐 등 아주 광범위한 약속 사항들이다.

 

3. 확장 가능성

환경과 요구사항은 끊임없이 변하기 때문에 물리적 설계든 논리적 설계든 수시로 변경될 가능성이 있다.

적절하게 분리되어 있지 않은 프로그램은 새로운 기능을 접목하거나 반대로 빼내기가 무척 어렵다. 개발자가 생각한 것과 다른 보이지 않는 사이드 이펙트를 일으킬 수 있기 때문이다. 그래서 변경 시 파급 범위를 최소화하고, 로직 간에 연결을 재조립할 수 있는 방안이 필요하다. 내가 실무에서 느꼈던 건, 이 부분이 오버엔지니어링과 언더엔지니어링을 가르는 분기점이다.

 

SI에서 새 프로젝트를 할 때, 기능을 코드로 구현하는 단계에서 기능 위주로 빠르게 치내는 데 집중하면 나중에 무언가를 추가하거나 수정하기가 까다로워진다. 하나의 컴포넌트가 여러 가지의 책임을 들고 있거나, 서로 다른 환경에서 억지로 공통으로 돌아가는 로직을 만든다고 플래그 매개 변수로 분기처리를 엄청 한다던가 말이다. 그런 코드는 뒤에 유지보수하는 사람에게는 설계 부채가 된다. 언젠가는 누군가가 그걸 재설계하여 풀어내던지, 아니면 코드 1줄을 추가하기 위해 1000줄을 추적하는 싸움을 계속해야 한다.

(나도 알고 싶지 않았어...)

 

반대로 처음부터 모든 경우를 예상하고 설계하려 하면 처음에는 좋은 생각 갔다가도, 점점 막연하고 흐릿해지는 미래의 요구사항을 걱정하다가 생각이 멈추게 된다. 그렇게 확장된 설계를 처음부터 하다가도 무언가 하나가 틀어지면 나머지 전부가 틀어지면서 설계를 전체적으로 재조정하게 되니 엄청난 시간이 소요된다.

 

그래서 좋은 설계는 당장에 필요한 비즈니스 요구사항과 그나마 쉽게 예측 가능한 수준의 미래 요구사항(e.g. 트래픽 증가, 장애 발생, 데이터 증가, 기능 수정 및 추가) 정도만 수용할 수 있는 최소한의 설계를 하고, 나머지는 확장 가능한 형태로 남겨둔다. 여기서 확장 가능한 형태를 지금 요구 사항에 맞게 어떻게 하느냐가 경험과 능력을 보여주는 거 아닐까 생각한다.

 

4. 재사용성

로직을 직접 만들다 보면 설계에서 보이지 않던 중복된 부분을 찾을 수 있다. 이런 경우 공통의 재사용 로직으로 별도로 빼주고 추상화한다면 코드 전체를 이해하는 데 큰 도움이 된다. 재사용 로직은 처음부터 계산하기 보단 구현을 하는 과정에서 주기적으로 리팩토링할 때 만들어주는 것이 좋다.

 

모듈과 컴포넌트의 차이점


모듈이나 컴포넌트나 프로그램이 서로 얽히지 않고 잘 분리되어 돌아가도록 하기 위해 설계에서 무언가를 분할하는 방식이라는 건 동일하다. 그렇지만, 무엇을 주요 관심사로 두고 분리하였느냐의 차이다.

 

모듈과 컴포넌트는 각자 정적 소스코드, 런타임 사용자의 관점을 기준으로 삼는다.

 

1. 이론적 차이 

1) 모듈

모듈은 소스코드의 계층에서 기능을 분할하고 설계한 최소 단위다. 소스 코드라는 건 당장에 실행되는 것이 아니다. 구축된 개발 환경이나 테스트 환경 아래에서 작동한다. 소스 코드 단에서 좋은 설계를 위한 관심사는 코드 단의 컨벤션, 그리고 파일과 디렉토리의 정리다. 주요 예시는 아래와 같다.

  • FSD 아키텍처나 모듈 아키텍처럼 디렉토리 구조를 정의
  • 로직을 역할과 작동 방식에 따라 도메인을 나눠 정리
  • 파일명 컨벤션
  • 코드 컨벤션
    • 로직 네이밍
    • 입력/출력 설정

좁은 의미에서 모듈은 최소 기능 단위인 로직(함수, 클래스)이지만, 더 넓게는 로직을 담은 소스 코드를 정리한 계층까지도 볼 수 있다. 왜냐하면 설계 중에서도 정적 코드 설계의 관점과 관련 있기 때문이다. 

소스 코드의 계층까지 확대해본다면 로직을 담은 함수나 클래스 외에도, 설정 파일,  번들링 설정, 환경 변수, db 스키마 등 파일 시스템 상에 독립적인 요소라면 모듈로 볼 수 있다.

 

2) 컴포넌트

1) 런타임 관점의 기능 분할

반면 컴포넌트는 런타임에서 발생할 수 있는 유즈 케이스를 기준으로 로직을 분할한다. 설계나 구현 시점이 아니라 사용자가 실제로 상호작용하고 결과를 보는 관점에서 분할을 한다는 게 중요한 포인트다. 왜 그럴까?

유즈 케이스의 관점에서 하나의 프로그램은 다수의 애플리케이션과 모듈을 포함할 수 있기 때문이다. 예컨대, B/S 서비스(브라우저가 클라이언트 역할을 하고 주요한 작업과 데이터 저장은 서버에서 전담하는 서비스) 에서 로그인이라는 하나의 유즈케이스/기능은 다음 같이 구성된다.

  • 사용자 인터페이스(view)
    • 사용자가 인풋 창에 아이디와 패스워드를 입력함
    • 로그인 버튼을 눌러서 로그인 요청을 날림
    • 로그인 실패 시 사유를 표출함
  • 이벤트 핸들러 및 서비스 호출(controller, service)
    • 사용자가 입력한 아이디, 패스워드에서 정적인 유효성 검사를 실시함
    • 로그인 버튼 클릭 시 서버를 경유하여 db에 유저 계정 여부를 확인함
    • 서버에서 인증이 유효함을 알려주고 쿠키 등의 방식으로 토큰을 부여함
  • 모델(model)
    • db는 유저의 계정 정보를 저장함
    • 로그인 요청 시, 입력 값에 해당하는 유저의 계정 정보를 쿼리
    • 쿼리 결과에 따라 성공과 실패 여부를 전달

만약 모듈이라면 프론트엔드와 백엔드, DB 각각의 시스템 요소와 각 요소 내 분할된 독립적 로직 단위가 모듈이 될 것이지만, 컴포넌트는 하나의 유즈 케이스 아래에 이를 하나의 컴포넌트로 볼 수 있다(아래에서 보겠지만, 컴포넌트 설계서는 view, controller, model을 하나의 기능 아래에 n:1로 매칭한다)

 

물론 위에서 사용하는 의미에서 컴포넌트는 실무에서의 용어에 비해 꽤나 협소하다. 당장 리엑트만 생각해봐도 독립적으로 조립가능한 기능 단위를 컴포넌트라고 부르니까 말이다. 하지만, 그것도 생각해보면, UI, 이벤트 핸들러, 서비스 호출, 사이드 이펙트(네트워크 요청) 를 런타임 단위(로그인, 상품 조회, 프로필)로 통합한다는 점에서 일맥상통한다.

 

(2) 하위 컴포넌트

좁은 의미에서 컴포넌트는 런타임 관점에서의 하나의 유즈케이스에 매칭되는 기능 단위이지만, 넓게 보자면 한 컴포넌트 내에서 런타임에 재사용 가능한 모든 하위 단위를 컴포넌트로 본다. 예컨대, 로그인 폼 컴포넌트는 UI(인풋, 버튼, 알림 메시지 모달), 이벤트 핸들러, 서비스(입력 값 정적 유효성 검사, 유저 계정 정보 확인, 결과 응답), db로 구성되지만, 여기서 UI는 이벤트 핸들러와 묶어서 인풋 컴포넌트, 버튼 컴포넌트, 모달 컴포넌트처럼 하위 컴포넌트가 될 수 있다. 서비스도 로그인 서비스 컴포넌트처럼 입력 값, 사이드 이펙트, 반환 값을 추상화하여 재조립가능한 하위 컴포넌트가 될 수 있다.

 

(3) 하나의 기능 단위에 대해 모듈과 하위 컴포넌트는 베타적이지 않다.

여기서 모듈과 상당히 혼동되는 부분일 수도 있다. 왜냐하면 하위 컴포넌트는 런타임에서 독립적으로 작동하는 단위라고 보긴 어렵고 정적 소스 단에서 재사용 단위로 볼수도 있기 때문이다. 이는 어느정도 사실이다. 

예컨대, 사용자 로그인을 담당하는 Login 기능은 LoginForm 이라는 프론트엔드 컴포넌트는 백엔드나 db를 다른 걸로 재조립할 수 있는, 독립적으로 작동하는 단위로 볼 수 있지만, 버튼 하나하나까지는 다른 것의 구성 요소로써 사용되는 거지, 완전히 독립적이라고 보긴 어렵다.

 

여기서는 하나의 기능은 모듈인 동시에 런타임에서 어떤 역할을 가지는 하위 컴포넌트일수도 있다고 생각하면 된다. 예컨대, BlueButton.tsx 라는 프론트엔드 버튼 UI 코드는 정적 소스의 관점에서는 버튼 기능을 하는 UI, 이벤트 핸들러를 응집시킨 버튼 모듈 중 하나다. 하지만, 런타임에서는 LoginForm 컴포넌트에서는 사용자의 로그인 요청을 받고 AJAX 서비스를 호출하는 컴포넌트다. 그리고 유저 간 채팅을 하는 Chatting 컴포넌트에서는 인풋에 적은 말을 서버로 보내는 서비스를 호출하는 버튼 컴포넌트일 수 있다. 즉, 모듈은 런타임에서 특정한 맥락을 가지는 인스턴스일 때 하위 컴포넌트라고도 인식할 수 있다.

이렇게 본다면 동일한 각 컴포넌트에서 동일한 BlueButton을 쓰더라도 런타임에서 역할에 따라 다른 컴포넌트로 인식하는 것이다.

 

(4) 빌드 타임에서의 독립성

컴포넌트는 런타임을 기준으로 분할된 기능 단위이다. 그런데, 런타임에서 돌아가는 프로그램은 바이너리 산출물 혹은 런타임 환경에 맞게 빌드된 산출물이다. 하지만, 개발자는 개발 소스를 기준으로 기능을 나눈다. 따라서 런타임 내 독립적 프로그램과 개발 소스 간에는 독립적인 빌드로 연결된 관계가 있어야 한다. 즉, 컴포넌트는 독립적인 빌드를 가정할 수 있어야 한다.

예컨대 로그인 컴포넌트는 개발 소스로 보자면, 컴포넌트 소스 코드, 유틸, 타입, 런타임 의존성(리엑트, api 호출 ), 개발 의존성(웹팩, 타입스크립트) 등을 묶는 개념이다.

 

2. 작성 방식의 차이 

1) 모듈

언어 내장 모듈 시스템

소스 코드에서의 최소 단위인 모듈은 코드가 채택하고 있는 언어의 모듈 시스템을 이용하여 분리한다.

자바스크립트에서는 ES6, commonJS 같은 모듈 기능을 지원하고 npm 같은 패키지 매니저는 패키지의 버전 고정, 공개 API 설정 등 언어 자체의 모듈 기능을 지원한다. 이를 사용하면 프로그래밍적으로 모듈 간 의존 관계를 명확히 하고, 공개 API와 은폐 API를 구분하여  유지보수에 도움이 된다.

 

디자인 패턴 

하지만 언어 자체에 내장된 모듈 방식으로는 모든 필요한 규칙을 만들지 못할 수도 있어서 추가 방법이 필요하다. 예컨대 디자인 패턴을 이용할 수도 있다. barrel export 기법은 디렉토리로 복잡하게 분할된 모듈 내에서 공개 모듈과 내부 모듈(모듈 내에서만 사용하는 모듈. 유틸이나 설정 등이 해당함)을 구분하게 할 수 있다.

 

확장 플러그인

컴파일러 도구나 정적 검사 도구 등을 사용할 수도 있다. typescript나 eslint는 모듈을 import하고 export하는 데 추가적인 규칙을 강제할 수 있어서, 사람이 수동으로 신경 써야할 규칙을 자동으로 검사할 수 있다. 이런 도구들은 VSCODE 같은 IDE의 정적 검사 및 표시 기능과 연동하면 실시간으로 에러를 빨간 줄로 표시해줄 수도 있으니 개발자 경험을 향상시켜준다.

 

2) 컴포넌트

내가 다니는 SI에서는 컴포넌트 설계를 산출물로 작성한다. 이 산출물은 개발 전 과정인 "분석 => 설계 => 구현 => 시험/인수" 단계 중에서 설계에 해당한다. 

 

컴포넌트 설계를 하려면 먼저 분석 단계에서 다음 과정을 거쳐야 한다.

  • 요구사항 정의서 | 고객의 요구사항을 리스트 업 하고 카테고리를 나눈다. 그리고 각 요구사항의 실현 방안을 기술적으로 디테일하진 않은 수준에서 구상한다.
  • 유즈케이스 명세서 | 시스템에서의 주요 액터(참가자, 혹은 참가 시스템)들이 런타임에 행동할 수 있는 시나리오를 리스트 업 하고 각 케이스 별 프로그램의 배치, 작동 시나리오를 작성한다.
  • 보통은 위 두 단계에서 리스트 별로 필요한 모듈과 유즈케이스를 대략 정리한다.

그 다음 분석 산출물을 토대로 설계 단계에서 클래스 설계서를 작성한다.

  • 클래스 설계서는 클래스의 명세(상태, 메서드), 그리고 외부에서 사용할 비즈니스 로직을 추상화한 API(오퍼레이션), 각 기능에 대한 설명을 작성한다. 

이렇게 설계된 클래스를 유즈 케이스 단위로 묶어서 하나의 프로그램으로 만드는 게 컴포넌트 설계서다.

  • 여기에는 연결된 유즈 케이스 ID 및 이름, UI 컴포넌트, 서비스 클래스, DB 테이블 등의 정보가 들어간다.
  • 필요하다면 각 레이어 간에 연결 관계를 명시한다. 예컨대, UI 컴포넌트에서 서비스 클래스의 어떤 오퍼레이션을 호출하는지 말이다. 
  • 추가로 컴포넌트와 컴포넌트 간 연결을 설명하기 위해 인터페이스 명세와 계약을 작성한다.(아래 설명)

 

(1) 의존 관계 작성하기: 명세(인터페이스) & 계약

구현을 위한 컴포넌트 설계는 명세(인터페이스)와 계약으로 구성된다.

둘 다 한 기능이 어떤 기능이며 아키텍처에서 어떤 역할을 하는지, 어떤 입력에 출력을 하는지 표현하기 위해 사용하지만 작성 방식이 다르다.


명세

명세란 컴포넌트의 형식적인 타입 시그니처다.

구체적인 구현 내용 없이, 기능과 메서드의 이름, 입력, 출력, 간단한 설명(주석)으로만 표현한다.

 

계약

계약은 컴포넌트에서 타입 시그니처를 넘어, 거시적 의도와 제약을 설명한다.

인터페이스는 코드 차원의 작동을 설명하는 시그니처라서 실제 유즈 케이스에서의 의도 및 제약까지 표현하기 어렵다. 그래서 설계 단계에서 계약은 거시적인 아키텍처 이해에 중요하다.

 

예시 : 돈 송금 기능

아래처럼 송금 기능을 담당하는 컴포넌트가 있다고 가정하자 

function transferMoney(from: Account, to: Account, amount: number): boolean;

 

이 기능에서 타입 시그니처(함수명, 인자, 리턴)를 보면 대략적인 목적과 인풋/아웃풋을 예측할 수 있다.하지만 추가적인 제약사항은 알 수 없다(음수를 허용하는가, 돈 부족 시에는 어떻게 처리하는가. 언제 에러를 배출하는가)
그래서 계약을 통해 위에서 드러나지 않는 제약 사항을 기술한다. 이는 구체적으로 아래와 같다.

  • 사전 조건 - 호출 전에 보장되어야할 조건
  • 사후 조건 - 호출 후 반드시 보장되어야할 조건(리턴 값을 넘어, 내부 프로시저 혹은 상태 등을 의미)
  • 불변식 - 항상 지켜져야할 규칙(참조 무결성 등의 규칙)

명세와 계약의 관계는 db로 비유하자면, 인터페이스는 값 타입, 계약은 제약 조건에 해당한다.
인터페이스가 코드 시그니처라면, 계약은 더 고차원의 의도, 제약을 나타내며, 전자는 사용자가 지켜야할 형식적인 문법 규칙이라면 후자는 실질적으로 지켜야할 의존성을 알려주므로써 내부 구현에 신경쓰지 않게 해준다.

 

보통, 클래스 설계에서는 전자의 명세를 상세히 기술하고, 컴포넌트 설계에서는 오퍼레이션 단위로 후자에 더 초점을 맞춰 기술한다.