Published on

명령형 프로그래밍과 선언형 프로그래밍, 무엇이 다를까?

Authors
  • avatar
    Name
    GW

개요

프로그래밍을 공부하다 보면 명령형(Imperative) 과 선언형(Declarative) 이라는 표현을 자주 접하게 됩니다.

React를 설명할 때도 "React는 선언형 UI 라이브러리다." 라는 말을 자주 사용합니다.

처음에는 두 개념의 차이를 단순하게 다음과 같이 이해할 수 있습니다.

명령형
→ 어떻게(How)

선언형
→ 무엇을(What)

틀린 설명은 아니지만 이것만으로는 한 가지 의문이 남습니다.

const doubled = numbers.map((number) => number * 2);

왜 이런 코드를 선언형에 가깝다고 이야기하는 걸까요?

결국 map 내부에서도 배열을 순회해야 하고, 컴퓨터는 어떤 방식으로든 실제 명령을 실행해야 합니다.

또 선언형 코드가 더 읽기 쉽다면 모든 코드를 선언형으로 작성하는 것이 항상 좋은 선택일까요?

이번 글에서는 단순히 How와 What의 차이를 넘어 아래 내용들을 살펴보겠습니다.

  • 명령형과 선언형 프로그래밍의 차이
  • 선언형 프로그래밍에서 추상화가 중요한 이유
  • JavaScript의 for와 map은 무엇이 다른지
  • React가 왜 선언형이라고 불리는지
  • 선언형 코드가 항상 더 좋은 것은 아닌 이유

명령형 프로그래밍이란?

명령형 프로그래밍은 원하는 결과를 만들기 위해 컴퓨터가 수행해야 할 절차를 직접 기술하는 방식입니다.

즉,

무엇을 만들어야 하는가?보다 그 결과를 어떻게 만들어야 하는가?

에 더 초점을 둡니다.

명령형 코드에서는 개발자가 프로그램의 상태를 변경하고, 반복과 분기 같은 제어 흐름을 직접 작성하는 경우가 많습니다.

예를 들어 배열의 모든 값에 2를 곱한 새로운 배열을 만들어 보겠습니다.

const numbers = [1, 2, 3, 4];
const doubled = [];

for (let i = 0; i < numbers.length; i++) {
  doubled.push(numbers[i] * 2);
}

console.log(doubled);
// [2, 4, 6, 8]

이 코드는 원하는 결과를 만들기 위한 과정을 하나씩 직접 설명합니다.

빈 배열 생성
↓
index를 0으로 초기화
↓
배열의 길이까지 반복
↓
현재 요소 조회
↓
2를 곱함
↓
결과 배열에 추가
↓
index 증가

개발자는 반복을 언제 시작하고 언제 끝낼지, 현재 요소에 어떻게 접근할지, 결과를 어디에 저장할지까지 직접 결정합니다.

즉, 명령형 프로그래밍에서는 프로그램의 실행 절차가 코드에 비교적 직접적으로 드러납니다.

*MDN - Loops and iteration

선언형 프로그래밍이란?

선언형 프로그래밍은 원하는 결과나 조건을 기술하고, 그 결과를 만들어내는 구체적인 제어 흐름은 추상화된 구현에 맡기는 방식입니다.

같은 코드를 map을 이용해 작성해 보겠습니다.

const numbers = [1, 2, 3, 4];

const doubled = numbers.map((number) => number * 2);

이 코드에는 다음과 같은 과정이 직접 드러나지 않습니다.

index 초기화
↓
반복 조건 검사
↓
현재 요소 조회
↓
결과 배열에 값 추가
↓
index 증가

대신 코드가 표현하는 것은 훨씬 단순합니다.

numbers
↓
각 값을 2배로 변환
↓
doubled

즉, "배열을 어떻게 순회할 것인가" 가 아니라 "각 요소를 2배로 변환한 배열이 필요하다." 는 의도가 코드에 더 직접적으로 나타납니다.

MDN에서도 map()을 배열의 각 요소에 callback 함수를 실행하고, 그 반환값으로 구성된 새로운 배열을 만드는 메서드로 설명합니다.

*MDN - Array.prototype.map()

How와 What의 차이

일상적인 예시로 생각하면 차이가 조금 더 쉽게 보입니다.

누군가 목적지에 가는 방법을 묻는다고 가정해 보겠습니다.

명령형 방식이라면 다음과 같이 설명할 수 있습니다.

현재 위치에서 직진한다
↓
첫 번째 사거리에서 좌회전한다
↓
200m 이동한다
↓
횡단보도를 건넌다
↓
오른쪽 건물로 들어간다

