티스토리 뷰
안녕하세요. 오늘은 제가 코드를 작성할 때 추구하는 프로그래밍인 선언적 프로그래밍에 대해 기술하고자 합니다.
1년 차에는 개발을 할 때 어떻게 구현할지를 고민했다면, 경험이 쌓이면서는 어떻게 짜야할지 고민을 더 많이 하게 되는 것 같습니다.
하나의 알고리즘 문제를 풀 때도 풀이 법이 수십 가지이듯, 하나의 기능 구현에도 개발자마다 중요도와 스타일에 맞게 다르게 작성됩니다. 그렇기에 "유지보수가 쉽고, 보기 좋고, 디버깅이 편한 코드"를 작성하는 건 더 이상 단순한 선택이 아닌 좋은 개발 습관이자 필요조건이라 생각합니다.
저 역시 처음엔 좋은 코드를 위해 "추상화"라는 개념부터 차근차근 익혀나갔습니다. 하지만 경험이 쌓이면서 점점 더 넓은 컨텍스트에서 코드를 바라보게 되었고, 단순한 추상화를 넘어서 코드를 읽는 방식 자체를 바꾸는 사고로 이어지게 되었습니다.
그 무렵부터 제게 "선언적으로 짜라"는 리액트의 철학이 다르게 들리기 시작했습니다. 처음에는 단순히 문법적인 이야기로만 받아들였지만, 코드 리뷰를 통해 함수형 프로그래밍(FP)이라는 개념을 접하게 되었고, 현재도 함수형 자바스크립트 책을 읽으며 조금씩 관심을 갖고 익혀나가고 있습니다.
지금부터 제가 그동안 익혀왔던 "선언적인 프로그래밍"에 대해 1편에서는 제가 이해했던 방식으로 아주 이해하기 쉽게 설명드린 후 React를 통해 자연스럽게 그 체감한 과정을, 2편에는 함수형 프로그래밍(FP) 소개와 제가 실제로 활용한 예제도 담아보려고 합니다.
내가 생각하는 "선언적" 코드
누군가 저에게 "선언적인 코드가 뭔가요?"라고 묻는다면, 저는 이렇게 답할 것 같습니다.
"제가 알아야하는 컨텍스트가 적어지는 코드입니다."
이 말은 곧, 코드를 이해하기 위해 머릿속에 담아야 할 정보(=컨텍스트)가 줄어든다는 뜻입니다.
그리고 이 문장 안에는 불변성을 유지한다는 선언적 프로그래밍의 핵심 개념도 자연스럽게 포함되어 있다고 생각합니다.
그렇다면 컨텍스트가 적어진다는 말은 어떤 말일까요?
이 부분은 "명령적"인 코드와 비교를 하면 좋을 것 같네요.
선언형 vs 명령형
제가 선언적인 코드를 좋아하는 이유는 앞서 말했듯이 제가 알아야하는 컨텍스트가 줄어들기 때문입니다. 반면 명령형 코드는 제가 따라가야 할 흐름이 많아지고, 알아야 할 상태가 점점 많아지게 됩니다.
예를 들어 자바스크립트에서 배열을 다룰 때를 생각해 볼까요?
push, pop 메서드는 원본 배열을 직접 수정합니다. 코드가 길어지고 상태가 여러 곳에서 변경되기 시작하면, 이 원본 배열이 어디서 어떻게 바뀌었는지 추적하는 데 많은 에너지가 들게 됩니다.
반면 concat, filter, slice는 원본 배열을 수정하지 않고 새로운 배열을 반환합니다. 이전 상태를 신경 쓰지 않아도 되니, 코드를 읽는 입장에서 부담이 훨씬 줄어듭니다.
아래 조건을 통해 선언형과 명령형 코드 예시를 보겠습니다.
- 배열 [1, 2, 3, 4]에서 짝수만 남기고
- 각 값에 제곱을 한 후
- 마지막 값 제거
- 결과: [4]
명령형 코드:
const arr = [1, 2, 3, 4];
const result = [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] % 2 === 0) {
result.push(arr[i] * arr[i]);
}
}
result.pop(); // 마지막 제거
console.log('최종 result:', result); // [4]
선언형 코드 :
const arr = [1, 2, 3, 4];
const filtered = arr.filter(n => n % 2 === 0); // [2, 4]
const squared = filtered.map(n => n * n); // [4, 16]
const trimmed = squared.slice(0, -1); // [4]
console.log('최종 result:', trimmed); // [4]
두 코드 모두 [4]를 출력하지만, 읽는 사람이 머릿속으로 따라가야 하는 상태의 복잡도는 다릅니다.
명령형 코드는 arr이라는 하나의 객체가 계속 바뀌는 것에 집중해야 하고, 선언형은 각 단계를 새로운 상태로 나눠볼 수 있어 흐름을 예측하기 더 쉽습니다.
지금 비교가 와닿지 않는다면 이슈가 났을 때를 생각하면 이해가 좀 더 쉬울 수 있습니다. 내가 생각한 배열 [4]가 나오지 않았을 때 어떻게 디버깅을 해봐야 할까요?
예기치 못한 결과가 나왔을 때
위 코드에 의도적으로 실수를 넣어서 결괏값이 [1]로 나오는 상황입니다.
명령형 코드 - 디버깅 예시:
최종 결과 [1]만 보고는 push 조건이 잘못된 건지, 제곱이 잘 안 된 건지, pop이 잘못된 건지 알기 어렵습니다.
조건을 확인하더라도, 위에서부터 코드 흐름을 따라가야 합니다.
간단한 예제라 흐름을 따라가서 조건문이 홀수로 잘 못 되어있다는 것은 디버깅을 하지 않아도 찾을 수도 있지만,
코드의 복잡도가 높아질수록 전체 루프 흐름을 모두 따라가며 각 단계의 값을 직접 출력해 봐야 원인을 찾을 수 있습니다.
const arr = [1, 2, 3, 4];
const result = [];
for (let i = 0; i < arr.length; i++) {
console.log('현재 값:', arr[i]);
// ❌ 실수: 홀수를 필터링
if (arr[i] % 2 !== 0) {
const squared = arr[i] * arr[i];
console.log('조건 통과 → push:', squared);
result.push(squared);
}
}
result.pop();
console.log('최종 result:', result);
// 출력
현재 값: 1
조건 통과 → push: 1 // 여기서 잘 못 됐구나!
현재 값: 2
현재 값: 3
조건 통과 → push: 9
현재 값: 4
최종 result: [1]
선언형 코드- 디버깅 예시:
선언적인 코드는 각 단계가 filter → map → slice처럼 명확히 나뉘어 있습니다. 이 덕분에 각 단계의 출력만으로 어디서 문제가 발생했는지 쉽게 파악할 수 있습니다.
const arr = [1, 2, 3, 4];
// ❌ 실수: 홀수 필터링
const filtered = arr.filter(n => n % 2 !== 0); // [1, 3]
console.log('filtered:', filtered);
const squared = filtered.map(n => n * n); // [1, 9]
console.log('squared:', squared);
const trimmed = squared.slice(0, -1); // [1]
console.log('trimmed:', trimmed);
// 출력
filtered: [1, 3] // 여기서 잘 못 됐구나!
squared: [1, 9]
trimmed: [1]
앞서 저는 선언형 코드를 “내가 알아야 하는 컨텍스트가 적어지는 코드”이며 이는 불변성과도 관련이 있다고 설명드렸습니다.
- filtered는 주어진 배열에서 조건에 맞는 요소만 골라 새로운 배열을 만듭니다.
- squared는 각 요소를 제곱해서 또 다른 새 배열을 생성하고,
- trimmed는 마지막 요소를 제거한 또 하나의 새 배열을 반환합니다.
이처럼 각 함수는 원본을 직접 수정하지 않고, 자신만의 역할을 수행하는 순수 함수입니다.
그래서 중간 어디서 잘못되었는지 로그 한 줄만 봐도 바로 찾을 수 있습니다.
결국, 선언적인 코드에서는 알아야 할 상태가 적고, 단계별로 원인을 좁히는 것이 쉬워 디버깅 부담이 줄어듭니다.
이 점이 제가 선언적인 코드를 좋아하게 된 가장 큰 이유 중 하나입니다.
그래도 이해가 어렵다면? -> 사람 느낌 나는 해석..
위 예시로 선언형과 명령형 코드의 차이가 어느 정도 감이 오셨을까요?
제가 이때까지 느낀 바로 좀 더 쉽게 사람 말처럼 표현하자면,
명령형은 "이걸 이렇게 바꿔!"라고 명확하게 지시하는 스타일이고,
선언형은 "이럴 땐 이거야"라고 규칙이나 조건을 서술하는 느낌에 더 가깝습니다.
그래서 명령형은 실행 흐름이 눈에 보이기 때문에 초반에는 이해하기 쉬울 수 있지만, 상태가 많아질수록 따라가야 할 컨텍스트가 점점 늘어나게 됩니다.
반면 선언형은 마치 수학 공식을 읽듯, 한눈에 "무슨 처리를 하는지"를 파악하기 쉬운 구조입니다.
그 대신 처음엔 조금 추상적으로 느껴질 수 있고, 익숙하지 않으면 해석에 시간이 걸릴 수 있습니다.
잠깐 보자 리액트의 선언적 프로그래밍!
제가 서두에 말씀드렸던 리액트의 선언적 프로그래밍도 잠깐 보고 가면 좋을 것 같습니다. Meta Opensource에서는 리액트를 이렇게 소개합니다.
React is:
Declarative: React makes it painless to create interactive UIs. Design simple views for each state in your application, and React will efficiently update and render just the right components when your data changes. Declarative views make your code more predictable, simpler to understand, and easier to debug.
선언적: React는 인터랙티브 UI를 손쉽게 만들 수 있도록 해줍니다. 애플리케이션의 각 상태에 맞는 간단한 뷰를 디자인하면 React가 데이터 변경 시 필요한 컴포넌트만 효율적으로 업데이트하고 렌더링 합니다. 선언적 뷰는 코드를 더 예측 가능하고, 이해하기 쉽고, 디버깅하기 쉽게 만들어줍니다.
이 문장을 저는 이렇게 해석했습니다.
"상태에 따라 어떻게 보여야 할지를 선언하세요. 나머지는 React가 알아서 처리합니다."
내부 로직은 모른 채로도 개발을 할 수 있다는 뜻입니다. 추상화 개념을 이해할 때 많이 쓰는 개념이죠? 선언적인 코드도 추상적인 범주에 들어가기 때문에 그렇게 이해해도 됩니다.
그래서 React로 개발할 때 개발자가 직접 DOM을 조작하거나, 언제 어떤 요소를 업데이트할지 고민하지 않아도 할 수 있는 거죠.
내부 로직까지 알 필요 없어 — "이 상태일 땐 이렇게 보여줄 거야"만 말하면 돼!
여기서 "내부 복잡한 로직을 숨겨 둘 수도 있다!"라는 선언적인 코드의 장점이 하나 더 추가되네요.
그런데, 내부를 몰라도 되는 걸까요?
그렇다고 해서 “프런트엔드개발자는 리액트 사용할 때 내부 동작을 몰라도 된다”는 뜻은 아닙니다.
실제로 저도 처음에는 선언적인 사용법만 익혀서 리액트로 개발을 했고, JSX와 리액트에서 제공하는 hooks 정도만 알아도 화면을 구현하는 데 큰 어려움은 없었습니다. 어떻게 보면 이건 리액트가 정말 잘 추상화된 프레임워크라는 증거이기도 하죠. 내부 동작을 몰라도 개발이 가능하다는 건, 엄청난 장점이라고 생각합니다.
하지만 선언형에 관심을 갖다 보니 리액트라는 프레임워크에 내부동작이 궁금해지게 되었고, 리액트의 렌더링 과정(Reconciliation, Commit Phase, Diffing 등)을 조금씩 이해하면서 왜 리액트가 선언적 프로그래밍 철학을 갖고 있는 프레임워크인지 더 명확히 알 수 있었습니다.
선언적인 코드도 결국 어디까지가 추상화되고, 어디부터 명시해야 하는지를 이해하려면, 그 안에서 돌아가는 흐름을 파악하는 것도 중요하다고 생각합니다.
리액트 렌더링 과정의 핵심: Diffing
개인적으로는 선언적인 부분을 React 로서 설명할 때의 핵심은 리액트의 랜더링 과정 중 Diffing 이라고 생각합니다. 왜냐하면 이 비교 개념에서 불변성 유지가 명확하게 드러나는 부분이라고 생각하기 때문입니다.
비교라는 건 항상 두 개의 대상이 필요합니다. 리액트에서는 그 대상이 이전 가상 돔(Virtual DOM)과, 새로운 가상 돔(Virtual DOM)이 됩니다.
간단하게 리액트 렌더링 과정을 설명하면 다음과 같습니다.
- 우리가 선언한 JSX는 렌더링이 발생할 때마다 새로운 Virtual DOM으로 변환됩니다.
- 리액트는 이전 Virtual DOM과 새 Virtual DOM을 비교(Diffing) 합니다.
- 그 결과 바뀐 부분만 실제 DOM에 반영(Commit)됩니다.
이 흐름을 선언형과 명령형으로 좀 더 설명드리자면,
개발할 때 우리는 선언적으로 코드를 작성하고, 리액트 내부에서 비교를 통해 최종적으로 리액트에서 실제 Dom에 명령적으로 수정을 하게 되는 흐름입니다.
이 구조를 이해하셨다면 우리가 항상 사용하는 useState 훅이 동작하는 방식도 다시 생각해 보게 됩니다. 저는 실제로 이 과정을 처음 공부했을 때, 문득 이런 생각이 들었습니다:
“setState는 기존 값을 바꾸는 게 아니라, 내부 어딘가에 새로운 값을 저장해 두는 거겠구나!”
지금 생각하면 당연한 이야기 같기도 하지만, 내부 동작을 이해하기 전에 “불변성이 왜 중요한데?”라는 질문을 받았다면 제대로 답변을 못 했을 것 같아요.
Diffing의 전제가 ‘이전과 새로운 값을 비교하는 것’ 이니, 불변성을 유지해야 하는 이유가 자연스럽게 연결됐습니다.
기존 값을 직접 바꿔버리면 비교 자체가 안 되니까요.
그래서 리액트는 상태의 불변성을 유지합니다. 이전 상태와 새로운 상태를 명확히 나눠야만, 바뀐 부분만 빠르게 찾고, 효율적으로 렌더링 할 수 있기 때문입니다.
선언형 사고가 남긴 것
이처럼 선언적인 프로그래밍은 ‘코드를 어떻게 읽히게 할까?’를 고민하는 과정에서 저에게 좋은 방향키가 되어 주었습니다.
아직 3년 차 개발자라 배울 것이 산더미지만, 오늘은 그동안 제가 코드를 작성하며 중요하게 여겨 온 관점을 나누고자 했습니다.
추상적 용어를 겉핥기로 넘기기보다 그 배경과 의도를 깊이 파고들었던 경험을 담았으니, 처음 개념을 접하는 분께도 작은 길잡이가 되길 바랍니다.
리액트의 ‘선언적 프로그래밍’을 탐구하다 보니 관심은 자연스레 함수형 프로그래밍(FP)으로 확장되었습니다. 아직 FP를 자유자재로 다루진 못하지만, “이 기능은 FP로 풀어야 더 깔끔하겠다!” 싶을 때마다 과감히 적용해 보고 있습니다. 처음엔 다소 낯설지만, 한 번 맛보면 제법 중독적인 매력이 있더라고요.
다음 글에서는 함수형 프로그래밍(FP) 이 선언형 사고와 어떻게 닿아 있는지 살펴보고, 핵심 개념을 간단히 짚은 뒤, 실제 프로젝트에 적용해 본 예제를 함께 살펴보겠습니다.
2편 바로보기
내가 선언적인 코드를 좋아하는 이유 (Feat. FP로 만드는 애니메이션) - 2
안녕하세요. 지난 글 「내가 선언적인 코드를 좋아하는 이유 (Feat. React) – 1」에서 저는 선언적인 코드를 작성하는 방식이 어떻게 코드 품질의 방향키가 되어 주었는지, 그리고 React 렌더링 과정
algoroot.tistory.com
'Frontend' 카테고리의 다른 글
내가 선언적인 코드를 좋아하는 이유 (Feat. FP로 만드는 애니메이션) - 2 (1) | 2025.06.22 |
---|---|
프론트엔드 개발자의 AI 챗봇 개발기 (0) | 2025.04.24 |
[귀찮은건 질색이야-2] 나는 코드를 작성했는데 문서가 생성된다고?: api-extractor (feat. Docusaurus, Github A (0) | 2024.12.20 |
활성화된 쿼리 및 로딩 상태를 효율적으로 관리하는 방법 (0) | 2024.04.05 |
[Mac OS Alias] Mac OS에서 Alias 설정하는 법 (0) | 2022.12.17 |
- Total
- Today
- Yesterday
- 자바스크립트 비동기 처리
- html
- 리액트
- 프로그래머스 자바스크립트
- javascript
- css
- 프로그래머스 베스트앨범 자바스크립트
- cs50
- python
- github
- 자바스크립트알고리즘
- 모두를 위한 컴퓨터 과학
- React Query
- 타입스크립트
- 모두를위한컴퓨터과학
- 자바스크립트 클로저
- 무한스크롤
- 실전프로젝트
- 알고리즘자바스크립트
- 클로저
- 네트워크
- GIT
- 자바스크립트
- reactquery
- React
- 백준
- 프로그래머스
- network
- 항해99
- 리액트네이티브
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |