Cllaude99Cllaude99

선언적으로 코드를 작성한다는 것

·Cllaude99
선언적 코드함수형 프로그래밍DXReact

얼마 전 몇 달 만에 예전에 작성했던 코드를 다시 열어볼 일이 있었어요. 분명 제가 작성한 코드인데, "이게 왜 이렇게 되어있지?"라는 생각이 들더라고요. 이런 순간들이 코드를 읽을 때마다 반복되면서 코드를 작성할 때의 나와 코드를 읽을 때의 나는 완전히 다른 사람이라는 것을 깨달았어요.

요즘은 개발자로 일하면서 코드를 작성하는 시간보다 읽는 시간이 훨씬 많았던 것 같아요. 동료가 작성한 코드, 오픈소스 라이브러리 코드, 그리고 과거의 내가 작성한 코드까지요. 이런 경험들이 쌓이면서 자연스럽게 "읽기 쉬운 코드는 어떤 코드일까?"라는 질문을 던지게 되었어요. 그리고 그 답을 찾아가는 과정에서 '선언적 코드'라는 개념을 만나게 되었습니다.

개발자도 사용자이다

저는 사용자를 두 가지 관점에서 바라보고는 해요.

첫 번째 관점은 사용자는 우리 서비스를 사용하는 고객이라고 보는 관점이에요. 흔히 UX라고 말할 때 떠올리는 바로 그 사용자죠. 이 분들은 비즈니스 가치를 직접적으로 제공해주는 사용자들이에요.

두 번째 관점은 사용자는 코드를 읽고 수정하는 개발자라고 보는 관점이에요. 저는 개발자도 일종의 사용자라고 생각해요. 다만 고객이 화면과 상호작용하는 것처럼, 개발자는 코드와 상호작용한다고 보고 있어요. 그래서 개발자가 코드를 빠르게 이해하고 수정할 수 있다면, 결국 고객에게 더 좋은 기능을 더 빠르게 제공할 수 있다고 생각해요.

이런 관점에서 DX를 높인다는 것은 단순히 개발자의 편의를 위한 것이 아니라, 궁극적으로 비즈니스 가치를 높이는 일이라고 생각해요.

코드가 선언적이라는 것

그렇다면 이해하기 쉬운 코드는 어떤 형태일까요? 이에 대해서도 아직 고민을 하고 있는데요. 이에 대한 여러 다양한 답변이 있을 수 있겠지만 저는 "선언적인 코드"가 그 답 중 하나라고 생각해요.

선언적이라는 말이 조금 추상적으로 느껴질 수 있는데요. 저는 이를 "어떻게(How) 하는지가 아니라, 무엇을(What) 원하는지를 표현하는 것"이라고 이해하고 있어요.

일상에서 예를 들어볼게요. 택시를 탔을 때를 생각해보면요.

  • 명령형: "직진하다가 두 번째 신호등에서 우회전하시고, 300미터 가다가 왼쪽 골목으로 들어가서 세 번째 건물 앞에 세워주세요."
  • 선언적: "강남역 2번 출구로 가주세요."

이처럼 명령형은 '어떻게' 가야 하는지를 하나하나 지시하는 거고, 선언적은 '어디로' 가고 싶은지만 말하는 거라고 생각해요. 실제로 저는 택시를 탈 때 목적지만 말하지, 경로를 일일이 설명하진 않거든요. 기사님이 알아서 최적의 경로를 찾아가시니까요.

코드도 마찬가지라고 생각해요. 배열에서 짝수만 필터링하고 각 값을 2배로 만드는 작업을 해야 한다고 가정해볼게요.

// 명령형 접근
const numbers = [1, 2, 3, 4, 5, 6];
const result = [];

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

위 코드는 컴퓨터에게 "인덱스 0부터 시작해서, 배열 길이만큼 반복하고, 각 요소가 2로 나누어 떨어지면 2를 곱해서 결과 배열에 넣어"라고 하나하나 지시하고 있어요.

// 선언적 접근
const numbers = [1, 2, 3, 4, 5, 6];
const result = numbers.filter((n) => n % 2 === 0).map((n) => n * 2);

반면 이 코드는 "짝수만 골라서 2배로 만들어"라고 원하는 결과를 선언하고 있어요. 어떻게 반복하고 어떻게 조건을 체크하는지는 filtermap이라는 추상화 뒤에 숨겨져 있는 것이죠.

두 코드 모두 같은 결과를 만들어내요. 하지만 코드를 처음 보는 사람 입장에서는 어떤 코드가 더 빠르게 이해될까요? 저는 선언적인 코드가 의도를 파악하기 더 쉽다고 느꼈어요.

명령형 코드가 나쁜 것은 아니다