목적지에 도착하기 위해 어떤 행동을 어떤 순서로 수행해야 하는지가 모두 포함되어 있습니다.

반면 선언형 방식에서는 목적지만 표현할 수 있습니다.

목적지
→ 서울의 특정 주소

우리는 목적지만 지정하고 실제 이동 경로를 계산하는 과정은 지도나 내비게이션에 맡깁니다.

명령형

현재 위치
↓
직진
↓
좌회전
↓
횡단보도
↓
목적지
선언형

현재 위치
↓
목적지 지정 (경로 탐색은 추상화된 시스템에 위임)

여기서 선언형 프로그래밍을 이해하는 데 중요한 개념 하나가 등장합니다.

바로 추상화(Abstraction) 입니다.

선언형 프로그래밍에서 추상화가 중요한 이유

앞의 예시에서 목적지만 입력해도 길을 찾아갈 수 있는 이유는 내비게이션이 복잡한 과정을 대신 처리하기 때문입니다.

목적지 입력
↓
현재 위치 확인
↓
도로 데이터 탐색
↓
경로 계산
↓
경로 안내

사용자는 이러한 과정을 직접 구현할 필요가 없습니다.

프로그래밍에서도 비슷합니다.

numbers.map((number) => number * 2);

위 코드를 사용하는 개발자는 map이 배열을 순회하는 구체적인 제어 흐름을 매번 직접 작성할 필요가 없습니다.

개발자가 작성하는 코드
map()
↓
추상화된 Array API
→ 반복 처리
→ Callback 실행
→ 새로운 배열 생성

따라서 선언형 프로그래밍을 이해할 때 중요한 것은 단순히 How vs What 관점만이 아닙니다.

구현의 세부적인 How를 추상화하고, 사용하는 쪽에서는 더 높은 수준의 What을 표현한다.

라고 이해하는 것이 좋습니다.

다만 "선언형 프로그래밍은 항상 명령형 코드 위에서 구현된다" 고 단정할 필요는 없습니다.

중요한 것은 내부 구현이 어떤 패러다임으로 작성되었는지가 아니라, 현재 코드를 작성하는 사람이 어느 수준의 구현 세부사항을 직접 다루고 있는가입니다.

같은 기능도 어떤 추상화 수준에서 바라보느냐에 따라 더 명령형으로 표현할 수도 있고, 더 선언형으로 표현할 수도 있습니다.

map을 사용하면 무조건 선언형일까?

그렇지는 않습니다.

예를 들어 다음 코드를 살펴보겠습니다.

let total = 0;

numbers.map((number) => {
  total += number;

  return number * 2;
});

문법적으로는 map을 사용했지만 callback 내부에서 외부 상태인 total을 직접 변경하고 있습니다.

또 반환된 새로운 배열을 사용할 목적이 아니라 Side Effect만 일으키기 위해 map을 사용한다면 map이라는 API의 의도에도 잘 맞지 않습니다.

MDN에서도 반환된 배열을 사용하지 않는다면 map() 대신 forEach()나 for...of 같은 다른 방법을 사용하는 것을 고려하도록 설명합니다.

즉,

선언형 API를 사용했다고 해서 전체 코드가 자동으로 선언형이 되는 것은 아닙니다.

중요한 것은 특정 문법을 사용했는지가 아니라 문제 해결의 절차를 얼마나 직접 표현하고 있는가입니다.

따라서

numbers.map((number) => number * 2);

를 선언형에 가깝다고 보는 이유는 단순히 map이라는 메서드를 사용했기 때문이 아닙니다.

"배열을 어떻게 순회할 것인가" 라는 구현 세부사항을 직접 작성하는 대신 "각 값을 어떻게 변환할 것인가" 라는 의도를 표현하고 있기 때문입니다.

React는 왜 선언형이라고 할까?

프론트엔드에서 선언형 프로그래밍을 이해하기 좋은 대표적인 예가 React입니다.

React에서는 DOM을 어떻게 변경할지를 하나씩 명령하기보다 현재 상태에서 UI가 어떤 모습이어야 하는지를 표현합니다.

예를 들어 버튼을 누르면 로딩 상태를 보여준다고 가정해 보겠습니다.

DOM을 직접 제어한다면 다음과 같이 작성할 수 있습니다.

const button = document.querySelector('#submit-button');
const message = document.querySelector('#message');

button.addEventListener('click', () => {
  button.disabled = true;
  message.textContent = '로딩 중...';
  message.classList.add('loading');
});

개발자가 DOM에 어떤 작업을 수행해야 하는지 직접 명령합니다.

