프로그래밍 패러다임의 종류 알아보기
#CS#Programming Paradigm

프로그래밍 패러다임의 종류 알아보기

책 [Clean Architecture]에서 Robert Martin은 프로그래밍 패러다임을 바라보는 독특한 관점을 제시합니다.

각 패러다임은 프로그래머에게서 무언가를 빼앗는다. 무엇을 하지 말아야 하는지를 알려준다.

이 ‘금지’의 관점이 소프트웨어 설계와 어떻게 연결되는지가 이번 게시물의 핵심입니다.

구조적 프로그래밍

우리에게 가장 친숙한 구조적(Structured, 절차지향이라고도 함) 프로그래밍에서는 제어를 직접적으로 옮기는 것(direct transfer of control), 즉 goto의 사용을 금지합니다. 이를 통해 제어의 흐름을 구조화하는 데 성공했습니다.

객체지향 프로그래밍 (OOP)

객체지향(Object-Oriented) 프로그래밍은 클래스를 통한 캡슐화, 상속, 다형성 등을 강조하는 패러다임입니다.

그렇다면 우리에게 많은 것을 가져다준 객체지향이 프로그래머에게서 빼앗는 것은 무엇일까요? 바로 제어를 간접적으로 옮기는 것(indirect transfer of control), 즉 함수 포인터의 사용을 제한합니다. 함수 포인터가 갑자기 왜?라는 생각이 드시겠지만 사실 이건 C 언어에서 다형성을 구현할 때 쓰이는 개념입니다. 다형성(Polymorphism)이란 하나의 함수(메서드)나 연산자가 여러 상황에서 다르게 쓰일 수 있는 성질입니다.

예시로, 여러 종류의 도형이 있고, 각각 draw()를 다르게 구현하고 싶다고 합시다. C에서는 이렇게 할 수 있습니다:

// 함수 포인터를 담은 구조체
struct Shape {
    void (*draw)(struct Shape* self);  // 함수 포인터
    // ...
};
 
void drawCircle(struct Shape* self) { /* 원 그리기 */ }
void drawSquare(struct Shape* self) { /* 사각형 그리기 */ }
 
// 사용
struct Shape circle = { .draw = drawCircle };
struct Shape square = { .draw = drawSquare };
 
circle.draw(&circle);  // 원이 그려짐
square.draw(&square);  // 사각형이 그려짐

같은 shape.draw() 호출이 실제로는 다른 함수를 실행하기 때문에, 다형성이 구현된 것이 맞습니다. 그러면 이 코드는 왜 문제가 될 수 있을까요?

  • circlesquare 모두 Shape 타입이지만, 내부의 함수 포인터가 다릅니다
    • 컴파일러는 이 차이를 모르죠 — 둘 다 같은 Shape입니다
  • 실수로 circle.draw = drawSquare라고 써도 컴파일러는 아무 경고 없이 통과시키고, 어떤 Shape가 실제로 무슨 동작을 하는지 알려면 코드를 추적해야 합니다

이제 OOP 언어, 예컨대 Java에서는 이걸 다음과 같이 구현할 수 있습니다:

interface Shape {
    void draw();
}
 
class Circle implements Shape {
    public void draw() { /* 원 그리기 */ }
}
 
class Square implements Shape {
    public void draw() { /* 사각형 그리기 */ }
}
 
// 사용
Shape circle = new Circle();
Shape square = new Square();
 
circle.draw();  // 원이 그려짐
square.draw();  // 사각형이 그려짐

이 코드에서 간접 호출(indirect transfer of control)은 여전히 일어납니다. circle.draw()를 호출할 때 실제로 어떤 코드가 실행될지는 런타임에 결정되기 때문입니다. 하지만 이제 컴파일러가 타입과 행동의 연결을 강제합니다.

즉, OOP는 함수 포인터 사용을 금지함으로써 간접 호출을 안전하고 구조화된 방식으로만 할 수 있게 규제(discipline)하는 것입니다.