그렇다고 명령형 코드가 항상 나쁜 것은 아니라고 보고 있어요. 오히려 특정 상황에서는 명령형 코드가 더 적합할 때도 있다고도 생각해요.

예를 들어, 성능이 극도로 중요한 상황에서는 for 루프가 filter().map() 체이닝보다 효율적일 수 있을 수 있다고 생각해요. 또한 복잡한 상태 변화를 세밀하게 제어해야 할 때는 각 단계를 명시적으로 작성하는 것이 디버깅에 유리할 수 있을 수 있다고 생각해요.

저는 선언적 코드와 명령형 코드를 "좋고 나쁨"의 관점이 아니라 "추상화 수준"의 관점에서 바라봐요. 선언적 코드는 높은 수준의 추상화를 통해 의도를 명확히 드러내고, 명령형 코드는 낮은 수준에서 세부 구현을 제어해요. 그래서 상황에 맞게 적절한 수준을 선택하는 것이 중요하다고 생각해요.

함수형 프로그래밍과 선언적 코드

선언적 코드를 이야기하다 보면 자연스럽게 함수형 프로그래밍을 만나게 되었는데요. 앞서 살펴본 filter, map 같은 메서드들이 바로 함수형 프로그래밍에서 온 개념이라고 알고 있어요.

함수형 프로그래밍이라는 단어가 어렵게 느껴질 수 있는데요. 얼마전 흑백요리사2를 인상깊게 봐서 레시피와 순수 함수를 비유해서 설명해볼게요.

레시피와 순수 함수

"달걀 2개와 소금 1g을 넣고 저으면 계란물이 된다"라는 레시피가 있다면, 같은 재료를 같은 방식으로 조리하면 항상 같은 계란물이 나와요. 즉, 옆에서 누가 라디오를 틀든, 날씨가 어떻든 결과는 동일하죠.

함수형 프로그래밍에서는 이런 특성을 가진 함수를 순수 함수라고 불러요.

// 순수 함수 - 같은 입력에는 항상 같은 출력
function add(a, b) {
  return a + b;
}

add(2, 3); // 항상 5
add(2, 3); // 언제 호출해도 5

반면, 레시피 중간에 "냉장고에서 아무 재료나 꺼내서 넣는다"라는 단계가 있다면 어떨까요? 매번 결과가 달라질 거예요. 이렇게 외부 상태에 따라 결과가 달라지는 함수는 순수하지 않은 함수예요.

// 순수하지 않은 함수 - 외부 상태에 의존
let counter = 0;

function increment() {
  counter += 1; // 외부 변수를 변경
  return counter;
}

increment(); // 1
increment(); // 2 - 같은 함수인데 결과가 다름

순수 함수는 예측 가능해요. 테스트하기 쉽고, 디버깅하기도 쉽죠. "이 함수에 이 값을 넣으면 무조건 이 결과가 나온다"는 확신이 있으니까요.

저는 이런 예측 가능성이 코드를 읽을 때 정말 큰 차이를 만든다고 느꼈어요. 순수 함수로 작성된 코드는 "이 함수가 뭘 하는지"만 파악하면 되는데, 순수하지 않은 함수는 "외부 상태가 어떤지"까지 추적해야 하거든요.

불변성 - 원본을 건드리지 않는다

함수형 프로그래밍의 또 다른 핵심 개념은 불변성이에요.

일상에서 비유하자면, 문서 작업을 할 때 원본 파일을 직접 수정하는 것과 복사본을 만들어서 수정하는 것의 차이와 비슷해요. 저도 예전에 원본 파일을 직접 수정했다가 "아, 원래 뭐였더라?" 하고 후회한 적이 여러 번 있었거든요. 그 이후로는 항상 복사본으로 작업하는 습관이 생겼어요.

// 원본을 변경하는 방식 (mutable)
const numbers = [1, 2, 3];
numbers.push(4); // 원본 배열이 변경됨
console.log(numbers); // [1, 2, 3, 4]

// 원본을 유지하는 방식 (immutable)
const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4]; // 새 배열을 생성
console.log(numbers); // [1, 2, 3] - 원본 유지
console.log(newNumbers); // [1, 2, 3, 4]

불변성을 지키면 "이 데이터가 어디서 바뀌었지?"라는 디버깅 지옥에서 벗어날 수 있어요. 데이터가 변경되는 지점이 명확하게 드러나기 때문이죠.

고차 함수 - 함수를 다루는 함수

고차 함수는 함수를 인자로 받거나 함수를 반환하는 함수예요. 앞서 본 filter, map, reduce가 대표적인 예시죠.

저는 고차 함수를 "작업 지시서를 받는 일꾼" 이라고 바라보곤 하는데요. filter라는 일꾼에게 "짝수인지 확인해줘"라는 작업 지시서(함수)를 주면, 그 기준대로 걸러내는 작업을 해주는 것처럼요.

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