버튼 찾기
↓
이벤트 등록
↓
disabled 변경
↓
텍스트 변경
↓
class 추가

React에서는 접근 방식이 달라집니다.

const SubmitButton = () => {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <>
      <button disabled={isLoading} onClick={() => setIsLoading(true)}>
        제출
      </button>

      {isLoading && <p className="loading">로딩 중...</p>}
    </>
  );
};

여기서는 개발자가 아래와 같은 DOM 변경 절차를 직접 작성하지 않습니다.

p 태그 생성
↓
textContent 변경
↓
class 추가

대신 현재 상태가 isLoading === true 일 때 UI가 어떤 모습이어야 하는지를 JSX로 표현합니다.

State
→ UI가 어떤 모습이어야 하는지 선언
↓
React
→ 필요한 DOM 변경 수행

React 공식 문서에서도 UI를 직접 조작하는 대신 컴포넌트가 가질 수 있는 UI 상태를 설명하고, 사용자 입력에 따라 상태를 변경하는 방식을 설명합니다.

*React - Reacting to Input with State

이러한 방식이 React를 선언적 UI와 연결해서 설명하는 이유입니다.

선언형 코드는 왜 읽기 쉬운 경우가 많을까?

선언형 코드의 장점 중 하나는 구현 세부사항보다 코드의 의도가 더 직접적으로 드러날 수 있다는 점입니다.

다음 두 코드를 비교해 보겠습니다.

const result = [];

for (let i = 0; i < users.length; i++) {
  if (users[i].isActive) {
    result.push(users[i].name);
  }
}

첫 번째 코드를 이해하려면 실행 순서를 따라가야 합니다.

빈 배열 생성
↓
반복
↓
조건 확인
↓
이름 추출
↓
배열에 추가

반면 다음과 같이 작성할 수도 있습니다.

const result = users.filter((user) => user.isActive).map((user) => user.name);

이 코드에서는 데이터가 어떤 과정을 거치는지가 연산 이름에 직접 나타납니다.

users
↓
Active 상태인 사용자만 필터
↓
이름으로 변환

Martin Fowler는 filter, map, reduce 등의 연산을 연결하여 Collection을 변환하는 방식을 Collection Pipeline이라는 패턴으로 설명하기도 합니다.

Collection Pipeline은 배열 같은 데이터 묶음(Collection)을 filter, map, reduce 등의 작은 변환 단계에 차례로 통과시키는 방식입니다.

*Martin Fowler - Collection Pipeline

추상화 수준이 높아지면서 개발자는 반복문 자체보다 데이터가 어떤 과정을 거쳐 변환되는지에 집중할 수 있습니다.

그렇다면 선언형 프로그래밍이 항상 더 좋은가?

그렇지는 않습니다.

선언형 프로그래밍은 구현 세부사항을 추상화하여 코드를 간결하고 읽기 쉽게 만들 수 있지만, 추상화 역시 트레이드오프를 가질 수 있습니다.

예를 들어 다음 코드를 생각해 보겠습니다.

const result = users
  .filter((user) => user.isActive)
  .map((user) => user.name)
  .filter((name) => name.length > 2);

코드의 의도는 비교적 명확합니다.

하지만 여러 배열 연산을 연결하면 각 단계에서 새로운 배열이 만들어질 수 있습니다.

성능이 중요한 코드라면 하나의 반복문에서 모든 작업을 처리하는 방식이 더 적합할 수도 있습니다.

const result = [];

for (const user of users) {
  if (!user.isActive) {
    continue;
  }

  if (user.name.length <= 2) {
    continue;
  }

  result.push(user.name);
}

이 방식은 더 명령형이지만 한 번의 순회 안에서 처리 과정을 세밀하게 제어할 수 있습니다.

한 번의 순회
↓
활성 상태 검사
↓
이름 길이 검사
↓
필요한 값만 추가

물론 일반적인 애플리케이션에서는 이런 미세한 성능 차이보다 가독성과 유지보수성이 훨씬 중요한 경우도 많습니다.

따라서

선언형 = 좋은 코드
명령형 = 나쁜 코드

처럼 이해해서는 안 됩니다.

더 정확하게는 다음과 같이 볼 수 있습니다.

선언형
→ 구현 세부사항을 숨기고 의도를 표현하기 좋음

명령형
→ 실행 순서와 상태 변화를 세밀하게 제어하기 좋음

명령형과 선언형은 완전히 반대되는 개념일까?

실제 애플리케이션에서는 대부분 명령형과 선언형 방식을 함께 사용합니다.

예를 들어 React 컴포넌트에서는 다음과 같이 선언적으로 UI를 표현할 수 있습니다.

