글을 작성한 배경
선언적 프로그래밍(declarative programming, 이하 DP)을 알려주는 좋은 글은 많다. 대표적인 웹 프론트엔드 기술 블로그를 운영하는 토스만 봐도 선언적 프로그래밍 글이 있어서 비전공자인 내가 봐도 이해할 수 있다.(링크 - 토스. 선언적인 코드 작성하기)
이런 글들은 DP를 다음 같이 설명한다.
- 프로그램이 어떻게(how)해야하는지 보단 무엇을(what)하는지 설명
- 대표적인 예시로는 자바스크립트에서는 고차 함수(map, filter, reduce 등), html, css
- 반복 작업으로 선언적, 명령적 코드를 비교한다면,
- for, while문은 명령적임 = 반복작업 수행을 위해 반복 절차 구현을 위한 변수 i, i값 증가(i++), 반복문 탈출 조건(i < 100)을 직접 작성해야 함. 즉, 어떻게 작업을 구현할지 for문의 호출자가 직접 정의해야 함
- 반면, map, filter 선언적임 = 배열 순회, 배열 내 조건에 부합하는 요소 검출 작업을 호출자가 직접 제어할 필요 없음.
그저, 작업 내용(순회 대상, 순회 작업 종류를 메소드로 선택, 순회로 실행할 로직)만 작성하면 됨.
- html, css도 dom 요소 생성이나 스타일 적용을 위해서 직접 dom이나 cssom 및 reflow, paint 로직을 제어할 필요가 없음. 무엇을 할지만 작성하면 됨(html 은 태그 작성, css는 스타일 시트 작성)
- 반복 작업으로 선언적, 명령적 코드를 비교한다면,
- 자연어에 가까운 직관적인 호출 방법
- 작업 목표 완수를 위한 상태 제어 흐름을 로직 구현부로 숨기고 추상화
- 함수형 프로그래밍(이하 FP)은 선언형 프로그래밍에 해당하는 방법중 하나
- 반면, 객체지향형 프로그래밍(이하 OOP)은 명령형 프로그래밍(imperative programming, 이하 IP)에 해당
여기까지는 DP가 뭔지 머리로 대략 감이 왔다. 하지만, 실무에서 직접 적용하려니까 내 코드가 DP인지 확신이 서지 않았다.
내 발목을 잡은 질문은 다음과 같았다.
- 그래서 추상화랑 DP랑 차이가 뭐야? 추상화나 DP나 세부적인 구현 로직을 숨기는 거니까 둘이 동일한거야?
- 그러면 OOP도 저수준의 로직과 상태들을 객체로 추상화하잖아. 그러면 OOP도 DP에 들어가야지 왜 명령형 프로그래밍으로 분류하는 거야?
이 글은 위 질문에 대답하기 위해 추상화를 다시 정의하고 그 관점에서 IP와 DP의 차이를 상태 흐름 제어의 맥락에서 바라본다. 이를 통해 IP와 DP의 대표적인 패러다임인 OOP와 FP가 상태 흐름 제어를 어떻게 다르게 추상화하는지 살펴본다.
추상화란 무엇인가: 자연어의 관점에서
코드는 추상화 없이도 돌아가긴 한다.
이론 상으로는 아래 기능 정도만 있으면 프로그램을 만들 수 있으니까.
- 순차(Sequence) → 코드가 위에서 아래로 차례대로 실행됨.
- 분기(Selection, 조건문) → 조건에 따라 실행 흐름이 달라짐. (if, switch 등)
- 반복(Iteration, Loop) → 특정 조건이 만족될 때까지 실행됨. (for, while 등)
- 참조(Reference) → 변수나 메모리 주소를 통해 데이터를 가리키는 개념. (객체 참조, 포인터 등)
- 재귀(Recursion) → 함수가 자기 자신을 호출하여 문제를 해결함.
- 예외 처리(Exception Handling) → 오류 발생 시 프로그램이 비정상 종료되지 않도록 처리함.
- 동시성(Concurrency, 비동기 처리) → 여러 작업을 동시에 수행하는 기법. (async/await, 스레드 등)
문제는 코드가 길어지면서 가독성이 떨어지며 생겨난다. 수많은 글로벌 변수들을 만들고 코드가 실행되는 흐름에 따라 상태가 시시각각 예측하기 어렵게 바뀐다. 분기로 얽히고 설킨 로직은 점점 그 의미를 알아보기 어려워진다.
그래서 우리는 추상화를 한다. SOLID이든 디자인 패턴이든 그리고 수많은 패러다임도 추상화를 어떻게 하는가와 연결되어 있다. 그래서 추상화란 무엇일까.
개인적으로 추상화를 설명하자면, 로직의 호출자 입장에서 코드가 자연어처럼 읽히도록 하는 것이다.
자연어는 함축을 통해 진짜 중요한 의미 전달에 집중하게 해준다.
자연어는 수많은 단어로 이뤄져 있고 단어는 수많은 의미를 함축하고 있다. 문법적으로도 단어의 특정한 순서와 배치는 암묵적으로 어떤 의미로의 해석을 암시한다.
예컨대, "동쪽으로 가라." 라는 문장은 다음 의미와 순서 배치에 의해 해석된다.
- "동쪽"에 대한 정의
- "간다"의 변형으로서 "가라"
- "~으로"라는 조사와 "~해라"라는 어미
결국, 자연어는 가장 중요한 것을 말하기 위해, 세부적인 것들은 함축하여 생략한다.
자연어가 청자 혹은 발화자에게 명시적인 것과 함축적인 것을 구분하듯,
추상화는 로직의 호출자에게 숨길 것과 명시적으로 전달 받을 것을 구분한다.
반복문을 예로 들자면, 아래 두번째 추상화 로직에서는 "배열 요소 각각에 1씩 더한다." 라는 작업을 구현하기 위해 호출자가 명시해야할 것을 제외하고는 모두 함수 내부로 숨겼다.
// 작업 목표: 아래 배열의 각 요소에 1을 더해
const target= [1,2,3];
// 추상화되지 않은 로직.
// 중요한 목표를 위해, 목표를 구현하는 하위 절차(순회를 위한 변수 i 제어)까지 호출자가 직접 제어
for(let i; i++; i< target.length) {
target[i]= target[i] ++;
};
// [2,3,4]
console.log(target);
const target= [1,2,3];
// 추상화된 로직
// 중요한 목표만 명시하며, 달성을 위한 하위 절차는 호출자가 신경쓰지 않음
// 하위 절차는 함수, 객체 등으로 숨김
// 하위 절차에 필요한 변수(i)는 캡슐화
function add1ToAllElement(arr){
for(let i; i++; i< arr.length) {
arr[i]= arr[i] ++;
};
}
// 호출 시, 적절한 함수명과 인자로만 작업 표현
// [2,3,4]
console.log(add1ToAllElement(target));
좋은 추상화는 캡슐화된 로직의 이름, 인풋, 아웃풋만으로 작업을 유추할 수 있다.
복잡한 기능이라도 API 레퍼런스 설명을 통해 호출자가 알아야할 최소한의 작동 로직만으로 전체적인 동작을 이해할 수 있다. 그러면 호출자는 자기가 정말 원하는 작업 내용에만 신경을 쓸 수 있다.
추상화 방식의 차이: OOP와 FP
OOP는 작업 수행에 필요한 상태를 객체로 추상화한다. 반면 FP는 작업에 필요한 상태를 함수의 클로저 기반의 렉시컬 스코프에 저장하는 방식 등으로 추상화한다.
둘의 어떤 차이가 OOP를 IP, FP를 DP로 분류하게 만드는가?
과거에 내가 했던 오해는, FP를 DP로 분류하는 이유를 FP 철학에서 순수함수의 멱등성, 참조투명성 때문으로 알았던 것이다.
* 멱등성: 순수함수는 몇 번이나 호출해도 인자가 동일하다면 반환값도 동일함. 사이드 이펙트가 없는 경우 성립
* 참조투명성: 함수의 호출은 함수의 반환 값으로 치환 가능. 함수의 구현부에서 외부에 끼치는 영향이 없을 때 성립
하지만, FP를 지키려는 개발에서도 함수가 불가피하게 반영구적인 상태 값을 가지는 경우가 있다. 클로저를 통해 저장하는 렉시컬 스코프 내 변수가 그러하고, React의 컴포넌트 함수 내에 저장되는 useState도 그러하다.
이 함수들은 반영구 상태의 흐름에 따라 멱등성과 참조투명성이 지켜지지 않는다. 즉, 호출 시점 및 맥락에 따라 값이 변경될 수 있고 이 점은 OOP도 동일하다.(객체 인스턴스 내부에 저장된 상태 값에 따라 메소드의 호출 반환 값은 변경된다)
둘의 차이 : 상태 흐름을 신경써야 하는지의 유무
둘의 어떤 차이가 선언형과 명령형을 가르는가는 "이전 작업이 이후 작업에 의해 변형될 수 있는가"와 관련 있다.
OOP는 호출단에서 객체의 상태를 직접 수정하고 참조하는데, 상태는 가변성을 가지고 있어서 한 메소드에서 참조하는 상태는 이후의 작업에 따라 변경될 수 있다.
const user = {
status: {
name : "charchar",
},
setName(name){
this.status.name= name;
}
getStatus(){
return this.status;
}
};
const status = user.getStatus();
/**
{ name : "charchar"}
*/
console.log(status);
user.setName("charchar2");
/**
작업결과 값 status는 이후의 작업에 의해 값이 변경됨
{ name : "charchar2"}
*/
console.log(status);
이렇게 되면 작업 호출자는 본인이 하고 싶은 작업 외에도, 이후에 실행될 작업과 같은 작업 흐름을 신경쓸 수 밖에 없다.
반면, FP는 불변성을 구현하여 이전의 작업 결과가 이후의 흐름에 영향받지 않도록 한다.
// FP 스타일: 불변성 기반 — 원본을 변경하지 않고 "새 값"을 만든다.
const user = Object.freeze({
status: Object.freeze({ name: "charchar" }),
});
// 순수 함수: 입력을 변경하지 않고, 새 객체(얕은 복사 + 필요한 필드만 교체)를 반환
const setName = (u, name) => ({
...u,
status: { ...u.status, name }, // 기존 status는 보존, name만 교체
});
const getStatus = (u) => u.status; // 읽기 전용 참조(원본은 freeze로 보호됨)
const status = getStatus(user);
/**
{ name : "charchar" }
*/
console.log(status);
// 이름 변경: user는 그대로 두고, "새 사용자 객체"를 만든다.
const user2 = setName(user, "charchar2");
/**
이전 작업결과 status는 불변.
user2는 새 값이며, user/status는 그대로 유지된다.
*/
console.log(status); // { name: "charchar" } ← 변함없음
console.log(user.status); // { name: "charchar" } ← 원본 유지
console.log(user2.status); // { name: "charchar2"} ← 새 결과
위 코드에서는 작업 호출자 호출한 작업의 결과가 이후의 작업 결과에 영향받지 않고 고정된다.
이는 호출단에서 작업의 내용에만 관심을 가지고 그 외 상태의 흐름은 직접 제어하지 않도록 해준다.
불변성은 선언형을 제대로 이해할 수 있는 한 가지 단서지만 선언형의 전부는 아니다. html, css에서 작성자가 불변성을 신경쓰진 않는 것을 봐도 알 수 있다.
그럼에도 이들이 선언형으로 묶이는 이유는 공통적으로 자신들이 하는 일 외에 상태 흐름을 신경쓰지 않기 때문이다. 그리고 이것을 위해서는 작업에 따라 변경되는 상태들이 존재하더라도 외부에 영향을 끼치는 걸 최소화하기 때문이다.
바꿔 말하면, 선언형 프로그래밍은 호출자가 작업 목표만 명시하고, 상태 변화와 절차는 내부 구현에 맡길 수 있는 방식이다.
선언형과 명령형은 비교 군에 따라 상대적이다
흔히 선언형에 대한 설명은 "보다 더 명령적인 코드"와 "더 선언적인 코드"를 비교하거나, "더 명령적인 언어"와 "더 선언적인 언어"를 비교하는 방식으로 이뤄진다. 결국, 명령형이나 선언형은 추상화 방향에서 작업 목표 외에 절차와 상태를 호출단으로 노출하고 호출자가 직접 다루냐에 대한 정도의 차이이며 누구를 비교하느냐에 따라 상대적일 뿐이다.
예컨대, 메모리를 직접 관리하는 c 보다는 GC로 메모리 관리를 개발자가 직접 하지 않는 자바가 더 선언적이라 할 수 있다. 반면, 작업 결과 반환 값의 불변성을 보장하느냐에 따라서 OOP인 자바보단 자바스크립트에서 FP를 구현한 코드가 더 선언적이다. 그리고 순서, 반복, 중간 상태를 전혀 작성하지 않는 HTML, CSS, SQL은 자바스크립트보다 더 선언적이라 할 수 있다.
마무리
선언형이 "무엇을"에 집중한다는 것과 FP가 OOP보다는 선언적이라는 말에 대한 내 의문은 작업 목적 외에 로직 내부의 절차나 상태가 외부에 영향을 끼치는가로 결론을 내렸다. 그리고 이 비교 역시도 상대적일 뿐, 무엇을 비교군으로 놓냐에 따라 FP가 선언적일수도 명령적일수도 있다.
이 말은 즉, 자바스크립트를 쓰더라도 명령형으로 코드를 작성해야 하는 부분이 있다는 것이다.
명령형과 선언형은 주로 다음 경우에 사용한다.
명령형이 좋은 경우
- 세밀한 제어가 필요한 경우
- 성능 최적화, 로직 튜닝
- 상태 변화가 로직의 핵심인 경우
- 시뮬레이션 등 상태의 조작이 호출단의 핵심 목적 그 자체인 경우
- 내부 동작 구조를 호출단에서 알아야 하는 경우
선언형이 좋은 경우
- 코드의 목적과 의도만 빠르게 전달하고 싶은 경우
- 절차 및 상태보단 결과가 중요한 경우
- 변경 가능성보단 안정성을 높이고 싶은 경우
- 정해진 상태 흐름에 대한 외부의 튜닝 여지를 줄이는 대신, 유지보수가 용이해짐
실제 프로젝트에서는 두 방식을 혼합해야 하니, 각자의 철학과 장단점을 이해하는 것으로 이 글을 마무리 짓는다.
'코드 디자인 패턴 > javascript' 카테고리의 다른 글
| 바벨 설정 파일 정리 (0) | 2025.10.14 |
|---|