// "짝수인지 확인해줘"라는 작업 지시서
const isEven = (n) => n % 2 === 0;

// filter라는 일꾼에게 지시서를 전달
const evenNumbers = numbers.filter(isEven);

이런 방식의 장점은 작업 지시서인 함수를 재사용할 수 있다는 거예요. isEven이라는 함수는 다른 배열에서도, 다른 상황에서도 쓸 수 있죠.

부수 효과 다루기

하지만 현실의 프로그램에서는 순수 함수만으로 모든 것을 해결할 수 없다고 생각해요. API 호출, DOM 조작, 로깅 같은 Side Effect가 필연적으로 발생할 수 밖에 없다고 생각하거든요.

하지만, 함수형 프로그래밍은 부수 효과를 없애는 것이 아니라, 부수 효과를 명확하게 분리하고 관리하는 것을 지향한다고 생각해요. 이 부분이 제가 함수형 프로그래밍을 좋아하는 이유 중 하나예요. "부수 효과가 나쁘다"가 아니라 "부수 효과를 어디서 발생시킬지 명확히 하자"는 실용적인 접근이거든요.

// 부수 효과가 섞인 함수
function processUser(userId) {
  const user = fetchUser(userId); // 부수 효과: API 호출
  const processed = transformUser(user); // 순수한 변환
  saveToDatabase(processed); // 부수 효과: DB 저장
  logAction('user_processed'); // 부수 효과: 로깅
  return processed;
}

// 부수 효과를 분리한 구조
function transformUser(user) {
  // 순수한 데이터 변환만 담당
  return {
    ...user,
    fullName: `${user.firstName} ${user.lastName}`,
    isAdult: user.age >= 18,
  };
}

// 부수 효과는 별도로 관리
async function processUser(userId) {
  const user = await fetchUser(userId); // 부수 효과
  const processed = transformUser(user); // 순수 함수
  await saveToDatabase(processed); // 부수 효과
}

이렇게 분리하면 transformUser 함수는 테스트하기 쉽고, 부수 효과가 발생하는 지점도 명확해져요.

객체지향과 함수형, 무엇이 더 좋은가?

함수형 프로그래밍을 공부하다 보면 "객체지향 프로그래밍과 뭐가 다르지?", "어떤 게 더 좋은 거지?"라는 질문이 생기게 되더라구요. 저도 처음에는 둘 중 하나를 선택해야 한다고 생각했어요. 마치 "iOS vs Android", "촉촉한 두쫀쿠파 vs 퍽퍽한 두쫀쿠파" 같은 논쟁처럼요. 하지만 실제로 코드를 작성하다 보니, 이 둘은 대립하는 개념이 아니라 서로 다른 관점이라는 걸 깨달았어요.

제가 바라보는 객체지향은 "세상을 객체들의 상호작용으로 바라본다"는 관점이라고 생각해요. 사용자, 주문, 상품 같은 개념을 객체로 모델링하고, 이들이 서로 메시지를 주고받으며 협력하는 방식으로 프로그램을 설계하는 것이죠.

또, 함수형은 "세상을 데이터와 변환의 흐름으로 바라본다"는 관점이라고 생각해요. 입력 데이터가 여러 함수를 거쳐 출력 데이터로 변환되는 파이프라인으로 프로그램을 설계하는 것이죠.

// 객체지향적 관점
class ShoppingCart {
  constructor() {
    this.items = [];
  }

  addItem(item) {
    this.items.push(item);
  }