OOP의 킥은 ‘다형성의 안전한 구현’이다.

흔히 OOP가 가져다 준 것으로 말하는 캡슐화와 상속에 대해서 이야기해보겠습니다. Robert Martin은 그의 책에서 이렇게 말했습니다:

  • C 언어에서 .h 파일(헤더)과 .c 파일(구현)을 분리하는 방식으로 이미 ‘완벽한’ 캡슐화가 가능했습니다. 사용자는 헤더만 보고, 실제 구현은 숨길 수 있었습니다.
  • C 언어에서 struct 안에 다른 struct를 첫 번째 멤버로 넣는 트릭을 사용하면 상속과 유사한 것을 구현할 수 있습니다.

이렇기 때문에 캡슐화와 상속은 OOP가 ‘발명’한 것은 아니고, 이미 있던 것에 좀 더 편리한 문법을 제공한 것에 가깝습니다. 반면 OOP의 진정한 기여는 다형성을 안전하고 편리하게 사용할 수 있게 만든 것이라고 하죠.

그렇다면 왜 이름이 ‘객체지향’일까?

C에서 draw()를 호출하는 코드를 다시 보면, circle.draw(&circle)와 같이 객체가 함수에 전달됩니다. 반면 OOP에서는 circle.draw()와 같이 객체가 중심이고, 우리는 객체에게 ‘너 자신을 그리세요’라고 메시지를 보내는 것처럼 코드를 작성합니다.

그리고 다형성 덕분에 객체는 자신이 어떻게 응답할지를 스스로 알고 있습니다. Circle은 원을 그리는 법을 알고 있고, 호출하는 쪽은 그가 원을 그리는지 세부사항을 몰라도 됩니다.

함수형 프로그래밍 (FP)

함수형(Functional) 프로그래밍의 근본 철학은 수학에서의 함수 개념과 맞닿아 있습니다.

프로그래밍에서의 함수가 일반적으로 수학에서의 함수와 다른 것은, ‘입력에 따라 출력이 하나로 정해지지 않는 것도 있다’는 점입니다. 수학에서 f(2)는 언제 계산해도 항상 같은 값을 내놓지만, 프로그래밍에서는 그렇지 않다는 거죠.

let counter = 0;
 
function getCount() {
    counter = counter + 1;
    return counter;
}
 
getCount();  // 1
getCount();  // 2
getCount();  // 3

getCount() 호출이 매번 다른 값을 반환하는 이유는 무엇일까요? 바로 외부 변수의 값이 업데이트, 즉 재할당되는 것 때문입니다.

그렇다면, 만약 프로그래밍에서 재할당을 금지하면 어떻게 될까요? 이러한 생각에서부터 나온 것이 함수형 프로그래밍이고, 재할당을 금지한 프로그램은 참조 투명성(referential transparency)을 가지게 됩니다. 어떤 표현식을 그 결과값으로 대체해도 프로그램의 동작이 바뀌지 않는다는 뜻입니다.

이렇게 함수형 프로그래밍을 도입하면 프로그래밍의 함수가 수학의 함수처럼 동작하게 되는데, 재할당을 금지하는 것이 소프트웨어 설계 관점에서는 어떤 이점이 있을까요?

  • 가장 먼저, 예측 가능성입니다. 프로그램 복잡성의 상당 부분이 ‘이 시점에서 이 변수 값이 뭐더라?’를 추적하는 데서 오기 때문이죠. 재할당이 자유롭게 가능하다면 코드의 어느 지점에서든 상태가 바뀔 수 있고, 버그를 찾으려면 관련된 모든 코드를 추적해야 합니다.
  • 또, 동시성(concurrency) 문제도 있습니다. 여러 스레드가 동시에 같은 변수를 수정하려 할 때 생기는 버그들은 재할당이 가능하기 때문에 발생하거든요.