return isLoggedIn ? <Dashboard /> : <Login />;

React는 개발자가 선언한 UI를 바탕으로 필요한 DOM 변경을 결정하고, 그 결과를 브라우저 화면에 반영합니다.

개발자의 선언
“어떤 UI가 필요한가”
↓
React의 내부 처리
“DOM을 어떻게 변경할 것인가”
↓
브라우저 DOM
“화면에 실제로 반영된 UI”

이 다이어그램의 핵심은 다음과 같습니다.

  1. 개발자는 원하는 UI의 모습을 선언합니다.
  2. React는 그 선언을 실제 DOM 조작 명령으로 바꿉니다.
  3. 브라우저는 React가 수행한 DOM 변경을 화면에 반영합니다.

따라서 React 코드가 선언적이라는 것은 명령형 작업이 사라졌다는 뜻이 아닙니다.

개발자가 직접 하던 구체적인 DOM 조작을 React라는 추상화 계층이 대신 수행한다는 뜻입니다.

이 관점에서 보면 선언형과 명령형은 서로 완전히 대체하는 관계라기보다 서로 다른 추상화 계층에서 함께 존재할 수 있는 방식이라고 볼 수 있습니다.

우리는 낮은 수준의 반복이나 DOM 조작을 매번 직접 작성하는 대신 이를 추상화한 API를 사용하면서 더 높은 수준의 문제를 표현합니다.

낮은 추상화 수준

How가 많이 드러남
↓
명령형에 가까움
높은 추상화 수준

구현 세부사항이 숨겨짐
↓
What이 더 잘 드러남
↓
선언형에 가까움

따라서 명령형과 선언형을 이해할 때 중요한 것은

이 코드는 둘 중 무엇인가?

라고 이분법적으로 분류하는 것보다

어떤 구현 세부사항을 직접 제어하고 있고, 어떤 부분을 추상화에 맡기고 있는가?

를 생각하는 것입니다.

어떤 방식을 선택해야 할까?

실무에서는 어느 하나를 고집하기보다 상황에 맞게 선택하는 것이 중요합니다.

일반적으로 비즈니스 로직이나 UI처럼 코드의 의도를 명확하게 전달하는 것이 중요한 영역에서는 선언적인 표현이 유지보수에 유리한 경우가 많습니다.

반대로

  • 복잡한 알고리즘을 구현하거나
  • 성능에 민감한 반복 작업을 처리하거나
  • 실행 순서와 상태 변화를 세밀하게 제어해야 하는 경우

에는 명령형 접근이 오히려 더 단순하고 효율적일 수 있습니다.

결국 중요한 것은 특정 패러다임을 사용하는 것 자체가 아니라 현재 해결하려는 문제를 가장 명확하고 안전하게 표현할 수 있는 방식을 선택하는 것 입니다.

정리

명령형 프로그래밍은 원하는 결과를 얻기 위해 어떤 순서와 절차로 작업해야 하는지(How) 를 직접 기술하는 방식입니다.

반면 선언형 프로그래밍은 구체적인 제어 흐름을 추상화하고 어떤 결과가 필요한지(What) 를 표현하는 데 더 집중합니다.

구분명령형선언형
핵심 관점How — 어떻게 처리할 것인가What — 무엇을 원하는가
구현 방식실행 절차를 직접 기술구현 세부 사항을 추상화
특징세밀한 제어의도 중심의 표현

JavaScript의 for와 map, DOM 직접 조작과 React를 비교하면 이러한 차이를 쉽게 확인할 수 있습니다.

하지만 선언형이 항상 더 좋은 것도 아니고, 명령형이 항상 더 성능이 좋은 것도 아닙니다.

또 실제 프로그램에서는 두 방식이 완전히 분리되어 사용되지도 않습니다.

선언적인 인터페이스 아래에는 구체적인 작업을 수행하는 구현이 존재할 수 있고, 하나의 애플리케이션에서도 필요한 제어 수준에 따라 두 방식을 함께 사용합니다.

이번 주제를 공부하면서 가장 중요하다고 느낀 점은 선언형 코드를 사용하는 것 자체보다 적절한 추상화 수준을 선택하는 것이었습니다.

구현 세부사항을 숨겼을 때 코드의 의도가 더 명확해진다면 선언적인 접근이 좋은 선택이 될 수 있고, 세밀한 제어가 문제 해결에 중요하다면 명령형 접근이 더 적합할 수 있습니다.

결국 어느 하나의 방식을 정답으로 생각하기보다, 각 방식이 어떤 문제를 해결하기 좋은지 이해하고 상황에 맞게 선택하는 것이 중요합니다.

참고 자료