  getTotal() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

const cart = new ShoppingCart();
cart.addItem({ name: '사과', price: 1000 });
cart.addItem({ name: '바나나', price: 500 });
console.log(cart.getTotal()); // 1500
// 함수형 관점
const addItem = (cart, item) => [...cart, item];

const getTotal = (cart) => cart.reduce((sum, item) => sum + item.price, 0);

const cart = [];
const cart1 = addItem(cart, { name: '사과', price: 1000 });
const cart2 = addItem(cart1, { name: '바나나', price: 500 });
console.log(getTotal(cart2)); // 1500

두 코드 모두 같은 문제를 해결해요. 객체지향은 상태와 행동을 객체 안에 캡슐화했고, 함수형은 데이터와 함수를 분리하여 데이터의 흐름을 명시적으로 보여줘요.

그래서 이 둘을 상황에 따라 잘 사용할 수 있다고도 보는데요. 도메인 모델을 설계할 때는 객체지향적 사고가 자연스럽고, 데이터 변환이나 UI 로직을 작성할 때는 함수형 접근이 깔끔하게 느껴지거든요. 그래서 중요한 것은 어떤 패러다임을 선택하느냐가 아니라, 코드를 읽는 사람이 의도를 쉽게 파악할 수 있느냐라고 생각해요.

React에서의 선언적 사고

프론트엔드 개발, 특히 React를 사용하면서 선언적 사고의 가치를 더욱 느끼게 되었어요.

React 이전의 jQuery 시절을 생각해보면, DOM을 직접 조작해야 했어요. (저는 jQuery 시절을 직접 경험하진 않았지만, 코드를 보면 그 차이가 확실히 느껴지더라고요.)

// jQuery - 명령형
$('#button').click(function () {
  $('#counter').text(parseInt($('#counter').text()) + 1);
  if (parseInt($('#counter').text()) > 10) {
    $('#warning').show();
  }
});

이 코드는 "버튼이 클릭되면 counter 요소의 텍스트를 가져와서, 숫자로 바꾸고, 1을 더한 값으로 다시 설정하고, 그 값이 10보다 크면 warning 요소를 보여줘"라고 말하고 있어요. 어떻게 화면을 업데이트할지를 하나하나 지시하는 방식이죠.

// React - 선언적
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
      {count > 10 && <p>10을 초과했습니다!</p>}
    </div>
  );
}

React 코드는 "count 상태가 있고, 이 상태에 따라 화면은 이렇게 보여야 해"라고 선언해요. 상태가 바뀌면 어떻게 DOM을 업데이트할지는 React가 알아서 처리해주죠.

이러한 선언적 접근 덕분에 저는 "화면이 어떤 상태일 때 어떻게 보여야 하는가"에만 집중할 수 있게 되었어요. DOM 조작의 세부사항에서 벗어나 비즈니스 로직에 더 집중할 수 있게 된 거죠. 이게 제가 React를 좋아하는 이유 중 하나예요.

선언적 코드를 작성하기 위한 나의 접근

실제로 코드를 작성할 때 저는 몇 가지 기준을 가지고 있는데요.

먼저, 코드가 위에서 아래로 자연스럽게 읽히는지 확인해요. 마치 글을 읽듯이 코드의 흐름이 자연스러워야 한다고 생각해요. 중간에 "이건 왜 여기 있지?"라는 의문이 드는 순간, 그 코드는 선언적이지 않은 것일 수 있어요.

// 흐름이 끊기는 예
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isAdmin, setIsAdmin] = useState(false);

  // 갑자기 등장하는 복잡한 로직
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      setUser(data);
      // 권한 체크 로직이 여기에?
      const adminList = await fetch('/api/admins');
      const admins = await adminList.json();
      setIsAdmin(admins.includes(userId));
    };
    fetchData();
  }, [userId]);

  // ... 렌더링 로직
}
// 의도가 드러나는 예
function UserProfile({ userId }) {
  const { user, isLoading } = useUser(userId);
  const { isAdmin } = useAdminCheck(userId);

  if (isLoading) return <Loading />;

  return (
    <div>
      <UserInfo user={user} />
      {isAdmin && <AdminPanel />}
    </div>
  );
}

위에서 두 번째 코드에서는 "사용자 정보를 가져오고, 관리자인지 확인한다"는 의도가 명확하게 드러나요. 어떻게 가져오는지는 각각의 훅 안에 숨겨져 있죠. 이렇게 작성하면 나중에 코드를 읽을 때 "아, 이 컴포넌트는 사용자 프로필을 보여주는 거구나"라는 게 바로 파악돼요.

또한 컴포넌트가 하나의 역할만 하도록 분리해요. 여러 역할이 섞여 있으면 코드를 읽는 사람이 "이 컴포넌트가 결국 뭘 하는 거지?"라는 의문을 갖게 되거든요.

마무리하며

선언적 코드에 대해 고민하면서 느낀 점은, 결국 "코드는 사람이 읽는 것"이라는 거에요. 반면 컴퓨터는 어떤 스타일의 코드든 실행할 수 있어요. 하지만 결국 그 코드를 수정하고 유지보수하는 것은 사람이 해야 해요. 그래서 6개월 뒤의 나, 혹은 다른 동료가 이 코드를 봤을 때 빠르게 이해할 수 있다면, 그것이 좋은 코드라고 생각해요.

물론 이것은 저의 생각일뿐 정답은 아니에요. 프로젝트의 특성, 팀의 컨벤션, 성능 요구사항 등 고려해야 할 요소는 많아요. 다만 "무엇을 원하는지를 명확하게 표현한다"는 선언적 사고방식은 어떤 상황에서든 유용한 기준이 될 수 있을 것 같다고 생각해요. 그래서 앞으로도 코드를 작성할 때 "이 코드를 처음 보는 사람이 빠르게 이해할 수 있을까?"라는 질문을 계속 던져보려고 해요.